diff --git a/backend/api/cameras.py b/backend/api/cameras.py index c1b97db..5504d7a 100644 --- a/backend/api/cameras.py +++ b/backend/api/cameras.py @@ -8,21 +8,21 @@ import structlog from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from backend.database import get_db from backend.models import Camera, CameraSubscriptionModel, CameraSubscriptionSceneOption +from backend.core.event_bus import event_bus +from backend.events.models import CompetitionEvent, EventType, EVENT_SEVERITY from backend.realization.camera_context import CameraContext, CameraSubscription, ObsSceneOption, ReactionMode from backend.realization.camera_subscriber import subscriber_registry -from backend.schemas import CameraCreate, CameraResponse, CameraUpdate +from backend.schemas import CameraCreate, CameraEventSimulateRequest, CameraResponse, CameraUpdate log = structlog.get_logger(__name__) router = APIRouter() - -from sqlalchemy.orm import selectinload - def _model_to_response(camera: Camera) -> CameraResponse: return CameraResponse( id=camera.id, @@ -82,7 +82,7 @@ async def _register_camera_subscriber(camera: Camera) -> None: async def list_cameras(db: AsyncSession = Depends(get_db)): result = await db.execute( select(Camera) - .where(Camera.enabled == True) + .where(Camera.enabled) .options( selectinload(Camera.subscriptions).selectinload(CameraSubscriptionModel.obs_scene_options), ) @@ -272,3 +272,51 @@ async def get_camera_context(camera_id: str): async def get_all_scores(): """Return live scores for all cameras — used by monitoring UI.""" return [ctx.to_dict() for ctx in subscriber_registry.get_all_contexts()] + + +@router.post("/{camera_id}/simulate", status_code=status.HTTP_202_ACCEPTED) +async def simulate_camera_event( + camera_id: str, + payload: CameraEventSimulateRequest, + db: AsyncSession = Depends(get_db), +): + camera = await db.get(Camera, camera_id) + if not camera: + raise HTTPException(status_code=404, detail="Camera not found") + if not camera.enabled: + raise HTTPException(status_code=400, detail="Camera is disabled") + + raw_event_type = payload.event_type.strip().upper() + if not raw_event_type: + raise HTTPException(status_code=400, detail="event_type is required") + + try: + event_type = EventType(raw_event_type) + except ValueError as exc: + valid_types = ", ".join(et.value for et in EventType if et != EventType.UNKNOWN) + raise HTTPException( + status_code=400, + detail=f"Invalid event_type. Supported values: {valid_types}", + ) from exc + + event = CompetitionEvent( + type=event_type, + severity=EVENT_SEVERITY.get(event_type), + competition_id=payload.competition_id, + athlete_id=payload.athlete_id, + lane=payload.lane, + raw_payload={ + "simulated": True, + "target_camera_id": camera_id, + **(payload.extra or {}), + }, + ) + + delivered = await event_bus.publish(event) + log.info( + "cameras.event_simulated", + camera_id=camera_id, + event_type=event_type, + delivered=delivered, + ) + return {"event_id": event.id, "camera_id": camera_id, "delivered_to": delivered} diff --git a/backend/realization/camera_subscriber.py b/backend/realization/camera_subscriber.py index 351065b..407e371 100644 --- a/backend/realization/camera_subscriber.py +++ b/backend/realization/camera_subscriber.py @@ -23,7 +23,7 @@ from backend.core.event_bus import event_bus from backend.core.scoring import compute_score, ScoreBreakdown from backend.events.models import CompetitionEvent -from backend.realization.camera_context import CameraContext, CameraSubscription, ReactionMode +from backend.realization.camera_context import CameraContext, ReactionMode log = structlog.get_logger(__name__) @@ -91,6 +91,10 @@ async def _process_event(self, event: CompetitionEvent) -> None: - Compute score - Set pending state """ + target_camera_id = event.raw_payload.get("target_camera_id") if isinstance(event.raw_payload, dict) else None + if target_camera_id and target_camera_id != self.camera_id: + return + sub = self._ctx.get_subscription(event.type) # Always push event to history (even if not subscribed, for global context) diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py index b068a12..88d7ce6 100644 --- a/backend/schemas/__init__.py +++ b/backend/schemas/__init__.py @@ -100,6 +100,15 @@ class EventSimulateRequest(BaseModel): extra: Optional[Dict[str, Any]] = None +class CameraEventSimulateRequest(BaseModel): + """Used by the camera UI to inject a test event targeted to one camera.""" + event_type: str + competition_id: Optional[str] = None + athlete_id: Optional[str] = None + lane: Optional[int] = None + extra: Optional[Dict[str, Any]] = None + + class EventLogResponse(BaseModel): id: str event_type: str diff --git a/frontend/src/app/core/api.service.ts b/frontend/src/app/core/api.service.ts index 5a82af0..015be32 100644 --- a/frontend/src/app/core/api.service.ts +++ b/frontend/src/app/core/api.service.ts @@ -112,6 +112,12 @@ export class ApiService { getAllScores(): Observable { return this.http.get(`${API_BASE}/cameras/live/scores`); } + simulateCameraEvent(cameraId: string, eventType: string, extra?: Record): Observable { + return this.http.post(`${API_BASE}/cameras/${cameraId}/simulate`, { + event_type: eventType, + ...extra, + }); + } // ── Rules ──────────────────────────────────────────────────────── getProfiles(): Observable { diff --git a/frontend/src/app/features/cameras/cameras.component.ts b/frontend/src/app/features/cameras/cameras.component.ts index dc33dcd..5c9ada5 100644 --- a/frontend/src/app/features/cameras/cameras.component.ts +++ b/frontend/src/app/features/cameras/cameras.component.ts @@ -118,6 +118,8 @@ const EVENT_TYPES = [
+
@@ -492,6 +494,32 @@ export class CamerasComponent implements OnInit { }); } + simulateForCamera(camera: Camera): void { + const sub = (camera.subscriptions ?? []) + .filter(s => s.enabled) + .sort((a, b) => b.priority - a.priority)[0]; + if (!sub) { + this.msg.add({ severity: 'warn', summary: 'Simulation', detail: 'No enabled subscription on this camera' }); + return; + } + this.api.simulateCameraEvent(camera.id, sub.event_type).subscribe({ + next: () => { + this.msg.add({ + severity: 'success', + summary: 'Simulation', + detail: `Simulated ${sub.event_type} for ${camera.label}`, + }); + }, + error: err => { + this.msg.add({ + severity: 'error', + summary: 'Simulation', + detail: err?.message ?? `Failed to simulate event for ${camera.label}`, + }); + }, + }); + } + private _refreshObsScenes(): void { if (!this.obsConnected) { return;