Skip to content
Merged
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
60 changes: 54 additions & 6 deletions backend/api/cameras.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
)
Expand Down Expand Up @@ -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}
6 changes: 5 additions & 1 deletion backend/realization/camera_subscriber.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions backend/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/app/core/api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ export class ApiService {
getAllScores(): Observable<CameraContext[]> {
return this.http.get<CameraContext[]>(`${API_BASE}/cameras/live/scores`);
}
simulateCameraEvent(cameraId: string, eventType: string, extra?: Record<string, unknown>): Observable<unknown> {
return this.http.post(`${API_BASE}/cameras/${cameraId}/simulate`, {
event_type: eventType,
...extra,
});
}

// ── Rules ────────────────────────────────────────────────────────
getProfiles(): Observable<RuleProfile[]> {
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/app/features/cameras/cameras.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ const EVENT_TYPES = [
<div class="action-btns">
<p-button icon="pi pi-pencil" severity="secondary" [text]="true" size="small"
(onClick)="openEditDialog(cam)" pTooltip="Edit" />
<p-button icon="pi pi-bolt" severity="help" [text]="true" size="small"
(onClick)="simulateForCamera(cam)" pTooltip="Simulate event" />
<p-button icon="pi pi-trash" severity="danger" [text]="true" size="small"
(onClick)="deleteCamera(cam.id)" pTooltip="Delete" />
</div>
Expand Down Expand Up @@ -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;
Expand Down