Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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]"),
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