From f7d325064a9cd15bd23d811289a4740c388e7e4a Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 16 Apr 2026 12:25:04 +0200 Subject: [PATCH 1/8] 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. --- src/app/api/api_v1/endpoints/camera_proxy.py | 109 ++++++++++++++++++- src/tests/endpoints/test_camera_proxy.py | 14 +++ 2 files changed, 119 insertions(+), 4 deletions(-) diff --git a/src/app/api/api_v1/endpoints/camera_proxy.py b/src/app/api/api_v1/endpoints/camera_proxy.py index 36a9fa04..3bfff76a 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,109 @@ 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 = 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_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"), From 5dd4c61aad28cc144a81947114adbe48aac76cf2 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 16 Apr 2026 12:30:25 +0200 Subject: [PATCH 2/8] 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. --- src/app/api/api_v1/endpoints/camera_proxy.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/api/api_v1/endpoints/camera_proxy.py b/src/app/api/api_v1/endpoints/camera_proxy.py index 3bfff76a..579dc845 100644 --- a/src/app/api/api_v1/endpoints/camera_proxy.py +++ b/src/app/api/api_v1/endpoints/camera_proxy.py @@ -229,7 +229,10 @@ async def proxy_move_for_duration( 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 = Query(default=10, description="Movement speed"), + 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) From 1f339cddbe456cd63503db48240ddc901ee9bb89 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Tue, 12 May 2026 17:18:44 +0200 Subject: [PATCH 3/8] Added direction validation and mark /control/move as deprecated --- src/app/api/api_v1/endpoints/camera_proxy.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/app/api/api_v1/endpoints/camera_proxy.py b/src/app/api/api_v1/endpoints/camera_proxy.py index 579dc845..5e5a1b44 100644 --- a/src/app/api/api_v1/endpoints/camera_proxy.py +++ b/src/app/api/api_v1/endpoints/camera_proxy.py @@ -8,7 +8,7 @@ import io from collections.abc import Callable from functools import partial -from typing import Any, cast +from typing import Any, Literal, cast import requests from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, Security, status @@ -21,6 +21,8 @@ router = APIRouter() +CameraDirection = Literal["Left", "Right", "Up", "Down"] + DEVICE_PORT = 8081 TIMEOUT = 10.0 @@ -151,7 +153,7 @@ async def proxy_latest_image( # ── Control ─────────────────────────────────────────────────────────────────── -@router.post("/{camera_id}/control/move", status_code=status.HTTP_200_OK, summary="Move the camera (legacy)") +@router.post("/{camera_id}/control/move", status_code=status.HTTP_200_OK, summary="Move the camera (legacy)", deprecated=True) async def proxy_move( direction: str | None = Query(default=None, description="Direction: Left, Right, Up, Down"), speed: int = Query(default=10, description="Movement speed"), @@ -186,7 +188,7 @@ async def proxy_goto_preset( @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"), + direction: CameraDirection = Query(..., description="Direction: Left, Right, Up, Down"), speed: int = Query(default=10, description="Movement speed"), camera: Camera = Depends(_require_write), ) -> Any: @@ -206,7 +208,7 @@ async def proxy_stop_move(camera: Camera = Depends(_require_write)) -> Any: summary="Move for a fixed duration (seconds)", ) async def proxy_move_for_duration( - direction: str = Query(..., description="Direction: Left, Right, Up, Down"), + direction: CameraDirection = 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), @@ -227,7 +229,7 @@ async def proxy_move_for_duration( summary="Move by an approximate angle", ) async def proxy_move_by_degrees( - direction: str = Query(..., description="Direction: Left, Right, Up, Down"), + direction: CameraDirection = Query(..., description="Direction: Left, Right, Up, Down"), degrees: float = Query(..., gt=0, description="Approximate rotation in degrees"), speed: int | None = Query( default=None, From 5678ccbf031c6a14e4e0cea8a7dc14cbb5b89009 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Tue, 12 May 2026 17:33:28 +0200 Subject: [PATCH 4/8] style --- src/app/api/api_v1/endpoints/camera_proxy.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/api/api_v1/endpoints/camera_proxy.py b/src/app/api/api_v1/endpoints/camera_proxy.py index 5e5a1b44..e02b3a5f 100644 --- a/src/app/api/api_v1/endpoints/camera_proxy.py +++ b/src/app/api/api_v1/endpoints/camera_proxy.py @@ -153,7 +153,9 @@ async def proxy_latest_image( # ── Control ─────────────────────────────────────────────────────────────────── -@router.post("/{camera_id}/control/move", status_code=status.HTTP_200_OK, summary="Move the camera (legacy)", deprecated=True) +@router.post( + "/{camera_id}/control/move", status_code=status.HTTP_200_OK, summary="Move the camera (legacy)", deprecated=True +) async def proxy_move( direction: str | None = Query(default=None, description="Direction: Left, Right, Up, Down"), speed: int = Query(default=10, description="Movement speed"), From fa83ceab528b49f1bd56383d58f861da62e9ad12 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 14 May 2026 09:35:47 +0200 Subject: [PATCH 5/8] 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. --- src/app/api/api_v1/endpoints/camera_proxy.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/app/api/api_v1/endpoints/camera_proxy.py b/src/app/api/api_v1/endpoints/camera_proxy.py index e02b3a5f..0fca4771 100644 --- a/src/app/api/api_v1/endpoints/camera_proxy.py +++ b/src/app/api/api_v1/endpoints/camera_proxy.py @@ -198,7 +198,11 @@ async def proxy_start_move( 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") +@router.post( + "/{camera_id}/control/stop_move", + status_code=status.HTTP_200_OK, + summary="Halt the current continuous PTZ move", +) 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) @@ -278,7 +282,12 @@ async def proxy_speed_tables(camera: Camera = Depends(_require_read)) -> Any: 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") +@router.post( + "/{camera_id}/control/stop", + status_code=status.HTTP_200_OK, + summary="Stop the camera (legacy)", + deprecated=True, +) 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) From a9ff8e9fa9c333df602c3ba57e729a6f16c5d6bf Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 14 May 2026 10:01:27 +0200 Subject: [PATCH 6/8] refactor: take JSON body on new focused PTZ control endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/api_v1/endpoints/camera_proxy.py | 72 ++++++++----- src/tests/endpoints/test_camera_proxy.py | 105 ++++++++++--------- 2 files changed, 104 insertions(+), 73 deletions(-) diff --git a/src/app/api/api_v1/endpoints/camera_proxy.py b/src/app/api/api_v1/endpoints/camera_proxy.py index 0fca4771..68202169 100644 --- a/src/app/api/api_v1/endpoints/camera_proxy.py +++ b/src/app/api/api_v1/endpoints/camera_proxy.py @@ -12,6 +12,7 @@ import requests from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, Security, status +from pydantic import BaseModel, Field from pyro_camera_api_client import PyroCameraAPIClient from app.api.dependencies import get_camera_crud, get_jwt @@ -23,6 +24,37 @@ CameraDirection = Literal["Left", "Right", "Up", "Down"] + +class GotoPresetRequest(BaseModel): + pose_id: int = Field(..., description="Preset pose index to move to") + speed: int = Field(default=50, description="Movement speed") + + +class StartMoveRequest(BaseModel): + direction: CameraDirection = Field(..., description="Direction: Left, Right, Up, Down") + speed: int = Field(default=10, description="Movement speed") + + +class MoveForDurationRequest(BaseModel): + direction: CameraDirection = Field(..., description="Direction: Left, Right, Up, Down") + duration: float = Field(..., gt=0, description="Movement duration in seconds") + speed: int = Field(default=10, description="Movement speed") + + +class MoveByDegreesRequest(BaseModel): + direction: CameraDirection = Field(..., description="Direction: Left, Right, Up, Down") + degrees: float = Field(..., gt=0, description="Approximate rotation in degrees") + speed: int | None = Field( + default=None, + description="Movement speed; omit to let the server auto-pick the best calibrated level (preferred)", + ) + + +class ClickToMoveRequest(BaseModel): + click_x: float = Field(..., ge=0.0, le=1.0, description="Normalized x coordinate in [0, 1]") + click_y: float = Field(..., ge=0.0, le=1.0, description="Normalized y coordinate in [0, 1]") + + DEVICE_PORT = 8081 TIMEOUT = 10.0 @@ -180,22 +212,20 @@ async def proxy_move( @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"), + payload: GotoPresetRequest, 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) + return await _run_sync(_make_client(device_ip).goto_preset, camera_ip, payload.pose_id, payload.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: CameraDirection = Query(..., description="Direction: Left, Right, Up, Down"), - speed: int = Query(default=10, description="Movement speed"), + payload: StartMoveRequest, 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) + return await _run_sync(_make_client(device_ip).start_move, camera_ip, payload.direction, payload.speed) @router.post( @@ -214,18 +244,16 @@ async def proxy_stop_move(camera: Camera = Depends(_require_write)) -> Any: summary="Move for a fixed duration (seconds)", ) async def proxy_move_for_duration( - direction: CameraDirection = 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"), + payload: MoveForDurationRequest, 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, + payload.direction, + payload.duration, + payload.speed, ) @@ -235,21 +263,16 @@ async def proxy_move_for_duration( summary="Move by an approximate angle", ) async def proxy_move_by_degrees( - direction: CameraDirection = 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)", - ), + payload: MoveByDegreesRequest, 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, + payload.direction, + payload.degrees, + payload.speed, ) @@ -259,16 +282,15 @@ async def proxy_move_by_degrees( 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]"), + payload: ClickToMoveRequest, 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, + payload.click_x, + payload.click_y, ) diff --git a/src/tests/endpoints/test_camera_proxy.py b/src/tests/endpoints/test_camera_proxy.py index 9a3cbf2f..7bdd141d 100644 --- a/src/tests/endpoints/test_camera_proxy.py +++ b/src/tests/endpoints/test_camera_proxy.py @@ -152,30 +152,35 @@ async def test_proxy_unconfigured_get(async_client: AsyncClient, camera_session: @pytest.mark.parametrize( - "path", + ("path", "json"), [ - "/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", - "/cameras/1/patrol/start", - "/cameras/1/patrol/stop", - "/cameras/1/stream/start", - "/cameras/1/stream/stop", - "/cameras/1/focus/manual?position=500", - "/cameras/1/focus/autofocus", - "/cameras/1/focus/optimize", + ("/cameras/1/control/move", None), + ("/cameras/1/control/goto_preset", {"pose_id": 1}), + ("/cameras/1/control/start_move", {"direction": "Left"}), + ("/cameras/1/control/stop_move", None), + ("/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", None), + ("/cameras/1/control/preset", None), + ("/cameras/1/control/zoom/5", None), + ("/cameras/1/patrol/start", None), + ("/cameras/1/patrol/stop", None), + ("/cameras/1/stream/start", None), + ("/cameras/1/stream/stop", None), + ("/cameras/1/focus/manual?position=500", None), + ("/cameras/1/focus/autofocus", None), + ("/cameras/1/focus/optimize", None), ], ) @pytest.mark.asyncio -async def test_proxy_unconfigured_post(async_client: AsyncClient, camera_session: AsyncSession, path: str): - response = await async_client.post(path, headers=_auth(0)) +async def test_proxy_unconfigured_post( + async_client: AsyncClient, camera_session: AsyncSession, path: str, json: dict | None +): + kwargs: dict = {"headers": _auth(0)} + if json is not None: + kwargs["json"] = json + response = await async_client.post(path, **kwargs) assert response.status_code == 409 assert "not configured" in response.json()["detail"] @@ -263,34 +268,34 @@ async def test_device_ip_not_leaked_in_camera_response( @pytest.mark.parametrize( - ("path", "method"), + ("path", "method", "json"), [ - (f"/cameras/{CONFIGURED_CAM_ID}/health", "get"), - (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"), - (f"/cameras/{CONFIGURED_CAM_ID}/focus/manual?position=500", "post"), - (f"/cameras/{CONFIGURED_CAM_ID}/focus/autofocus", "post"), - (f"/cameras/{CONFIGURED_CAM_ID}/focus/optimize", "post"), - (f"/cameras/{CONFIGURED_CAM_ID}/patrol/start", "post"), - (f"/cameras/{CONFIGURED_CAM_ID}/patrol/stop", "post"), - (f"/cameras/{CONFIGURED_CAM_ID}/stream/start", "post"), - (f"/cameras/{CONFIGURED_CAM_ID}/stream/stop", "post"), + (f"/cameras/{CONFIGURED_CAM_ID}/health", "get", None), + (f"/cameras/{CONFIGURED_CAM_ID}/cameras_list", "get", None), + (f"/cameras/{CONFIGURED_CAM_ID}/camera_infos", "get", None), + (f"/cameras/{CONFIGURED_CAM_ID}/control/presets", "get", None), + (f"/cameras/{CONFIGURED_CAM_ID}/control/speed_tables", "get", None), + (f"/cameras/{CONFIGURED_CAM_ID}/focus/status", "get", None), + (f"/cameras/{CONFIGURED_CAM_ID}/patrol/status", "get", None), + (f"/cameras/{CONFIGURED_CAM_ID}/stream/status", "get", None), + (f"/cameras/{CONFIGURED_CAM_ID}/stream/is_running", "get", None), + (f"/cameras/{CONFIGURED_CAM_ID}/control/move", "post", None), + (f"/cameras/{CONFIGURED_CAM_ID}/control/goto_preset", "post", {"pose_id": 1}), + (f"/cameras/{CONFIGURED_CAM_ID}/control/start_move", "post", {"direction": "Left"}), + (f"/cameras/{CONFIGURED_CAM_ID}/control/stop_move", "post", None), + (f"/cameras/{CONFIGURED_CAM_ID}/control/move_for_duration", "post", {"direction": "Left", "duration": 1}), + (f"/cameras/{CONFIGURED_CAM_ID}/control/move_by_degrees", "post", {"direction": "Left", "degrees": 5}), + (f"/cameras/{CONFIGURED_CAM_ID}/control/click_to_move", "post", {"click_x": 0.5, "click_y": 0.5}), + (f"/cameras/{CONFIGURED_CAM_ID}/control/stop", "post", None), + (f"/cameras/{CONFIGURED_CAM_ID}/control/preset", "post", None), + (f"/cameras/{CONFIGURED_CAM_ID}/control/zoom/5", "post", None), + (f"/cameras/{CONFIGURED_CAM_ID}/focus/manual?position=500", "post", None), + (f"/cameras/{CONFIGURED_CAM_ID}/focus/autofocus", "post", None), + (f"/cameras/{CONFIGURED_CAM_ID}/focus/optimize", "post", None), + (f"/cameras/{CONFIGURED_CAM_ID}/patrol/start", "post", None), + (f"/cameras/{CONFIGURED_CAM_ID}/patrol/stop", "post", None), + (f"/cameras/{CONFIGURED_CAM_ID}/stream/start", "post", None), + (f"/cameras/{CONFIGURED_CAM_ID}/stream/stop", "post", None), ], ) @pytest.mark.asyncio @@ -299,7 +304,11 @@ async def test_proxy_happy_path( configured_camera_session: AsyncSession, path: str, method: str, + json: dict | None, ): + kwargs: dict = {"headers": _auth(0)} + if json is not None: + kwargs["json"] = json with patch(f"{_PROXY_MODULE}._run_sync", new=AsyncMock(return_value={"ok": True})): - response = await getattr(async_client, method)(path, headers=_auth(0)) + response = await getattr(async_client, method)(path, **kwargs) assert response.status_code == 200 From 6e3d5bed472543b7ce997ff074d845d45b1748d3 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 14 May 2026 10:25:55 +0200 Subject: [PATCH 7/8] 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. --- poetry.lock | 12 ++++++------ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index 2916ba3c..917c56f4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "aiosqlite" @@ -1880,8 +1880,8 @@ dev = ["httpx", "mypy", "pytest", "ruff"] [package.source] type = "git" url = "https://github.com/pyronear/pyro-engine.git" -reference = "develop" -resolved_reference = "e2a6de993902cd589de031792fbfe27a898e73c1" +reference = "main" +resolved_reference = "6d525b150a764447e62beec26e39702f71d8461e" subdirectory = "pyro_camera_api/client" [[package]] @@ -2194,10 +2194,10 @@ files = [ ] [package.dependencies] -botocore = ">=1.37.4,<2.0a0" +botocore = ">=1.37.4,<2.0a.0" [package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] [[package]] name = "sentry-sdk" @@ -2643,4 +2643,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "5c3801f7e9bc66dc943e81e09dff2ed30b1959b85c5ed719a7589e8673adb8e8" +content-hash = "ebf43805c29443b88db3f151010b3c4d065ca764c169ba3786ac0b9851ed0333" diff --git a/pyproject.toml b/pyproject.toml index fb0a5803..634ab9b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ python-multipart = "==0.0.7" python-magic = "^0.4.17" boto3 = "^1.26.0" httpx = "^0.24.0" -pyro-camera-api-client = {git = "https://github.com/pyronear/pyro-engine.git", subdirectory = "pyro_camera_api/client", branch = "develop"} +pyro-camera-api-client = {git = "https://github.com/pyronear/pyro-engine.git", subdirectory = "pyro_camera_api/client", branch = "main"} geopy = "^2.4.0" networkx = "^3.2.0" numpy = "^1.26.0" From 5e7be808c2b0424b1b8518c4ca06f287ca06ba41 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 14 May 2026 11:35:24 +0200 Subject: [PATCH 8/8] 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). --- client/pyroclient/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/pyroclient/client.py b/client/pyroclient/client.py index 6fc3fb4d..5355caac 100644 --- a/client/pyroclient/client.py +++ b/client/pyroclient/client.py @@ -4,7 +4,7 @@ # See LICENSE or go to for full license details. from enum import Enum -from typing import Dict, List, Tuple +from typing import Any, Dict, List, Tuple, Union from urllib.parse import urljoin import requests @@ -105,7 +105,7 @@ def __init__( self.timeout = timeout @property - def headers(self) -> Dict[str, str]: + def headers(self) -> Dict[str, Union[str, bytes]]: return {"Authorization": f"Bearer {self.token}"} # CAMERAS @@ -282,7 +282,7 @@ def create_occlusion_mask( >>> api_client.create_occlusion_mask(pose_id=1, mask="(0.1,0.1,0.9,0.9)") """ - payload = { + payload: Dict[str, Any] = { "pose_id": pose_id, "mask": mask, }