From e21899ae55f57c486997c9966d38b81e1829ba72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 10:54:19 +0000 Subject: [PATCH 1/4] Initial plan From 0bcd8896611c30b3926644fb7a79759d6649a185 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 15:38:30 +0000 Subject: [PATCH 2/4] feat: add camera-targeted event simulation endpoint and UI trigger Agent-Logs-Url: https://github.com/subvision-soft/subvision-studio/sessions/4cf44499-c9bc-4c1f-b948-b30168b3183b Co-authored-by: DayUx <61156794+DayUx@users.noreply.github.com> --- backend/api/cameras.py | 52 ++++++++++++++++--- backend/realization/camera_subscriber.py | 10 +++- backend/schemas/__init__.py | 9 ++++ frontend/src/app/core/api.service.ts | 6 +++ .../app/features/cameras/cameras.component.ts | 22 ++++++++ 5 files changed, 92 insertions(+), 7 deletions(-) diff --git a/backend/api/cameras.py b/backend/api/cameras.py index c1b97db..4aa9b7f 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,43 @@ 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") + + try: + event_type = EventType(payload.event_type.upper()) + except ValueError: + event_type = EventType.UNKNOWN + + 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..5ab97f2 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,14 @@ 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..c02aa00 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,26 @@ export class CamerasComponent implements OnInit { }); } + simulateForCamera(camera: Camera): void { + const sub = camera.subscriptions.find(s => s.enabled); + 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' }); + }, + }); + } + private _refreshObsScenes(): void { if (!this.obsConnected) { return; From e0efcb59c19649777940ca8b4489e9a3d3dd49ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 15:41:22 +0000 Subject: [PATCH 3/4] fix: validate camera simulation event type and prioritize subscription selection Agent-Logs-Url: https://github.com/subvision-soft/subvision-studio/sessions/4cf44499-c9bc-4c1f-b948-b30168b3183b Co-authored-by: DayUx <61156794+DayUx@users.noreply.github.com> --- backend/api/cameras.py | 8 ++++++-- frontend/src/app/features/cameras/cameras.component.ts | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/api/cameras.py b/backend/api/cameras.py index 4aa9b7f..790ff07 100644 --- a/backend/api/cameras.py +++ b/backend/api/cameras.py @@ -288,8 +288,12 @@ async def simulate_camera_event( try: event_type = EventType(payload.event_type.upper()) - except ValueError: - event_type = EventType.UNKNOWN + 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, diff --git a/frontend/src/app/features/cameras/cameras.component.ts b/frontend/src/app/features/cameras/cameras.component.ts index c02aa00..cb1e5ab 100644 --- a/frontend/src/app/features/cameras/cameras.component.ts +++ b/frontend/src/app/features/cameras/cameras.component.ts @@ -495,7 +495,9 @@ export class CamerasComponent implements OnInit { } simulateForCamera(camera: Camera): void { - const sub = camera.subscriptions.find(s => s.enabled); + 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; From c006f8ff707078817d166ac70ad72d989f847918 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 15:43:09 +0000 Subject: [PATCH 4/4] refactor: harden camera simulation input and improve simulator UX messaging Agent-Logs-Url: https://github.com/subvision-soft/subvision-studio/sessions/4cf44499-c9bc-4c1f-b948-b30168b3183b Co-authored-by: DayUx <61156794+DayUx@users.noreply.github.com> --- backend/api/cameras.py | 6 +++++- backend/realization/camera_subscriber.py | 6 +----- frontend/src/app/features/cameras/cameras.component.ts | 8 ++++++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/backend/api/cameras.py b/backend/api/cameras.py index 790ff07..5504d7a 100644 --- a/backend/api/cameras.py +++ b/backend/api/cameras.py @@ -286,8 +286,12 @@ async def simulate_camera_event( 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(payload.event_type.upper()) + 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( diff --git a/backend/realization/camera_subscriber.py b/backend/realization/camera_subscriber.py index 5ab97f2..407e371 100644 --- a/backend/realization/camera_subscriber.py +++ b/backend/realization/camera_subscriber.py @@ -91,11 +91,7 @@ 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 - ) + 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 diff --git a/frontend/src/app/features/cameras/cameras.component.ts b/frontend/src/app/features/cameras/cameras.component.ts index cb1e5ab..5c9ada5 100644 --- a/frontend/src/app/features/cameras/cameras.component.ts +++ b/frontend/src/app/features/cameras/cameras.component.ts @@ -495,7 +495,7 @@ export class CamerasComponent implements OnInit { } simulateForCamera(camera: Camera): void { - const sub = camera.subscriptions + const sub = (camera.subscriptions ?? []) .filter(s => s.enabled) .sort((a, b) => b.priority - a.priority)[0]; if (!sub) { @@ -511,7 +511,11 @@ export class CamerasComponent implements OnInit { }); }, error: err => { - this.msg.add({ severity: 'error', summary: 'Simulation', detail: err?.message ?? 'Failed to simulate event' }); + this.msg.add({ + severity: 'error', + summary: 'Simulation', + detail: err?.message ?? `Failed to simulate event for ${camera.label}`, + }); }, }); }