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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 137 additions & 4 deletions poetry.lock

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -62,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"
Expand Down Expand Up @@ -134,6 +139,7 @@ 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/api/api_v1/endpoints/camera_proxy.py" = ["ANN401"]
"src/migrations/versions/**.py" = ["CPY001"]
"src/migrations/**.py" = ["ANN"]
"src/app/main.py" = ["ANN"]
Expand Down Expand Up @@ -184,5 +190,6 @@ module = [
"posthog",
"prometheus_fastapi_instrumentator",
"pydantic_settings",
"pyro_camera_api_client",
]
ignore_missing_imports = true
2 changes: 1 addition & 1 deletion src/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/*

Expand Down
286 changes: 286 additions & 0 deletions src/app/api/api_v1/endpoints/camera_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
# Copyright (C) 2025-2026, Pyronear.

# This program is licensed under the Apache License 2.0.
# See LICENSE or go to <https://www.apache.org/licenses/LICENSE-2.0> 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

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 ────────────────────────────────────────────────────────────


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 _run_sync(_make_client(device_ip).health)


# ── 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 _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 _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")
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 _run_sync(
_make_client(device_ip).capture_jpeg,
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)
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)
buf = io.BytesIO()
image.save(buf, format="JPEG")
return Response(content=buf.getvalue(), 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 _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 _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 _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")
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 _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")
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 _run_sync(_make_client(device_ip).zoom, 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 _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")
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 _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 _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")
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 _run_sync(_make_client(device_ip).run_focus_optimization, camera_ip, save_images=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 _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 _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 _run_sync(_make_client(device_ip).get_patrol_status, 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 _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 _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 _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 _run_sync(_make_client(device_ip).is_stream_running, camera_ip)
Loading
Loading