diff --git a/src/app/api/api_v1/endpoints/camera_proxy.py b/src/app/api/api_v1/endpoints/camera_proxy.py index 36a9fa04..579dc845 100644 --- a/src/app/api/api_v1/endpoints/camera_proxy.py +++ b/src/app/api/api_v1/endpoints/camera_proxy.py @@ -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"), @@ -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, @@ -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) @@ -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) diff --git a/src/tests/endpoints/test_camera_proxy.py b/src/tests/endpoints/test_camera_proxy.py index 52921b01..9a3cbf2f 100644 --- a/src/tests/endpoints/test_camera_proxy.py +++ b/src/tests/endpoints/test_camera_proxy.py @@ -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", @@ -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°rees=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", @@ -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°rees=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"),