Skip to content

Commit 3a69b32

Browse files
MateoLostanlenfe51
andauthored
Adapt camera-proxy to new pyro-camera-api client (#569)
* feat: adapt camera-proxy to new pyro-camera-api client Rename pos_id to patrol_id on /capture, add duration/zoom to legacy /control/move, and expose focused PTZ endpoints (goto_preset, start_move, stop_move, move_for_duration, move_by_degrees, click_to_move) plus /control/speed_tables. * fix: make speed optional on /control/move_by_degrees Aligns with the client change where omitting speed lets the server auto-pick the best calibrated level for the target angle. * Added direction validation and mark /control/move as deprecated * style * fix: deprecate /control/stop as alias of /control/stop_move Upstream pyro_camera_api implements stop_move as `return stop_camera(camera_ip)`, so /control/stop and /control/stop_move call the same downstream operation. Pair the /control/stop deprecation with the existing /control/move deprecation, and tighten the stop_move summary for OpenAPI clarity. * refactor: take JSON body on new focused PTZ control endpoints The five new POSTs introduced in this PR (goto_preset, start_move, move_for_duration, move_by_degrees, click_to_move) now accept their operands as a JSON body instead of query parameters. Defines small Pydantic request models with the same validation constraints (gt=0, ge=0.0, le=1.0). Tests parametrize updated to send JSON bodies. POST state-changing commands belong in the body, not the URL — and since these endpoints are net-new and unreleased, the change is free. * chore: install pyro-camera-api-client from main Switch the pyro-camera-api-client git source from the develop branch to main, and regenerate poetry.lock so the resolved reference picks up commit 6d525b1 which carries the new focused PTZ client methods (goto_preset, start_move, stop_move, move_for_duration, move_by_degrees, click_to_move, get_speed_tables). Unblocks pytest CI. * fix(client): satisfy stricter types-requests stubs The latest types-requests release narrowed the expected types for `headers` (now MutableMapping[str, str | bytes]) and `json` (now the invariant JsonType). Widen the `headers` property return type and annotate the heterogeneous occlusion-mask payload accordingly. Restores green mypy-client CI (red on main since dca6654). --------- Co-authored-by: fe51 <55736935+fe51@users.noreply.github.com>
1 parent dca6654 commit 3a69b32

5 files changed

Lines changed: 212 additions & 50 deletions

File tree

client/pyroclient/client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# See LICENSE or go to <https://opensource.org/licenses/Apache-2.0> for full license details.
55

66
from enum import Enum
7-
from typing import Any, Dict, List, Tuple
7+
from typing import Any, Dict, List, Tuple, Union
88
from urllib.parse import urljoin
99

1010
import requests
@@ -105,7 +105,7 @@ def __init__(
105105
self.timeout = timeout
106106

107107
@property
108-
def headers(self) -> Dict[str, str]:
108+
def headers(self) -> Dict[str, Union[str, bytes]]:
109109
return {"Authorization": f"Bearer {self.token}"}
110110

111111
# CAMERAS
@@ -287,7 +287,7 @@ def create_occlusion_mask(
287287
288288
>>> api_client.create_occlusion_mask(pose_id=1, mask="(0.1,0.1,0.9,0.9)")
289289
"""
290-
payload = {
290+
payload: Dict[str, Any] = {
291291
"pose_id": pose_id,
292292
"mask": mask,
293293
}

poetry.lock

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ python-multipart = "==0.0.7"
2929
python-magic = "^0.4.17"
3030
boto3 = "^1.26.0"
3131
httpx = "^0.24.0"
32-
pyro-camera-api-client = {git = "https://github.com/pyronear/pyro-engine.git", subdirectory = "pyro_camera_api/client", branch = "develop"}
32+
pyro-camera-api-client = {git = "https://github.com/pyronear/pyro-engine.git", subdirectory = "pyro_camera_api/client", branch = "main"}
3333
geopy = "^2.4.0"
3434
networkx = "^3.2.0"
3535
numpy = "^1.26.0"

src/app/api/api_v1/endpoints/camera_proxy.py

Lines changed: 144 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
import io
99
from collections.abc import Callable
1010
from functools import partial
11-
from typing import Any, cast
11+
from typing import Any, Literal, cast
1212

1313
import requests
1414
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, Security, status
15+
from pydantic import BaseModel, Field
1516
from pyro_camera_api_client import PyroCameraAPIClient
1617

1718
from app.api.dependencies import get_camera_crud, get_jwt
@@ -21,6 +22,39 @@
2122

2223
router = APIRouter()
2324

25+
CameraDirection = Literal["Left", "Right", "Up", "Down"]
26+
27+
28+
class GotoPresetRequest(BaseModel):
29+
pose_id: int = Field(..., description="Preset pose index to move to")
30+
speed: int = Field(default=50, description="Movement speed")
31+
32+
33+
class StartMoveRequest(BaseModel):
34+
direction: CameraDirection = Field(..., description="Direction: Left, Right, Up, Down")
35+
speed: int = Field(default=10, description="Movement speed")
36+
37+
38+
class MoveForDurationRequest(BaseModel):
39+
direction: CameraDirection = Field(..., description="Direction: Left, Right, Up, Down")
40+
duration: float = Field(..., gt=0, description="Movement duration in seconds")
41+
speed: int = Field(default=10, description="Movement speed")
42+
43+
44+
class MoveByDegreesRequest(BaseModel):
45+
direction: CameraDirection = Field(..., description="Direction: Left, Right, Up, Down")
46+
degrees: float = Field(..., gt=0, description="Approximate rotation in degrees")
47+
speed: int | None = Field(
48+
default=None,
49+
description="Movement speed; omit to let the server auto-pick the best calibrated level (preferred)",
50+
)
51+
52+
53+
class ClickToMoveRequest(BaseModel):
54+
click_x: float = Field(..., ge=0.0, le=1.0, description="Normalized x coordinate in [0, 1]")
55+
click_y: float = Field(..., ge=0.0, le=1.0, description="Normalized y coordinate in [0, 1]")
56+
57+
2458
DEVICE_PORT = 8081
2559
TIMEOUT = 10.0
2660

@@ -111,7 +145,7 @@ async def proxy_camera_infos(camera: Camera = Depends(_require_read)) -> Any:
111145

112146
@router.get("/{camera_id}/capture", status_code=status.HTTP_200_OK, summary="Capture a JPEG snapshot from the camera")
113147
async def proxy_capture(
114-
pos_id: int | None = Query(default=None, description="Move to this preset pose before capturing"),
148+
patrol_id: int | None = Query(default=None, description="Move to this preset pose before capturing"),
115149
anonymize: bool = Query(default=True, description="Overlay anonymization masks on the image"),
116150
max_age_ms: int | None = Query(default=None, description="Only use detection boxes newer than this many ms"),
117151
strict: bool = Query(default=False, description="Return 503 if no recent boxes are available for anonymization"),
@@ -123,7 +157,7 @@ async def proxy_capture(
123157
data = await _run_sync(
124158
_make_client(device_ip).capture_jpeg,
125159
camera_ip,
126-
pos_id=pos_id,
160+
patrol_id=patrol_id,
127161
anonymize=anonymize,
128162
max_age_ms=max_age_ms,
129163
strict=strict,
@@ -151,12 +185,16 @@ async def proxy_latest_image(
151185
# ── Control ───────────────────────────────────────────────────────────────────
152186

153187

154-
@router.post("/{camera_id}/control/move", status_code=status.HTTP_200_OK, summary="Move the camera")
188+
@router.post(
189+
"/{camera_id}/control/move", status_code=status.HTTP_200_OK, summary="Move the camera (legacy)", deprecated=True
190+
)
155191
async def proxy_move(
156192
direction: str | None = Query(default=None, description="Direction: Left, Right, Up, Down"),
157193
speed: int = Query(default=10, description="Movement speed"),
158194
pose_id: int | None = Query(default=None, description="Move to this preset pose index"),
159195
degrees: float | None = Query(default=None, description="Rotate by this many degrees (requires direction)"),
196+
duration: float | None = Query(default=None, description="Move for this many seconds (requires direction)"),
197+
zoom: int = Query(default=0, description="Zoom level; speed is forced to 1 server-side when zoom > 0"),
160198
camera: Camera = Depends(_require_write),
161199
) -> Any:
162200
device_ip, camera_ip = _device_config(camera)
@@ -167,10 +205,111 @@ async def proxy_move(
167205
speed=speed,
168206
pose_id=pose_id,
169207
degrees=degrees,
208+
duration=duration,
209+
zoom=zoom,
170210
)
171211

172212

173-
@router.post("/{camera_id}/control/stop", status_code=status.HTTP_200_OK, summary="Stop camera movement")
213+
@router.post("/{camera_id}/control/goto_preset", status_code=status.HTTP_200_OK, summary="Move to a preset pose")
214+
async def proxy_goto_preset(
215+
payload: GotoPresetRequest,
216+
camera: Camera = Depends(_require_write),
217+
) -> Any:
218+
device_ip, camera_ip = _device_config(camera)
219+
return await _run_sync(_make_client(device_ip).goto_preset, camera_ip, payload.pose_id, payload.speed)
220+
221+
222+
@router.post("/{camera_id}/control/start_move", status_code=status.HTTP_200_OK, summary="Start a continuous move")
223+
async def proxy_start_move(
224+
payload: StartMoveRequest,
225+
camera: Camera = Depends(_require_write),
226+
) -> Any:
227+
device_ip, camera_ip = _device_config(camera)
228+
return await _run_sync(_make_client(device_ip).start_move, camera_ip, payload.direction, payload.speed)
229+
230+
231+
@router.post(
232+
"/{camera_id}/control/stop_move",
233+
status_code=status.HTTP_200_OK,
234+
summary="Halt the current continuous PTZ move",
235+
)
236+
async def proxy_stop_move(camera: Camera = Depends(_require_write)) -> Any:
237+
device_ip, camera_ip = _device_config(camera)
238+
return await _run_sync(_make_client(device_ip).stop_move, camera_ip)
239+
240+
241+
@router.post(
242+
"/{camera_id}/control/move_for_duration",
243+
status_code=status.HTTP_200_OK,
244+
summary="Move for a fixed duration (seconds)",
245+
)
246+
async def proxy_move_for_duration(
247+
payload: MoveForDurationRequest,
248+
camera: Camera = Depends(_require_write),
249+
) -> Any:
250+
device_ip, camera_ip = _device_config(camera)
251+
return await _run_sync(
252+
_make_client(device_ip).move_for_duration,
253+
camera_ip,
254+
payload.direction,
255+
payload.duration,
256+
payload.speed,
257+
)
258+
259+
260+
@router.post(
261+
"/{camera_id}/control/move_by_degrees",
262+
status_code=status.HTTP_200_OK,
263+
summary="Move by an approximate angle",
264+
)
265+
async def proxy_move_by_degrees(
266+
payload: MoveByDegreesRequest,
267+
camera: Camera = Depends(_require_write),
268+
) -> Any:
269+
device_ip, camera_ip = _device_config(camera)
270+
return await _run_sync(
271+
_make_client(device_ip).move_by_degrees,
272+
camera_ip,
273+
payload.direction,
274+
payload.degrees,
275+
payload.speed,
276+
)
277+
278+
279+
@router.post(
280+
"/{camera_id}/control/click_to_move",
281+
status_code=status.HTTP_200_OK,
282+
summary="Move toward a normalized image click",
283+
)
284+
async def proxy_click_to_move(
285+
payload: ClickToMoveRequest,
286+
camera: Camera = Depends(_require_write),
287+
) -> Any:
288+
device_ip, camera_ip = _device_config(camera)
289+
return await _run_sync(
290+
_make_client(device_ip).click_to_move,
291+
camera_ip,
292+
payload.click_x,
293+
payload.click_y,
294+
)
295+
296+
297+
@router.get(
298+
"/{camera_id}/control/speed_tables",
299+
status_code=status.HTTP_200_OK,
300+
summary="Get calibrated speed tables",
301+
)
302+
async def proxy_speed_tables(camera: Camera = Depends(_require_read)) -> Any:
303+
device_ip, camera_ip = _device_config(camera)
304+
return await _run_sync(_make_client(device_ip).get_speed_tables, camera_ip)
305+
306+
307+
@router.post(
308+
"/{camera_id}/control/stop",
309+
status_code=status.HTTP_200_OK,
310+
summary="Stop the camera (legacy)",
311+
deprecated=True,
312+
)
174313
async def proxy_stop(camera: Camera = Depends(_require_write)) -> Any:
175314
device_ip, camera_ip = _device_config(camera)
176315
return await _run_sync(_make_client(device_ip).stop_camera, camera_ip)

src/tests/endpoints/test_camera_proxy.py

Lines changed: 58 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ async def test_proxy_write_auth(
137137
"/cameras/1/capture",
138138
"/cameras/1/latest_image?pose=0",
139139
"/cameras/1/control/presets",
140+
"/cameras/1/control/speed_tables",
140141
"/cameras/1/focus/status",
141142
"/cameras/1/patrol/status",
142143
"/cameras/1/stream/status",
@@ -151,24 +152,35 @@ async def test_proxy_unconfigured_get(async_client: AsyncClient, camera_session:
151152

152153

153154
@pytest.mark.parametrize(
154-
"path",
155+
("path", "json"),
155156
[
156-
"/cameras/1/control/move",
157-
"/cameras/1/control/stop",
158-
"/cameras/1/control/preset",
159-
"/cameras/1/control/zoom/5",
160-
"/cameras/1/patrol/start",
161-
"/cameras/1/patrol/stop",
162-
"/cameras/1/stream/start",
163-
"/cameras/1/stream/stop",
164-
"/cameras/1/focus/manual?position=500",
165-
"/cameras/1/focus/autofocus",
166-
"/cameras/1/focus/optimize",
157+
("/cameras/1/control/move", None),
158+
("/cameras/1/control/goto_preset", {"pose_id": 1}),
159+
("/cameras/1/control/start_move", {"direction": "Left"}),
160+
("/cameras/1/control/stop_move", None),
161+
("/cameras/1/control/move_for_duration", {"direction": "Left", "duration": 1}),
162+
("/cameras/1/control/move_by_degrees", {"direction": "Left", "degrees": 5}),
163+
("/cameras/1/control/click_to_move", {"click_x": 0.5, "click_y": 0.5}),
164+
("/cameras/1/control/stop", None),
165+
("/cameras/1/control/preset", None),
166+
("/cameras/1/control/zoom/5", None),
167+
("/cameras/1/patrol/start", None),
168+
("/cameras/1/patrol/stop", None),
169+
("/cameras/1/stream/start", None),
170+
("/cameras/1/stream/stop", None),
171+
("/cameras/1/focus/manual?position=500", None),
172+
("/cameras/1/focus/autofocus", None),
173+
("/cameras/1/focus/optimize", None),
167174
],
168175
)
169176
@pytest.mark.asyncio
170-
async def test_proxy_unconfigured_post(async_client: AsyncClient, camera_session: AsyncSession, path: str):
171-
response = await async_client.post(path, headers=_auth(0))
177+
async def test_proxy_unconfigured_post(
178+
async_client: AsyncClient, camera_session: AsyncSession, path: str, json: dict | None
179+
):
180+
kwargs: dict = {"headers": _auth(0)}
181+
if json is not None:
182+
kwargs["json"] = json
183+
response = await async_client.post(path, **kwargs)
172184
assert response.status_code == 409
173185
assert "not configured" in response.json()["detail"]
174186

@@ -256,27 +268,34 @@ async def test_device_ip_not_leaked_in_camera_response(
256268

257269

258270
@pytest.mark.parametrize(
259-
("path", "method"),
271+
("path", "method", "json"),
260272
[
261-
(f"/cameras/{CONFIGURED_CAM_ID}/health", "get"),
262-
(f"/cameras/{CONFIGURED_CAM_ID}/cameras_list", "get"),
263-
(f"/cameras/{CONFIGURED_CAM_ID}/camera_infos", "get"),
264-
(f"/cameras/{CONFIGURED_CAM_ID}/control/presets", "get"),
265-
(f"/cameras/{CONFIGURED_CAM_ID}/focus/status", "get"),
266-
(f"/cameras/{CONFIGURED_CAM_ID}/patrol/status", "get"),
267-
(f"/cameras/{CONFIGURED_CAM_ID}/stream/status", "get"),
268-
(f"/cameras/{CONFIGURED_CAM_ID}/stream/is_running", "get"),
269-
(f"/cameras/{CONFIGURED_CAM_ID}/control/move", "post"),
270-
(f"/cameras/{CONFIGURED_CAM_ID}/control/stop", "post"),
271-
(f"/cameras/{CONFIGURED_CAM_ID}/control/preset", "post"),
272-
(f"/cameras/{CONFIGURED_CAM_ID}/control/zoom/5", "post"),
273-
(f"/cameras/{CONFIGURED_CAM_ID}/focus/manual?position=500", "post"),
274-
(f"/cameras/{CONFIGURED_CAM_ID}/focus/autofocus", "post"),
275-
(f"/cameras/{CONFIGURED_CAM_ID}/focus/optimize", "post"),
276-
(f"/cameras/{CONFIGURED_CAM_ID}/patrol/start", "post"),
277-
(f"/cameras/{CONFIGURED_CAM_ID}/patrol/stop", "post"),
278-
(f"/cameras/{CONFIGURED_CAM_ID}/stream/start", "post"),
279-
(f"/cameras/{CONFIGURED_CAM_ID}/stream/stop", "post"),
273+
(f"/cameras/{CONFIGURED_CAM_ID}/health", "get", None),
274+
(f"/cameras/{CONFIGURED_CAM_ID}/cameras_list", "get", None),
275+
(f"/cameras/{CONFIGURED_CAM_ID}/camera_infos", "get", None),
276+
(f"/cameras/{CONFIGURED_CAM_ID}/control/presets", "get", None),
277+
(f"/cameras/{CONFIGURED_CAM_ID}/control/speed_tables", "get", None),
278+
(f"/cameras/{CONFIGURED_CAM_ID}/focus/status", "get", None),
279+
(f"/cameras/{CONFIGURED_CAM_ID}/patrol/status", "get", None),
280+
(f"/cameras/{CONFIGURED_CAM_ID}/stream/status", "get", None),
281+
(f"/cameras/{CONFIGURED_CAM_ID}/stream/is_running", "get", None),
282+
(f"/cameras/{CONFIGURED_CAM_ID}/control/move", "post", None),
283+
(f"/cameras/{CONFIGURED_CAM_ID}/control/goto_preset", "post", {"pose_id": 1}),
284+
(f"/cameras/{CONFIGURED_CAM_ID}/control/start_move", "post", {"direction": "Left"}),
285+
(f"/cameras/{CONFIGURED_CAM_ID}/control/stop_move", "post", None),
286+
(f"/cameras/{CONFIGURED_CAM_ID}/control/move_for_duration", "post", {"direction": "Left", "duration": 1}),
287+
(f"/cameras/{CONFIGURED_CAM_ID}/control/move_by_degrees", "post", {"direction": "Left", "degrees": 5}),
288+
(f"/cameras/{CONFIGURED_CAM_ID}/control/click_to_move", "post", {"click_x": 0.5, "click_y": 0.5}),
289+
(f"/cameras/{CONFIGURED_CAM_ID}/control/stop", "post", None),
290+
(f"/cameras/{CONFIGURED_CAM_ID}/control/preset", "post", None),
291+
(f"/cameras/{CONFIGURED_CAM_ID}/control/zoom/5", "post", None),
292+
(f"/cameras/{CONFIGURED_CAM_ID}/focus/manual?position=500", "post", None),
293+
(f"/cameras/{CONFIGURED_CAM_ID}/focus/autofocus", "post", None),
294+
(f"/cameras/{CONFIGURED_CAM_ID}/focus/optimize", "post", None),
295+
(f"/cameras/{CONFIGURED_CAM_ID}/patrol/start", "post", None),
296+
(f"/cameras/{CONFIGURED_CAM_ID}/patrol/stop", "post", None),
297+
(f"/cameras/{CONFIGURED_CAM_ID}/stream/start", "post", None),
298+
(f"/cameras/{CONFIGURED_CAM_ID}/stream/stop", "post", None),
280299
],
281300
)
282301
@pytest.mark.asyncio
@@ -285,7 +304,11 @@ async def test_proxy_happy_path(
285304
configured_camera_session: AsyncSession,
286305
path: str,
287306
method: str,
307+
json: dict | None,
288308
):
309+
kwargs: dict = {"headers": _auth(0)}
310+
if json is not None:
311+
kwargs["json"] = json
289312
with patch(f"{_PROXY_MODULE}._run_sync", new=AsyncMock(return_value={"ok": True})):
290-
response = await getattr(async_client, method)(path, headers=_auth(0))
313+
response = await getattr(async_client, method)(path, **kwargs)
291314
assert response.status_code == 200

0 commit comments

Comments
 (0)