Skip to content
Merged
6 changes: 3 additions & 3 deletions client/pyroclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# See LICENSE or go to <https://opensource.org/licenses/Apache-2.0> for full license details.

from enum import Enum
from typing import Any, Dict, List, Tuple
from typing import Any, Dict, List, Tuple, Union
from urllib.parse import urljoin

import requests
Expand Down Expand Up @@ -105,7 +105,7 @@ def __init__(
self.timeout = timeout

@property
def headers(self) -> Dict[str, str]:
def headers(self) -> Dict[str, Union[str, bytes]]:
return {"Authorization": f"Bearer {self.token}"}

# CAMERAS
Expand Down Expand Up @@ -287,7 +287,7 @@ def create_occlusion_mask(

>>> api_client.create_occlusion_mask(pose_id=1, mask="(0.1,0.1,0.9,0.9)")
"""
payload = {
payload: Dict[str, Any] = {
"pose_id": pose_id,
"mask": mask,
}
Expand Down
12 changes: 6 additions & 6 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ python-multipart = "==0.0.7"
python-magic = "^0.4.17"
boto3 = "^1.26.0"
httpx = "^0.24.0"
pyro-camera-api-client = {git = "https://github.com/pyronear/pyro-engine.git", subdirectory = "pyro_camera_api/client", branch = "develop"}
pyro-camera-api-client = {git = "https://github.com/pyronear/pyro-engine.git", subdirectory = "pyro_camera_api/client", branch = "main"}
geopy = "^2.4.0"
networkx = "^3.2.0"
numpy = "^1.26.0"
Expand Down
149 changes: 144 additions & 5 deletions src/app/api/api_v1/endpoints/camera_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
import io
from collections.abc import Callable
from functools import partial
from typing import Any, cast
from typing import Any, Literal, cast

import requests
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, Security, status
from pydantic import BaseModel, Field
from pyro_camera_api_client import PyroCameraAPIClient

from app.api.dependencies import get_camera_crud, get_jwt
Expand All @@ -21,6 +22,39 @@

router = APIRouter()

CameraDirection = Literal["Left", "Right", "Up", "Down"]


class GotoPresetRequest(BaseModel):
pose_id: int = Field(..., description="Preset pose index to move to")
speed: int = Field(default=50, description="Movement speed")


class StartMoveRequest(BaseModel):
direction: CameraDirection = Field(..., description="Direction: Left, Right, Up, Down")
speed: int = Field(default=10, description="Movement speed")


class MoveForDurationRequest(BaseModel):
direction: CameraDirection = Field(..., description="Direction: Left, Right, Up, Down")
duration: float = Field(..., gt=0, description="Movement duration in seconds")
speed: int = Field(default=10, description="Movement speed")


class MoveByDegreesRequest(BaseModel):
direction: CameraDirection = Field(..., description="Direction: Left, Right, Up, Down")
degrees: float = Field(..., gt=0, description="Approximate rotation in degrees")
speed: int | None = Field(
default=None,
description="Movement speed; omit to let the server auto-pick the best calibrated level (preferred)",
)


class ClickToMoveRequest(BaseModel):
click_x: float = Field(..., ge=0.0, le=1.0, description="Normalized x coordinate in [0, 1]")
click_y: float = Field(..., ge=0.0, le=1.0, description="Normalized y coordinate in [0, 1]")


DEVICE_PORT = 8081
TIMEOUT = 10.0

Expand Down Expand Up @@ -111,7 +145,7 @@ async def proxy_camera_infos(camera: Camera = Depends(_require_read)) -> Any:

@router.get("/{camera_id}/capture", status_code=status.HTTP_200_OK, summary="Capture a JPEG snapshot from the camera")
async def proxy_capture(
pos_id: int | None = Query(default=None, description="Move to this preset pose before capturing"),
patrol_id: int | None = Query(default=None, description="Move to this preset pose before capturing"),
anonymize: bool = Query(default=True, description="Overlay anonymization masks on the image"),
max_age_ms: int | None = Query(default=None, description="Only use detection boxes newer than this many ms"),
strict: bool = Query(default=False, description="Return 503 if no recent boxes are available for anonymization"),
Expand All @@ -123,7 +157,7 @@ async def proxy_capture(
data = await _run_sync(
_make_client(device_ip).capture_jpeg,
camera_ip,
pos_id=pos_id,
patrol_id=patrol_id,
anonymize=anonymize,
max_age_ms=max_age_ms,
strict=strict,
Expand Down Expand Up @@ -151,12 +185,16 @@ async def proxy_latest_image(
# ── Control ───────────────────────────────────────────────────────────────────


@router.post("/{camera_id}/control/move", status_code=status.HTTP_200_OK, summary="Move the camera")
@router.post(
"/{camera_id}/control/move", status_code=status.HTTP_200_OK, summary="Move the camera (legacy)", deprecated=True
)
async def proxy_move(
direction: str | None = Query(default=None, description="Direction: Left, Right, Up, Down"),
speed: int = Query(default=10, description="Movement speed"),
pose_id: int | None = Query(default=None, description="Move to this preset pose index"),
degrees: float | None = Query(default=None, description="Rotate by this many degrees (requires direction)"),
duration: float | None = Query(default=None, description="Move for this many seconds (requires direction)"),
zoom: int = Query(default=0, description="Zoom level; speed is forced to 1 server-side when zoom > 0"),
camera: Camera = Depends(_require_write),
) -> Any:
device_ip, camera_ip = _device_config(camera)
Expand All @@ -167,10 +205,111 @@ async def proxy_move(
speed=speed,
pose_id=pose_id,
degrees=degrees,
duration=duration,
zoom=zoom,
)


@router.post("/{camera_id}/control/stop", status_code=status.HTTP_200_OK, summary="Stop camera movement")
@router.post("/{camera_id}/control/goto_preset", status_code=status.HTTP_200_OK, summary="Move to a preset pose")
async def proxy_goto_preset(
payload: GotoPresetRequest,
camera: Camera = Depends(_require_write),
) -> Any:
device_ip, camera_ip = _device_config(camera)
return await _run_sync(_make_client(device_ip).goto_preset, camera_ip, payload.pose_id, payload.speed)


@router.post("/{camera_id}/control/start_move", status_code=status.HTTP_200_OK, summary="Start a continuous move")
async def proxy_start_move(
payload: StartMoveRequest,
camera: Camera = Depends(_require_write),
) -> Any:
device_ip, camera_ip = _device_config(camera)
return await _run_sync(_make_client(device_ip).start_move, camera_ip, payload.direction, payload.speed)


@router.post(
"/{camera_id}/control/stop_move",
status_code=status.HTTP_200_OK,
summary="Halt the current continuous PTZ move",
)
async def proxy_stop_move(camera: Camera = Depends(_require_write)) -> Any:
device_ip, camera_ip = _device_config(camera)
return await _run_sync(_make_client(device_ip).stop_move, camera_ip)


@router.post(
"/{camera_id}/control/move_for_duration",
status_code=status.HTTP_200_OK,
summary="Move for a fixed duration (seconds)",
)
async def proxy_move_for_duration(
payload: MoveForDurationRequest,
camera: Camera = Depends(_require_write),
) -> Any:
device_ip, camera_ip = _device_config(camera)
return await _run_sync(
_make_client(device_ip).move_for_duration,
camera_ip,
payload.direction,
payload.duration,
payload.speed,
)


@router.post(
"/{camera_id}/control/move_by_degrees",
status_code=status.HTTP_200_OK,
summary="Move by an approximate angle",
)
async def proxy_move_by_degrees(
payload: MoveByDegreesRequest,
camera: Camera = Depends(_require_write),
) -> Any:
device_ip, camera_ip = _device_config(camera)
return await _run_sync(
_make_client(device_ip).move_by_degrees,
camera_ip,
payload.direction,
payload.degrees,
payload.speed,
)


@router.post(
"/{camera_id}/control/click_to_move",
status_code=status.HTTP_200_OK,
summary="Move toward a normalized image click",
)
async def proxy_click_to_move(
payload: ClickToMoveRequest,
camera: Camera = Depends(_require_write),
) -> Any:
device_ip, camera_ip = _device_config(camera)
return await _run_sync(
_make_client(device_ip).click_to_move,
camera_ip,
payload.click_x,
payload.click_y,
)


@router.get(
"/{camera_id}/control/speed_tables",
status_code=status.HTTP_200_OK,
summary="Get calibrated speed tables",
)
async def proxy_speed_tables(camera: Camera = Depends(_require_read)) -> Any:
device_ip, camera_ip = _device_config(camera)
return await _run_sync(_make_client(device_ip).get_speed_tables, camera_ip)


@router.post(
"/{camera_id}/control/stop",
status_code=status.HTTP_200_OK,
summary="Stop the camera (legacy)",
deprecated=True,
)
async def proxy_stop(camera: Camera = Depends(_require_write)) -> Any:
device_ip, camera_ip = _device_config(camera)
return await _run_sync(_make_client(device_ip).stop_camera, camera_ip)
Expand Down
93 changes: 58 additions & 35 deletions src/tests/endpoints/test_camera_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ async def test_proxy_write_auth(
"/cameras/1/capture",
"/cameras/1/latest_image?pose=0",
"/cameras/1/control/presets",
"/cameras/1/control/speed_tables",
"/cameras/1/focus/status",
"/cameras/1/patrol/status",
"/cameras/1/stream/status",
Expand All @@ -151,24 +152,35 @@ async def test_proxy_unconfigured_get(async_client: AsyncClient, camera_session:


@pytest.mark.parametrize(
"path",
("path", "json"),
[
"/cameras/1/control/move",
"/cameras/1/control/stop",
"/cameras/1/control/preset",
"/cameras/1/control/zoom/5",
"/cameras/1/patrol/start",
"/cameras/1/patrol/stop",
"/cameras/1/stream/start",
"/cameras/1/stream/stop",
"/cameras/1/focus/manual?position=500",
"/cameras/1/focus/autofocus",
"/cameras/1/focus/optimize",
("/cameras/1/control/move", None),
("/cameras/1/control/goto_preset", {"pose_id": 1}),
("/cameras/1/control/start_move", {"direction": "Left"}),
("/cameras/1/control/stop_move", None),
("/cameras/1/control/move_for_duration", {"direction": "Left", "duration": 1}),
("/cameras/1/control/move_by_degrees", {"direction": "Left", "degrees": 5}),
("/cameras/1/control/click_to_move", {"click_x": 0.5, "click_y": 0.5}),
("/cameras/1/control/stop", None),
("/cameras/1/control/preset", None),
("/cameras/1/control/zoom/5", None),
("/cameras/1/patrol/start", None),
("/cameras/1/patrol/stop", None),
("/cameras/1/stream/start", None),
("/cameras/1/stream/stop", None),
("/cameras/1/focus/manual?position=500", None),
("/cameras/1/focus/autofocus", None),
("/cameras/1/focus/optimize", None),
],
)
@pytest.mark.asyncio
async def test_proxy_unconfigured_post(async_client: AsyncClient, camera_session: AsyncSession, path: str):
response = await async_client.post(path, headers=_auth(0))
async def test_proxy_unconfigured_post(
async_client: AsyncClient, camera_session: AsyncSession, path: str, json: dict | None
):
kwargs: dict = {"headers": _auth(0)}
if json is not None:
kwargs["json"] = json
response = await async_client.post(path, **kwargs)
assert response.status_code == 409
assert "not configured" in response.json()["detail"]

Expand Down Expand Up @@ -256,27 +268,34 @@ async def test_device_ip_not_leaked_in_camera_response(


@pytest.mark.parametrize(
("path", "method"),
("path", "method", "json"),
[
(f"/cameras/{CONFIGURED_CAM_ID}/health", "get"),
(f"/cameras/{CONFIGURED_CAM_ID}/cameras_list", "get"),
(f"/cameras/{CONFIGURED_CAM_ID}/camera_infos", "get"),
(f"/cameras/{CONFIGURED_CAM_ID}/control/presets", "get"),
(f"/cameras/{CONFIGURED_CAM_ID}/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"),
(f"/cameras/{CONFIGURED_CAM_ID}/health", "get", None),
(f"/cameras/{CONFIGURED_CAM_ID}/cameras_list", "get", None),
(f"/cameras/{CONFIGURED_CAM_ID}/camera_infos", "get", None),
(f"/cameras/{CONFIGURED_CAM_ID}/control/presets", "get", None),
(f"/cameras/{CONFIGURED_CAM_ID}/control/speed_tables", "get", None),
(f"/cameras/{CONFIGURED_CAM_ID}/focus/status", "get", None),
(f"/cameras/{CONFIGURED_CAM_ID}/patrol/status", "get", None),
(f"/cameras/{CONFIGURED_CAM_ID}/stream/status", "get", None),
(f"/cameras/{CONFIGURED_CAM_ID}/stream/is_running", "get", None),
(f"/cameras/{CONFIGURED_CAM_ID}/control/move", "post", None),
(f"/cameras/{CONFIGURED_CAM_ID}/control/goto_preset", "post", {"pose_id": 1}),
(f"/cameras/{CONFIGURED_CAM_ID}/control/start_move", "post", {"direction": "Left"}),
(f"/cameras/{CONFIGURED_CAM_ID}/control/stop_move", "post", None),
(f"/cameras/{CONFIGURED_CAM_ID}/control/move_for_duration", "post", {"direction": "Left", "duration": 1}),
(f"/cameras/{CONFIGURED_CAM_ID}/control/move_by_degrees", "post", {"direction": "Left", "degrees": 5}),
(f"/cameras/{CONFIGURED_CAM_ID}/control/click_to_move", "post", {"click_x": 0.5, "click_y": 0.5}),
(f"/cameras/{CONFIGURED_CAM_ID}/control/stop", "post", None),
(f"/cameras/{CONFIGURED_CAM_ID}/control/preset", "post", None),
(f"/cameras/{CONFIGURED_CAM_ID}/control/zoom/5", "post", None),
(f"/cameras/{CONFIGURED_CAM_ID}/focus/manual?position=500", "post", None),
(f"/cameras/{CONFIGURED_CAM_ID}/focus/autofocus", "post", None),
(f"/cameras/{CONFIGURED_CAM_ID}/focus/optimize", "post", None),
(f"/cameras/{CONFIGURED_CAM_ID}/patrol/start", "post", None),
(f"/cameras/{CONFIGURED_CAM_ID}/patrol/stop", "post", None),
(f"/cameras/{CONFIGURED_CAM_ID}/stream/start", "post", None),
(f"/cameras/{CONFIGURED_CAM_ID}/stream/stop", "post", None),
],
)
@pytest.mark.asyncio
Expand All @@ -285,7 +304,11 @@ async def test_proxy_happy_path(
configured_camera_session: AsyncSession,
path: str,
method: str,
json: dict | None,
):
kwargs: dict = {"headers": _auth(0)}
if json is not None:
kwargs["json"] = json
with patch(f"{_PROXY_MODULE}._run_sync", new=AsyncMock(return_value={"ok": True})):
response = await getattr(async_client, method)(path, headers=_auth(0))
response = await getattr(async_client, method)(path, **kwargs)
assert response.status_code == 200
Loading