Files
server-26/drb-c2-core/app/internal/cameras/registry.py
T
Logan CusanoandClaude Opus 5.5 aa5b71d853 Live ops wall + pluggable camera providers with licence gating (server-26#198)
Backend (c2-core):
- app/internal/cameras/: provider interface, licence model, per-provider
  catalog cache; one adapter per source in design-canvas/camera-sources.md.
  Implemented: 511NY (legacy shape + tolerant XML inventory parser, 511x
  endpoints UNVERIFIED), Caltrans, DelDOT, Ontario 511, DriveBC, Calgary,
  TfL, NSW, Fintraffic. Stubs: NYC DOT, Westchester DPW, TRANSCOM, CTroads,
  511NJ, Austin, TxDOT, MD CHART, WSDOT, Tallinn, Tarktee, Warendorf,
  TrafficLand, Windy, EarthCam.
- Every provider defaults OFF (config/camera_providers). Gated licences
  (agreement required / unverified / non-commercial / paid) need an explicit
  acknowledgement stored with who/when and written to audit_log; the audit
  write is a precondition. Stubs and prohibited sources cannot be enabled.
  Ack is bound to the licence status it was given for.
- GET /cameras (signed-in), GET/PUT /admin/camera-providers (admin only).
- /traffic/511 follows the ny511 provider switch and returns the DAA credit
  + NYSDOT as-is disclaimer; 511NY event descriptions no longer truncated
  ("may not alter the substance of a report").

Frontend:
- LiveView becomes the ops wall: Region > Incident > Camera drill-down,
  worst-first rail (MapView), KPIs, transcript ticker, scrubber incident
  bands, auto-tour, deep links (#inc=&cam=&tour), Esc steps up.
- Incident panel: nearest cameras (path-weighted), fused situational context
  (ADS-B, AIS, 511NY events, NEXRAD tile sample), units, call spine.
- MapView keeps every layer; adds Camera Viewsheds (approx.) overlay and a
  nearest-camera layer for the selected incident.
- CameraFeed hygiene: hidden-tab/off-screen gating, 2 concurrent HLS cap,
  last good frame + stale badge, frozen-frame signature, HLS stall watchdog,
  provider attribution rendered on the imagery.
- Camera/event popups show source credit, update time and the disclaimer.
- Admin > Camera providers tab with licence status, health, gated confirm.
- Camera detection is not built (blocked on #199/#200); seam only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:56:06 -04:00

199 lines
9.0 KiB
Python

"""Camera provider registry: settings, licence gate, and bbox queries.
Settings live in Firestore ``config/camera_providers``::
{"providers": {"<id>": {"enabled": bool, "updated_at": iso, "updated_by": {...},
"acknowledgement": {"by_uid", "by_email", "at", "licence_status", "warning"} | None}}}
Rules (owner brief, 2026-10-04):
* every provider defaults OFF -- a missing doc/entry means off;
* a stub (no adapter) or a PROHIBITED source can never be enabled;
* a GATED licence (agreement required / non-commercial / unverified / paid)
can only be enabled with an explicit acknowledgement, recorded with who and
when in the settings doc AND audit_log. The audit write is a precondition
for a gated enable: no record, no enable;
* an acknowledgement is bound to the licence status it was given for. If a
provider's catalogued status changes in code, the old acknowledgement no
longer counts and the provider stops serving until re-acknowledged.
"""
import asyncio
import time
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple
from app.config import settings
from app.internal import firestore as fstore
from app.internal.cameras import general, regional
from app.internal.cameras.base import (
BBox, Camera, CameraProvider, Catalog, Licence, bbox_intersects, in_bbox,
)
from app.internal.logger import logger
COLLECTION, DOC_ID = "config", "camera_providers"
_TTL_S = 30.0
MAX_ITEMS = 1500 # per response, same cap as /traffic/511
PROVIDERS: Dict[str, CameraProvider] = {p.meta.id: p for p in [*regional.PROVIDERS, *general.PROVIDERS]}
CATALOGS: Dict[str, Catalog] = {pid: Catalog(p) for pid, p in PROVIDERS.items() if p.meta.implemented}
_cache: Dict[str, dict] = {}
_cache_ts = 0.0
class ProviderError(Exception):
"""A refused settings change. ``code`` is the HTTP status the router returns."""
def __init__(self, code: int, message: str, **extra):
super().__init__(message)
self.code, self.message, self.extra = code, message, extra
def invalidate_cache() -> None:
global _cache_ts
_cache_ts = 0.0
async def get_settings() -> Dict[str, dict]:
"""Per-provider settings. Unreadable Firestore means everything is OFF (fail closed)."""
global _cache, _cache_ts
if _cache_ts and time.monotonic() - _cache_ts < _TTL_S:
return _cache
try:
doc = await fstore.doc_get(COLLECTION, DOC_ID) or {}
provs = doc.get("providers") if isinstance(doc.get("providers"), dict) else {}
_cache = {k: v for k, v in provs.items() if isinstance(v, dict)}
except Exception as e:
logger.warning(f"camera providers: settings unreadable ({e}); treating every provider as OFF")
_cache = {}
_cache_ts = time.monotonic()
return _cache
def effective(provider: CameraProvider, entry: Optional[dict]) -> Tuple[bool, Optional[str]]:
"""(serving?, reason-if-not) for a provider given its settings entry."""
meta = provider.meta
if not meta.implemented:
return False, "adapter is a stub"
if meta.licence.status == Licence.PROHIBITED:
return False, "source terms forbid this use"
if not entry or not entry.get("enabled"):
return False, "disabled (default)"
if meta.licence.gated:
ack = entry.get("acknowledgement") or {}
if ack.get("licence_status") != meta.licence.status.value:
return False, "licence acknowledgement missing or out of date"
return True, None
async def is_serving(provider_id: str) -> bool:
p = PROVIDERS.get(provider_id)
if p is None:
return False
return effective(p, (await get_settings()).get(provider_id))[0]
async def set_enabled(provider_id: str, enabled: bool, actor: Tuple[str, str],
acknowledge_licence: bool = False) -> dict:
p = PROVIDERS.get(provider_id)
if p is None:
raise ProviderError(404, f"unknown camera provider '{provider_id}'")
meta, lic = p.meta, p.meta.licence
uid, email = actor
now = datetime.now(timezone.utc).isoformat()
entry: dict = {"enabled": bool(enabled), "updated_at": now, "updated_by": {"uid": uid, "email": email}}
if enabled:
if not meta.implemented:
raise ProviderError(409, f"{meta.name}: no working adapter -- {meta.notes}")
if lic.status == Licence.PROHIBITED:
raise ProviderError(409, lic.warning() or "prohibited")
if lic.gated:
if not acknowledge_licence:
raise ProviderError(409, f"{meta.name}: licence acknowledgement required",
requires_acknowledgement=True, warning=lic.warning(),
licence=lic.as_dict())
entry["acknowledgement"] = {"by_uid": uid, "by_email": email, "at": now,
"licence_status": lic.status.value, "warning": lic.warning()}
# Precondition, not a side effect: a gated enable with no audit
# record must not happen. A failure here aborts before the write.
try:
await _audit(actor, "camera_provider.enable_acknowledged", provider_id,
{"licence_status": lic.status.value, "licence_label": lic.label,
"warning": lic.warning(), "acknowledged_at": now})
except Exception as e:
logger.error(f"camera providers: audit write failed, refusing gated enable of {provider_id} ({e})")
raise ProviderError(503, "audit log unavailable -- a licence-gated provider cannot be enabled "
"without an audit record; try again")
else:
entry["acknowledgement"] = None # re-enabling a gated source needs a fresh acknowledgement
before = (await get_settings()).get(provider_id) or {}
await fstore.doc_set(COLLECTION, DOC_ID, {"providers": {provider_id: entry}}, merge=True)
invalidate_cache()
if not (enabled and lic.gated):
try:
await _audit(actor, "camera_provider.enable" if enabled else "camera_provider.disable", provider_id,
{"from": bool(before.get("enabled")), "to": bool(enabled),
"licence_status": lic.status.value})
except Exception as e: # the toggle already happened; never 500 a completed write
logger.error(f"camera providers: audit write failed for {provider_id} ({e})")
logger.info(f"camera provider {provider_id} -> enabled={enabled} by {email or uid}")
return await describe(provider_id)
async def _audit(actor: Tuple[str, str], action: str, provider_id: str, details: dict) -> None:
from app.internal import audit
await audit.write_audit(actor_uid=actor[0], actor_email=actor[1], action=action,
details={"provider_id": provider_id, **details})
async def describe(provider_id: str) -> dict:
p = PROVIDERS[provider_id]
entry = (await get_settings()).get(provider_id) or {}
serving, reason = effective(p, entry)
m = p.meta
cat = CATALOGS.get(provider_id)
key_missing = bool(m.key_setting) and not getattr(settings, m.key_setting, None)
return {
"id": m.id, "name": m.name, "region": m.region, "formats": m.formats,
"implemented": m.implemented, "notes": m.notes, "homepage": m.homepage,
"licence": m.licence.as_dict(),
"enabled": bool(entry.get("enabled")),
"serving": serving, "not_serving_reason": reason,
"acknowledgement": entry.get("acknowledgement"),
"updated_at": entry.get("updated_at"), "updated_by": entry.get("updated_by"),
"key_setting": m.key_setting, "key_missing": key_missing,
"health": cat.status() if cat else None,
}
async def list_providers() -> List[dict]:
await get_settings()
return [await describe(pid) for pid in PROVIDERS]
def credit(provider_id: str) -> dict:
lic = PROVIDERS[provider_id].meta.licence
return {"attribution": lic.attribution, "attribution_url": lic.attribution_url, "disclaimer": lic.disclaimer}
async def cameras_in_bbox(bbox: BBox) -> dict:
"""Cameras from every SERVING provider whose coverage meets ``bbox``.
Providers that are off are never fetched."""
conf = await get_settings()
live = [pid for pid, cat in CATALOGS.items()
if effective(PROVIDERS[pid], conf.get(pid))[0] and bbox_intersects(PROVIDERS[pid].meta.coverage, bbox)]
await asyncio.gather(*(CATALOGS[pid].get() for pid in live))
cams: List[Camera] = []
providers = []
for pid in live:
cat = CATALOGS[pid]
hits = in_bbox(cat.items, bbox)
room = max(0, MAX_ITEMS - len(cams))
cams.extend(hits[:room])
providers.append({"id": pid, "name": PROVIDERS[pid].meta.name, **credit(pid),
"fetched_at": cat.fetched_at, "error": cat.error,
"total_in_bbox": len(hits), "truncated": len(hits) > room})
return {"cameras": cams, "providers": providers,
"serving_count": sum(1 for pid in CATALOGS if effective(PROVIDERS[pid], conf.get(pid))[0])}