From 8b92c8f4267ca12d3d68f9adcb0032e303924f3a Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:01:02 +0100 Subject: [PATCH 01/14] update camera model & add CameraDeviceConfig schema --- src/app/models.py | 3 +++ src/app/schemas/cameras.py | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/src/app/models.py b/src/app/models.py index 88ea6d61..c3b30b3c 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -57,6 +57,9 @@ class Camera(SQLModel, table=True): last_active_at: Union[datetime, None] = None last_image: Union[str, None] = None created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) + # Device connection — never exposed in public API responses + camera_ip: Union[str, None] = Field(default=None, nullable=True) + device_ip: Union[str, None] = Field(default=None, nullable=True) class Pose(SQLModel, table=True): diff --git a/src/app/schemas/cameras.py b/src/app/schemas/cameras.py index 95cf523f..d2769746 100644 --- a/src/app/schemas/cameras.py +++ b/src/app/schemas/cameras.py @@ -58,6 +58,15 @@ class CameraName(BaseModel): name: str = Field(..., min_length=5, max_length=100, description="name of the camera") +class CameraDeviceConfig(BaseModel): + """Internal-only: set the device connection details for a camera. Never returned in public responses.""" + + camera_ip: str | None = Field(default=None, description="IP of the camera within the device's local network") + device_ip: str | None = Field( + default=None, description="IP of the device box (VPN-reachable) running the camera API" + ) + + class CameraRead(CameraCreate): id: int last_active_at: datetime | None From 87c7cc94fe1f60aada1027c344cb8555b11ebab8 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:01:17 +0100 Subject: [PATCH 02/14] update crud camera --- src/app/crud/crud_camera.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/crud/crud_camera.py b/src/app/crud/crud_camera.py index eb5401b1..0e16414d 100644 --- a/src/app/crud/crud_camera.py +++ b/src/app/crud/crud_camera.py @@ -7,11 +7,17 @@ from app.crud.base import BaseCRUD from app.models import Camera -from app.schemas.cameras import CameraCreate, CameraEdit, CameraName, LastActive +from app.schemas.cameras import ( + CameraCreate, + CameraDeviceConfig, + CameraEdit, + CameraName, + LastActive, +) __all__ = ["CameraCRUD"] -class CameraCRUD(BaseCRUD[Camera, CameraCreate, LastActive | CameraEdit | CameraName]): +class CameraCRUD(BaseCRUD[Camera, CameraCreate, LastActive | CameraEdit | CameraName | CameraDeviceConfig]): def __init__(self, session: AsyncSession) -> None: super().__init__(session, Camera) From 5be1b4adf1f3670389fdd6c0277e6298e462039c Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:01:38 +0100 Subject: [PATCH 03/14] Added camera device_config Patch endpoint --- src/app/api/api_v1/endpoints/cameras.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/app/api/api_v1/endpoints/cameras.py b/src/app/api/api_v1/endpoints/cameras.py index d63c0197..200e0170 100644 --- a/src/app/api/api_v1/endpoints/cameras.py +++ b/src/app/api/api_v1/endpoints/cameras.py @@ -17,6 +17,7 @@ from app.models import Camera, Role, UserRole from app.schemas.cameras import ( CameraCreate, + CameraDeviceConfig, CameraEdit, CameraName, CameraRead, @@ -180,3 +181,18 @@ async def delete_camera( ) -> None: telemetry_client.capture(token_payload.sub, event="cameras-deletion", properties={"camera_id": camera_id}) await cameras.delete(camera_id) + + +@router.patch( + "/{camera_id}/device_config", status_code=status.HTTP_200_OK, summary="Update camera device connection config" +) +async def update_camera_device_config( + payload: CameraDeviceConfig, + camera_id: int = Path(..., gt=0), + cameras: CameraCRUD = Depends(get_camera_crud), + token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN]), +) -> Camera: + telemetry_client.capture( + token_payload.sub, event="cameras-update-device-config", properties={"camera_id": camera_id} + ) + return await cameras.update(camera_id, payload) From c6c8329654889c607a28f1d39fde56780938cc52 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:01:51 +0100 Subject: [PATCH 04/14] Added services camera_client --- src/app/services/camera_client.py | 250 ++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 src/app/services/camera_client.py diff --git a/src/app/services/camera_client.py b/src/app/services/camera_client.py new file mode 100644 index 00000000..cd72a01f --- /dev/null +++ b/src/app/services/camera_client.py @@ -0,0 +1,250 @@ +# Copyright (C) 2024-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +"""HTTP client for the camera device API. + +Base URL: http://{device_ip}:8081 + +camera_ip is the IP of the specific PTZ camera managed by the device. It is +passed as a path parameter when the swagger path contains {camera_ip}, or as +a query parameter otherwise. POST endpoints on the device API use query params +(not request body) for all their inputs. + +Endpoints that return JPEG images (capture, get_latest_image) use raw=True and +return bytes instead of JSON. get_latest_image may return None when the device +responds with 204 (no cached image for the requested pose). + +All functions raise FastAPI HTTPExceptions so they can be used directly in route handlers: + - 504 if the device does not respond within TIMEOUT seconds + - 502 if the device is unreachable (connection error) + - the device's own status code for any other HTTP error +""" + +from typing import Any + +import httpx +from fastapi import HTTPException, status + +__all__ = [ + "capture", + "get_camera_infos", + "get_focus_status", + "get_health", + "get_latest_image", + "get_patrol_status", + "get_stream_status", + "is_stream_running", + "list_cameras", + "list_presets", + "manual_focus", + "move", + "run_focus_finder", + "set_autofocus", + "set_preset", + "start_patrol", + "start_stream", + "stop", + "stop_patrol", + "stop_stream", + "zoom", +] + +DEVICE_PORT = 8081 +TIMEOUT = 10.0 + + +async def _request( + method: str, + device_ip: str, + path: str, + json: Any = None, + params: dict[str, Any] | None = None, + raw: bool = False, +) -> Any: + url = f"http://{device_ip}:{DEVICE_PORT}{path}" + async with httpx.AsyncClient(timeout=TIMEOUT) as client: + try: + response = await client.request(method, url, json=json, params=params) + if response.status_code == status.HTTP_204_NO_CONTENT: + return None + response.raise_for_status() + return response.content if raw else response.json() + except httpx.TimeoutException: + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail="Camera device is not responding.", + ) + except httpx.HTTPStatusError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail=exc.response.text, + ) + except httpx.RequestError: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to reach camera device.", + ) + + +# ── Health ──────────────────────────────────────────────────────────────────── + + +async def get_health(device_ip: str) -> Any: + return await _request("GET", device_ip, "/health") + + +# ── Cameras ─────────────────────────────────────────────────────────────────── + + +async def list_cameras(device_ip: str) -> Any: + """List all cameras managed by the device.""" + return await _request("GET", device_ip, "/cameras/cameras_list") + + +async def get_camera_infos(device_ip: str) -> Any: + """Return metadata for all configured cameras on the device (no camera_ip filter).""" + return await _request("GET", device_ip, "/cameras/camera_infos") + + +async def capture( + device_ip: str, + camera_ip: str, + *, + pos_id: int | None = None, + anonymize: bool = True, + max_age_ms: int | None = None, + strict: bool = False, + width: int | None = None, + quality: int = 95, +) -> bytes | None: + """Capture a JPEG snapshot. Returns raw bytes.""" + params: dict[str, Any] = { + "camera_ip": camera_ip, + "anonymize": anonymize, + "strict": strict, + "quality": quality, + } + if pos_id is not None: + params["pos_id"] = pos_id + if max_age_ms is not None: + params["max_age_ms"] = max_age_ms + if width is not None: + params["width"] = width + return await _request("GET", device_ip, "/cameras/capture", params=params, raw=True) + + +async def get_latest_image( + device_ip: str, + camera_ip: str, + pose: int, + quality: int = 95, +) -> bytes | None: + """Return the last stored JPEG for a given pose. Returns None if no image (204).""" + return await _request( + "GET", + device_ip, + "/cameras/latest_image", + params={"camera_ip": camera_ip, "pose": pose, "quality": quality}, + raw=True, + ) + + +# ── Control ─────────────────────────────────────────────────────────────────── + + +async def move( + device_ip: str, + camera_ip: str, + direction: str | None = None, + speed: int = 10, + pose_id: int | None = None, + degrees: float | None = None, +) -> Any: + params: dict[str, Any] = {"camera_ip": camera_ip, "speed": speed} + if direction is not None: + params["direction"] = direction + if pose_id is not None: + params["pose_id"] = pose_id + if degrees is not None: + params["degrees"] = degrees + return await _request("POST", device_ip, "/control/move", params=params) + + +async def stop(device_ip: str, camera_ip: str) -> Any: + return await _request("POST", device_ip, f"/control/stop/{camera_ip}") + + +async def list_presets(device_ip: str, camera_ip: str) -> Any: + return await _request("GET", device_ip, "/control/preset/list", params={"camera_ip": camera_ip}) + + +async def set_preset(device_ip: str, camera_ip: str, idx: int | None = None) -> Any: + params: dict[str, Any] = {"camera_ip": camera_ip} + if idx is not None: + params["idx"] = idx + return await _request("POST", device_ip, "/control/preset/set", params=params) + + +async def zoom(device_ip: str, camera_ip: str, level: int) -> Any: + return await _request("POST", device_ip, f"/control/zoom/{camera_ip}/{level}") + + +# ── Focus ───────────────────────────────────────────────────────────────────── + + +async def manual_focus(device_ip: str, camera_ip: str, position: int) -> Any: + return await _request("POST", device_ip, "/focus/manual", params={"camera_ip": camera_ip, "position": position}) + + +async def set_autofocus(device_ip: str, camera_ip: str, disable: bool = True) -> Any: + return await _request( + "POST", device_ip, "/focus/set_autofocus", params={"camera_ip": camera_ip, "disable": disable} + ) + + +async def get_focus_status(device_ip: str, camera_ip: str) -> Any: + return await _request("GET", device_ip, "/focus/status", params={"camera_ip": camera_ip}) + + +async def run_focus_finder(device_ip: str, camera_ip: str, save_images: bool = False) -> Any: + return await _request( + "POST", device_ip, "/focus/focus_finder", params={"camera_ip": camera_ip, "save_images": save_images} + ) + + +# ── Patrol ──────────────────────────────────────────────────────────────────── + + +async def start_patrol(device_ip: str, camera_ip: str) -> Any: + return await _request("POST", device_ip, "/patrol/start_patrol", params={"camera_ip": camera_ip}) + + +async def stop_patrol(device_ip: str, camera_ip: str) -> Any: + return await _request("POST", device_ip, "/patrol/stop_patrol", params={"camera_ip": camera_ip}) + + +async def get_patrol_status(device_ip: str, camera_ip: str) -> Any: + return await _request("GET", device_ip, "/patrol/patrol_status", params={"camera_ip": camera_ip}) + + +# ── Stream ──────────────────────────────────────────────────────────────────── + + +async def start_stream(device_ip: str, camera_ip: str) -> Any: + return await _request("POST", device_ip, f"/stream/start_stream/{camera_ip}") + + +async def stop_stream(device_ip: str) -> Any: + """Stop whatever stream is currently running on the device (device-global, no camera_ip).""" + return await _request("POST", device_ip, "/stream/stop_stream") + + +async def get_stream_status(device_ip: str) -> Any: + """Return the stream status for the device (device-global, no camera_ip).""" + return await _request("GET", device_ip, "/stream/status") + + +async def is_stream_running(device_ip: str, camera_ip: str) -> Any: + return await _request("GET", device_ip, f"/stream/is_stream_running/{camera_ip}") From d9c0ed260a6732eedf9935b6f1449de7f4a56d20 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:02:04 +0100 Subject: [PATCH 05/14] Added camera_proxy --- src/app/api/api_v1/endpoints/camera_proxy.py | 263 +++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 src/app/api/api_v1/endpoints/camera_proxy.py diff --git a/src/app/api/api_v1/endpoints/camera_proxy.py b/src/app/api/api_v1/endpoints/camera_proxy.py new file mode 100644 index 00000000..7ab34b0c --- /dev/null +++ b/src/app/api/api_v1/endpoints/camera_proxy.py @@ -0,0 +1,263 @@ +# Copyright (C) 2025-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + + +from typing import Any, cast + +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, Security, status + +from app.api.dependencies import get_camera_crud, get_jwt +from app.crud import CameraCRUD +from app.models import Camera, UserRole +from app.schemas.login import TokenPayload +from app.services import camera_client + +router = APIRouter() + + +# ── Shared helpers ──────────────────────────────────────────────────────────── + + +async def _require_read( + camera_id: int = Path(..., gt=0), + cameras: CameraCRUD = Depends(get_camera_crud), + token_payload: TokenPayload = Security( + get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]), +) -> Camera: + camera = cast(Camera, await cameras.get(camera_id, strict=True)) + if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") + return camera + + +async def _require_write( + camera_id: int = Path(..., gt=0), + cameras: CameraCRUD = Depends(get_camera_crud), + token_payload: TokenPayload = Security( + get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT]), +) -> Camera: + camera = cast(Camera, await cameras.get(camera_id, strict=True)) + if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") + return camera + + +def _device_config(camera: Camera) -> tuple[str, str]: + """Return (device_ip, camera_ip) or raise 409 if the camera is not configured.""" + if not camera.device_ip or not camera.camera_ip: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Camera device connection is not configured (missing device_ip or camera_ip).", + ) + return camera.device_ip, camera.camera_ip + + +# ── Health ──────────────────────────────────────────────────────────────────── + + +@router.get("/{camera_id}/health", status_code=status.HTTP_200_OK, summary="Camera device health check") +async def proxy_health(camera: Camera = Depends(_require_read)) -> Any: + device_ip, _ = _device_config(camera) + return await camera_client.get_health(device_ip) + + +# ── Device cameras ──────────────────────────────────────────────────────────── + + +@router.get("/{camera_id}/cameras_list", status_code=status.HTTP_200_OK, summary="List all cameras on the device") +async def proxy_cameras_list(camera: Camera = Depends(_require_read)) -> Any: + device_ip, _ = _device_config(camera) + return await camera_client.list_cameras(device_ip) + + +@router.get("/{camera_id}/camera_infos", status_code=status.HTTP_200_OK, summary="Get all camera infos from the device") +async def proxy_camera_infos(camera: Camera = Depends(_require_read)) -> Any: + device_ip, _ = _device_config(camera) + return await camera_client.get_camera_infos(device_ip) + + +@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"), + 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"), + width: int | None = Query( + default=None, description="Resize output to this width (px), preserving aspect ratio"), + quality: int = Query(default=95, ge=1, le=100, + description="JPEG quality (1–100)"), + camera: Camera = Depends(_require_read), +) -> Response: + device_ip, camera_ip = _device_config(camera) + data = await camera_client.capture( + device_ip, + camera_ip, + pos_id=pos_id, + anonymize=anonymize, + max_age_ms=max_age_ms, + strict=strict, + width=width, + quality=quality, + ) + return Response(content=data, media_type="image/jpeg") + + +@router.get("/{camera_id}/latest_image", status_code=status.HTTP_200_OK, summary="Get the last stored image for a pose") +async def proxy_latest_image( + pose: int = Query(..., + description="Pose index whose cached image to retrieve"), + quality: int = Query(default=95, ge=1, le=100, + description="JPEG quality (1–100)"), + camera: Camera = Depends(_require_read), +) -> Response: + device_ip, camera_ip = _device_config(camera) + data = await camera_client.get_latest_image(device_ip, camera_ip, pose, quality) + if data is None: + return Response(status_code=status.HTTP_204_NO_CONTENT) + return Response(content=data, media_type="image/jpeg") + + +# ── Control ─────────────────────────────────────────────────────────────────── + + +@router.post("/{camera_id}/control/move", status_code=status.HTTP_200_OK, summary="Move the camera") +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)"), + camera: Camera = Depends(_require_write), +) -> Any: + device_ip, camera_ip = _device_config(camera) + return await camera_client.move( + device_ip, camera_ip, direction=direction, speed=speed, pose_id=pose_id, degrees=degrees + ) + + +@router.post("/{camera_id}/control/stop", status_code=status.HTTP_200_OK, summary="Stop camera movement") +async def proxy_stop(camera: Camera = Depends(_require_write)) -> Any: + device_ip, camera_ip = _device_config(camera) + return await camera_client.stop(device_ip, camera_ip) + + +@router.get("/{camera_id}/control/presets", status_code=status.HTTP_200_OK, summary="List available presets") +async def proxy_list_presets(camera: Camera = Depends(_require_read)) -> Any: + device_ip, camera_ip = _device_config(camera) + return await camera_client.list_presets(device_ip, camera_ip) + + +@router.post("/{camera_id}/control/preset", status_code=status.HTTP_200_OK, summary="Set a preset position") +async def proxy_set_preset( + idx: int | None = Query( + default=None, description="Preset slot index to write (adapter picks free slot if omitted)" + ), + camera: Camera = Depends(_require_write), +) -> Any: + device_ip, camera_ip = _device_config(camera) + return await camera_client.set_preset(device_ip, camera_ip, idx=idx) + + +@router.post("/{camera_id}/control/zoom/{level}", status_code=status.HTTP_200_OK, summary="Zoom the camera") +async def proxy_zoom( + level: int = Path(..., ge=0, le=64, description="Zoom level (0–64)"), + camera: Camera = Depends(_require_write), +) -> Any: + device_ip, camera_ip = _device_config(camera) + return await camera_client.zoom(device_ip, camera_ip, level) + + +# ── Focus ───────────────────────────────────────────────────────────────────── + + +@router.post("/{camera_id}/focus/manual", status_code=status.HTTP_200_OK, summary="Set manual focus position") +async def proxy_manual_focus( + position: int = Query(..., description="Focus motor position (0–1000)"), + camera: Camera = Depends(_require_write), +) -> Any: + device_ip, camera_ip = _device_config(camera) + return await camera_client.manual_focus(device_ip, camera_ip, position) + + +@router.post("/{camera_id}/focus/autofocus", status_code=status.HTTP_200_OK, summary="Toggle autofocus") +async def proxy_set_autofocus( + disable: bool = Query( + default=True, description="True to disable autofocus (enable manual), False to re-enable it"), + camera: Camera = Depends(_require_write), +) -> Any: + device_ip, camera_ip = _device_config(camera) + return await camera_client.set_autofocus(device_ip, camera_ip, disable) + + +@router.get("/{camera_id}/focus/status", status_code=status.HTTP_200_OK, summary="Get focus status") +async def proxy_focus_status(camera: Camera = Depends(_require_read)) -> Any: + device_ip, camera_ip = _device_config(camera) + return await camera_client.get_focus_status(device_ip, camera_ip) + + +@router.post("/{camera_id}/focus/optimize", status_code=status.HTTP_200_OK, summary="Run focus optimization") +async def proxy_focus_finder( + save_images: bool = Query( + default=False, description="Save intermediate frames captured during focus search"), + camera: Camera = Depends(_require_write), +) -> Any: + device_ip, camera_ip = _device_config(camera) + return await camera_client.run_focus_finder(device_ip, camera_ip, save_images) + + +# ── Patrol ──────────────────────────────────────────────────────────────────── + + +@router.post("/{camera_id}/patrol/start", status_code=status.HTTP_200_OK, summary="Start patrol") +async def proxy_start_patrol(camera: Camera = Depends(_require_write)) -> Any: + device_ip, camera_ip = _device_config(camera) + return await camera_client.start_patrol(device_ip, camera_ip) + + +@router.post("/{camera_id}/patrol/stop", status_code=status.HTTP_200_OK, summary="Stop patrol") +async def proxy_stop_patrol(camera: Camera = Depends(_require_write)) -> Any: + device_ip, camera_ip = _device_config(camera) + return await camera_client.stop_patrol(device_ip, camera_ip) + + +@router.get("/{camera_id}/patrol/status", status_code=status.HTTP_200_OK, summary="Get patrol status") +async def proxy_patrol_status(camera: Camera = Depends(_require_read)) -> Any: + device_ip, camera_ip = _device_config(camera) + return await camera_client.get_patrol_status(device_ip, camera_ip) + + +# ── Stream ──────────────────────────────────────────────────────────────────── + + +@router.post("/{camera_id}/stream/start", status_code=status.HTTP_200_OK, summary="Start video stream") +async def proxy_start_stream(camera: Camera = Depends(_require_write)) -> Any: + device_ip, camera_ip = _device_config(camera) + return await camera_client.start_stream(device_ip, camera_ip) + + +@router.post("/{camera_id}/stream/stop", status_code=status.HTTP_200_OK, summary="Stop video stream") +async def proxy_stop_stream(camera: Camera = Depends(_require_write)) -> Any: + device_ip, _ = _device_config(camera) + return await camera_client.stop_stream(device_ip) + + +@router.get("/{camera_id}/stream/status", status_code=status.HTTP_200_OK, summary="Get stream status") +async def proxy_stream_status(camera: Camera = Depends(_require_read)) -> Any: + device_ip, _ = _device_config(camera) + return await camera_client.get_stream_status(device_ip) + + +@router.get("/{camera_id}/stream/is_running", status_code=status.HTTP_200_OK, summary="Check if stream is running") +async def proxy_is_stream_running(camera: Camera = Depends(_require_read)) -> Any: + device_ip, camera_ip = _device_config(camera) + return await camera_client.is_stream_running(device_ip, camera_ip) From eb9e7624157782b5a8b203be2e7f39c99ec30288 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:02:10 +0100 Subject: [PATCH 06/14] added to router --- src/app/api/api_v1/router.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/api/api_v1/router.py b/src/app/api/api_v1/router.py index 1bdbe27f..e9cb614b 100644 --- a/src/app/api/api_v1/router.py +++ b/src/app/api/api_v1/router.py @@ -7,6 +7,7 @@ from app.api.api_v1.endpoints import ( alerts, + camera_proxy, cameras, detections, login, @@ -22,6 +23,7 @@ api_router.include_router(login.router, prefix="/login", tags=["login"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(cameras.router, prefix="/cameras", tags=["cameras"]) +api_router.include_router(camera_proxy.router, prefix="/cameras", tags=["camera-proxy"]) api_router.include_router(poses.router, prefix="/poses", tags=["poses"]) api_router.include_router(occlusion_masks.router, prefix="/occlusion_masks", tags=["occlusion_masks"]) api_router.include_router(detections.router, prefix="/detections", tags=["detections"]) From b51d763c448a7c358b67a1d1773308e6fdeedd1b Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:02:27 +0100 Subject: [PATCH 07/14] draft tests --- src/tests/endpoints/test_camera_proxy.py | 250 +++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 src/tests/endpoints/test_camera_proxy.py diff --git a/src/tests/endpoints/test_camera_proxy.py b/src/tests/endpoints/test_camera_proxy.py new file mode 100644 index 00000000..b0695387 --- /dev/null +++ b/src/tests/endpoints/test_camera_proxy.py @@ -0,0 +1,250 @@ +from unittest.mock import AsyncMock, patch + +import pytest +import pytest_asyncio +from fastapi import HTTPException +from httpx import AsyncClient +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models import Camera + +# A camera that has device_ip and camera_ip configured (org1, so admin & agent can access it). +CONFIGURED_CAM_ID = 10 +CONFIGURED_CAM = { + "id": CONFIGURED_CAM_ID, + "organization_id": 1, + "name": "cam-configured", + "angle_of_view": 90.0, + "elevation": 100.0, + "lat": 44.0, + "lon": 4.0, + "device_ip": "192.168.1.100", + "camera_ip": "192.168.1.101", +} + +# Fake JPEG bytes (minimal valid header is enough for content-type tests) +FAKE_JPEG = b"\xff\xd8\xff" + b"\x00" * 64 + + +@pytest_asyncio.fixture() +async def configured_camera_session(camera_session: AsyncSession): + """camera_session extended with one camera that has device_ip / camera_ip set.""" + camera_session.add(Camera(**CONFIGURED_CAM)) + await camera_session.commit() + yield camera_session + await camera_session.rollback() + + +def _auth(user_idx: int) -> dict: + return pytest.get_token( + pytest.user_table[user_idx]["id"], + pytest.user_table[user_idx]["role"].split(), + pytest.user_table[user_idx]["organization_id"], + ) + + +# ── Auth / Organisation isolation ───────────────────────────────────────────── +# +# Users in conftest: +# [0] admin – org 1 +# [1] agent – org 1 +# [2] user – org 2 +# +# Cameras in conftest (no device_ip / camera_ip): +# id=1 org 1 (cam-1) +# id=2 org 2 (cam-2) +# +# Expected flow for a correctly-scoped caller on a cam without device config: +# auth passes → org check passes → _device_config raises 409 +# This lets us use the unconfigured cameras to cover all auth cases for free. + + +@pytest.mark.parametrize( + ("user_idx", "cam_id", "status_code", "status_detail"), + [ + # No token + (None, 1, 401, "Not authenticated"), + # Camera does not exist + (0, 999, 404, "Table Camera has no corresponding entry."), + # Cross-org: agent org1 → cam-2 org2 + (1, 2, 403, "Access forbidden."), + # Cross-org: user org2 → cam-1 org1 + (2, 1, 403, "Access forbidden."), + # Correct scope + own org → no device config → 409 + (0, 1, 409, "Camera device connection is not configured"), + (1, 1, 409, "Camera device connection is not configured"), + (2, 2, 409, "Camera device connection is not configured"), + ], +) +@pytest.mark.asyncio +async def test_proxy_read_auth( + async_client: AsyncClient, + camera_session: AsyncSession, + user_idx, + cam_id, + status_code, + status_detail, +): + auth = _auth(user_idx) if user_idx is not None else None + response = await async_client.get(f"/cameras/{cam_id}/health", headers=auth) + assert response.status_code == status_code + if status_detail: + assert status_detail in response.json()["detail"] + + +@pytest.mark.parametrize( + ("user_idx", "cam_id", "status_code", "status_detail"), + [ + # No token + (None, 1, 401, "Not authenticated"), + # USER role is not allowed on write endpoints + (2, 2, 403, "Incompatible token scope."), + # Cross-org: agent org1 → cam-2 org2 + (1, 2, 403, "Access forbidden."), + # Correct scope + own org → no device config → 409 + (0, 1, 409, "Camera device connection is not configured"), + (1, 1, 409, "Camera device connection is not configured"), + ], +) +@pytest.mark.asyncio +async def test_proxy_write_auth( + async_client: AsyncClient, + camera_session: AsyncSession, + user_idx, + cam_id, + status_code, + status_detail, +): + auth = _auth(user_idx) if user_idx is not None else None + response = await async_client.post(f"/cameras/{cam_id}/control/move", headers=auth) + assert response.status_code == status_code + if status_detail: + assert status_detail in response.json()["detail"] + + +# ── Device not configured → 409 on every route ─────────────────────────────── + + +@pytest.mark.parametrize( + "path", + [ + "/cameras/1/health", + "/cameras/1/cameras_list", + "/cameras/1/camera_infos", + "/cameras/1/capture", + "/cameras/1/latest_image?pose=0", + "/cameras/1/control/presets", + "/cameras/1/focus/status", + "/cameras/1/patrol/status", + "/cameras/1/stream/status", + "/cameras/1/stream/is_running", + ], +) +@pytest.mark.asyncio +async def test_proxy_unconfigured_get(async_client: AsyncClient, camera_session: AsyncSession, path: str): + response = await async_client.get(path, headers=_auth(0)) + assert response.status_code == 409 + assert "not configured" in response.json()["detail"] + + +@pytest.mark.parametrize( + "path", + [ + "/cameras/1/control/move", + "/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", + ], +) +@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)) + assert response.status_code == 409 + assert "not configured" in response.json()["detail"] + + +# ── Device error forwarding ─────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_proxy_device_timeout(async_client: AsyncClient, configured_camera_session: AsyncSession): + with patch( + "app.services.camera_client.get_health", + side_effect=HTTPException(status_code=504, detail="Camera device is not responding."), + ): + response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/health", headers=_auth(0)) + assert response.status_code == 504 + assert "not responding" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_proxy_device_unreachable(async_client: AsyncClient, configured_camera_session: AsyncSession): + with patch( + "app.services.camera_client.get_health", + side_effect=HTTPException(status_code=502, detail="Failed to reach camera device."), + ): + response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/health", headers=_auth(0)) + assert response.status_code == 502 + assert "reach camera device" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_proxy_device_error_forwarded(async_client: AsyncClient, configured_camera_session: AsyncSession): + """A 404 from the device (unknown camera_ip) is forwarded as-is.""" + with patch( + "app.services.camera_client.get_health", + side_effect=HTTPException(status_code=404, detail="Unknown camera"), + ): + response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/health", headers=_auth(0)) + assert response.status_code == 404 + + +# ── Binary (JPEG) responses ─────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_proxy_capture_returns_jpeg(async_client: AsyncClient, configured_camera_session: AsyncSession): + with patch("app.services.camera_client.capture", new=AsyncMock(return_value=FAKE_JPEG)): + response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/capture", headers=_auth(0)) + assert response.status_code == 200 + assert response.headers["content-type"] == "image/jpeg" + assert response.content == FAKE_JPEG + + +@pytest.mark.asyncio +async def test_proxy_latest_image_returns_jpeg(async_client: AsyncClient, configured_camera_session: AsyncSession): + with patch("app.services.camera_client.get_latest_image", new=AsyncMock(return_value=FAKE_JPEG)): + response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/latest_image?pose=0", headers=_auth(0)) + assert response.status_code == 200 + assert response.headers["content-type"] == "image/jpeg" + assert response.content == FAKE_JPEG + + +@pytest.mark.asyncio +async def test_proxy_latest_image_no_content(async_client: AsyncClient, configured_camera_session: AsyncSession): + """When the device has no cached image for the requested pose it returns 204.""" + with patch("app.services.camera_client.get_latest_image", new=AsyncMock(return_value=None)): + response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/latest_image?pose=0", headers=_auth(0)) + assert response.status_code == 204 + + +# ── device_ip / camera_ip must never appear in API responses ───────────────── + + +@pytest.mark.asyncio +async def test_device_ip_not_leaked_in_camera_response( + async_client: AsyncClient, configured_camera_session: AsyncSession, pose_session: AsyncSession +): + """GET /cameras/{id} must not expose device_ip or camera_ip even for a configured camera.""" + response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}", headers=_auth(0)) + assert response.status_code == 200 + data = response.json() + assert "device_ip" not in data + assert "camera_ip" not in data From eb6cd04c310e2017e636726a3116120beec93418 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:49:52 +0100 Subject: [PATCH 08/14] Updates schemas and preserve output structure --- src/app/api/api_v1/endpoints/cameras.py | 31 +++++++++++++++---------- src/app/schemas/cameras.py | 15 ++++++++++-- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/app/api/api_v1/endpoints/cameras.py b/src/app/api/api_v1/endpoints/cameras.py index 200e0170..3d402664 100644 --- a/src/app/api/api_v1/endpoints/cameras.py +++ b/src/app/api/api_v1/endpoints/cameras.py @@ -20,6 +20,7 @@ CameraDeviceConfig, CameraEdit, CameraName, + CameraOut, CameraRead, LastActive, LastImage, @@ -37,11 +38,12 @@ async def register_camera( payload: CameraCreate, cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT]), -) -> Camera: +) -> CameraOut: telemetry_client.capture(token_payload.sub, event="cameras-create", properties={"device_login": payload.name}) if token_payload.organization_id != payload.organization_id and UserRole.ADMIN not in token_payload.scopes: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") - return await cameras.create(payload) + camera = await cameras.create(payload) + return CameraOut(**camera.model_dump()) @router.get("/{camera_id}", status_code=status.HTTP_200_OK, summary="Fetch the information of a specific camera") @@ -116,9 +118,10 @@ async def get_poses(cam: Camera) -> list[PoseReadWithoutImgInfo]: async def heartbeat( cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[Role.CAMERA]), -) -> Camera: +) -> CameraOut: # telemetry_client.capture(f"camera|{token_payload.sub}", event="cameras-heartbeat") - return await cameras.update(token_payload.sub, LastActive(last_active_at=datetime.utcnow())) + camera = await cameras.update(token_payload.sub, LastActive(last_active_at=datetime.utcnow())) + return CameraOut(**camera.model_dump()) @router.patch("/image", status_code=status.HTTP_200_OK, summary="Update last image of a camera") @@ -126,7 +129,7 @@ async def update_image( file: UploadFile = File(..., alias="file"), cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[Role.CAMERA]), -) -> Camera: +) -> CameraOut: # telemetry_client.capture(f"camera|{token_payload.sub}", event="cameras-image") cam = cast(Camera, await cameras.get(token_payload.sub, strict=True)) bucket_key = await upload_file(file, token_payload.organization_id, token_payload.sub) @@ -134,7 +137,8 @@ async def update_image( if isinstance(cam.last_image, str): s3_service.get_bucket(s3_service.resolve_bucket_name(token_payload.organization_id)).delete_file(cam.last_image) # Update the DB entry - return await cameras.update(token_payload.sub, LastImage(last_image=bucket_key, last_active_at=datetime.utcnow())) + camera = await cameras.update(token_payload.sub, LastImage(last_image=bucket_key, last_active_at=datetime.utcnow())) + return CameraOut(**camera.model_dump()) @router.post("/{camera_id}/token", status_code=status.HTTP_200_OK, summary="Request an access token for the camera") @@ -157,9 +161,10 @@ async def update_camera_location( camera_id: int = Path(..., gt=0), cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN]), -) -> Camera: +) -> CameraOut: telemetry_client.capture(token_payload.sub, event="cameras-update-location", properties={"camera_id": camera_id}) - return await cameras.update(camera_id, payload) + camera = await cameras.update(camera_id, payload) + return CameraOut(**camera.model_dump()) @router.patch("/{camera_id}/name", status_code=status.HTTP_200_OK, summary="Update the name of a camera") @@ -168,9 +173,10 @@ async def update_camera_name( camera_id: int = Path(..., gt=0), cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN]), -) -> Camera: +) -> CameraOut: telemetry_client.capture(token_payload.sub, event="cameras-update-name", properties={"camera_id": camera_id}) - return await cameras.update(camera_id, payload) + camera = await cameras.update(camera_id, payload) + return CameraOut(**camera.model_dump()) @router.delete("/{camera_id}", status_code=status.HTTP_200_OK, summary="Delete a camera") @@ -191,8 +197,9 @@ async def update_camera_device_config( camera_id: int = Path(..., gt=0), cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN]), -) -> Camera: +) -> CameraOut: telemetry_client.capture( token_payload.sub, event="cameras-update-device-config", properties={"camera_id": camera_id} ) - return await cameras.update(camera_id, payload) + camera = await cameras.update(camera_id, payload) + return CameraOut(**camera.model_dump()) diff --git a/src/app/schemas/cameras.py b/src/app/schemas/cameras.py index d2769746..d0f4e82e 100644 --- a/src/app/schemas/cameras.py +++ b/src/app/schemas/cameras.py @@ -67,10 +67,21 @@ class CameraDeviceConfig(BaseModel): ) -class CameraRead(CameraCreate): +class CameraOut(CameraCreate): + """ + Returned by mutation endpoints + """ + id: int last_active_at: datetime | None last_image: str | None + created_at: datetime + + +class CameraRead(CameraOut): + """ + Returned by read endpoints + """ + last_image_url: str | None = Field(None, description="URL of the last image of the camera") poses: list[PoseReadWithoutImgInfo] = Field(default_factory=list) - created_at: datetime From f9da2b5f6739385e0a916380a48dfbf21074faf1 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:50:04 +0100 Subject: [PATCH 09/14] style --- pyproject.toml | 2 + src/app/api/api_v1/endpoints/camera_proxy.py | 55 +++++++------------- src/tests/endpoints/test_camera_proxy.py | 6 +-- 3 files changed, 24 insertions(+), 39 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8dbc7e61..da1a0c10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,6 +134,8 @@ known-third-party = ["fastapi"] "scripts/**.py" = ["D", "T201", "S101", "ANN", "RUF030"] ".github/**.py" = ["D", "T201", "ANN"] "src/tests/**.py" = ["D103", "CPY001", "S101", "T201", "ANN001", "ANN201", "ANN202", "ARG001", "RUF030"] +"src/app/services/camera_client.py" = ["ANN401"] +"src/app/api/api_v1/endpoints/camera_proxy.py" = ["ANN401"] "src/migrations/versions/**.py" = ["CPY001"] "src/migrations/**.py" = ["ANN"] "src/app/main.py" = ["ANN"] diff --git a/src/app/api/api_v1/endpoints/camera_proxy.py b/src/app/api/api_v1/endpoints/camera_proxy.py index 7ab34b0c..434c69d7 100644 --- a/src/app/api/api_v1/endpoints/camera_proxy.py +++ b/src/app/api/api_v1/endpoints/camera_proxy.py @@ -23,26 +23,22 @@ async def _require_read( camera_id: int = Path(..., gt=0), cameras: CameraCRUD = Depends(get_camera_crud), - token_payload: TokenPayload = Security( - get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]), + token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]), ) -> Camera: camera = cast(Camera, await cameras.get(camera_id, strict=True)) if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") return camera async def _require_write( camera_id: int = Path(..., gt=0), cameras: CameraCRUD = Depends(get_camera_crud), - token_payload: TokenPayload = Security( - get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT]), + token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT]), ) -> Camera: camera = cast(Camera, await cameras.get(camera_id, strict=True)) if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") return camera @@ -82,18 +78,12 @@ 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"), - 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"), - width: int | None = Query( - default=None, description="Resize output to this width (px), preserving aspect ratio"), - quality: int = Query(default=95, ge=1, le=100, - description="JPEG quality (1–100)"), + pos_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"), + width: int | None = Query(default=None, description="Resize output to this width (px), preserving aspect ratio"), + quality: int = Query(default=95, ge=1, le=100, description="JPEG quality (1-100)"), camera: Camera = Depends(_require_read), ) -> Response: device_ip, camera_ip = _device_config(camera) @@ -112,10 +102,8 @@ async def proxy_capture( @router.get("/{camera_id}/latest_image", status_code=status.HTTP_200_OK, summary="Get the last stored image for a pose") async def proxy_latest_image( - pose: int = Query(..., - description="Pose index whose cached image to retrieve"), - quality: int = Query(default=95, ge=1, le=100, - description="JPEG quality (1–100)"), + pose: int = Query(..., description="Pose index whose cached image to retrieve"), + quality: int = Query(default=95, ge=1, le=100, description="JPEG quality (1-100)"), camera: Camera = Depends(_require_read), ) -> Response: device_ip, camera_ip = _device_config(camera) @@ -130,13 +118,10 @@ async def proxy_latest_image( @router.post("/{camera_id}/control/move", status_code=status.HTTP_200_OK, summary="Move the camera") async def proxy_move( - direction: str | None = Query( - default=None, description="Direction: Left, Right, Up, Down"), + 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)"), + 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)"), camera: Camera = Depends(_require_write), ) -> Any: device_ip, camera_ip = _device_config(camera) @@ -170,7 +155,7 @@ async def proxy_set_preset( @router.post("/{camera_id}/control/zoom/{level}", status_code=status.HTTP_200_OK, summary="Zoom the camera") async def proxy_zoom( - level: int = Path(..., ge=0, le=64, description="Zoom level (0–64)"), + level: int = Path(..., ge=0, le=64, description="Zoom level (0-64)"), camera: Camera = Depends(_require_write), ) -> Any: device_ip, camera_ip = _device_config(camera) @@ -182,7 +167,7 @@ async def proxy_zoom( @router.post("/{camera_id}/focus/manual", status_code=status.HTTP_200_OK, summary="Set manual focus position") async def proxy_manual_focus( - position: int = Query(..., description="Focus motor position (0–1000)"), + position: int = Query(..., description="Focus motor position (0-1000)"), camera: Camera = Depends(_require_write), ) -> Any: device_ip, camera_ip = _device_config(camera) @@ -191,8 +176,7 @@ async def proxy_manual_focus( @router.post("/{camera_id}/focus/autofocus", status_code=status.HTTP_200_OK, summary="Toggle autofocus") async def proxy_set_autofocus( - disable: bool = Query( - default=True, description="True to disable autofocus (enable manual), False to re-enable it"), + disable: bool = Query(default=True, description="True to disable autofocus (enable manual), False to re-enable it"), camera: Camera = Depends(_require_write), ) -> Any: device_ip, camera_ip = _device_config(camera) @@ -207,8 +191,7 @@ async def proxy_focus_status(camera: Camera = Depends(_require_read)) -> Any: @router.post("/{camera_id}/focus/optimize", status_code=status.HTTP_200_OK, summary="Run focus optimization") async def proxy_focus_finder( - save_images: bool = Query( - default=False, description="Save intermediate frames captured during focus search"), + save_images: bool = Query(default=False, description="Save intermediate frames captured during focus search"), camera: Camera = Depends(_require_write), ) -> Any: device_ip, camera_ip = _device_config(camera) diff --git a/src/tests/endpoints/test_camera_proxy.py b/src/tests/endpoints/test_camera_proxy.py index b0695387..1e1f1895 100644 --- a/src/tests/endpoints/test_camera_proxy.py +++ b/src/tests/endpoints/test_camera_proxy.py @@ -46,9 +46,9 @@ def _auth(user_idx: int) -> dict: # ── Auth / Organisation isolation ───────────────────────────────────────────── # # Users in conftest: -# [0] admin – org 1 -# [1] agent – org 1 -# [2] user – org 2 +# [0] admin - org 1 +# [1] agent - org 1 +# [2] user - org 2 # # Cameras in conftest (no device_ip / camera_ip): # id=1 org 1 (cam-1) From 3cceaecfcb0f32c999967bc77da3ccc1c3bb2423 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:32:18 +0100 Subject: [PATCH 10/14] try fix codecov --- src/tests/endpoints/test_camera_proxy.py | 39 +++++++ src/tests/services/test_camera_client.py | 133 +++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 src/tests/services/test_camera_client.py diff --git a/src/tests/endpoints/test_camera_proxy.py b/src/tests/endpoints/test_camera_proxy.py index 1e1f1895..a0447f30 100644 --- a/src/tests/endpoints/test_camera_proxy.py +++ b/src/tests/endpoints/test_camera_proxy.py @@ -248,3 +248,42 @@ async def test_device_ip_not_leaked_in_camera_response( data = response.json() assert "device_ip" not in data assert "camera_ip" not in data + + +# ── Happy-path coverage for all remaining proxy endpoints ──────────────────── + + +@pytest.mark.parametrize( + ("path", "method", "client_fn"), + [ + (f"/cameras/{CONFIGURED_CAM_ID}/cameras_list", "get", "list_cameras"), + (f"/cameras/{CONFIGURED_CAM_ID}/camera_infos", "get", "get_camera_infos"), + (f"/cameras/{CONFIGURED_CAM_ID}/control/presets", "get", "list_presets"), + (f"/cameras/{CONFIGURED_CAM_ID}/focus/status", "get", "get_focus_status"), + (f"/cameras/{CONFIGURED_CAM_ID}/patrol/status", "get", "get_patrol_status"), + (f"/cameras/{CONFIGURED_CAM_ID}/stream/status", "get", "get_stream_status"), + (f"/cameras/{CONFIGURED_CAM_ID}/stream/is_running", "get", "is_stream_running"), + (f"/cameras/{CONFIGURED_CAM_ID}/control/move", "post", "move"), + (f"/cameras/{CONFIGURED_CAM_ID}/control/stop", "post", "stop"), + (f"/cameras/{CONFIGURED_CAM_ID}/control/preset", "post", "set_preset"), + (f"/cameras/{CONFIGURED_CAM_ID}/control/zoom/5", "post", "zoom"), + (f"/cameras/{CONFIGURED_CAM_ID}/focus/manual?position=500", "post", "manual_focus"), + (f"/cameras/{CONFIGURED_CAM_ID}/focus/autofocus", "post", "set_autofocus"), + (f"/cameras/{CONFIGURED_CAM_ID}/focus/optimize", "post", "run_focus_finder"), + (f"/cameras/{CONFIGURED_CAM_ID}/patrol/start", "post", "start_patrol"), + (f"/cameras/{CONFIGURED_CAM_ID}/patrol/stop", "post", "stop_patrol"), + (f"/cameras/{CONFIGURED_CAM_ID}/stream/start", "post", "start_stream"), + (f"/cameras/{CONFIGURED_CAM_ID}/stream/stop", "post", "stop_stream"), + ], +) +@pytest.mark.asyncio +async def test_proxy_happy_path( + async_client: AsyncClient, + configured_camera_session: AsyncSession, + path: str, + method: str, + client_fn: str, +): + with patch(f"app.services.camera_client.{client_fn}", new=AsyncMock(return_value={"ok": True})): + response = await getattr(async_client, method)(path, headers=_auth(0)) + assert response.status_code == 200 diff --git a/src/tests/services/test_camera_client.py b/src/tests/services/test_camera_client.py new file mode 100644 index 00000000..3e278eff --- /dev/null +++ b/src/tests/services/test_camera_client.py @@ -0,0 +1,133 @@ +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +from fastapi import HTTPException + +from app.services import camera_client + +DEVICE_IP = "192.168.1.100" +CAMERA_IP = "192.168.1.101" +FAKE_JPEG = b"\xff\xd8\xff" + b"\x00" * 16 + + +@contextmanager +def _mock_httpx(*, status_code: int = 200, json_body=None, content: bytes = b"", side_effect=None, raise_status=None): + """Patch httpx.AsyncClient so _request never opens a real connection.""" + mock_response = MagicMock() + mock_response.status_code = status_code + mock_response.json.return_value = json_body if json_body is not None else {} + mock_response.content = content + if raise_status is not None: + mock_response.raise_for_status.side_effect = raise_status + else: + mock_response.raise_for_status = MagicMock() + + mock_http = AsyncMock() + mock_http.request = ( + AsyncMock(side_effect=side_effect) if side_effect is not None else AsyncMock(return_value=mock_response) + ) + + mock_instance = MagicMock() + mock_instance.__aenter__ = AsyncMock(return_value=mock_http) + mock_instance.__aexit__ = AsyncMock(return_value=False) + + with patch("app.services.camera_client.httpx.AsyncClient", return_value=mock_instance): + yield mock_http + + +# ── _request error paths ────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_request_timeout(): + with _mock_httpx(side_effect=httpx.TimeoutException("")), pytest.raises(HTTPException) as exc_info: + await camera_client.get_health(DEVICE_IP) + assert exc_info.value.status_code == 504 + assert "not responding" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_request_connection_error(): + with _mock_httpx(side_effect=httpx.ConnectError("")), pytest.raises(HTTPException) as exc_info: + await camera_client.get_health(DEVICE_IP) + assert exc_info.value.status_code == 502 + assert "reach camera device" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_request_http_status_forwarded(): + err_resp = MagicMock() + err_resp.status_code = 404 + err_resp.text = "Unknown camera" + with ( + _mock_httpx(raise_status=httpx.HTTPStatusError("", request=MagicMock(), response=err_resp)), + pytest.raises(HTTPException) as exc_info, + ): + await camera_client.get_health(DEVICE_IP) + assert exc_info.value.status_code == 404 + assert "Unknown camera" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_request_204_returns_none(): + with _mock_httpx(status_code=204): + result = await camera_client.get_latest_image(DEVICE_IP, CAMERA_IP, pose=0) + assert result is None + + +@pytest.mark.asyncio +async def test_request_raw_returns_bytes(): + with _mock_httpx(content=FAKE_JPEG): + result = await camera_client.capture(DEVICE_IP, CAMERA_IP) + assert result == FAKE_JPEG + + +@pytest.mark.asyncio +async def test_capture_optional_params(): + """Covers the pos_id / max_age_ms / width conditional branches in capture().""" + with _mock_httpx(content=FAKE_JPEG): + result = await camera_client.capture(DEVICE_IP, CAMERA_IP, pos_id=1, max_age_ms=200, width=640) + assert result == FAKE_JPEG + + +# ── Individual client functions ─────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("func", "args", "kwargs"), + [ + (camera_client.get_health, (DEVICE_IP,), {}), + (camera_client.list_cameras, (DEVICE_IP,), {}), + (camera_client.get_camera_infos, (DEVICE_IP,), {}), + # move: no optional params + (camera_client.move, (DEVICE_IP, CAMERA_IP), {}), + # move: all optional params (covers direction / pose_id / degrees branches) + (camera_client.move, (DEVICE_IP, CAMERA_IP), {"direction": "Left", "pose_id": 1, "degrees": 45.0}), + (camera_client.stop, (DEVICE_IP, CAMERA_IP), {}), + (camera_client.list_presets, (DEVICE_IP, CAMERA_IP), {}), + # set_preset: without idx (None branch) + (camera_client.set_preset, (DEVICE_IP, CAMERA_IP), {}), + # set_preset: with idx + (camera_client.set_preset, (DEVICE_IP, CAMERA_IP), {"idx": 2}), + (camera_client.zoom, (DEVICE_IP, CAMERA_IP, 10), {}), + (camera_client.manual_focus, (DEVICE_IP, CAMERA_IP, 500), {}), + (camera_client.set_autofocus, (DEVICE_IP, CAMERA_IP), {}), + (camera_client.get_focus_status, (DEVICE_IP, CAMERA_IP), {}), + (camera_client.run_focus_finder, (DEVICE_IP, CAMERA_IP), {}), + (camera_client.run_focus_finder, (DEVICE_IP, CAMERA_IP), {"save_images": True}), + (camera_client.start_patrol, (DEVICE_IP, CAMERA_IP), {}), + (camera_client.stop_patrol, (DEVICE_IP, CAMERA_IP), {}), + (camera_client.get_patrol_status, (DEVICE_IP, CAMERA_IP), {}), + (camera_client.start_stream, (DEVICE_IP, CAMERA_IP), {}), + (camera_client.stop_stream, (DEVICE_IP,), {}), + (camera_client.get_stream_status, (DEVICE_IP,), {}), + (camera_client.is_stream_running, (DEVICE_IP, CAMERA_IP), {}), + ], +) +@pytest.mark.asyncio +async def test_client_function_success(func, args, kwargs): + with _mock_httpx(json_body={"ok": True}): + result = await func(*args, **kwargs) + assert result == {"ok": True} From 9a4bd9454a544b13eba1570970e71808e921a1fa Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:33:34 +0100 Subject: [PATCH 11/14] use api camera pyro client instead of recreating --- poetry.lock | 141 ++++++++++- pyproject.toml | 2 +- src/Dockerfile | 2 +- src/app/api/api_v1/endpoints/camera_proxy.py | 92 +++++-- src/app/services/camera_client.py | 250 ------------------- src/tests/endpoints/test_camera_proxy.py | 70 +++--- src/tests/services/test_camera_client.py | 133 ---------- 7 files changed, 238 insertions(+), 452 deletions(-) delete mode 100644 src/app/services/camera_client.py delete mode 100644 src/tests/services/test_camera_client.py diff --git a/poetry.lock b/poetry.lock index d052e730..2916ba3c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "aiosqlite" @@ -1348,6 +1348,115 @@ bcrypt = ["bcrypt (>=3.1.0)"] build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"] totp = ["cryptography"] +[[package]] +name = "pillow" +version = "12.1.1" +description = "Python Imaging Library (fork)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0"}, + {file = "pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4"}, + {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e"}, + {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff"}, + {file = "pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40"}, + {file = "pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23"}, + {file = "pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9"}, + {file = "pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32"}, + {file = "pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b"}, + {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5"}, + {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d"}, + {file = "pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c"}, + {file = "pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563"}, + {file = "pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80"}, + {file = "pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052"}, + {file = "pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0"}, + {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3"}, + {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35"}, + {file = "pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a"}, + {file = "pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6"}, + {file = "pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6"}, + {file = "pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60"}, + {file = "pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717"}, + {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a"}, + {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029"}, + {file = "pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b"}, + {file = "pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1"}, + {file = "pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a"}, + {file = "pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da"}, + {file = "pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13"}, + {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf"}, + {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524"}, + {file = "pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986"}, + {file = "pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c"}, + {file = "pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642"}, + {file = "pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd"}, + {file = "pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e"}, + {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0"}, + {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb"}, + {file = "pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f"}, + {file = "pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15"}, + {file = "pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f"}, + {file = "pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8"}, + {file = "pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586"}, + {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce"}, + {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8"}, + {file = "pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36"}, + {file = "pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b"}, + {file = "pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e"}, + {file = "pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +xmp = ["defusedxml"] + [[package]] name = "platformdirs" version = "4.5.1" @@ -1751,6 +1860,30 @@ files = [ [package.dependencies] certifi = "*" +[[package]] +name = "pyro-camera-api-client" +version = "0.1.0" +description = "Python client to interact with the Pyro Camera API" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [] +develop = false + +[package.dependencies] +pillow = "*" +requests = "*" + +[package.extras] +dev = ["httpx", "mypy", "pytest", "ruff"] + +[package.source] +type = "git" +url = "https://github.com/pyronear/pyro-engine.git" +reference = "develop" +resolved_reference = "e2a6de993902cd589de031792fbfe27a898e73c1" +subdirectory = "pyro_camera_api/client" + [[package]] name = "pytest" version = "8.3.5" @@ -2061,10 +2194,10 @@ files = [ ] [package.dependencies] -botocore = ">=1.37.4,<2.0a.0" +botocore = ">=1.37.4,<2.0a0" [package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] [[package]] name = "sentry-sdk" @@ -2510,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 = "aa48b3d2633da3dff397471f72c63e0bbce90edde9c7a9235101518e549770b6" +content-hash = "5c3801f7e9bc66dc943e81e09dff2ed30b1959b85c5ed719a7589e8673adb8e8" diff --git a/pyproject.toml b/pyproject.toml index da1a0c10..6a542374 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +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"} geopy = "^2.4.0" networkx = "^3.2.0" numpy = "^1.26.0" @@ -134,7 +135,6 @@ known-third-party = ["fastapi"] "scripts/**.py" = ["D", "T201", "S101", "ANN", "RUF030"] ".github/**.py" = ["D", "T201", "ANN"] "src/tests/**.py" = ["D103", "CPY001", "S101", "T201", "ANN001", "ANN201", "ANN202", "ARG001", "RUF030"] -"src/app/services/camera_client.py" = ["ANN401"] "src/app/api/api_v1/endpoints/camera_proxy.py" = ["ANN401"] "src/migrations/versions/**.py" = ["CPY001"] "src/migrations/**.py" = ["ANN"] diff --git a/src/Dockerfile b/src/Dockerfile index d1c31790..8627a527 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -9,7 +9,7 @@ ENV PYTHONPATH="/app" # Install curl RUN apt-get -y update \ - && apt-get -y install curl libmagic1 \ + && apt-get -y install curl git libmagic1 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/src/app/api/api_v1/endpoints/camera_proxy.py b/src/app/api/api_v1/endpoints/camera_proxy.py index 434c69d7..36a9fa04 100644 --- a/src/app/api/api_v1/endpoints/camera_proxy.py +++ b/src/app/api/api_v1/endpoints/camera_proxy.py @@ -4,18 +4,51 @@ # See LICENSE or go to for full license details. +import asyncio +import io +from collections.abc import Callable +from functools import partial from typing import Any, cast +import requests from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, Security, status +from pyro_camera_api_client import PyroCameraAPIClient from app.api.dependencies import get_camera_crud, get_jwt from app.crud import CameraCRUD from app.models import Camera, UserRole from app.schemas.login import TokenPayload -from app.services import camera_client router = APIRouter() +DEVICE_PORT = 8081 +TIMEOUT = 10.0 + + +def _make_client(device_ip: str) -> PyroCameraAPIClient: + return PyroCameraAPIClient(base_url=f"http://{device_ip}:{DEVICE_PORT}", timeout=TIMEOUT) + + +async def _run_sync(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: + loop = asyncio.get_running_loop() + try: + return await loop.run_in_executor(None, partial(fn, *args, **kwargs)) + except requests.exceptions.Timeout: + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail="Camera device is not responding.", + ) + except requests.exceptions.HTTPError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail=exc.response.text, + ) + except requests.exceptions.ConnectionError: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to reach camera device.", + ) + # ── Shared helpers ──────────────────────────────────────────────────────────── @@ -58,7 +91,7 @@ def _device_config(camera: Camera) -> tuple[str, str]: @router.get("/{camera_id}/health", status_code=status.HTTP_200_OK, summary="Camera device health check") async def proxy_health(camera: Camera = Depends(_require_read)) -> Any: device_ip, _ = _device_config(camera) - return await camera_client.get_health(device_ip) + return await _run_sync(_make_client(device_ip).health) # ── Device cameras ──────────────────────────────────────────────────────────── @@ -67,13 +100,13 @@ async def proxy_health(camera: Camera = Depends(_require_read)) -> Any: @router.get("/{camera_id}/cameras_list", status_code=status.HTTP_200_OK, summary="List all cameras on the device") async def proxy_cameras_list(camera: Camera = Depends(_require_read)) -> Any: device_ip, _ = _device_config(camera) - return await camera_client.list_cameras(device_ip) + return await _run_sync(_make_client(device_ip).list_cameras) @router.get("/{camera_id}/camera_infos", status_code=status.HTTP_200_OK, summary="Get all camera infos from the device") async def proxy_camera_infos(camera: Camera = Depends(_require_read)) -> Any: device_ip, _ = _device_config(camera) - return await camera_client.get_camera_infos(device_ip) + return await _run_sync(_make_client(device_ip).get_camera_infos) @router.get("/{camera_id}/capture", status_code=status.HTTP_200_OK, summary="Capture a JPEG snapshot from the camera") @@ -87,8 +120,8 @@ async def proxy_capture( camera: Camera = Depends(_require_read), ) -> Response: device_ip, camera_ip = _device_config(camera) - data = await camera_client.capture( - device_ip, + data = await _run_sync( + _make_client(device_ip).capture_jpeg, camera_ip, pos_id=pos_id, anonymize=anonymize, @@ -107,10 +140,12 @@ async def proxy_latest_image( camera: Camera = Depends(_require_read), ) -> Response: device_ip, camera_ip = _device_config(camera) - data = await camera_client.get_latest_image(device_ip, camera_ip, pose, quality) - if data is None: + image = await _run_sync(_make_client(device_ip).get_latest_image, camera_ip, pose, quality) + if image is None: return Response(status_code=status.HTTP_204_NO_CONTENT) - return Response(content=data, media_type="image/jpeg") + buf = io.BytesIO() + image.save(buf, format="JPEG") + return Response(content=buf.getvalue(), media_type="image/jpeg") # ── Control ─────────────────────────────────────────────────────────────────── @@ -125,21 +160,26 @@ async def proxy_move( camera: Camera = Depends(_require_write), ) -> Any: device_ip, camera_ip = _device_config(camera) - return await camera_client.move( - device_ip, camera_ip, direction=direction, speed=speed, pose_id=pose_id, degrees=degrees + return await _run_sync( + _make_client(device_ip).move_camera, + camera_ip, + direction=direction, + speed=speed, + pose_id=pose_id, + degrees=degrees, ) @router.post("/{camera_id}/control/stop", status_code=status.HTTP_200_OK, summary="Stop camera movement") async def proxy_stop(camera: Camera = Depends(_require_write)) -> Any: device_ip, camera_ip = _device_config(camera) - return await camera_client.stop(device_ip, camera_ip) + return await _run_sync(_make_client(device_ip).stop_camera, camera_ip) @router.get("/{camera_id}/control/presets", status_code=status.HTTP_200_OK, summary="List available presets") async def proxy_list_presets(camera: Camera = Depends(_require_read)) -> Any: device_ip, camera_ip = _device_config(camera) - return await camera_client.list_presets(device_ip, camera_ip) + return await _run_sync(_make_client(device_ip).list_presets, camera_ip) @router.post("/{camera_id}/control/preset", status_code=status.HTTP_200_OK, summary="Set a preset position") @@ -150,7 +190,7 @@ async def proxy_set_preset( camera: Camera = Depends(_require_write), ) -> Any: device_ip, camera_ip = _device_config(camera) - return await camera_client.set_preset(device_ip, camera_ip, idx=idx) + return await _run_sync(_make_client(device_ip).set_preset, camera_ip, idx=idx) @router.post("/{camera_id}/control/zoom/{level}", status_code=status.HTTP_200_OK, summary="Zoom the camera") @@ -159,7 +199,7 @@ async def proxy_zoom( camera: Camera = Depends(_require_write), ) -> Any: device_ip, camera_ip = _device_config(camera) - return await camera_client.zoom(device_ip, camera_ip, level) + return await _run_sync(_make_client(device_ip).zoom, camera_ip, level) # ── Focus ───────────────────────────────────────────────────────────────────── @@ -171,7 +211,7 @@ async def proxy_manual_focus( camera: Camera = Depends(_require_write), ) -> Any: device_ip, camera_ip = _device_config(camera) - return await camera_client.manual_focus(device_ip, camera_ip, position) + return await _run_sync(_make_client(device_ip).set_manual_focus, camera_ip, position) @router.post("/{camera_id}/focus/autofocus", status_code=status.HTTP_200_OK, summary="Toggle autofocus") @@ -180,13 +220,13 @@ async def proxy_set_autofocus( camera: Camera = Depends(_require_write), ) -> Any: device_ip, camera_ip = _device_config(camera) - return await camera_client.set_autofocus(device_ip, camera_ip, disable) + return await _run_sync(_make_client(device_ip).set_autofocus, camera_ip, disable) @router.get("/{camera_id}/focus/status", status_code=status.HTTP_200_OK, summary="Get focus status") async def proxy_focus_status(camera: Camera = Depends(_require_read)) -> Any: device_ip, camera_ip = _device_config(camera) - return await camera_client.get_focus_status(device_ip, camera_ip) + return await _run_sync(_make_client(device_ip).get_focus_status, camera_ip) @router.post("/{camera_id}/focus/optimize", status_code=status.HTTP_200_OK, summary="Run focus optimization") @@ -195,7 +235,7 @@ async def proxy_focus_finder( camera: Camera = Depends(_require_write), ) -> Any: device_ip, camera_ip = _device_config(camera) - return await camera_client.run_focus_finder(device_ip, camera_ip, save_images) + return await _run_sync(_make_client(device_ip).run_focus_optimization, camera_ip, save_images=save_images) # ── Patrol ──────────────────────────────────────────────────────────────────── @@ -204,19 +244,19 @@ async def proxy_focus_finder( @router.post("/{camera_id}/patrol/start", status_code=status.HTTP_200_OK, summary="Start patrol") async def proxy_start_patrol(camera: Camera = Depends(_require_write)) -> Any: device_ip, camera_ip = _device_config(camera) - return await camera_client.start_patrol(device_ip, camera_ip) + return await _run_sync(_make_client(device_ip).start_patrol, camera_ip) @router.post("/{camera_id}/patrol/stop", status_code=status.HTTP_200_OK, summary="Stop patrol") async def proxy_stop_patrol(camera: Camera = Depends(_require_write)) -> Any: device_ip, camera_ip = _device_config(camera) - return await camera_client.stop_patrol(device_ip, camera_ip) + return await _run_sync(_make_client(device_ip).stop_patrol, camera_ip) @router.get("/{camera_id}/patrol/status", status_code=status.HTTP_200_OK, summary="Get patrol status") async def proxy_patrol_status(camera: Camera = Depends(_require_read)) -> Any: device_ip, camera_ip = _device_config(camera) - return await camera_client.get_patrol_status(device_ip, camera_ip) + return await _run_sync(_make_client(device_ip).get_patrol_status, camera_ip) # ── Stream ──────────────────────────────────────────────────────────────────── @@ -225,22 +265,22 @@ async def proxy_patrol_status(camera: Camera = Depends(_require_read)) -> Any: @router.post("/{camera_id}/stream/start", status_code=status.HTTP_200_OK, summary="Start video stream") async def proxy_start_stream(camera: Camera = Depends(_require_write)) -> Any: device_ip, camera_ip = _device_config(camera) - return await camera_client.start_stream(device_ip, camera_ip) + return await _run_sync(_make_client(device_ip).start_stream, camera_ip) @router.post("/{camera_id}/stream/stop", status_code=status.HTTP_200_OK, summary="Stop video stream") async def proxy_stop_stream(camera: Camera = Depends(_require_write)) -> Any: device_ip, _ = _device_config(camera) - return await camera_client.stop_stream(device_ip) + return await _run_sync(_make_client(device_ip).stop_stream) @router.get("/{camera_id}/stream/status", status_code=status.HTTP_200_OK, summary="Get stream status") async def proxy_stream_status(camera: Camera = Depends(_require_read)) -> Any: device_ip, _ = _device_config(camera) - return await camera_client.get_stream_status(device_ip) + return await _run_sync(_make_client(device_ip).get_stream_status) @router.get("/{camera_id}/stream/is_running", status_code=status.HTTP_200_OK, summary="Check if stream is running") async def proxy_is_stream_running(camera: Camera = Depends(_require_read)) -> Any: device_ip, camera_ip = _device_config(camera) - return await camera_client.is_stream_running(device_ip, camera_ip) + return await _run_sync(_make_client(device_ip).is_stream_running, camera_ip) diff --git a/src/app/services/camera_client.py b/src/app/services/camera_client.py deleted file mode 100644 index cd72a01f..00000000 --- a/src/app/services/camera_client.py +++ /dev/null @@ -1,250 +0,0 @@ -# Copyright (C) 2024-2026, Pyronear. - -# This program is licensed under the Apache License 2.0. -# See LICENSE or go to for full license details. - -"""HTTP client for the camera device API. - -Base URL: http://{device_ip}:8081 - -camera_ip is the IP of the specific PTZ camera managed by the device. It is -passed as a path parameter when the swagger path contains {camera_ip}, or as -a query parameter otherwise. POST endpoints on the device API use query params -(not request body) for all their inputs. - -Endpoints that return JPEG images (capture, get_latest_image) use raw=True and -return bytes instead of JSON. get_latest_image may return None when the device -responds with 204 (no cached image for the requested pose). - -All functions raise FastAPI HTTPExceptions so they can be used directly in route handlers: - - 504 if the device does not respond within TIMEOUT seconds - - 502 if the device is unreachable (connection error) - - the device's own status code for any other HTTP error -""" - -from typing import Any - -import httpx -from fastapi import HTTPException, status - -__all__ = [ - "capture", - "get_camera_infos", - "get_focus_status", - "get_health", - "get_latest_image", - "get_patrol_status", - "get_stream_status", - "is_stream_running", - "list_cameras", - "list_presets", - "manual_focus", - "move", - "run_focus_finder", - "set_autofocus", - "set_preset", - "start_patrol", - "start_stream", - "stop", - "stop_patrol", - "stop_stream", - "zoom", -] - -DEVICE_PORT = 8081 -TIMEOUT = 10.0 - - -async def _request( - method: str, - device_ip: str, - path: str, - json: Any = None, - params: dict[str, Any] | None = None, - raw: bool = False, -) -> Any: - url = f"http://{device_ip}:{DEVICE_PORT}{path}" - async with httpx.AsyncClient(timeout=TIMEOUT) as client: - try: - response = await client.request(method, url, json=json, params=params) - if response.status_code == status.HTTP_204_NO_CONTENT: - return None - response.raise_for_status() - return response.content if raw else response.json() - except httpx.TimeoutException: - raise HTTPException( - status_code=status.HTTP_504_GATEWAY_TIMEOUT, - detail="Camera device is not responding.", - ) - except httpx.HTTPStatusError as exc: - raise HTTPException( - status_code=exc.response.status_code, - detail=exc.response.text, - ) - except httpx.RequestError: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="Failed to reach camera device.", - ) - - -# ── Health ──────────────────────────────────────────────────────────────────── - - -async def get_health(device_ip: str) -> Any: - return await _request("GET", device_ip, "/health") - - -# ── Cameras ─────────────────────────────────────────────────────────────────── - - -async def list_cameras(device_ip: str) -> Any: - """List all cameras managed by the device.""" - return await _request("GET", device_ip, "/cameras/cameras_list") - - -async def get_camera_infos(device_ip: str) -> Any: - """Return metadata for all configured cameras on the device (no camera_ip filter).""" - return await _request("GET", device_ip, "/cameras/camera_infos") - - -async def capture( - device_ip: str, - camera_ip: str, - *, - pos_id: int | None = None, - anonymize: bool = True, - max_age_ms: int | None = None, - strict: bool = False, - width: int | None = None, - quality: int = 95, -) -> bytes | None: - """Capture a JPEG snapshot. Returns raw bytes.""" - params: dict[str, Any] = { - "camera_ip": camera_ip, - "anonymize": anonymize, - "strict": strict, - "quality": quality, - } - if pos_id is not None: - params["pos_id"] = pos_id - if max_age_ms is not None: - params["max_age_ms"] = max_age_ms - if width is not None: - params["width"] = width - return await _request("GET", device_ip, "/cameras/capture", params=params, raw=True) - - -async def get_latest_image( - device_ip: str, - camera_ip: str, - pose: int, - quality: int = 95, -) -> bytes | None: - """Return the last stored JPEG for a given pose. Returns None if no image (204).""" - return await _request( - "GET", - device_ip, - "/cameras/latest_image", - params={"camera_ip": camera_ip, "pose": pose, "quality": quality}, - raw=True, - ) - - -# ── Control ─────────────────────────────────────────────────────────────────── - - -async def move( - device_ip: str, - camera_ip: str, - direction: str | None = None, - speed: int = 10, - pose_id: int | None = None, - degrees: float | None = None, -) -> Any: - params: dict[str, Any] = {"camera_ip": camera_ip, "speed": speed} - if direction is not None: - params["direction"] = direction - if pose_id is not None: - params["pose_id"] = pose_id - if degrees is not None: - params["degrees"] = degrees - return await _request("POST", device_ip, "/control/move", params=params) - - -async def stop(device_ip: str, camera_ip: str) -> Any: - return await _request("POST", device_ip, f"/control/stop/{camera_ip}") - - -async def list_presets(device_ip: str, camera_ip: str) -> Any: - return await _request("GET", device_ip, "/control/preset/list", params={"camera_ip": camera_ip}) - - -async def set_preset(device_ip: str, camera_ip: str, idx: int | None = None) -> Any: - params: dict[str, Any] = {"camera_ip": camera_ip} - if idx is not None: - params["idx"] = idx - return await _request("POST", device_ip, "/control/preset/set", params=params) - - -async def zoom(device_ip: str, camera_ip: str, level: int) -> Any: - return await _request("POST", device_ip, f"/control/zoom/{camera_ip}/{level}") - - -# ── Focus ───────────────────────────────────────────────────────────────────── - - -async def manual_focus(device_ip: str, camera_ip: str, position: int) -> Any: - return await _request("POST", device_ip, "/focus/manual", params={"camera_ip": camera_ip, "position": position}) - - -async def set_autofocus(device_ip: str, camera_ip: str, disable: bool = True) -> Any: - return await _request( - "POST", device_ip, "/focus/set_autofocus", params={"camera_ip": camera_ip, "disable": disable} - ) - - -async def get_focus_status(device_ip: str, camera_ip: str) -> Any: - return await _request("GET", device_ip, "/focus/status", params={"camera_ip": camera_ip}) - - -async def run_focus_finder(device_ip: str, camera_ip: str, save_images: bool = False) -> Any: - return await _request( - "POST", device_ip, "/focus/focus_finder", params={"camera_ip": camera_ip, "save_images": save_images} - ) - - -# ── Patrol ──────────────────────────────────────────────────────────────────── - - -async def start_patrol(device_ip: str, camera_ip: str) -> Any: - return await _request("POST", device_ip, "/patrol/start_patrol", params={"camera_ip": camera_ip}) - - -async def stop_patrol(device_ip: str, camera_ip: str) -> Any: - return await _request("POST", device_ip, "/patrol/stop_patrol", params={"camera_ip": camera_ip}) - - -async def get_patrol_status(device_ip: str, camera_ip: str) -> Any: - return await _request("GET", device_ip, "/patrol/patrol_status", params={"camera_ip": camera_ip}) - - -# ── Stream ──────────────────────────────────────────────────────────────────── - - -async def start_stream(device_ip: str, camera_ip: str) -> Any: - return await _request("POST", device_ip, f"/stream/start_stream/{camera_ip}") - - -async def stop_stream(device_ip: str) -> Any: - """Stop whatever stream is currently running on the device (device-global, no camera_ip).""" - return await _request("POST", device_ip, "/stream/stop_stream") - - -async def get_stream_status(device_ip: str) -> Any: - """Return the stream status for the device (device-global, no camera_ip).""" - return await _request("GET", device_ip, "/stream/status") - - -async def is_stream_running(device_ip: str, camera_ip: str) -> Any: - return await _request("GET", device_ip, f"/stream/is_stream_running/{camera_ip}") diff --git a/src/tests/endpoints/test_camera_proxy.py b/src/tests/endpoints/test_camera_proxy.py index a0447f30..092c0231 100644 --- a/src/tests/endpoints/test_camera_proxy.py +++ b/src/tests/endpoints/test_camera_proxy.py @@ -4,6 +4,7 @@ import pytest_asyncio from fastapi import HTTPException from httpx import AsyncClient +from PIL import Image from sqlmodel.ext.asyncio.session import AsyncSession from app.models import Camera @@ -25,6 +26,8 @@ # Fake JPEG bytes (minimal valid header is enough for content-type tests) FAKE_JPEG = b"\xff\xd8\xff" + b"\x00" * 64 +_PROXY_MODULE = "app.api.api_v1.endpoints.camera_proxy" + @pytest_asyncio.fixture() async def configured_camera_session(camera_session: AsyncSession): @@ -175,10 +178,7 @@ async def test_proxy_unconfigured_post(async_client: AsyncClient, camera_session @pytest.mark.asyncio async def test_proxy_device_timeout(async_client: AsyncClient, configured_camera_session: AsyncSession): - with patch( - "app.services.camera_client.get_health", - side_effect=HTTPException(status_code=504, detail="Camera device is not responding."), - ): + with patch(f"{_PROXY_MODULE}._run_sync", side_effect=HTTPException(status_code=504, detail="Camera device is not responding.")): response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/health", headers=_auth(0)) assert response.status_code == 504 assert "not responding" in response.json()["detail"] @@ -186,10 +186,7 @@ async def test_proxy_device_timeout(async_client: AsyncClient, configured_camera @pytest.mark.asyncio async def test_proxy_device_unreachable(async_client: AsyncClient, configured_camera_session: AsyncSession): - with patch( - "app.services.camera_client.get_health", - side_effect=HTTPException(status_code=502, detail="Failed to reach camera device."), - ): + with patch(f"{_PROXY_MODULE}._run_sync", side_effect=HTTPException(status_code=502, detail="Failed to reach camera device.")): response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/health", headers=_auth(0)) assert response.status_code == 502 assert "reach camera device" in response.json()["detail"] @@ -198,10 +195,7 @@ async def test_proxy_device_unreachable(async_client: AsyncClient, configured_ca @pytest.mark.asyncio async def test_proxy_device_error_forwarded(async_client: AsyncClient, configured_camera_session: AsyncSession): """A 404 from the device (unknown camera_ip) is forwarded as-is.""" - with patch( - "app.services.camera_client.get_health", - side_effect=HTTPException(status_code=404, detail="Unknown camera"), - ): + with patch(f"{_PROXY_MODULE}._run_sync", side_effect=HTTPException(status_code=404, detail="Unknown camera")): response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/health", headers=_auth(0)) assert response.status_code == 404 @@ -211,7 +205,7 @@ async def test_proxy_device_error_forwarded(async_client: AsyncClient, configure @pytest.mark.asyncio async def test_proxy_capture_returns_jpeg(async_client: AsyncClient, configured_camera_session: AsyncSession): - with patch("app.services.camera_client.capture", new=AsyncMock(return_value=FAKE_JPEG)): + with patch(f"{_PROXY_MODULE}._run_sync", new=AsyncMock(return_value=FAKE_JPEG)): response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/capture", headers=_auth(0)) assert response.status_code == 200 assert response.headers["content-type"] == "image/jpeg" @@ -220,17 +214,19 @@ async def test_proxy_capture_returns_jpeg(async_client: AsyncClient, configured_ @pytest.mark.asyncio async def test_proxy_latest_image_returns_jpeg(async_client: AsyncClient, configured_camera_session: AsyncSession): - with patch("app.services.camera_client.get_latest_image", new=AsyncMock(return_value=FAKE_JPEG)): + """The endpoint re-encodes the PIL Image returned by the client into JPEG bytes.""" + img = Image.new("RGB", (4, 4), color=(255, 0, 0)) + with patch(f"{_PROXY_MODULE}._run_sync", new=AsyncMock(return_value=img)): response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/latest_image?pose=0", headers=_auth(0)) assert response.status_code == 200 assert response.headers["content-type"] == "image/jpeg" - assert response.content == FAKE_JPEG + assert response.content[:2] == b"\xff\xd8" # JPEG magic bytes @pytest.mark.asyncio async def test_proxy_latest_image_no_content(async_client: AsyncClient, configured_camera_session: AsyncSession): """When the device has no cached image for the requested pose it returns 204.""" - with patch("app.services.camera_client.get_latest_image", new=AsyncMock(return_value=None)): + with patch(f"{_PROXY_MODULE}._run_sync", new=AsyncMock(return_value=None)): response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/latest_image?pose=0", headers=_auth(0)) assert response.status_code == 204 @@ -254,26 +250,27 @@ async def test_device_ip_not_leaked_in_camera_response( @pytest.mark.parametrize( - ("path", "method", "client_fn"), + ("path", "method"), [ - (f"/cameras/{CONFIGURED_CAM_ID}/cameras_list", "get", "list_cameras"), - (f"/cameras/{CONFIGURED_CAM_ID}/camera_infos", "get", "get_camera_infos"), - (f"/cameras/{CONFIGURED_CAM_ID}/control/presets", "get", "list_presets"), - (f"/cameras/{CONFIGURED_CAM_ID}/focus/status", "get", "get_focus_status"), - (f"/cameras/{CONFIGURED_CAM_ID}/patrol/status", "get", "get_patrol_status"), - (f"/cameras/{CONFIGURED_CAM_ID}/stream/status", "get", "get_stream_status"), - (f"/cameras/{CONFIGURED_CAM_ID}/stream/is_running", "get", "is_stream_running"), - (f"/cameras/{CONFIGURED_CAM_ID}/control/move", "post", "move"), - (f"/cameras/{CONFIGURED_CAM_ID}/control/stop", "post", "stop"), - (f"/cameras/{CONFIGURED_CAM_ID}/control/preset", "post", "set_preset"), - (f"/cameras/{CONFIGURED_CAM_ID}/control/zoom/5", "post", "zoom"), - (f"/cameras/{CONFIGURED_CAM_ID}/focus/manual?position=500", "post", "manual_focus"), - (f"/cameras/{CONFIGURED_CAM_ID}/focus/autofocus", "post", "set_autofocus"), - (f"/cameras/{CONFIGURED_CAM_ID}/focus/optimize", "post", "run_focus_finder"), - (f"/cameras/{CONFIGURED_CAM_ID}/patrol/start", "post", "start_patrol"), - (f"/cameras/{CONFIGURED_CAM_ID}/patrol/stop", "post", "stop_patrol"), - (f"/cameras/{CONFIGURED_CAM_ID}/stream/start", "post", "start_stream"), - (f"/cameras/{CONFIGURED_CAM_ID}/stream/stop", "post", "stop_stream"), + (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}/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/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"), ], ) @pytest.mark.asyncio @@ -282,8 +279,7 @@ async def test_proxy_happy_path( configured_camera_session: AsyncSession, path: str, method: str, - client_fn: str, ): - with patch(f"app.services.camera_client.{client_fn}", new=AsyncMock(return_value={"ok": True})): + with patch(f"{_PROXY_MODULE}._run_sync", new=AsyncMock(return_value={"ok": True})): response = await getattr(async_client, method)(path, headers=_auth(0)) assert response.status_code == 200 diff --git a/src/tests/services/test_camera_client.py b/src/tests/services/test_camera_client.py deleted file mode 100644 index 3e278eff..00000000 --- a/src/tests/services/test_camera_client.py +++ /dev/null @@ -1,133 +0,0 @@ -from contextlib import contextmanager -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx -import pytest -from fastapi import HTTPException - -from app.services import camera_client - -DEVICE_IP = "192.168.1.100" -CAMERA_IP = "192.168.1.101" -FAKE_JPEG = b"\xff\xd8\xff" + b"\x00" * 16 - - -@contextmanager -def _mock_httpx(*, status_code: int = 200, json_body=None, content: bytes = b"", side_effect=None, raise_status=None): - """Patch httpx.AsyncClient so _request never opens a real connection.""" - mock_response = MagicMock() - mock_response.status_code = status_code - mock_response.json.return_value = json_body if json_body is not None else {} - mock_response.content = content - if raise_status is not None: - mock_response.raise_for_status.side_effect = raise_status - else: - mock_response.raise_for_status = MagicMock() - - mock_http = AsyncMock() - mock_http.request = ( - AsyncMock(side_effect=side_effect) if side_effect is not None else AsyncMock(return_value=mock_response) - ) - - mock_instance = MagicMock() - mock_instance.__aenter__ = AsyncMock(return_value=mock_http) - mock_instance.__aexit__ = AsyncMock(return_value=False) - - with patch("app.services.camera_client.httpx.AsyncClient", return_value=mock_instance): - yield mock_http - - -# ── _request error paths ────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_request_timeout(): - with _mock_httpx(side_effect=httpx.TimeoutException("")), pytest.raises(HTTPException) as exc_info: - await camera_client.get_health(DEVICE_IP) - assert exc_info.value.status_code == 504 - assert "not responding" in exc_info.value.detail - - -@pytest.mark.asyncio -async def test_request_connection_error(): - with _mock_httpx(side_effect=httpx.ConnectError("")), pytest.raises(HTTPException) as exc_info: - await camera_client.get_health(DEVICE_IP) - assert exc_info.value.status_code == 502 - assert "reach camera device" in exc_info.value.detail - - -@pytest.mark.asyncio -async def test_request_http_status_forwarded(): - err_resp = MagicMock() - err_resp.status_code = 404 - err_resp.text = "Unknown camera" - with ( - _mock_httpx(raise_status=httpx.HTTPStatusError("", request=MagicMock(), response=err_resp)), - pytest.raises(HTTPException) as exc_info, - ): - await camera_client.get_health(DEVICE_IP) - assert exc_info.value.status_code == 404 - assert "Unknown camera" in exc_info.value.detail - - -@pytest.mark.asyncio -async def test_request_204_returns_none(): - with _mock_httpx(status_code=204): - result = await camera_client.get_latest_image(DEVICE_IP, CAMERA_IP, pose=0) - assert result is None - - -@pytest.mark.asyncio -async def test_request_raw_returns_bytes(): - with _mock_httpx(content=FAKE_JPEG): - result = await camera_client.capture(DEVICE_IP, CAMERA_IP) - assert result == FAKE_JPEG - - -@pytest.mark.asyncio -async def test_capture_optional_params(): - """Covers the pos_id / max_age_ms / width conditional branches in capture().""" - with _mock_httpx(content=FAKE_JPEG): - result = await camera_client.capture(DEVICE_IP, CAMERA_IP, pos_id=1, max_age_ms=200, width=640) - assert result == FAKE_JPEG - - -# ── Individual client functions ─────────────────────────────────────────────── - - -@pytest.mark.parametrize( - ("func", "args", "kwargs"), - [ - (camera_client.get_health, (DEVICE_IP,), {}), - (camera_client.list_cameras, (DEVICE_IP,), {}), - (camera_client.get_camera_infos, (DEVICE_IP,), {}), - # move: no optional params - (camera_client.move, (DEVICE_IP, CAMERA_IP), {}), - # move: all optional params (covers direction / pose_id / degrees branches) - (camera_client.move, (DEVICE_IP, CAMERA_IP), {"direction": "Left", "pose_id": 1, "degrees": 45.0}), - (camera_client.stop, (DEVICE_IP, CAMERA_IP), {}), - (camera_client.list_presets, (DEVICE_IP, CAMERA_IP), {}), - # set_preset: without idx (None branch) - (camera_client.set_preset, (DEVICE_IP, CAMERA_IP), {}), - # set_preset: with idx - (camera_client.set_preset, (DEVICE_IP, CAMERA_IP), {"idx": 2}), - (camera_client.zoom, (DEVICE_IP, CAMERA_IP, 10), {}), - (camera_client.manual_focus, (DEVICE_IP, CAMERA_IP, 500), {}), - (camera_client.set_autofocus, (DEVICE_IP, CAMERA_IP), {}), - (camera_client.get_focus_status, (DEVICE_IP, CAMERA_IP), {}), - (camera_client.run_focus_finder, (DEVICE_IP, CAMERA_IP), {}), - (camera_client.run_focus_finder, (DEVICE_IP, CAMERA_IP), {"save_images": True}), - (camera_client.start_patrol, (DEVICE_IP, CAMERA_IP), {}), - (camera_client.stop_patrol, (DEVICE_IP, CAMERA_IP), {}), - (camera_client.get_patrol_status, (DEVICE_IP, CAMERA_IP), {}), - (camera_client.start_stream, (DEVICE_IP, CAMERA_IP), {}), - (camera_client.stop_stream, (DEVICE_IP,), {}), - (camera_client.get_stream_status, (DEVICE_IP,), {}), - (camera_client.is_stream_running, (DEVICE_IP, CAMERA_IP), {}), - ], -) -@pytest.mark.asyncio -async def test_client_function_success(func, args, kwargs): - with _mock_httpx(json_body={"ok": True}): - result = await func(*args, **kwargs) - assert result == {"ok": True} From b26c183f37a04b1fbef2dc86f14c8a72f063d5ae Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:38:31 +0100 Subject: [PATCH 12/14] style --- src/tests/endpoints/test_camera_proxy.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/tests/endpoints/test_camera_proxy.py b/src/tests/endpoints/test_camera_proxy.py index 092c0231..52921b01 100644 --- a/src/tests/endpoints/test_camera_proxy.py +++ b/src/tests/endpoints/test_camera_proxy.py @@ -178,7 +178,10 @@ async def test_proxy_unconfigured_post(async_client: AsyncClient, camera_session @pytest.mark.asyncio async def test_proxy_device_timeout(async_client: AsyncClient, configured_camera_session: AsyncSession): - with patch(f"{_PROXY_MODULE}._run_sync", side_effect=HTTPException(status_code=504, detail="Camera device is not responding.")): + with patch( + f"{_PROXY_MODULE}._run_sync", + side_effect=HTTPException(status_code=504, detail="Camera device is not responding."), + ): response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/health", headers=_auth(0)) assert response.status_code == 504 assert "not responding" in response.json()["detail"] @@ -186,7 +189,10 @@ async def test_proxy_device_timeout(async_client: AsyncClient, configured_camera @pytest.mark.asyncio async def test_proxy_device_unreachable(async_client: AsyncClient, configured_camera_session: AsyncSession): - with patch(f"{_PROXY_MODULE}._run_sync", side_effect=HTTPException(status_code=502, detail="Failed to reach camera device.")): + with patch( + f"{_PROXY_MODULE}._run_sync", + side_effect=HTTPException(status_code=502, detail="Failed to reach camera device."), + ): response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/health", headers=_auth(0)) assert response.status_code == 502 assert "reach camera device" in response.json()["detail"] From 8835222460320089b69b922e1023ee1b96621222 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:42:14 +0100 Subject: [PATCH 13/14] style --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 6a542374..0951da25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -186,5 +186,6 @@ module = [ "posthog", "prometheus_fastapi_instrumentator", "pydantic_settings", + "pyro_camera_api_client", ] ignore_missing_imports = true From ce0fe5e179417269e96a5fecfa4e7204011275dc Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:12:58 +0100 Subject: [PATCH 14/14] skip bandit in tests file in codacy --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 0951da25..c77b780b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,10 @@ aiosqlite = ">=0.16.0,<1.0.0" [tool.coverage.run] source = ["src/app", "client/pyroclient"] +[tool.bandit] +exclude_dirs = ["src/tests", "client/tests"] +skips = ["B101"] + [tool.ruff] line-length = 120 target-version = "py311"