Skip to content
112 changes: 108 additions & 4 deletions src/app/api/api_v1/endpoints/camera_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ async def proxy_camera_infos(camera: Camera = Depends(_require_read)) -> Any:

@router.get("/{camera_id}/capture", status_code=status.HTTP_200_OK, summary="Capture a JPEG snapshot from the camera")
async def proxy_capture(
pos_id: int | None = Query(default=None, description="Move to this preset pose before capturing"),
patrol_id: int | None = Query(default=None, description="Move to this preset pose before capturing"),
anonymize: bool = Query(default=True, description="Overlay anonymization masks on the image"),
max_age_ms: int | None = Query(default=None, description="Only use detection boxes newer than this many ms"),
strict: bool = Query(default=False, description="Return 503 if no recent boxes are available for anonymization"),
Expand All @@ -123,7 +123,7 @@ async def proxy_capture(
data = await _run_sync(
_make_client(device_ip).capture_jpeg,
camera_ip,
pos_id=pos_id,
patrol_id=patrol_id,
anonymize=anonymize,
max_age_ms=max_age_ms,
strict=strict,
Expand Down Expand Up @@ -151,12 +151,14 @@ async def proxy_latest_image(
# ── Control ───────────────────────────────────────────────────────────────────


@router.post("/{camera_id}/control/move", status_code=status.HTTP_200_OK, summary="Move the camera")
@router.post("/{camera_id}/control/move", status_code=status.HTTP_200_OK, summary="Move the camera (legacy)")
async def proxy_move(
direction: str | None = Query(default=None, description="Direction: Left, Right, Up, Down"),
speed: int = Query(default=10, description="Movement speed"),
pose_id: int | None = Query(default=None, description="Move to this preset pose index"),
degrees: float | None = Query(default=None, description="Rotate by this many degrees (requires direction)"),
duration: float | None = Query(default=None, description="Move for this many seconds (requires direction)"),
zoom: int = Query(default=0, description="Zoom level; speed is forced to 1 server-side when zoom > 0"),
camera: Camera = Depends(_require_write),
) -> Any:
device_ip, camera_ip = _device_config(camera)
Expand All @@ -167,10 +169,112 @@ async def proxy_move(
speed=speed,
pose_id=pose_id,
degrees=degrees,
duration=duration,
zoom=zoom,
)


@router.post("/{camera_id}/control/stop", status_code=status.HTTP_200_OK, summary="Stop camera movement")
@router.post("/{camera_id}/control/goto_preset", status_code=status.HTTP_200_OK, summary="Move to a preset pose")
async def proxy_goto_preset(
pose_id: int = Query(..., description="Preset pose index to move to"),
speed: int = Query(default=50, description="Movement speed"),
camera: Camera = Depends(_require_write),
) -> Any:
device_ip, camera_ip = _device_config(camera)
return await _run_sync(_make_client(device_ip).goto_preset, camera_ip, pose_id, speed)


@router.post("/{camera_id}/control/start_move", status_code=status.HTTP_200_OK, summary="Start a continuous move")
async def proxy_start_move(
direction: str = Query(..., description="Direction: Left, Right, Up, Down"),
speed: int = Query(default=10, description="Movement speed"),
camera: Camera = Depends(_require_write),
) -> Any:
device_ip, camera_ip = _device_config(camera)
return await _run_sync(_make_client(device_ip).start_move, camera_ip, direction, speed)


@router.post("/{camera_id}/control/stop_move", status_code=status.HTTP_200_OK, summary="Halt current movement")
async def proxy_stop_move(camera: Camera = Depends(_require_write)) -> Any:
device_ip, camera_ip = _device_config(camera)
return await _run_sync(_make_client(device_ip).stop_move, camera_ip)


@router.post(
"/{camera_id}/control/move_for_duration",
status_code=status.HTTP_200_OK,
summary="Move for a fixed duration (seconds)",
)
async def proxy_move_for_duration(
direction: str = Query(..., description="Direction: Left, Right, Up, Down"),
duration: float = Query(..., gt=0, description="Movement duration in seconds"),
speed: int = Query(default=10, description="Movement speed"),
camera: Camera = Depends(_require_write),
) -> Any:
device_ip, camera_ip = _device_config(camera)
return await _run_sync(
_make_client(device_ip).move_for_duration,
camera_ip,
direction,
duration,
speed,
)


@router.post(
"/{camera_id}/control/move_by_degrees",
status_code=status.HTTP_200_OK,
summary="Move by an approximate angle",
)
async def proxy_move_by_degrees(
direction: str = Query(..., description="Direction: Left, Right, Up, Down"),
degrees: float = Query(..., gt=0, description="Approximate rotation in degrees"),
speed: int | None = Query(
default=None,
description="Movement speed; omit to let the server auto-pick the best calibrated level (preferred)",
),
camera: Camera = Depends(_require_write),
) -> Any:
device_ip, camera_ip = _device_config(camera)
return await _run_sync(
_make_client(device_ip).move_by_degrees,
camera_ip,
direction,
degrees,
speed,
)


@router.post(
"/{camera_id}/control/click_to_move",
status_code=status.HTTP_200_OK,
summary="Move toward a normalized image click",
)
async def proxy_click_to_move(
click_x: float = Query(..., ge=0.0, le=1.0, description="Normalized x coordinate in [0, 1]"),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would not be more robust to put x y. in the payload/body instead of a param url ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed

click_y: float = Query(..., ge=0.0, le=1.0, description="Normalized y coordinate in [0, 1]"),
camera: Camera = Depends(_require_write),
) -> Any:
device_ip, camera_ip = _device_config(camera)
return await _run_sync(
_make_client(device_ip).click_to_move,
camera_ip,
click_x,
click_y,
)


@router.get(
"/{camera_id}/control/speed_tables",
status_code=status.HTTP_200_OK,
summary="Get calibrated speed tables",
)
async def proxy_speed_tables(camera: Camera = Depends(_require_read)) -> Any:
device_ip, camera_ip = _device_config(camera)
return await _run_sync(_make_client(device_ip).get_speed_tables, camera_ip)


@router.post("/{camera_id}/control/stop", status_code=status.HTTP_200_OK, summary="Stop the camera")
async def proxy_stop(camera: Camera = Depends(_require_write)) -> Any:
device_ip, camera_ip = _device_config(camera)
return await _run_sync(_make_client(device_ip).stop_camera, camera_ip)
Expand Down
14 changes: 14 additions & 0 deletions src/tests/endpoints/test_camera_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ async def test_proxy_write_auth(
"/cameras/1/capture",
"/cameras/1/latest_image?pose=0",
"/cameras/1/control/presets",
"/cameras/1/control/speed_tables",
"/cameras/1/focus/status",
"/cameras/1/patrol/status",
"/cameras/1/stream/status",
Expand All @@ -154,6 +155,12 @@ async def test_proxy_unconfigured_get(async_client: AsyncClient, camera_session:
"path",
[
"/cameras/1/control/move",
"/cameras/1/control/goto_preset?pose_id=1",
"/cameras/1/control/start_move?direction=Left",
"/cameras/1/control/stop_move",
"/cameras/1/control/move_for_duration?direction=Left&duration=1",
"/cameras/1/control/move_by_degrees?direction=Left&degrees=5",
"/cameras/1/control/click_to_move?click_x=0.5&click_y=0.5",
"/cameras/1/control/stop",
"/cameras/1/control/preset",
"/cameras/1/control/zoom/5",
Expand Down Expand Up @@ -262,11 +269,18 @@ async def test_device_ip_not_leaked_in_camera_response(
(f"/cameras/{CONFIGURED_CAM_ID}/cameras_list", "get"),
(f"/cameras/{CONFIGURED_CAM_ID}/camera_infos", "get"),
(f"/cameras/{CONFIGURED_CAM_ID}/control/presets", "get"),
(f"/cameras/{CONFIGURED_CAM_ID}/control/speed_tables", "get"),
(f"/cameras/{CONFIGURED_CAM_ID}/focus/status", "get"),
(f"/cameras/{CONFIGURED_CAM_ID}/patrol/status", "get"),
(f"/cameras/{CONFIGURED_CAM_ID}/stream/status", "get"),
(f"/cameras/{CONFIGURED_CAM_ID}/stream/is_running", "get"),
(f"/cameras/{CONFIGURED_CAM_ID}/control/move", "post"),
(f"/cameras/{CONFIGURED_CAM_ID}/control/goto_preset?pose_id=1", "post"),
(f"/cameras/{CONFIGURED_CAM_ID}/control/start_move?direction=Left", "post"),
(f"/cameras/{CONFIGURED_CAM_ID}/control/stop_move", "post"),
(f"/cameras/{CONFIGURED_CAM_ID}/control/move_for_duration?direction=Left&duration=1", "post"),
(f"/cameras/{CONFIGURED_CAM_ID}/control/move_by_degrees?direction=Left&degrees=5", "post"),
(f"/cameras/{CONFIGURED_CAM_ID}/control/click_to_move?click_x=0.5&click_y=0.5", "post"),
(f"/cameras/{CONFIGURED_CAM_ID}/control/stop", "post"),
(f"/cameras/{CONFIGURED_CAM_ID}/control/preset", "post"),
(f"/cameras/{CONFIGURED_CAM_ID}/control/zoom/5", "post"),
Expand Down
Loading