Merge branch 'main' into fix/167-deploy-concurrency

This commit is contained in:
2026-10-04 18:29:21 -04:00
43 changed files with 4085 additions and 171 deletions
+4
View File
@@ -27,6 +27,10 @@ NODE_OFFLINE_THRESHOLD=90
# Enable "Geocoding API" in Cloud Console for this key
GOOGLE_MAPS_API_KEY=
NY511_API_KEY=
# 511NY feed base URL from the DAA grant (default: legacy 511ny.org/api, dead since the 511x cutover)
NY511_API_BASE=
# Ontario 511 developer key (camera provider "ontario511"; providers default OFF)
ONTARIO511_API_KEY=
# OpenAI — for transcription (Whisper), intelligence extraction, embeddings, and summaries
OPENAI_API_KEY=
+26 -2
View File
@@ -30,11 +30,35 @@ class Settings(BaseSettings):
openai_api_key: Optional[str] = None
stt_model: str = "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
# AI request timeouts (server-26#153). Without these the OpenAI SDK waits
# up to 600s per attempt and retries twice, and the google-genai client
# waits forever, so a hung provider call had no upper bound at all — and
# recorrelation_sweep.py's hold-back could only guess at one. Every OpenAI
# client is built by app/internal/ai_clients.py and the one Gemini client
# by app/internal/gemini.py; both read these. A timeout raises like any
# other provider error, so every call site's existing fallback applies
# (rules-only correlation, raw transcript, no summary, ...).
# The timeout bounds each phase of one HTTP attempt (connect/write/read);
# these endpoints are non-streaming, so in practice it bounds the attempt.
stt_timeout_seconds: float = 120.0 # Whisper: uploads up to MAX_RECORDING_SECONDS of audio
openai_timeout_seconds: float = 60.0 # chat completions + embeddings
# Retries per OpenAI request (SDK default 2). The SDK retries timeouts too,
# so each one multiplies the worst case by another full timeout.
openai_max_retries: int = 1
# google-genai does not retry unless told to, so this is the whole bound.
gemini_timeout_seconds: float = 60.0
# Google Maps (geocoding)
google_maps_api_key: Optional[str] = None
# 511NY developer key (server-26#183). Optional: the API answered without one
# as of 2026-09-27, but its terms require a registered key.
# 511NY developer key (server-26#183/#198). Issued with the NYSDOT Developer
# Access Agreement grant; the keyless legacy API is gone since the 511x cutover.
ny511_api_key: Optional[str] = None
# Base URL for the 511NY feed. Defaults to the legacy API, which is dead since
# the 511x cutover (server-26#198); set it to whatever the DAA grant documents.
ny511_api_base: Optional[str] = None
# Camera providers (app/internal/cameras). Keys only; every provider is still
# OFF until an admin enables it, and licence-gated ones need an acknowledgement.
ontario511_api_key: Optional[str] = None
# Gemini (intelligence extraction, embeddings, incident summaries)
gemini_api_key: Optional[str] = None
+44
View File
@@ -0,0 +1,44 @@
"""
One place every OpenAI client is built, so none of them runs on SDK-default
timeouts (server-26#153).
The OpenAI SDK's defaults are a 600s read timeout and 2 retries, which it also
applies to timeouts: one hung Whisper request could hold a call's pipeline for
half an hour, twice over with stt_retry_on_degenerate. That unbounded tail is
what recorrelation_sweep.py's MIN_MINUTES_SINCE_PIPELINE_START had to guess
against. With these settings it is derived instead (see
recorrelation_sweep.pipeline_worst_case_seconds).
A timeout raises openai.APITimeoutError, an ordinary Exception, so each call
site's existing failure path handles it unchanged: ai_health.classify() reads
"Request timed out." as transient.
The Gemini client is built in app/internal/gemini.py with the same intent.
"""
from typing import Optional
from app.config import settings
def _timeout(timeout: Optional[float]) -> float:
return settings.openai_timeout_seconds if timeout is None else timeout
def openai_client(timeout: Optional[float] = None):
"""Synchronous OpenAI client. `timeout` overrides openai_timeout_seconds (STT passes its own)."""
from openai import OpenAI # lazy, like every call site before this module
return OpenAI(
api_key=settings.openai_api_key,
timeout=_timeout(timeout),
max_retries=settings.openai_max_retries,
)
def async_openai_client(timeout: Optional[float] = None):
"""Async OpenAI client, same limits as openai_client()."""
from openai import AsyncOpenAI
return AsyncOpenAI(
api_key=settings.openai_api_key,
timeout=_timeout(timeout),
max_retries=settings.openai_max_retries,
)
@@ -0,0 +1,6 @@
"""Pluggable public-camera providers (live ops wall).
Every source in ``design-canvas/camera-sources.md`` is one adapter here, each
carrying its licence status from that catalog. Providers default OFF; the
licence gate and the admin toggle live in ``registry``.
"""
+262
View File
@@ -0,0 +1,262 @@
"""Provider interface, licence model and the per-provider catalog cache.
A provider is metadata (who runs the cameras, what their terms say, what
region they cover) plus one async ``fetch_catalog`` that returns normalised
camera records. Catalogs are public and identical for every org, so -- like
ny511.py -- they are cached in memory, fetched lazily on request, and never
written to Firestore. A failed refresh keeps serving the last good catalog and
reports the error and its age (the AI-silent-failures lesson: an empty layer
must never be the only symptom of a dead feed).
Camera detection (God's Eye View idea #5) is NOT built: it is blocked on
server-26#199 (NYSDOT's written answer on imagery analytics) and #200 (CISO
ruling on frame retention). ``CameraSignalSink`` is the seam it would plug into.
"""
import asyncio
import re
import time
from dataclasses import dataclass
from enum import Enum
from typing import Any, Dict, List, Optional, Protocol, Tuple
from app.internal.logger import logger
RETRY_AFTER_FAILURE_S = 60
DEFAULT_CATALOG_TTL_S = 15 * 60 # camera lists change rarely (GEV uses the same 15 min)
FETCH_TIMEOUT_S = 20.0
USER_AGENT = "DRB-c2-core/1.0 (+public camera catalog; contact via 511/agency programs)"
Camera = Dict[str, Any]
BBox = Tuple[float, float, float, float] # south, west, north, east
class Licence(str, Enum):
"""Licence status, as catalogued in design-canvas/camera-sources.md."""
COMMERCIAL_OK = "commercial_ok"
ATTRIBUTION_REQUIRED = "attribution_required" # commercial OK, credit must render with imagery
AGREEMENT_REQUIRED = "agreement_required" # signed agreement / written permission DRB does not hold
NON_COMMERCIAL = "non_commercial"
UNVERIFIED = "unverified" # no licence read first-hand
PAID = "paid" # needs a commercial contract
PROHIBITED = "prohibited" # terms forbid this use outright -- cannot be enabled
# Statuses that may only be enabled after an admin explicitly acknowledges the
# warning (recorded with who/when in the settings doc and audit_log).
GATED = frozenset({Licence.AGREEMENT_REQUIRED, Licence.NON_COMMERCIAL, Licence.UNVERIFIED, Licence.PAID})
@dataclass(frozen=True)
class LicenceInfo:
status: Licence
label: str # one line, shown prominently in Admin
terms: str # what the source's own terms say (quoted where read)
terms_url: Optional[str] = None
attribution: Optional[str] = None # must render wherever this provider's imagery appears
attribution_url: Optional[str] = None
disclaimer: Optional[str] = None # pass-through disclaimer (e.g. NYSDOT DAA)
issue: Optional[str] = None # tracking issue, e.g. "server-26#198"
@property
def gated(self) -> bool:
return self.status in GATED
def warning(self) -> Optional[str]:
"""The blocking warning an admin must acknowledge, or None when ungated."""
if self.status == Licence.PROHIBITED:
return f"{self.label}. This source's terms forbid this use; it cannot be enabled."
if not self.gated:
return None
return (f"{self.label}. {self.terms} Enabling it means DRB displays this source's imagery "
f"to every signed-in user. Only confirm if DRB actually holds the right to do so.")
def as_dict(self) -> dict:
return {
"status": self.status.value, "label": self.label, "terms": self.terms,
"terms_url": self.terms_url, "attribution": self.attribution,
"attribution_url": self.attribution_url, "disclaimer": self.disclaimer,
"issue": self.issue, "gated": self.gated, "warning": self.warning(),
}
@dataclass(frozen=True)
class ProviderMeta:
id: str
name: str
region: str
coverage: BBox # rough bbox; a query outside it never triggers a fetch
licence: LicenceInfo
formats: str # "stills", "stills + HLS", ...
implemented: bool
notes: str # what is verified vs unverified about this adapter
homepage: Optional[str] = None
key_setting: Optional[str] = None # settings attribute holding a required API key
ttl_s: int = DEFAULT_CATALOG_TTL_S
class CameraSignalSink(Protocol):
"""Future seam (NOT implemented): aggregate per-class counts from a camera
still attached to an incident as corroborating evidence. Blocked on
server-26#199 and #200. No frames, plates or faces -- class counts only."""
async def submit(self, camera_id: str, incident_id: str, counts: Dict[str, int], observed_at: float) -> None: ...
class CameraProvider:
"""Base adapter. Subclasses set ``meta`` and implement ``fetch_catalog``."""
meta: ProviderMeta
async def fetch_catalog(self) -> List[Camera]:
raise NotImplementedError(f"{self.meta.id}: adapter is a stub ({self.meta.notes})")
class StubProvider(CameraProvider):
"""A catalogued source with no working adapter -- listed so Admin shows its
licence status, but it can never be enabled."""
def __init__(self, meta: ProviderMeta):
assert not meta.implemented
self.meta = meta
class Catalog:
"""In-memory cache of one provider's normalised camera list."""
def __init__(self, provider: CameraProvider):
self.provider = provider
self.items: List[Camera] = []
self.fetched_at: Optional[float] = None # epoch s of last SUCCESSFUL fetch
self.last_attempt_at: Optional[float] = None
self.error: Optional[str] = None
self._next_attempt = 0.0
self._lock = asyncio.Lock()
async def get(self) -> "Catalog":
if time.time() < self._next_attempt:
return self
async with self._lock:
if time.time() < self._next_attempt:
return self
ttl = self.provider.meta.ttl_s
self.last_attempt_at = time.time()
try:
items = await self.provider.fetch_catalog()
self.items = items
self.fetched_at = time.time()
self.error = None
self._next_attempt = time.time() + ttl
except Exception as e:
self._next_attempt = time.time() + min(ttl, RETRY_AFTER_FAILURE_S)
self.error = safe_error(e)
logger.warning(f"cameras/{self.provider.meta.id}: refresh failed, serving {len(self.items)} cached: {self.error}")
return self
def status(self) -> dict:
return {"fetched_at": self.fetched_at, "last_attempt_at": self.last_attempt_at,
"error": self.error, "count": len(self.items)}
# ── helpers shared by adapters ───────────────────────────────────────────────
# Query strings are where API keys travel (?key=...). httpx puts the full URL in
# HTTPStatusError text, and this text reaches every signed-in user through the
# status fields of GET /cameras and /traffic/511, so it is dropped here.
_URL_QUERY = re.compile(r"(https?://[^\s'\"?#]+)\?[^\s'\"#]*")
def safe_error(e: BaseException) -> str:
"""Error text safe to show users: URL query strings removed, length capped."""
return _URL_QUERY.sub(r"\1?[redacted]", f"{type(e).__name__}: {e}")[:300]
def make_camera(provider_id: str, raw_id: Any, lat: float, lon: float, *, name: str = "", roadway: str = "",
direction: str = "", image_url: Optional[str] = None, video_url: Optional[str] = None,
heading_deg: Optional[float] = None) -> Camera:
"""One normalised record. ``id`` is provider-prefixed so two catalogs can't collide."""
if heading_deg is None:
heading_deg = heading_from_text(direction)
return {
"id": f"{provider_id}:{raw_id}",
"provider": provider_id,
"lat": lat, "lon": lon,
"name": name, "roadway": roadway, "direction": direction,
# Approximate at best: most sources publish the road's direction of
# travel, not the camera's true heading. The UI labels viewsheds so.
"heading_deg": heading_deg,
"image_url": image_url, "video_url": video_url,
}
_COMPASS = {"N": 0, "NE": 45, "E": 90, "SE": 135, "S": 180, "SW": 225, "W": 270, "NW": 315}
_WORDS = {"north": "N", "south": "S", "east": "E", "west": "W",
"northeast": "NE", "northwest": "NW", "southeast": "SE", "southwest": "SW"}
def heading_from_text(text: Optional[str]) -> Optional[float]:
"""'Northbound' / 'NB' / 'West' / 'SE' -> degrees; None when it can't tell.
'Both directions', 'Unknown', 'Eastbound/Westbound' etc. are None on purpose."""
if not text:
return None
t = text.strip().lower()
t = re.sub(r"bound$", "", t).strip()
if t in _WORDS:
return float(_COMPASS[_WORDS[t]])
t2 = re.sub(r"b$", "", t).upper() if re.fullmatch(r"(n|s|e|w|ne|nw|se|sw)b?", t) else t.upper()
return float(_COMPASS[t2]) if t2 in _COMPASS else None
def finite(v: Any) -> Optional[float]:
try:
f = float(v)
except (TypeError, ValueError):
return None
return f if f == f and f not in (float("inf"), float("-inf")) else None
def plausible(lat: Optional[float], lon: Optional[float], coverage: BBox) -> bool:
if lat is None or lon is None or (lat == 0 and lon == 0):
return False
s, w, n, e = coverage
return s <= lat <= n and w <= lon <= e
def https_on(url: Any, *hosts: str) -> Optional[str]:
"""Return ``url`` only when it is https on one of ``hosts`` (or a subdomain).
Pins imagery to the agency's own hosts so no catalog field can point the
browser somewhere else."""
if not isinstance(url, str) or not url.startswith("https://"):
return None
host = url[len("https://"):].split("/", 1)[0].split(":", 1)[0].lower()
return url if any(host == h or host.endswith("." + h) for h in hosts) else None
def bbox_intersects(a: BBox, b: BBox) -> bool:
return not (a[2] < b[0] or b[2] < a[0] or a[3] < b[1] or b[3] < a[1])
def in_bbox(items: List[Camera], bbox: BBox) -> List[Camera]:
s, w, n, e = bbox
return [i for i in items if s <= i["lat"] <= n and w <= i["lon"] <= e]
async def get_json(url: str, params: Optional[dict] = None, headers: Optional[dict] = None) -> Any:
"""GET a JSON catalog. Redirects are refused: a catalog that moves is a
change to review, not something to follow to an unknown host."""
import httpx # local import keeps module import cheap for the test stubs
h = {"User-Agent": USER_AGENT, "Accept": "application/json", **(headers or {})}
async with httpx.AsyncClient(timeout=FETCH_TIMEOUT_S, follow_redirects=False) as client:
r = await client.get(url, params=params, headers=h)
r.raise_for_status()
ctype = r.headers.get("content-type", "")
if "json" not in ctype:
# 511NY's dead legacy API answers 200 text/html (server-26#198).
raise ValueError(f"expected JSON, got {ctype or 'no content-type'} (HTTP {r.status_code})")
return r.json()
def require_parsed(raw_count: int, out: List[Camera], what: str) -> List[Camera]:
"""Every record failing to normalise means the schema changed under us --
that is an error, not an empty catalog."""
if raw_count and not out:
raise ValueError(f"0 of {raw_count} {what} records parsed -- upstream schema change?")
return out
+373
View File
@@ -0,0 +1,373 @@
"""General / other-region camera sources -- design-canvas/camera-sources.md §C,
including every God's Eye View (GEV) source.
Endpoints and record shapes for the implemented adapters were checked
first-hand against each live catalog on 2026-10-04 (Ontario 511 excepted: it now
answers "Invalid Key" without a developer key, so its shape is the legacy CARS
v2 shape, UNVERIFIED). Imagery URLs are pinned to each agency's own hosts. None
of these cover DRB's region; they exist so a non-NY customer has a path, and
every one of them is OFF until an admin enables it.
GEV is MIT-licensed code, but nothing here is copied from it, and none of GEV's
bundled data (config/*.json curated catalogs) is used -- the MIT grant does not
cover data.
"""
import asyncio
from typing import List
from app.config import settings
from app.internal.cameras.base import (
Camera, CameraProvider, Licence, LicenceInfo, ProviderMeta, StubProvider, finite, get_json, https_on,
make_camera, plausible, require_parsed,
)
def _unverified(label: str, terms: str, url: str = None) -> LicenceInfo:
return LicenceInfo(status=Licence.UNVERIFIED, label=label, terms=terms, terms_url=url)
# ── Caltrans CWWP2 ───────────────────────────────────────────────────────────
class CaltransProvider(CameraProvider):
DISTRICTS = (4, 7, 11, 3) # SF Bay, LA, San Diego, Sacramento
meta = ProviderMeta(
id="caltrans", name="Caltrans CWWP2", region="California (districts 4, 7, 11, 3)",
coverage=(32.4, -124.6, 42.1, -114.0),
licence=_unverified(
"Licence UNVERIFIED -- likely commercial OK; Conditions of Use not yet read",
"\"Commercial Wholesale Web Portal\", \"no charge\"; Conditions of Use apply and have not been read.",
"https://cwwp2.dot.ca.gov/documentation/cctv/cctv.htm"),
formats="stills (~5 min) + HLS", implemented=True,
notes="Schema verified 2026-10-04 (D4). HLS host CORS unverified -- the player falls back to the still.",
homepage="https://cwwp2.dot.ca.gov",
)
async def _district(self, d: int) -> list:
payload = await get_json(f"https://cwwp2.dot.ca.gov/data/d{d}/cctv/cctvStatusD{d:02d}.json")
return payload.get("data") or [] if isinstance(payload, dict) else []
async def fetch_catalog(self) -> List[Camera]:
results = await asyncio.gather(*(self._district(d) for d in self.DISTRICTS), return_exceptions=True)
ok = [r for r in results if not isinstance(r, BaseException)]
if not ok:
raise RuntimeError(f"every Caltrans district failed: {results[0]}")
rows = [row for district in ok for row in district]
out: List[Camera] = []
for row in rows:
c = (row or {}).get("cctv") or {}
if str(c.get("inService", "")).lower() != "true":
continue
loc = c.get("location") or {}
lat, lon = finite(loc.get("latitude")), finite(loc.get("longitude"))
if not plausible(lat, lon, self.meta.coverage):
continue
img = https_on(((c.get("imageData") or {}).get("static") or {}).get("currentImageURL"), "dot.ca.gov")
if not img:
continue
vid = https_on((c.get("imageData") or {}).get("streamingVideoURL"), "dot.ca.gov")
name = str(loc.get("locationName") or "")
code = name.split("--", 1)[0].strip() or str(c.get("index"))
out.append(make_camera("caltrans", f"d{loc.get('district')}-{code}", lat, lon,
name=name.split("--", 1)[-1].strip() or name, roadway=str(loc.get("route") or ""),
direction=str(loc.get("direction") or ""), image_url=img, video_url=vid))
return require_parsed(len(rows), out, "Caltrans") if rows else out
# ── DelDOT ───────────────────────────────────────────────────────────────────
class DelDotProvider(CameraProvider):
meta = ProviderMeta(
id="deldot", name="DelDOT TMC", region="Delaware",
coverage=(38.4, -75.8, 39.9, -75.0),
licence=_unverified("Licence UNVERIFIED -- \"public data\", no explicit licence found",
"No licence or terms of reuse located for tmc.deldot.gov's camera catalog."),
formats="live HLS only (no stills)", implemented=True,
notes="Schema verified 2026-10-04 (361 cams); https HLS host sends ACAO: * (verified). Video-only, so a "
"camera shows nothing when its stream is down. Played directly from the browser -- no DRB egress.",
homepage="https://deldot.gov/map/",
)
async def fetch_catalog(self) -> List[Camera]:
payload = await get_json("https://tmc.deldot.gov/json/videocamera.json")
rows = payload.get("videoCameras") or [] if isinstance(payload, dict) else []
out: List[Camera] = []
for r in rows:
if r.get("enabled") is not True or r.get("status") != "Active" or not r.get("id"):
continue
lat, lon = finite(r.get("lat")), finite(r.get("lon"))
vid = https_on((r.get("urls") or {}).get("m3u8s"), "deldot.gov")
if not plausible(lat, lon, self.meta.coverage) or not vid:
continue
out.append(make_camera("deldot", r["id"], lat, lon, name=str(r.get("title") or r["id"]),
roadway=str(r.get("county") or ""), video_url=vid))
return require_parsed(len(rows), out, "DelDOT")
# ── Ontario 511 ──────────────────────────────────────────────────────────────
class Ontario511Provider(CameraProvider):
meta = ProviderMeta(
id="ontario511", name="Ontario 511", region="Ontario, Canada",
coverage=(41.6, -95.2, 57.0, -74.3),
licence=_unverified("Licence UNVERIFIED -- Ontario open-data terms page not read",
"GEV reports the Open Government Licence - Ontario; not confirmed first-hand.",
"https://511on.ca/developers/doc"),
formats="stills", implemented=True,
notes="Needs ONTARIO511_API_KEY (verified 2026-10-04: keyless now answers \"Invalid Key\"); 10 calls/60 s. "
"Record shape is the CARS v2 shape (Id/Latitude/Longitude/Views[]), UNVERIFIED since the key change.",
homepage="https://511on.ca", key_setting="ontario511_api_key",
)
async def fetch_catalog(self) -> List[Camera]:
if not settings.ontario511_api_key:
raise RuntimeError("ONTARIO511_API_KEY not set")
rows = await get_json("https://511on.ca/api/v2/get/cameras",
params={"format": "json", "lang": "en", "key": settings.ontario511_api_key})
rows = rows if isinstance(rows, list) else []
out: List[Camera] = []
for r in rows:
lat, lon = finite(r.get("Latitude")), finite(r.get("Longitude"))
if not r.get("Id") or not plausible(lat, lon, self.meta.coverage):
continue
views = [v for v in (r.get("Views") or []) if str(v.get("Status", "")).lower() == "enabled"]
img = next((https_on(v.get("Url"), "511on.ca") for v in views if https_on(v.get("Url"), "511on.ca")), None)
if not img:
continue
out.append(make_camera("ontario511", r["Id"], lat, lon, name=str(r.get("Location") or ""),
roadway=str(r.get("Roadway") or ""), direction=str(r.get("Direction") or ""),
image_url=img))
return require_parsed(len(rows), out, "Ontario 511")
# ── DriveBC ──────────────────────────────────────────────────────────────────
class DriveBcProvider(CameraProvider):
meta = ProviderMeta(
id="drivebc", name="DriveBC", region="British Columbia, Canada",
coverage=(48.2, -139.1, 60.1, -114.0),
licence=LicenceInfo(
status=Licence.ATTRIBUTION_REQUIRED, label="Commercial OK with attribution (OGL - British Columbia)",
terms="Open Government Licence - British Columbia: reuse permitted with attribution.",
terms_url="https://www2.gov.bc.ca/gov/content/data/policy-standards/open-data/open-government-licence-bc",
attribution="DriveBC -- contains information licensed under the Open Government Licence - British Columbia",
attribution_url="https://www.drivebc.ca"),
formats="stills", implemented=True, notes="Schema verified 2026-10-04 (1,066 cams).",
homepage="https://www.drivebc.ca",
)
async def fetch_catalog(self) -> List[Camera]:
rows = await get_json("https://www.drivebc.ca/api/webcams/")
rows = rows if isinstance(rows, list) else []
out: List[Camera] = []
for r in rows:
if r.get("is_on") is not True or r.get("should_appear") is not True:
continue
cid = r.get("id")
coords = (r.get("location") or {}).get("coordinates") or []
if not isinstance(cid, int) or len(coords) < 2:
continue
lon, lat = finite(coords[0]), finite(coords[1])
if not plausible(lat, lon, self.meta.coverage):
continue
out.append(make_camera("drivebc", cid, lat, lon, name=str(r.get("name") or ""),
roadway=str(r.get("highway_description") or ""),
direction=str(r.get("orientation") or ""),
image_url=f"https://www.drivebc.ca/images/{cid}.jpg"))
return require_parsed(len(rows), out, "DriveBC")
# ── Open Calgary ─────────────────────────────────────────────────────────────
class CalgaryProvider(CameraProvider):
meta = ProviderMeta(
id="calgary", name="Open Calgary traffic cameras", region="Calgary, Alberta",
coverage=(50.8, -114.5, 51.3, -113.8),
# UNVERIFIED like Ontario 511: the OGL - Calgary grant is per GEV, not read
# first-hand (camera-sources.md: "Yes, attribution (per GEV)"), so it is gated.
licence=LicenceInfo(
status=Licence.UNVERIFIED,
label="Licence UNVERIFIED -- OGL - City of Calgary per GEV, terms page not read first-hand",
terms="Open Government Licence - City of Calgary (reuse with attribution) per GEV; not confirmed first-hand.",
terms_url="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa",
attribution="Contains information licensed under the Open Government Licence - City of Calgary",
attribution_url="https://data.calgary.ca"),
formats="stills", implemented=True,
notes="Schema verified 2026-10-04 (216 cams). Catalog lists http:// stills; upgraded to https on the same host.",
homepage="https://data.calgary.ca",
)
async def fetch_catalog(self) -> List[Camera]:
rows = await get_json("https://data.calgary.ca/resource/k7p9-kppz.json", params={"$limit": "1000"})
rows = rows if isinstance(rows, list) else []
out: List[Camera] = []
for r in rows:
url = str((r.get("camera_url") or {}).get("url") or "")
if url.startswith("http://"):
url = "https://" + url[len("http://"):]
img = https_on(url, "trafficcam.calgary.ca")
coords = (r.get("point") or {}).get("coordinates") or []
if not img or len(coords) < 2:
continue
lon, lat = finite(coords[0]), finite(coords[1])
if not plausible(lat, lon, self.meta.coverage):
continue
cid = img.rsplit("/", 1)[-1].rsplit(".", 1)[0]
out.append(make_camera("calgary", cid, lat, lon, name=str(r.get("camera_location") or cid),
roadway=str(r.get("quadrant") or ""), image_url=img))
return require_parsed(len(rows), out, "Calgary")
# ── TfL JamCams ──────────────────────────────────────────────────────────────
class TflProvider(CameraProvider):
IMAGE_PREFIX = "https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/"
meta = ProviderMeta(
id="tfl", name="TfL JamCams", region="London, UK",
coverage=(51.2, -0.7, 51.8, 0.4),
# UNVERIFIED: the TfL terms page 403'd during the survey and the grant is
# per GEV only (camera-sources.md: "re-read"), so it is gated until re-read.
licence=LicenceInfo(
status=Licence.UNVERIFIED,
label="Licence UNVERIFIED -- commercial OK with attribution per GEV; TfL terms page not read first-hand",
terms="TfL transport data terms: free incl. commercial, with attribution (per GEV; the terms page "
"returned 403 during the survey -- re-read before selling).",
terms_url="https://tfl.gov.uk/info-for/open-data-users/our-open-data",
attribution="Powered by TfL Open Data", attribution_url="https://tfl.gov.uk"),
formats="stills (+ short MP4, unused)", implemented=True, notes="Schema verified 2026-10-04 (890 cams).",
homepage="https://api.tfl.gov.uk",
)
async def fetch_catalog(self) -> List[Camera]:
rows = await get_json("https://api.tfl.gov.uk/Place/Type/JamCam")
rows = rows if isinstance(rows, list) else []
out: List[Camera] = []
for r in rows:
props = {p.get("key"): p.get("value") for p in (r.get("additionalProperties") or []) if isinstance(p, dict)}
if str(props.get("available", "")).lower() != "true":
continue
img = str(props.get("imageUrl") or "")
lat, lon = finite(r.get("lat")), finite(r.get("lon"))
raw_id = str(r.get("id") or "").replace("JamCams_", "")
if not img.startswith(self.IMAGE_PREFIX) or not raw_id or not plausible(lat, lon, self.meta.coverage):
continue
out.append(make_camera("tfl", raw_id, lat, lon, name=str(r.get("commonName") or raw_id),
direction=str(props.get("view") or ""), image_url=img))
return require_parsed(len(rows), out, "TfL")
# ── Live Traffic NSW ─────────────────────────────────────────────────────────
class NswProvider(CameraProvider):
meta = ProviderMeta(
id="nsw", name="Live Traffic NSW", region="New South Wales, Australia",
coverage=(-37.6, 140.9, -28.0, 153.7),
licence=LicenceInfo(
status=Licence.ATTRIBUTION_REQUIRED, label="Commercial OK with attribution (CC BY 4.0)",
terms="Catalog's own rights block: copyright Transport for NSW, licence per the Open Data Hub dataset "
"(CC BY 4.0).",
terms_url="https://opendata.transport.nsw.gov.au/dataset/live-traffic-cameras",
attribution="Transport for NSW, CC BY 4.0", attribution_url="https://www.livetraffic.com"),
formats="stills", implemented=True, notes="Schema verified 2026-10-04 (241 cams).",
homepage="https://www.livetraffic.com",
)
async def fetch_catalog(self) -> List[Camera]:
payload = await get_json("https://data.livetraffic.com/cameras/traffic-cam.json")
feats = payload.get("features") or [] if isinstance(payload, dict) else []
out: List[Camera] = []
for f in feats:
p = f.get("properties") or {}
coords = (f.get("geometry") or {}).get("coordinates") or []
img = https_on(p.get("href"), "transport.nsw.gov.au")
if not img or len(coords) < 2 or not f.get("id"):
continue
lon, lat = finite(coords[0]), finite(coords[1])
if not plausible(lat, lon, self.meta.coverage):
continue
out.append(make_camera("nsw", f["id"], lat, lon, name=str(p.get("title") or ""),
roadway=str(p.get("region") or ""), direction=str(p.get("direction") or ""),
image_url=img))
return require_parsed(len(feats), out, "NSW")
# ── Fintraffic / Digitraffic weathercams ─────────────────────────────────────
class FintrafficProvider(CameraProvider):
meta = ProviderMeta(
id="fintraffic", name="Fintraffic / Digitraffic weathercams", region="Finland",
coverage=(59.5, 19.0, 70.2, 31.7),
licence=LicenceInfo(
status=Licence.ATTRIBUTION_REQUIRED, label="Commercial OK with attribution (CC BY 4.0)",
terms="\"Source: Fintraffic / digitraffic.fi, license CC 4.0 BY\".",
terms_url="https://www.digitraffic.fi/en/terms-of-service/",
attribution="Source: Fintraffic / digitraffic.fi, license CC 4.0 BY",
attribution_url="https://www.digitraffic.fi"),
formats="stills (~10 min)", implemented=True,
notes="Schema verified 2026-10-04 (810 stations, several presets each). Digitraffic asks clients to "
"identify themselves (Digitraffic-User header).",
homepage="https://www.digitraffic.fi", ttl_s=30 * 60,
)
async def fetch_catalog(self) -> List[Camera]:
payload = await get_json("https://tie.digitraffic.fi/api/weathercam/v1/stations",
headers={"Digitraffic-User": "DRB/c2-core"})
feats = payload.get("features") or [] if isinstance(payload, dict) else []
out: List[Camera] = []
for f in feats:
p = f.get("properties") or {}
coords = (f.get("geometry") or {}).get("coordinates") or []
sid = str(p.get("id") or "")
if not sid or str(p.get("collectionStatus", "")).upper() != "GATHERING" or len(coords) < 2:
continue
lon, lat = finite(coords[0]), finite(coords[1])
if not plausible(lat, lon, self.meta.coverage):
continue
for preset in p.get("presets") or []:
pid = str((preset or {}).get("id") or "")
if preset.get("inCollection") is not True or not pid.startswith(sid) or not pid[1:].isdigit():
continue
out.append(make_camera("fintraffic", pid, lat, lon, name=f"{p.get('name') or sid} ({pid[-2:]})",
image_url=f"https://weathercam.digitraffic.fi/{pid}.jpg"))
return require_parsed(len(feats), out, "Fintraffic")
# ── Stubs: catalogued, no adapter ────────────────────────────────────────────
def _stub(id, name, region, coverage, licence, formats, notes, homepage=None) -> StubProvider:
return StubProvider(ProviderMeta(id=id, name=name, region=region, coverage=coverage, licence=licence,
formats=formats, implemented=False, notes=notes, homepage=homepage))
STUBS = [
_stub("austin", "City of Austin open data", "Austin, Texas", (30.0, -98.1, 30.6, -97.5),
_unverified("Licence UNVERIFIED -- city open data, no explicit licence", "No explicit licence per GEV."),
"stills (5 min)", "Stub: Socrata column mapping not built.", "https://data.austintexas.gov"),
_stub("txdot", "TxDOT ITS", "Texas (per district)", (25.8, -106.7, 36.6, -93.5),
_unverified("Licence UNVERIFIED -- \"public data\"", "No explicit licence per GEV."),
"base64 JPEG inside JSON", "Stub: frames are base64 inside JSON, so the browser cannot show them "
"without a DRB frame proxy (video/image egress on DRB's bill -- not built).", "https://its.txdot.gov"),
_stub("md_chart", "Maryland CHART", "Maryland", (37.9, -79.5, 39.8, -75.0),
_unverified("Licence UNVERIFIED -- MD open data portal terms not read", "Not read."),
"live video URLs", "Stub: catalog schema not verified.", "https://chart.maryland.gov"),
_stub("wsdot", "WSDOT Traveler Information API", "Washington State", (45.5, -124.9, 49.1, -116.9),
_unverified("Licence UNVERIFIED -- WSDOT disclaimer only", "Free access code required; terms not read."),
"stills", "Stub: needs a WSDOT access code; schema not verified.", "https://wsdot.wa.gov/traffic/api/"),
_stub("tallinn", "Tallinn city cameras", "Tallinn, Estonia", (59.35, 24.55, 59.5, 24.95),
_unverified("Licence UNVERIFIED", "No licence found."),
"stills", "Stub: GEV uses a curated catalog file; GEV's data is not covered by its MIT licence.",
"https://ristmikud.tallinn.ee"),
_stub("tarktee", "Tarktee (Estonian Transport Administration)", "Estonia", (57.5, 21.7, 59.8, 28.3),
_unverified("Licence UNVERIFIED", "No licence found."),
"DATEX2 stills", "Stub: DATEX2 XML parser not built.", "https://tarktee.transpordiamet.ee"),
_stub("warendorf", "Warendorf municipal webcam", "Warendorf, Germany", (51.9, 7.9, 52.0, 8.1),
_unverified("Licence UNVERIFIED", "No licence found."),
"stills", "Stub: single curated webcam; no catalog API.", "https://www.warendorf.de"),
_stub("trafficland", "TrafficLand", "US (18k cams, 50+ DOTs)", (18.0, -170.0, 72.0, -60.0),
LicenceInfo(status=Licence.PAID, label="Paid commercial licence -- no contract",
terms="Licensed API; contractual redistribution with 50+ DOTs. Price UNVERIFIED.",
terms_url="http://www.trafficland.com/api.html"),
"live video", "Stub: the clean route if DRB ever needs video beyond 511NY; needs a contract."),
_stub("windy", "Windy Webcams API", "Global (scenic/weather)", (-90.0, -180.0, 90.0, 180.0),
LicenceInfo(status=Licence.PAID, label="Paid professional tier for commercial use -- no contract",
terms="Free tier image URLs expire after 10 min; commercial terms UNVERIFIED.",
terms_url="https://api.windy.com/webcams/docs"),
"tokenised stills", "Stub: scenic/weather cams, not traffic."),
_stub("earthcam", "EarthCam", "Global", (-90.0, -180.0, 90.0, 180.0),
LicenceInfo(status=Licence.PAID, label="Paid commercial licence -- no contract",
terms="Commercial licensing exists; terms UNVERIFIED."),
"live video", "Stub: low local value."),
]
PROVIDERS = [CaltransProvider(), DelDotProvider(), Ontario511Provider(), DriveBcProvider(), CalgaryProvider(),
TflProvider(), NswProvider(), FintrafficProvider(), *STUBS]
@@ -0,0 +1,168 @@
"""Tri-state (Westchester-first) camera sources -- design-canvas/camera-sources.md §A.
Only 511NY has a working adapter. Every other regional source is listed so
Admin shows its licence status, and is a stub because DRB has no right to use
it yet (NYC DOT, Westchester DPW), it carries no cameras (CTroads), or no
developer program exists (511NJ). Do not "fix" a stub by scraping: CTroads,
511NJ's app and the new 511ny.org backend all ban robots/reverse engineering.
"""
from typing import List
from app.internal import ny511
from app.internal.cameras.base import (
Camera, CameraProvider, Licence, LicenceInfo, ProviderMeta, StubProvider, make_camera, require_parsed,
)
NY_BBOX = (40.4, -80.0, 45.1, -71.7)
TRISTATE_BBOX = (40.3, -75.6, 42.1, -71.7)
NYSDOT_DISCLAIMER = (
"NYSDOT compiles the data from the best available sources and makes a best effort to disseminate it. "
"Data availability and accuracy are subject to change, and may change as often as daily. Posted "
"restrictions in the field may differ from, and supersede, the information in this feed. NYSDOT and its "
"partner agencies make no warranty regarding the data, express or implied, and expressly disclaim all "
"warranties of merchantability and fitness for any particular purpose. NYSDOT makes no warranty that the "
"data will be provided without interruption or that it will be free of errors. Access is provided on an "
"\"as is\" and \"with all faults\" basis."
)
class Ny511Provider(CameraProvider):
meta = ProviderMeta(
id="ny511",
name="511NY (NYSDOT)",
region="New York State (aggregates NYSDOT, Thruway, NYC DOT, MTA B&T, PANYNJ, TRANSCOM partners)",
coverage=NY_BBOX,
licence=LicenceInfo(
status=Licence.AGREEMENT_REQUIRED,
label="Requires NYSDOT Developer Access Agreement -- access to the new 511x feed NOT granted yet",
terms=("Commercial redistribution is allowed under the DAA (\"You may redistribute, enhance, repackage, "
"and add value to the data\"; \"You may not alter the substance of a report\"; must display the "
"time the data was published or last updated). Access is granted per request via the 511NY "
"Data Feed Request Form; DRB's 2026-09-27 request went to the retired system. Overlays, "
"detection and frame archiving are not addressed (server-26#199)."),
terms_url="https://511ny.org/help/24",
# DAA permitted use: tag line + logo + link. The approved logo is not
# in the repo yet (owner action), so only the tag line + link render.
attribution="powered by 511NY",
attribution_url="https://511ny.org",
disclaimer=NYSDOT_DISCLAIMER,
issue="server-26#198",
),
formats="stills (60 s) + HLS on NYSDOT hosts (legacy shape)",
implemented=True,
notes=("Legacy request/record shape behind settings.ny511_api_base + ny511_api_key, plus a tolerant "
"parser for the 511x XML camera inventory. UNVERIFIED until the DAA grant documents the new "
"endpoints: base URL, query params, XML element names, still/HLS URL shape and CORS. The "
"legacy endpoint has returned HTML since the 511x cutover, so expect a feed error until then."),
homepage="https://511ny.org",
key_setting="ny511_api_key",
ttl_s=ny511.CAMERAS_TTL_S,
)
async def fetch_catalog(self) -> List[Camera]:
rows = await ny511._fetch("getcameras", ny511.normalize_camera)
# Hosts for the 511x imagery are unverified, so pin the scheme at least:
# only https URLs reach the browser.
def https(u):
return u if isinstance(u, str) and u.startswith("https://") else None
out = [
make_camera("ny511", r["id"], r["lat"], r["lon"], name=r["name"], roadway=r["roadway"],
direction=r["direction"], image_url=https(r["image_url"]), video_url=https(r["video_url"]))
for r in rows
]
return require_parsed(len(rows), out, "511NY")
NYC_DOT = StubProvider(ProviderMeta(
id="nyc_dot",
name="NYC DOT TMC (webcams.nyctmc.org)",
region="New York City (~975 cams, ~80 in the Bronx)",
coverage=(40.45, -74.30, 40.95, -73.65),
licence=LicenceInfo(
status=Licence.AGREEMENT_REQUIRED,
label="Requires a signed NYC DOT data-sharing agreement -- NOT signed",
terms=("NYC DOT Data Feeds page: \"Developers must sign a data-sharing agreement\" (tmcdot@dot.nyc.gov). "
"The API answers keyless, but keyless use is outside those terms. Agreement terms unpublished."),
terms_url="https://www.nyc.gov/html/dot/html/about/datafeeds.shtml",
issue="server-26#201",
),
formats="JPEG stills (~15 s), no CORS",
implemented=False,
notes="Stub on purpose: do not integrate until the agreement is signed (server-26#201). Many of these cams "
"are likely already re-published by 511NY.",
homepage="https://webcams.nyctmc.org",
))
WESTCHESTER_DPW = StubProvider(ProviderMeta(
id="westchester_dpw",
name="Westchester County DPW traffic cameras",
region="Westchester County (~21 locations, mostly Bronx River Pkwy)",
coverage=(40.88, -73.99, 41.37, -73.48),
licence=LicenceInfo(
status=Licence.AGREEMENT_REQUIRED,
label="No reuse licence -- written permission from County DPW required, NOT obtained",
terms="Only a disclaimer: data \"provided 'as is' without warranty\". No API, no reuse licence.",
terms_url="https://experience.arcgis.com/experience/a59314e926e94301b9083edd6bb52a11",
issue="server-26#202",
),
formats="stills (5 s), viewer only",
implemented=False,
notes="Stub: there is no documented API or feed, and no permission. Ask DPW in writing (server-26#202).",
homepage="https://www.westchestergov.com/trafficcamera",
))
TRANSCOM = StubProvider(ProviderMeta(
id="transcom_xcm",
name="TRANSCOM XCM data exchange",
region="NY/NJ/CT tri-state",
coverage=TRISTATE_BBOX,
licence=LicenceInfo(
status=Licence.UNVERIFIED,
label="Events/travel times commercial OK; cameras UNVERIFIED",
terms="Free registration, open to \"commercial vendors ... for use in their applications\". Whether the "
"feed carries camera URLs is unverified.",
terms_url="https://data.xcmdata.org/DEWeb/Pages/aboutus",
),
formats="XML events + link travel times (cameras unverified)",
implemented=False,
notes="Stub: register first, then confirm whether any camera inventory exists. Its events feed would be a "
"separate (event) provider, not a camera one.",
homepage="https://data.xcmdata.org",
))
CTROADS = StubProvider(ProviderMeta(
id="ctroads",
name="CTDOT / CTroads",
region="Connecticut",
coverage=(40.95, -73.75, 42.06, -71.78),
licence=LicenceInfo(
status=Licence.PROHIBITED,
label="No sanctioned camera path -- site terms forbid camera reuse",
terms="The keyed developer API covers Message Signs, Events and Advisories only -- no cameras. The site "
"T&C forbid use \"for any personal, public or commercial purpose\" and robots.",
terms_url="https://ctroads.org/termsandconditions",
),
formats="events/signs only (no cameras)",
implemented=False,
notes="Cannot be enabled. CT cameras may surface via 511NY's \"Connecticut Statewide\" region once the DAA "
"grant arrives.",
homepage="https://ctroads.org/developers/doc",
))
NJ511 = StubProvider(ProviderMeta(
id="nj511",
name="511NJ / NJDOT / NJTA / PANYNJ",
region="New Jersey",
coverage=(38.9, -75.6, 41.4, -73.9),
licence=LicenceInfo(
status=Licence.UNVERIFIED,
label="No published developer program -- reuse UNVERIFIED",
terms="511NJ is a JS app with no published developer program; NJDOT only cites RSS/Twitter.",
),
formats="unknown",
implemented=False,
notes="Stub: ask NJDOT 511 if northern NJ matters. Some cams appear in 511NY.",
))
PROVIDERS = [Ny511Provider(), NYC_DOT, WESTCHESTER_DPW, TRANSCOM, CTROADS, NJ511]
@@ -0,0 +1,200 @@
"""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()
serving = [pid for pid in CATALOGS if effective(PROVIDERS[pid], conf.get(pid))[0]]
live = [pid for pid in serving if bbox_intersects(PROVIDERS[pid].meta.coverage, bbox)]
await asyncio.gather(*(CATALOGS[pid].get() for pid in live))
cams: List[Camera] = []
providers = []
# Every SERVING provider is listed (credits included) even when the bbox
# misses it, so a client holding one of its cameras from another view can
# still render the required attribution. Only intersecting ones are fetched.
for pid in serving:
cat = CATALOGS[pid]
hits = in_bbox(cat.items, bbox) if pid in live else []
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": len(serving)}
+16 -1
View File
@@ -312,7 +312,8 @@ async def delete_node_client(node_id: str) -> None:
async def reconcile_all() -> None:
"""
Rebuild dynsec state for every approved node from Firestore
(node_keys is the source of truth). Called once at c2-core startup,
(node_keys is the source of truth), and delete the login of every
rejected node. Called once at c2-core startup,
after ensure_roles_and_c2core_grant(). Self-heals a lost/corrupted
dynamic-security.json (e.g. volume wiped, or a prior approve/reissue's
dynsec publish silently failed to persist for some other reason) —
@@ -320,6 +321,20 @@ async def reconcile_all() -> None:
dynsec store would lock out every previously-approved node until
someone noticed and manually re-approved each one.
"""
# server-26#195: revoke first. A node rejected before reject_node()
# revoked anything (or whose revoke hit a dynsec failure) still has a
# broker login in dynamic-security.json; startup is where that heals.
# delete_node_client() treats "not found" as success, so this is a no-op
# for every node already revoked.
for node in await fstore.collection_list("nodes", approval_status="rejected"):
node_id = node.get("node_id")
if not node_id:
continue
try:
await delete_node_client(node_id)
except DynsecError as e:
logger.error(f"dynsec reconcile: failed to revoke rejected node {node_id!r}: {e}")
nodes = await fstore.collection_list("nodes", approval_status="approved")
if not nodes:
return
+12 -1
View File
@@ -44,7 +44,18 @@ def _get_client():
with _client_lock:
if _client is None:
from google import genai # lazy — only when a Gemini call is made
_client = genai.Client(api_key=settings.gemini_api_key)
from google.genai import types
# server-26#153: google-genai's default is no timeout at all, so a
# hung call held its pipeline (and the sweep hold-back sized
# against it) open indefinitely. HttpOptions.timeout is in ms.
# The client does not retry unless retry_options is set, so this
# is the whole bound per request. A timeout raises from
# generate_json like any API error, so every caller's fallback
# (rules-only correlation, uncorrected transcript) is unchanged.
_client = genai.Client(
api_key=settings.gemini_api_key,
http_options=types.HttpOptions(timeout=int(settings.gemini_timeout_seconds * 1000)),
)
return _client
+4 -4
View File
@@ -938,7 +938,7 @@ def _sync_extract(
) -> list[dict]:
"""Call GPT-4o-mini and return a list of scene dicts."""
from app.config import settings
from openai import OpenAI
from app.internal.ai_clients import openai_client
if not settings.openai_api_key:
logger.warning("OPENAI_API_KEY not set — intelligence extraction disabled.")
@@ -956,7 +956,7 @@ def _sync_extract(
)
try:
client = OpenAI(api_key=settings.openai_api_key)
client = openai_client()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
@@ -985,13 +985,13 @@ def _sync_extract(
def _sync_embed(text: str) -> Optional[list[float]]:
"""Generate a text-embedding-3-small vector for semantic similarity."""
from app.config import settings
from openai import OpenAI
from app.internal.ai_clients import openai_client
if not settings.openai_api_key:
return None
try:
client = OpenAI(api_key=settings.openai_api_key)
client = openai_client()
result = client.embeddings.create(model="text-embedding-3-small", input=text)
return result.data[0].embedding
except Exception as e:
+104 -5
View File
@@ -9,6 +9,25 @@ makes no 511 calls at all.
A failed refresh keeps serving the last good data and reports the error and
its age to the caller -- an empty layer must never be the only symptom of a
dead feed (the AI-silent-failures lesson).
**Feed status (server-26#198), 2026-10-04:** 511ny.org cut over to the CARS
"511x" platform around 2026-09-24..30 and the legacy ``/api/getcameras`` and
``/api/getevents`` endpoints now answer HTTP 200 with the SPA's HTML. The new
developer feed is granted only after the NYSDOT Developer Access Agreement
request is approved ("If approved for use, you will receive instructions for
accessing the data feeds & documentation" -- 511NY "For Developers" page), so
its URL shape is NOT public. This client therefore keeps the LEGACY request and
record shape behind ``settings.ny511_api_base`` (default: the legacy base) and
``settings.ny511_api_key``. UNVERIFIED until the grant arrives:
* the new base URL and whether it still takes ``?key=&format=json``;
* whether cameras still come as JSON (the public feed list says
"Camera inventory (XML)") and keep the legacy field names;
* the still/HLS URL shape and its CORS headers.
Never point this at the new site's own backend (api-511x-nysdot.carsprogram.org):
the 511NY Terms of Use ban robots and reverse engineering outside the DAA.
Access is also gated by the camera-provider registry (``ny511`` provider,
default OFF, DAA acknowledgement required) -- see app/internal/cameras/.
"""
import asyncio
import time
@@ -19,12 +38,16 @@ import httpx
from app.config import settings
from app.internal.logger import logger
from app.internal.cameras.base import safe_error
_BASE = "https://511ny.org/api"
LEGACY_BASE = "https://511ny.org/api"
CAMERAS_TTL_S = 60 * 60 # camera list is near-static
EVENTS_TTL_S = 2 * 60 # accidents/closures change minute to minute
RETRY_AFTER_FAILURE_S = 60
_DESCRIPTION_MAX = 500
# The DAA forbids altering "the substance of a report", so descriptions are
# shown as published. This cap only guards against a pathological record; no
# real 511NY description comes near it, and a cut one is marked with an ellipsis.
_DESCRIPTION_MAX = 4000
class _Feed:
@@ -51,7 +74,7 @@ class _Feed:
self._next_attempt = time.time() + self.ttl_s
except Exception as e:
self._next_attempt = time.time() + min(self.ttl_s, RETRY_AFTER_FAILURE_S)
self.error = f"{type(e).__name__}: {e}"[:300]
self.error = safe_error(e) # never echo ?key= to viewers
logger.warning(f"511NY {self.path} refresh failed, serving {len(self.items)} cached: {self.error}")
return self
@@ -61,9 +84,22 @@ async def _fetch(path: str, normalize) -> List[Dict[str, Any]]:
if settings.ny511_api_key:
params["key"] = settings.ny511_api_key
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(f"{_BASE}/{path}", params=params)
base = (settings.ny511_api_base or LEGACY_BASE).rstrip("/")
r = await client.get(f"{base}/{path}", params=params)
r.raise_for_status()
raw = r.json()
text = getattr(r, "text", None)
if isinstance(text, str) and text.lstrip()[:1] == "<":
# The 511x camera inventory is published as XML; HTML is the dead
# legacy endpoint serving the SPA shell.
raw = parse_inventory_xml(text)
else:
try:
raw = r.json()
except ValueError as e:
raise ValueError(
"511NY answered neither JSON nor XML (the legacy API has served the 511x "
"site's HTML since the cutover, server-26#198)"
) from e
if not isinstance(raw, list):
raise ValueError(f"expected a JSON list, got {type(raw).__name__}")
out = [n for n in (normalize(x) for x in raw) if n is not None]
@@ -73,6 +109,69 @@ async def _fetch(path: str, normalize) -> List[Dict[str, Any]]:
return out
# ── XML camera inventory (511x) -- UNVERIFIED shape ──────────────────────────
# The 511NY "For Developers" page lists the camera feed as "Camera inventory
# (XML)" but publishes no schema (it arrives with the DAA grant). This parser is
# deliberately tolerant: any element carrying latitude + longitude children (or
# attributes) is a record, and child names are matched case-insensitively onto
# the legacy JSON field names so normalize_camera() works unchanged. Replace the
# alias table with the real element names once the grant's documentation is in hand.
_XML_ALIASES = {
"ID": ("id", "cameraid", "camera_id", "deviceid", "device_id"),
"Latitude": ("latitude", "lat"),
"Longitude": ("longitude", "lon", "lng", "long"),
"Name": ("name", "cameraname", "location", "description", "title"),
"RoadwayName": ("roadwayname", "roadway", "road", "route"),
"DirectionOfTravel": ("directionoftravel", "direction"),
"Url": ("url", "imageurl", "image_url", "snapshoturl", "image", "stillurl"),
"VideoUrl": ("videourl", "video_url", "streamurl", "video", "hlsurl"),
"Disabled": ("disabled",),
"Blocked": ("blocked",),
}
_XML_LOOKUP = {alias: key for key, aliases in _XML_ALIASES.items() for alias in aliases}
_XML_MAX_BYTES = 32 * 1024 * 1024
def _local(tag: str) -> str:
return tag.rsplit("}", 1)[-1].lower()
def _xml_bool(v: Any) -> Any:
if isinstance(v, str) and v.strip().lower() in ("true", "false", "1", "0"):
return v.strip().lower() in ("true", "1")
return v
def parse_inventory_xml(text: str) -> List[Dict[str, Any]]:
"""Map an XML camera inventory onto legacy-shaped dicts. Raises on HTML or
on any DTD/entity declaration (stdlib expat would expand entities)."""
head = text.lstrip()[:512].lower()
if head.startswith("<!doctype html") or head.startswith("<html"):
raise ValueError("511NY answered HTML, not a feed (legacy API dead since the 511x cutover, server-26#198)")
if len(text) > _XML_MAX_BYTES:
raise ValueError("511NY XML inventory larger than the 32 MB safety cap")
if "<!doctype" in text.lower() or "<!entity" in text.lower():
raise ValueError("511NY XML carries a DTD/entity declaration -- refused")
import xml.etree.ElementTree as ET
root = ET.fromstring(text)
out: List[Dict[str, Any]] = []
for el in root.iter():
fields: Dict[str, Any] = {}
for k, v in el.attrib.items():
key = _XML_LOOKUP.get(_local(k))
if key:
fields.setdefault(key, v)
for child in el:
key = _XML_LOOKUP.get(_local(child.tag))
if key and len(child) == 0 and child.text is not None:
fields.setdefault(key, child.text.strip())
if "Latitude" in fields and "Longitude" in fields:
fields["Disabled"] = _xml_bool(fields.get("Disabled", False))
fields["Blocked"] = _xml_bool(fields.get("Blocked", False))
out.append(fields)
return out
def _coords(x: Dict[str, Any]) -> Optional[tuple]:
try:
lat, lon = float(x["Latitude"]), float(x["Longitude"])
+59 -11
View File
@@ -14,6 +14,7 @@ Never creates new incidents — link-only. Zero LLM tokens (uses pre-computed
talkgroup strings, haversine math, and stored embeddings).
"""
import asyncio
import math
from datetime import datetime, timezone, timedelta
from typing import Optional
from app.internal.logger import logger
@@ -33,16 +34,54 @@ from app.config import settings
# itself failed) is NOT held back by this — absence isn't evidence of an
# in-flight pipeline, and #131's own bug predates this field existing.
#
# 15, not 5: neither OpenAI's Whisper client nor Gemini's call in
# llm_correlator.py sets a request timeout (server-26#153), so a hung call can
# run well past a few minutes on SDK-default retries, and this constant is a
# guess against that unbounded tail, not a measured bound. Raising it costs
# nothing on the recovery side: a call that finished processing (linked OR
# genuinely orphaned) always has corr_path set (_apply_and_log writes it even
# on the orphan action), so it's already excluded by the
# `not c.get("corr_path")` filter below and never reaches this check at all —
# this constant only ever delays calls that are still actually running.
MIN_MINUTES_SINCE_PIPELINE_START = 15
# Derived, not guessed (server-26#153). Every AI request the real-time
# pipeline makes now has an explicit timeout (config.py: stt_timeout_seconds,
# openai_timeout_seconds, openai_max_retries, gemini_timeout_seconds), so the
# longest a call can stay "started but no corr_path yet" has an upper bound:
# pipeline_worst_case_seconds() below. The hold-back is that bound rounded up,
# never below the old hand-picked 15.
#
# Raising it costs nothing on the recovery side: a call that finished
# processing (linked OR genuinely orphaned) always has corr_path set
# (_apply_and_log writes it even on the orphan action), so it's already
# excluded by the `not c.get("corr_path")` filter below and never reaches this
# check at all — this constant only ever delays calls that are still actually
# running, or whose pipeline died after writing the marker.
# Scenes whose embeddings are computed before the first scene's correlation
# commits (intelligence.extract_scenes embeds every scene, then upload.py
# correlates them in order; scene 0's commit writes corr_path). Extraction
# rarely returns more than 2; 3 is the budget, not a hard cap.
_BOUND_SCENES = 3
# Non-AI work on the same path: GCS download, Firestore reads/writes, geocoding
# and place verification (httpx, 5s timeouts each).
_NON_AI_SLACK_SECONDS = 120.0
_FLOOR_MINUTES = 15
def pipeline_worst_case_seconds(n_scenes: int = _BOUND_SCENES) -> float:
"""Upper bound on intelligence_started_at -> first corr_path write, from
the configured AI timeouts. Order follows routers/upload.py
_run_intelligence_pipeline."""
openai_attempts = 1 + max(0, settings.openai_max_retries)
stt_rounds = 2 if settings.stt_retry_on_degenerate else 1
stt = stt_rounds * openai_attempts * settings.stt_timeout_seconds
# Transcript correction (inside transcribe_call). gemini.generate_json only
# re-issues a request when the model rejects a thinking level — a fast
# 400, not a timeout — and google-genai itself does not retry.
correction = settings.gemini_timeout_seconds
extraction = openai_attempts * settings.openai_timeout_seconds
embeddings = n_scenes * openai_attempts * settings.openai_timeout_seconds
correlation = 2 * settings.gemini_timeout_seconds # decide + tiebreak, scene 0
return stt + correction + extraction + embeddings + correlation + _NON_AI_SLACK_SECONDS
def _derive_min_minutes() -> int:
return max(_FLOOR_MINUTES, math.ceil(pipeline_worst_case_seconds() / 60))
# 21 with the defaults (stt 120s, openai 60s x2 attempts, gemini 60s, 3 scenes).
MIN_MINUTES_SINCE_PIPELINE_START = _derive_min_minutes()
# Standard link-only retry budget before a call is tombstoned corr_path="unlinked".
MAX_SWEEP_ATTEMPTS = 3
@@ -66,8 +105,17 @@ async def recorrelation_loop() -> None:
logger.info(
f"Re-correlation sweep started — "
f"interval: {settings.summary_interval_minutes}m, "
f"scan window: {settings.recorrelation_scan_minutes}m"
f"scan window: {settings.recorrelation_scan_minutes}m, "
f"pipeline hold-back: {MIN_MINUTES_SINCE_PIPELINE_START}m"
)
if MIN_MINUTES_SINCE_PIPELINE_START >= settings.recorrelation_scan_minutes:
# A call is held back longer than the window that finds it, so the
# sweep can never recover a call whose pipeline died mid-flight.
logger.warning(
f"Re-correlation sweep: pipeline hold-back ({MIN_MINUTES_SINCE_PIPELINE_START}m) "
f">= scan window ({settings.recorrelation_scan_minutes}m) — lower the AI "
f"timeouts or raise RECORRELATION_SCAN_MINUTES"
)
while True:
await asyncio.sleep(interval)
try:
+2 -2
View File
@@ -213,7 +213,7 @@ async def _resolve_stale_incidents() -> None:
def _sync_summarize(inc: dict, transcripts: list[str]) -> Optional[str]:
from app.config import settings
from openai import OpenAI
from app.internal.ai_clients import openai_client
if not settings.openai_api_key:
return None
@@ -241,7 +241,7 @@ Write a concise factual summary of this incident in 2-4 sentences. Include:
Be factual. Do not speculate beyond what the transcripts say. Do not use bullet points."""
try:
client = OpenAI(api_key=settings.openai_api_key)
client = openai_client()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
+5 -3
View File
@@ -271,8 +271,8 @@ def _sync_transcribe(
"""
from google.cloud import storage as gcs
from google.oauth2 import service_account
from openai import OpenAI
from app.config import settings
from app.internal.ai_clients import openai_client
if not settings.openai_api_key:
logger.warning("OPENAI_API_KEY not set — transcription disabled.")
@@ -315,9 +315,11 @@ def _sync_transcribe(
# gpt-4o-transcribe and gpt-4o-mini-transcribe only support json/text.
use_verbose = settings.stt_model == "whisper-1"
openai_client = OpenAI(api_key=settings.openai_api_key)
# Own timeout, not openai_timeout_seconds: this request uploads the
# whole recording (server-26#153).
stt_client = openai_client(timeout=settings.stt_timeout_seconds)
with open(tmp_path, "rb") as f:
response = openai_client.audio.transcriptions.create(
response = stt_client.audio.transcriptions.create(
model=settings.stt_model,
file=f,
language="en",
@@ -470,7 +470,7 @@ def _diff_new_terms(original: str, corrected: str) -> list[str]:
def _sync_bootstrap(system_name: str, system_type: str, area_hint: str) -> list[str]:
from app.config import settings as cfg
from openai import OpenAI
from app.internal.ai_clients import openai_client
if not cfg.openai_api_key:
return []
@@ -481,7 +481,7 @@ def _sync_bootstrap(system_name: str, system_type: str, area_hint: str) -> list[
area_hint=area_hint,
)
try:
client = OpenAI(api_key=cfg.openai_api_key)
client = openai_client()
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
@@ -504,7 +504,7 @@ def _sync_induct(
) -> list[dict]:
"""Returns [{term, meaning}] — a bare string is still accepted from the model."""
from app.config import settings as cfg
from openai import OpenAI
from app.internal.ai_clients import openai_client
if not cfg.openai_api_key:
return []
@@ -518,7 +518,7 @@ def _sync_induct(
transcript_block=transcript_block[:8000],
)
try:
client = OpenAI(api_key=cfg.openai_api_key)
client = openai_client()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
+3 -1
View File
@@ -17,7 +17,7 @@ from app.internal.auth import (
require_node_service_or_firebase_token,
)
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic
from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic, cameras
from app.internal import dynsec
from app.internal import firestore as fstore
@@ -128,6 +128,8 @@ app.include_router(alerts.router, dependencies=[Depends(require_service_or_fi
app.include_router(trips.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(traffic.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(cameras.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(cameras.admin_router) # auth: admin only, per endpoint (needs the principal for audit)
app.include_router(upload.router) # auth is per-node, handled inline
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
app.include_router(replay.router) # auth: admin only (every route spends or reads a replay run)
+51
View File
@@ -0,0 +1,51 @@
"""Public-camera endpoints for the live ops wall.
``GET /cameras`` -- any signed-in user (auth applied at include time in main.py):
cameras inside a bbox from every provider an admin has enabled, each with the
provider's required attribution, disclaimer and catalog freshness.
``/admin/camera-providers`` -- platform admin only: list every catalogued
provider with its licence status and health, and toggle it. Gated licences
need ``acknowledge_licence: true``; that acknowledgement is stored with who and
when and written to audit_log (see app/internal/cameras/registry.py).
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from app.internal.auth import describe_actor, require_admin_token
from app.internal.cameras import registry
router = APIRouter(prefix="/cameras", tags=["cameras"])
admin_router = APIRouter(prefix="/admin/camera-providers", tags=["admin"])
@router.get("")
async def get_cameras(
south: float = Query(..., ge=-90, le=90),
west: float = Query(..., ge=-180, le=180),
north: float = Query(..., ge=-90, le=90),
east: float = Query(..., ge=-180, le=180),
):
if south > north or west > east:
raise HTTPException(400, "bbox must satisfy south<=north and west<=east")
return await registry.cameras_in_bbox((south, west, north, east))
class ProviderToggle(BaseModel):
enabled: bool
acknowledge_licence: bool = False
@admin_router.get("")
async def list_camera_providers(_=Depends(require_admin_token)):
return {"providers": await registry.list_providers()}
@admin_router.put("/{provider_id}")
async def set_camera_provider(provider_id: str, body: ProviderToggle, principal: dict = Depends(require_admin_token)):
try:
return await registry.set_enabled(provider_id, body.enabled, describe_actor(principal),
acknowledge_licence=body.acknowledge_licence)
except registry.ProviderError as e:
return JSONResponse(status_code=e.code, content={"detail": e.message, **e.extra})
+26 -1
View File
@@ -119,11 +119,36 @@ async def delete_node(node_id: str, _: dict = Depends(require_admin_token)):
@router.post("/{node_id}/reject")
async def reject_node(node_id: str, _: dict = Depends(require_admin_token)):
"""Reject = revoke (server-26#195). Before this, reject only flipped
``approval_status``, so an approved-then-rejected node kept its MQTT login
(dynsec client), its ``/upload`` key and ``/systems`` read access (both
keyed off ``node_keys``).
Order is fail-closed, the opposite of approve's: the status flips to
``rejected`` FIRST, so even if the broker call fails, ``/upload`` (which
now checks approval_status) and ``GET /nodes/{id}/credentials`` already
refuse the node, and ``dynsec.reconcile_all()`` will no longer re-create
its login on the next c2-core restart (it also deletes rejected nodes'
logins there). Then the broker login is deleted, then the key. A dynsec
failure returns 502 with node_keys left intact, and the admin retries:
every step is idempotent. Re-approving later mints a fresh key, because
node_keys is gone; the revoked key is never handed back.
"""
node = await fstore.doc_get("nodes", node_id)
if not node:
raise HTTPException(404, f"Node '{node_id}' not found.")
await fstore.doc_update("nodes", node_id, {"approval_status": "rejected"})
return {"ok": True}
try:
await dynsec.delete_node_client(node_id)
except dynsec.DynsecError as e:
logger.error(f"Reject {node_id!r}: dynsec deleteClient failed, node_keys NOT deleted: {e}")
raise HTTPException(
502,
f"Node marked rejected, but its MQTT login could not be revoked: {e}. Retry reject.",
)
await fstore.doc_delete("node_keys", node_id)
logger.info(f"Reject {node_id!r}: status rejected, MQTT login and API key revoked")
return {"ok": True, "revoked": True}
@router.post("/{node_id}/command")
+9
View File
@@ -4,6 +4,7 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from app.internal import ny511
from app.internal.cameras import registry
router = APIRouter(prefix="/traffic", tags=["traffic"])
@@ -27,6 +28,13 @@ async def get_511(
if south > north or west > east:
raise HTTPException(400, "bbox must satisfy south<=north and west<=east")
wanted = {s.strip() for s in (layers or "").split(",") if s.strip()}
# 511NY data (events included) is covered by the same NYSDOT DAA as its
# cameras, so the whole feed follows the ny511 camera provider's switch,
# which defaults OFF and needs a licence acknowledgement (server-26#198).
if not await registry.is_serving("ny511"):
off = {"fetched_at": None, "error": None, "total_in_bbox": 0, "truncated": False, "disabled": True}
return {**{n: [] for n in ("cameras", "events") if n in wanted},
**{f"{n}_status": off for n in ("cameras", "events") if n in wanted}}
feeds = {name: getattr(ny511, name) for name in ("cameras", "events") if name in wanted}
await asyncio.gather(*(f.get() for f in feeds.values()))
@@ -35,4 +43,5 @@ async def get_511(
hits = ny511.in_bbox(feed.items, south, west, north, east)
out[name] = hits[:MAX_ITEMS]
out[f"{name}_status"] = {**_feed_status(feed), "total_in_bbox": len(hits), "truncated": len(hits) > MAX_ITEMS}
out["credit"] = registry.credit("ny511") # DAA: source credit + as-is disclaimer travel with the data
return out
+2 -2
View File
@@ -531,8 +531,8 @@ async def trip_chat(
events = await fstore.collection_list("trip_events", trip_id=trip_id)
from openai import AsyncOpenAI
oai = AsyncOpenAI(api_key=settings.openai_api_key)
from app.internal.ai_clients import async_openai_client
oai = async_openai_client()
# Strip history to only user/assistant roles to prevent prompt injection
safe_history = [
+16 -4
View File
@@ -47,6 +47,18 @@ async def upload_call_audio(
f"(received prefix: {credentials.credentials[:8]}...)"
)
raise HTTPException(401, "Invalid node API key")
# server-26#195: a valid key is not enough, the node must still be
# approved. reject_node() deletes node_keys too, but this closes the
# window where that delete failed or was never run (a node rejected before
# reject revoked anything). Uncached read: revocation must bite on the
# next upload, not after doc_get_cached's 5-minute TTL.
node = await fstore.doc_get("nodes", node_id)
if not node or node.get("approval_status") != "approved":
logger.warning(
f"Upload 403: node_id={node_id!r} has a valid key but approval_status="
f"{(node or {}).get('approval_status')!r}"
)
raise HTTPException(403, "Node is not approved")
data = await file.read()
if not data:
@@ -63,10 +75,10 @@ async def upload_call_audio(
# org_id is stamped defensively here too (not just in
# mqtt_handler.py's call_start/call_end): key_doc above proves this
# node_id is real and authenticated, so resolving org_id from the
# node doc here covers a call whose Firestore doc was somehow
# never written by call_start (the upload is otherwise the
# authoritative record of which node this audio came from).
node = await fstore.doc_get_cached("nodes", node_id)
# node doc (fetched by the approval gate above) here covers a call
# whose Firestore doc was somehow never written by call_start (the
# upload is otherwise the authoritative record of which node this
# audio came from).
updates = {"audio_gcs_uri": gcs_uri}
if node and node.get("org_id"):
updates["org_id"] = node["org_id"]
+246
View File
@@ -0,0 +1,246 @@
"""
server-26#153 — no OpenAI or Gemini request ran with an explicit timeout.
OpenAI's SDK waits 600s per attempt and retries twice (timeouts included);
google-genai waits forever. A hung provider call therefore had no upper bound,
and recorrelation_sweep's MIN_MINUTES_SINCE_PIPELINE_START was a guess against
that tail. These tests pin:
- every client is built with the configured timeout / retry budget,
- a hung request really does give up (real SDK, local socket that never answers),
- a timeout takes the same fallback path as any other provider error,
- the sweep hold-back is derived from the timeouts, not hand-picked.
"""
import pathlib
import re
import socket
import threading
import time
from unittest.mock import AsyncMock, patch
import httpx
import openai
import pytest
from app.config import settings
from app.internal import ai_clients, ai_health, gemini, llm_correlator, recorrelation_sweep
from app.internal import intelligence, summarizer, transcript_correction
APP_DIR = pathlib.Path(__file__).resolve().parent.parent / "app"
def _timeout_error() -> openai.APITimeoutError:
return openai.APITimeoutError(request=httpx.Request("POST", "https://api.openai.com/v1/x"))
@pytest.fixture(autouse=True)
def _reset_ai_health():
ai_health._state = {t: ai_health._default_state() for t in ai_health.TIERS}
yield
ai_health._state = {t: ai_health._default_state() for t in ai_health.TIERS}
@pytest.fixture
def openai_key():
with patch.object(settings, "openai_api_key", "sk-test"):
yield
# ── Every client carries the configured limits ──────────────────────────────
def test_openai_client_uses_configured_timeout_and_retries(openai_key):
with patch.object(settings, "openai_timeout_seconds", 42.0), \
patch.object(settings, "openai_max_retries", 1):
client = ai_clients.openai_client()
assert client.timeout == 42.0
assert client.max_retries == 1
def test_openai_client_timeout_override_for_stt(openai_key):
client = ai_clients.openai_client(timeout=settings.stt_timeout_seconds)
assert client.timeout == settings.stt_timeout_seconds
def test_async_openai_client_uses_configured_limits(openai_key):
with patch.object(settings, "openai_timeout_seconds", 7.0), \
patch.object(settings, "openai_max_retries", 0):
client = ai_clients.async_openai_client()
assert client.timeout == 7.0
assert client.max_retries == 0
def test_defaults_are_not_the_sdk_defaults():
# SDK defaults: OpenAI 600s x 3 attempts, Gemini unbounded.
assert settings.stt_timeout_seconds < 600
assert settings.openai_timeout_seconds < 600
assert settings.openai_max_retries < openai.DEFAULT_MAX_RETRIES
assert settings.gemini_timeout_seconds > 0
def test_gemini_client_sets_timeout_in_milliseconds():
saved = gemini._client
gemini._client = None
try:
with patch.object(settings, "gemini_api_key", "test-key"), \
patch.object(settings, "gemini_timeout_seconds", 45.0):
client = gemini._get_client()
assert client._api_client._http_options.timeout == 45_000
finally:
gemini._client = saved
def test_no_openai_client_is_built_outside_ai_clients():
"""Regression guard: a new `OpenAI(...)` elsewhere would silently run on
the 600s SDK default again."""
offenders = []
for path in APP_DIR.rglob("*.py"):
if path.name == "ai_clients.py":
continue
for n, line in enumerate(path.read_text().splitlines(), 1):
if re.search(r"\b(Async)?OpenAI\(", line) or re.search(r"genai\.Client\(", line):
if path.name == "gemini.py" and "genai.Client(" in line:
continue
offenders.append(f"{path.relative_to(APP_DIR)}:{n}: {line.strip()}")
assert offenders == []
# ── A hung request actually gives up ────────────────────────────────────────
@pytest.fixture
def hung_server():
"""A TCP server that accepts connections and never sends a byte."""
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.bind(("127.0.0.1", 0))
srv.listen(8)
conns = []
stop = threading.Event()
def accept():
srv.settimeout(0.1)
while not stop.is_set():
try:
conns.append(srv.accept()[0])
except OSError:
continue
t = threading.Thread(target=accept, daemon=True)
t.start()
yield f"http://127.0.0.1:{srv.getsockname()[1]}/v1"
stop.set()
t.join(timeout=2)
for c in conns:
c.close()
srv.close()
def test_hung_openai_request_times_out_instead_of_waiting_600s(openai_key, hung_server, monkeypatch):
monkeypatch.setenv("OPENAI_BASE_URL", hung_server)
with patch.object(settings, "openai_timeout_seconds", 0.3), \
patch.object(settings, "openai_max_retries", 1):
client = ai_clients.openai_client()
t0 = time.monotonic()
with pytest.raises(openai.APITimeoutError):
client.chat.completions.create(model="gpt-4o-mini", messages=[{"role": "user", "content": "x"}])
elapsed = time.monotonic() - t0
# 2 attempts x 0.3s + SDK backoff (<~1s); far below the 600s default.
assert elapsed < 5
# ── Timeouts degrade exactly like other provider errors ────────────────────
def test_timeout_messages_classify_as_transient():
assert ai_health.classify(str(_timeout_error())) == "transient"
assert ai_health.classify(str(httpx.ReadTimeout("The read operation timed out"))) == "transient"
def _corr_ctx():
return {"call_id": "c1", "is_thin_call": False, "recent": [{"incident_id": "i1"}],
"all_active": [], "call_units": ["12"], "call_cleared": [], "tags": ["mva"]}
@pytest.mark.asyncio
async def test_gemini_timeout_in_decide_falls_back_to_rules():
def hang(model, prompt):
raise httpx.ReadTimeout("The read operation timed out")
with patch.object(settings, "gemini_api_key", "test-key"), \
patch.object(llm_correlator, "_build_decide_prompt", lambda ctx: "p"), \
patch.object(llm_correlator, "_sync_gemini", hang):
assert await llm_correlator.decide("c1", _corr_ctx()) is None # None == rules_only
state = ai_health.snapshot()["correlation_cheap"]
assert state["consecutive_failures"] == 1
assert state["permanent"] is False
@pytest.mark.asyncio
async def test_gemini_timeout_in_tiebreak_keeps_the_rules_decision():
def hang(model, prompt):
raise httpx.ReadTimeout("The read operation timed out")
rules = {"action": "new", "matched_incident": None, "corr_debug": {}}
with patch.object(llm_correlator, "_build_tiebreak_prompt", lambda r, l, c: "p"), \
patch.object(llm_correlator, "_sync_gemini", hang):
out = await llm_correlator.tiebreak(rules, {"action": "orphan"}, _corr_ctx())
assert out is rules
assert ai_health.snapshot()["correlation_smart"]["permanent"] is False
@pytest.mark.asyncio
async def test_gemini_timeout_in_transcript_correction_keeps_raw_text():
def hang(model, prompt):
raise httpx.ReadTimeout("The read operation timed out")
text = "engine 5 respond to a structure fire at 12 main street smoke showing"
with patch.object(settings, "gemini_api_key", "test-key"), \
patch.object(transcript_correction, "resolve_context", AsyncMock(return_value={})), \
patch.object(transcript_correction, "_sync_gemini", hang):
assert await transcript_correction.correct("c1", text) == (None, None, False)
class _TimingOutOpenAI:
"""Stands in for the client ai_clients builds; every endpoint times out."""
def __init__(self, *a, **k):
self.chat = self.embeddings = self
self.completions = self
def create(self, **kwargs):
raise _timeout_error()
def test_extraction_timeout_propagates_to_the_ai_health_path(openai_key):
# _sync_extract re-raises API failures so extract_scenes reports them to
# ai_health (tier "extraction"); a timeout must not be swallowed as "no scenes".
with patch.object(ai_clients, "openai_client", _TimingOutOpenAI):
with pytest.raises(openai.APITimeoutError):
intelligence._sync_extract("unit 12 on scene", "PD Dispatch", 1, "sys", None)
def test_embedding_timeout_returns_none(openai_key):
with patch.object(ai_clients, "openai_client", _TimingOutOpenAI):
assert intelligence._sync_embed("unit 12 on scene") is None
def test_summary_timeout_returns_none(openai_key):
with patch.object(ai_clients, "openai_client", _TimingOutOpenAI):
assert summarizer._sync_summarize({"type": "fire"}, ["engine 5 on scene"]) is None
# ── The sweep hold-back is derived from the timeouts ────────────────────────
def test_worst_case_bound_follows_the_settings():
with patch.object(settings, "stt_timeout_seconds", 100.0), \
patch.object(settings, "openai_timeout_seconds", 10.0), \
patch.object(settings, "openai_max_retries", 1), \
patch.object(settings, "gemini_timeout_seconds", 20.0), \
patch.object(settings, "stt_retry_on_degenerate", True):
bound = recorrelation_sweep.pipeline_worst_case_seconds(n_scenes=2)
# stt 2x2x100 + correction 20 + extraction 2x10 + embeds 2x2x10 + decide/tiebreak 2x20 + slack
assert bound == 400 + 20 + 20 + 40 + 40 + recorrelation_sweep._NON_AI_SLACK_SECONDS
def test_hold_back_covers_the_worst_case_and_never_drops_below_old_floor():
bound_min = recorrelation_sweep.pipeline_worst_case_seconds() / 60
assert recorrelation_sweep.MIN_MINUTES_SINCE_PIPELINE_START >= bound_min
assert recorrelation_sweep.MIN_MINUTES_SINCE_PIPELINE_START >= 15
# ...and stays inside the scan window, or the sweep could never recover a
# call whose pipeline died after writing intelligence_started_at.
assert recorrelation_sweep.MIN_MINUTES_SINCE_PIPELINE_START < settings.recorrelation_scan_minutes
+312
View File
@@ -0,0 +1,312 @@
"""Live ops wall camera providers: registry, licence gate, admin routes, adapters.
What is held here:
* every provider defaults OFF, and an off provider is never fetched;
* stubs and PROHIBITED sources can never be enabled;
* a gated licence needs ``acknowledge_licence``, which is stored with who/when
and audited -- and an audit failure refuses the enable (fail closed);
* an acknowledgement given for a different licence status no longer counts;
* the admin routes are admin-only;
* each implemented adapter parses its (verified) upstream shape, pins imagery
to the agency's hosts, and treats an all-unparseable payload as an error.
"""
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app.internal.auth import require_admin_token, require_service_or_firebase_token
from app.internal.cameras import base, general, regional, registry
from app.internal.cameras.base import Licence
from app.main import app
client = TestClient(app)
ADMIN = {"uid": "admin-1", "email": "admin@example.com", "role": "admin"}
class FakeStore:
def __init__(self):
self.docs = {}
self.audit = []
async def doc_get(self, col, doc):
return self.docs.get((col, doc))
async def doc_set(self, col, doc, data, merge=True):
cur = self.docs.setdefault((col, doc), {})
if col == "audit_log":
self.audit.append(data)
return
for k, v in data.items(): # one-level deep merge is all registry needs
if merge and isinstance(v, dict) and isinstance(cur.get(k), dict):
cur[k] = {**cur[k], **v}
else:
cur[k] = v
@pytest.fixture(autouse=True)
def store():
s = FakeStore()
registry.invalidate_cache()
for cat in registry.CATALOGS.values():
cat.items, cat.fetched_at, cat.error, cat._next_attempt = [], None, None, 0.0
with patch.object(registry.fstore, "doc_get", s.doc_get), patch.object(registry.fstore, "doc_set", s.doc_set), \
patch("app.internal.audit.fstore.doc_set", s.doc_set):
yield s
registry.invalidate_cache()
app.dependency_overrides.clear()
def run(coro):
return asyncio.run(coro)
# ── registry / gate ──────────────────────────────────────────────────────────
def test_every_catalogued_source_is_registered_and_defaults_off():
listed = run(registry.list_providers())
ids = {p["id"] for p in listed}
assert {"ny511", "nyc_dot", "westchester_dpw", "caltrans", "deldot", "tfl", "ontario511", "drivebc",
"calgary", "nsw", "fintraffic", "txdot", "austin", "tallinn", "tarktee", "warendorf"} <= ids
assert all(p["enabled"] is False and p["serving"] is False for p in listed)
assert all(p["licence"]["label"] for p in listed)
def test_off_provider_is_never_fetched():
fetch = AsyncMock(return_value=[])
with patch.object(registry.PROVIDERS["tfl"], "fetch_catalog", fetch):
out = run(registry.cameras_in_bbox((51.4, -0.2, 51.6, 0.1)))
assert out["cameras"] == [] and out["providers"] == [] and fetch.await_count == 0
def test_stub_and_prohibited_cannot_be_enabled():
for pid in ("nyc_dot", "westchester_dpw", "ctroads", "trafficland"):
with pytest.raises(registry.ProviderError) as e:
run(registry.set_enabled(pid, True, ("u", "e"), acknowledge_licence=True))
assert e.value.code == 409
def test_attribution_licence_enables_without_acknowledgement_and_is_audited(store):
out = run(registry.set_enabled("nsw", True, ("u1", "a@x")))
assert out["serving"] is True and out["acknowledgement"] is None
assert store.audit[-1]["action"] == "camera_provider.enable"
assert store.audit[-1]["actor_email"] == "a@x"
def test_gated_licence_requires_acknowledgement(store):
with pytest.raises(registry.ProviderError) as e:
run(registry.set_enabled("ny511", True, ("u1", "a@x")))
assert e.value.code == 409 and e.value.extra["requires_acknowledgement"] is True
assert "Developer Access Agreement" in e.value.extra["warning"]
assert not store.audit and not run(registry.is_serving("ny511"))
out = run(registry.set_enabled("ny511", True, ("u1", "a@x"), acknowledge_licence=True))
assert out["serving"] is True
ack = out["acknowledgement"]
assert ack["by_uid"] == "u1" and ack["by_email"] == "a@x" and ack["at"]
assert ack["licence_status"] == Licence.AGREEMENT_REQUIRED.value
assert store.audit[-1]["action"] == "camera_provider.enable_acknowledged"
assert store.audit[-1]["details"]["provider_id"] == "ny511"
def test_licences_not_read_first_hand_are_gated_but_keep_attribution():
# TfL (terms page 403'd) and Calgary (per GEV only) are not confirmed, so an
# admin must acknowledge before they serve; their credit still renders.
for pid in ("tfl", "calgary", "ontario511"):
lic = registry.PROVIDERS[pid].meta.licence
assert lic.status == Licence.UNVERIFIED and lic.gated, pid
assert registry.PROVIDERS["tfl"].meta.licence.attribution == "Powered by TfL Open Data"
with pytest.raises(registry.ProviderError) as e:
run(registry.set_enabled("tfl", True, ("u", "e")))
assert e.value.extra["requires_acknowledgement"] is True
def test_gated_enable_refused_when_audit_cannot_be_written(store):
with patch.object(registry, "_audit", AsyncMock(side_effect=RuntimeError("firestore down"))):
with pytest.raises(registry.ProviderError) as e:
run(registry.set_enabled("deldot", True, ("u", "e"), acknowledge_licence=True))
assert e.value.code == 503
assert not run(registry.is_serving("deldot"))
def test_disable_clears_acknowledgement(store):
run(registry.set_enabled("deldot", True, ("u", "e"), acknowledge_licence=True))
out = run(registry.set_enabled("deldot", False, ("u", "e")))
assert out["serving"] is False and out["acknowledgement"] is None
def test_stale_acknowledgement_stops_serving():
p = registry.PROVIDERS["deldot"]
entry = {"enabled": True, "acknowledgement": {"licence_status": "non_commercial"}}
assert registry.effective(p, entry) == (False, "licence acknowledgement missing or out of date")
entry["acknowledgement"]["licence_status"] = p.meta.licence.status.value
assert registry.effective(p, entry) == (True, None)
def test_unreadable_settings_fail_closed():
with patch.object(registry.fstore, "doc_get", AsyncMock(side_effect=RuntimeError("boom"))):
registry.invalidate_cache()
assert run(registry.get_settings()) == {}
def test_query_returns_attribution_and_skips_providers_outside_bbox(store):
run(registry.set_enabled("nsw", True, ("u", "e")))
run(registry.set_enabled("tfl", True, ("u", "e"), acknowledge_licence=True))
cam = base.make_camera("nsw", "x", -33.87, 151.2, name="Sydney", image_url="https://webcams.transport.nsw.gov.au/a.jpg")
nsw_fetch, tfl_fetch = AsyncMock(return_value=[cam]), AsyncMock(return_value=[])
with patch.object(registry.PROVIDERS["nsw"], "fetch_catalog", nsw_fetch), \
patch.object(registry.PROVIDERS["tfl"], "fetch_catalog", tfl_fetch):
out = run(registry.cameras_in_bbox((-34.0, 151.0, -33.7, 151.4)))
assert [c["id"] for c in out["cameras"]] == ["nsw:x"]
by_id = {p["id"]: p for p in out["providers"]}
assert by_id["nsw"]["attribution"] == "Transport for NSW, CC BY 4.0"
assert tfl_fetch.await_count == 0 # London never fetched for a Sydney bbox
# ...but its credit is still listed, for cameras the client holds from another view
assert by_id["tfl"]["attribution"] == "Powered by TfL Open Data" and by_id["tfl"]["total_in_bbox"] == 0
assert out["serving_count"] == 2
# ── admin routes ─────────────────────────────────────────────────────────────
def test_admin_routes_require_admin():
def deny():
raise HTTPException(403, "Admin access required")
app.dependency_overrides[require_admin_token] = deny
assert client.get("/admin/camera-providers").status_code == 403
assert client.put("/admin/camera-providers/nsw", json={"enabled": True}).status_code == 403
assert client.get("/admin/camera-providers", headers={}).status_code == 403
def test_admin_route_toggle_and_gate(store):
app.dependency_overrides[require_admin_token] = lambda: ADMIN
r = client.put("/admin/camera-providers/caltrans", json={"enabled": True})
assert r.status_code == 409 and r.json()["requires_acknowledgement"] is True
r = client.put("/admin/camera-providers/caltrans", json={"enabled": True, "acknowledge_licence": True})
assert r.status_code == 200 and r.json()["acknowledgement"]["by_email"] == "admin@example.com"
assert client.put("/admin/camera-providers/nope", json={"enabled": True}).status_code == 404
listed = client.get("/admin/camera-providers").json()["providers"]
assert next(p for p in listed if p["id"] == "caltrans")["serving"] is True
def test_cameras_route_rejects_inverted_bbox():
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"uid": "v"}
assert client.get("/cameras", params={"south": 2, "west": 0, "north": 1, "east": 1}).status_code == 400
r = client.get("/cameras", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4})
assert r.status_code == 200 and r.json()["cameras"] == []
# ── adapters ─────────────────────────────────────────────────────────────────
def _with_payload(payload):
return patch.object(general, "get_json", AsyncMock(return_value=payload))
def test_heading_from_text():
assert base.heading_from_text("Northbound") == 0
assert base.heading_from_text("WB") == 270
assert base.heading_from_text("SE") == 135
assert base.heading_from_text("Both directions") is None
assert base.heading_from_text("Unknown") is None
def test_https_on_pins_hosts():
assert base.https_on("https://cwwp2.dot.ca.gov/x.jpg", "dot.ca.gov")
assert base.https_on("http://cwwp2.dot.ca.gov/x.jpg", "dot.ca.gov") is None
assert base.https_on("https://dot.ca.gov.evil.com/x.jpg", "dot.ca.gov") is None
def test_caltrans_adapter():
row = {"cctv": {"inService": "true", "index": "1", "location": {
"district": "4", "locationName": "TV102 -- I-580 : West of SR-24", "latitude": "37.82539",
"longitude": "-122.27291", "direction": "West", "route": "I-580"},
"imageData": {"streamingVideoURL": "https://wzmedia.dot.ca.gov/D4/x.stream/playlist.m3u8",
"static": {"currentImageURL": "https://cwwp2.dot.ca.gov/data/d4/cctv/image/a/a.jpg"}}}}
off = {"cctv": {**row["cctv"], "inService": "false"}}
with _with_payload({"data": [row, off]}):
cams = run(general.CaltransProvider().fetch_catalog())
assert len(cams) == len(general.CaltransProvider.DISTRICTS) # same row per district fetch
c = cams[0]
assert c["id"] == "caltrans:d4-TV102" and c["heading_deg"] == 270 and c["video_url"].endswith(".m3u8")
def test_deldot_adapter_requires_https_hls():
good = {"id": "KCAM001", "title": "DE 1", "lat": 38.99, "lon": -75.44, "enabled": True, "status": "Active",
"urls": {"m3u8s": "https://video.deldot.gov:443/live/KCAM001.stream/playlist.m3u8",
"m3u8": "http://video.deldot.gov:1935/live/KCAM001.stream/playlist.m3u8"}}
down = {**good, "id": "KCAM002", "status": "Unavailable"}
with _with_payload({"videoCameras": [good, down]}):
cams = run(general.DelDotProvider().fetch_catalog())
assert [c["id"] for c in cams] == ["deldot:KCAM001"] and cams[0]["image_url"] is None
def test_drivebc_calgary_tfl_nsw_fintraffic_adapters():
with _with_payload([{"id": 1176, "name": "Lougheed", "is_on": True, "should_appear": True, "orientation": "W",
"location": {"coordinates": [-122.5, 49.2]}, "highway_description": "Hwy 7"}]):
assert run(general.DriveBcProvider().fetch_catalog())[0]["image_url"] == "https://www.drivebc.ca/images/1176.jpg"
with _with_payload([{"camera_url": {"url": "http://trafficcam.calgary.ca/loc86.jpg"}, "camera_location": "Stoney",
"point": {"coordinates": [-113.97, 50.9]}, "quadrant": "SE"}]):
c = run(general.CalgaryProvider().fetch_catalog())[0]
assert c["image_url"] == "https://trafficcam.calgary.ca/loc86.jpg" and c["id"] == "calgary:loc86"
with _with_payload([{"id": "JamCams_00002.00865", "commonName": "A406", "lat": 51.6, "lon": -0.016,
"additionalProperties": [{"key": "available", "value": "true"},
{"key": "imageUrl", "value": general.TflProvider.IMAGE_PREFIX + "a.jpg"}]}]):
assert run(general.TflProvider().fetch_catalog())[0]["id"] == "tfl:00002.00865"
with _with_payload({"features": [{"id": "abc", "geometry": {"coordinates": [151.1, -34.0]},
"properties": {"title": "5 Ways", "direction": "W",
"href": "https://webcams.transport.nsw.gov.au/x.jpeg"}}]}):
assert run(general.NswProvider().fetch_catalog())[0]["heading_deg"] == 270
with _with_payload({"features": [{"geometry": {"coordinates": [23.99, 60.05, 0]},
"properties": {"id": "C01503", "name": "kt51", "collectionStatus": "GATHERING",
"presets": [{"id": "C0150301", "inCollection": True},
{"id": "C0150302", "inCollection": False}]}}]}):
cams = run(general.FintrafficProvider().fetch_catalog())
assert [c["image_url"] for c in cams] == ["https://weathercam.digitraffic.fi/C0150301.jpg"]
def test_schema_change_is_an_error_not_an_empty_catalog():
with _with_payload([{"renamed": True}]):
with pytest.raises(ValueError, match="schema"):
run(general.DriveBcProvider().fetch_catalog())
def test_ontario_needs_its_key(monkeypatch):
monkeypatch.setattr(general.settings, "ontario511_api_key", None, raising=False)
with pytest.raises(RuntimeError, match="ONTARIO511_API_KEY"):
run(general.Ontario511Provider().fetch_catalog())
def test_ny511_provider_wraps_legacy_client_and_carries_daa_metadata():
row = {"id": "NYSDOT-1", "lat": 41.03, "lon": -73.76, "name": "I-287", "roadway": "I-287",
"direction": "Northbound", "image_url": "https://x/1", "video_url": "javascript:alert(1)"}
with patch.object(regional.ny511, "_fetch", AsyncMock(return_value=[row])):
cams = run(regional.Ny511Provider().fetch_catalog())
assert cams[0]["id"] == "ny511:NYSDOT-1" and cams[0]["heading_deg"] == 0
assert cams[0]["video_url"] is None # non-https never reaches the browser
lic = regional.Ny511Provider.meta.licence
assert lic.attribution == "powered by 511NY" and lic.attribution_url == "https://511ny.org"
assert "with all faults" in lic.disclaimer and lic.gated
def test_refresh_error_never_echoes_an_api_key():
import httpx
req = httpx.Request("GET", "https://511on.ca/api/v2/get/cameras", params={"format": "json", "key": "SECRET123"})
with pytest.raises(httpx.HTTPStatusError) as raised: # httpx's own message carries the full URL
httpx.Response(403, request=req).raise_for_status()
err = raised.value
cat = registry.CATALOGS["ontario511"]
with patch.object(cat.provider, "fetch_catalog", AsyncMock(side_effect=err)):
run(cat.get())
assert "SECRET123" not in cat.error and "403" in cat.error and "511on.ca/api/v2/get/cameras" in cat.error
def test_failed_refresh_keeps_last_good_catalog():
cat = registry.CATALOGS["nsw"]
cam = base.make_camera("nsw", "x", -33.8, 151.2)
with patch.object(cat.provider, "fetch_catalog", AsyncMock(return_value=[cam])):
run(cat.get())
cat._next_attempt = 0.0
with patch.object(cat.provider, "fetch_catalog", AsyncMock(side_effect=RuntimeError("down"))):
run(cat.get())
assert cat.items == [cam] and "down" in cat.error and cat.fetched_at
@@ -0,0 +1,203 @@
"""
server-26#195 — reject must revoke: delete the node's dynsec (MQTT) login and
its node_keys entry, /upload must refuse a node that is not approved, and
dynsec.reconcile_all() must delete rejected nodes' logins on startup.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi.testclient import TestClient
from app.main import app
from app.internal import dynsec
from app.internal.auth import require_admin_token, require_service_or_firebase_token
from app.routers import nodes, upload
client = TestClient(app)
def setup_function():
app.dependency_overrides[require_admin_token] = lambda: {"admin": True}
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"admin": True}
def teardown_function():
app.dependency_overrides.pop(require_admin_token, None)
app.dependency_overrides.pop(require_service_or_firebase_token, None)
def _fake_store(node, key_doc):
store = {"nodes": node, "node_keys": key_doc}
calls = []
async def doc_get(c, i):
return store.get(c)
async def doc_update(c, i, data):
calls.append(("doc_update", c, data))
store[c] = {**(store[c] or {}), **data}
async def doc_delete(c, i):
calls.append(("doc_delete", c))
store[c] = None
async def doc_set(c, i, data, merge=False):
store[c] = data
return store, calls, doc_get, doc_update, doc_delete, doc_set
# ---------------------------------------------------------------------------
# reject_node
# ---------------------------------------------------------------------------
def test_reject_revokes_mqtt_login_and_key_in_fail_closed_order():
store, calls, doc_get, doc_update, doc_delete, _ = _fake_store(
{"node_id": "n1", "approval_status": "approved"}, {"node_id": "n1", "api_key": "live-key"})
async def delete_client(node_id):
calls.append(("dynsec_delete", node_id))
with patch.object(nodes.fstore, "doc_get", doc_get), \
patch.object(nodes.fstore, "doc_update", doc_update), \
patch.object(nodes.fstore, "doc_delete", doc_delete), \
patch.object(nodes.dynsec, "delete_node_client", delete_client):
resp = client.post("/nodes/n1/reject")
assert resp.status_code == 200
assert resp.json() == {"ok": True, "revoked": True}
assert calls == [
("doc_update", "nodes", {"approval_status": "rejected"}),
("dynsec_delete", "n1"),
("doc_delete", "node_keys"),
]
assert store["node_keys"] is None
assert store["nodes"]["approval_status"] == "rejected"
def test_reject_dynsec_failure_is_502_and_keeps_key_but_status_is_already_rejected():
store, calls, doc_get, doc_update, doc_delete, _ = _fake_store(
{"node_id": "n1", "approval_status": "approved"}, {"node_id": "n1", "api_key": "live-key"})
with patch.object(nodes.fstore, "doc_get", doc_get), \
patch.object(nodes.fstore, "doc_update", doc_update), \
patch.object(nodes.fstore, "doc_delete", doc_delete), \
patch.object(nodes.dynsec, "delete_node_client",
AsyncMock(side_effect=dynsec.DynsecError("timeout"))):
resp = client.post("/nodes/n1/reject")
assert resp.status_code == 502
assert "Retry reject" in resp.json()["detail"]
assert store["nodes"]["approval_status"] == "rejected"
assert store["node_keys"] == {"node_id": "n1", "api_key": "live-key"}
assert ("doc_delete", "node_keys") not in calls
def test_reject_unknown_node_is_404_and_touches_nothing():
with patch.object(nodes.fstore, "doc_get", AsyncMock(return_value=None)), \
patch.object(nodes.fstore, "doc_update", AsyncMock()) as doc_update, \
patch.object(nodes.fstore, "doc_delete", AsyncMock()) as doc_delete, \
patch.object(nodes.dynsec, "delete_node_client", AsyncMock()) as delete_client:
resp = client.post("/nodes/ghost/reject")
assert resp.status_code == 404
doc_update.assert_not_awaited()
doc_delete.assert_not_awaited()
delete_client.assert_not_awaited()
def test_reject_requires_admin():
app.dependency_overrides.pop(require_admin_token, None)
resp = client.post("/nodes/n1/reject")
assert resp.status_code in (401, 403)
def test_reapprove_after_reject_mints_a_new_key_never_the_revoked_one():
store, _, doc_get, doc_update, doc_delete, doc_set = _fake_store(
{"node_id": "n1", "approval_status": "approved"}, {"node_id": "n1", "api_key": "old-key"})
with patch.object(nodes.fstore, "doc_get", doc_get), \
patch.object(nodes.fstore, "doc_update", doc_update), \
patch.object(nodes.fstore, "doc_delete", doc_delete), \
patch.object(nodes.fstore, "doc_set", doc_set), \
patch.object(nodes.dynsec, "delete_node_client", AsyncMock()), \
patch.object(nodes.dynsec, "upsert_node_client", AsyncMock()) as upsert, \
patch.object(nodes.mqtt_handler, "publish_node_key", MagicMock()):
assert client.post("/nodes/n1/reject").status_code == 200
assert client.post("/nodes/n1/approve").status_code == 200
new_key = store["node_keys"]["api_key"]
assert new_key != "old-key" and len(new_key) == 64
upsert.assert_awaited_once_with("n1", new_key)
# ---------------------------------------------------------------------------
# /upload approval gate
# ---------------------------------------------------------------------------
def _upload(node_doc, key="live-key"):
store = {"node_keys": {"node_id": "n1", "api_key": "live-key"}, "nodes": node_doc}
async def doc_get(c, i):
return store.get(c)
with patch.object(upload.fstore, "doc_get", doc_get), \
patch.object(upload, "upload_audio", AsyncMock(return_value=None)) as up:
resp = client.post(
"/upload",
data={"call_id": "c1", "node_id": "n1"},
files={"file": ("a.mp3", b"\x00\x01", "audio/mpeg")},
headers={"Authorization": f"Bearer {key}"},
)
return resp, up
def test_upload_refuses_valid_key_from_rejected_node():
resp, up = _upload({"node_id": "n1", "approval_status": "rejected"})
assert resp.status_code == 403
up.assert_not_awaited()
def test_upload_refuses_valid_key_from_pending_or_missing_node():
for node_doc in ({"node_id": "n1", "approval_status": "pending"}, {"node_id": "n1"}, None):
resp, up = _upload(node_doc)
assert resp.status_code == 403, node_doc
up.assert_not_awaited()
def test_upload_accepts_approved_node():
resp, up = _upload({"node_id": "n1", "approval_status": "approved"})
assert resp.status_code == 200
up.assert_awaited_once()
def test_upload_wrong_key_still_401_before_approval_check():
resp, up = _upload({"node_id": "n1", "approval_status": "approved"}, key="wrong")
assert resp.status_code == 401
up.assert_not_awaited()
# ---------------------------------------------------------------------------
# dynsec.reconcile_all
# ---------------------------------------------------------------------------
def test_reconcile_deletes_rejected_logins_and_still_syncs_approved():
async def collection_list(c, **filters):
status = filters.get("approval_status")
return {
"rejected": [{"node_id": "bad1"}, {"node_id": "bad2"}],
"approved": [{"node_id": "good"}],
}[status]
async def doc_get(c, i):
return {"node_id": i, "api_key": "k-" + i}
delete_client = AsyncMock(side_effect=[dynsec.DynsecError("boom"), None])
with patch.object(dynsec.fstore, "collection_list", collection_list), \
patch.object(dynsec.fstore, "doc_get", doc_get), \
patch.object(dynsec, "delete_node_client", delete_client), \
patch.object(dynsec, "upsert_node_client", AsyncMock()) as upsert:
asyncio.run(dynsec.reconcile_all())
# one failed revoke does not stop the rest, nor the approved sync
assert [c.args[0] for c in delete_client.await_args_list] == ["bad1", "bad2"]
upsert.assert_awaited_once_with("good", "k-good")
+42 -3
View File
@@ -43,11 +43,14 @@ def test_normalize_camera_skips_disabled_blocked_and_zero_coords():
assert ny511.normalize_camera({**CAM, "Latitude": 0, "Longitude": 0}) is None
def test_normalize_event_parses_day_first_dates_and_truncates_description():
def test_normalize_event_parses_day_first_dates_and_keeps_description_whole():
e = ny511.normalize_event(EVENT)
assert e["start_local"] == "2026-09-28T09:00:00" # DD/MM, not MM/DD
assert e["planned_end_local"] is None
assert len(e["description"]) == ny511._DESCRIPTION_MAX + 1
# DAA: "You may not alter the substance of a report" -- no 500-char cut any more.
assert e["description"] == EVENT["Description"]
long = ny511.normalize_event({**EVENT, "Description": "y" * (ny511._DESCRIPTION_MAX + 50)})
assert len(long["description"]) == ny511._DESCRIPTION_MAX + 1 # pathological only
def test_failed_refresh_keeps_last_good_data_and_reports_error():
@@ -80,14 +83,29 @@ def test_schema_change_is_an_error_not_an_empty_layer():
assert "schema" in str(e)
def _serving(value: bool):
from app.internal.cameras import registry
return patch.object(registry, "is_serving", AsyncMock(return_value=value))
def test_endpoint_is_off_until_the_ny511_provider_is_enabled():
with _serving(False):
r = client.get("/traffic/511", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4})
body = r.json()
assert r.status_code == 200 and body["cameras"] == [] and body["events"] == []
assert body["events_status"]["disabled"] is True
def test_endpoint_filters_to_bbox_and_reports_status():
far = {**CAM, "ID": "NYSDOT-2", "Latitude": 42.9, "Longitude": -78.8} # Buffalo
for feed, rows, norm in ((ny511.cameras, [CAM, far], ny511.normalize_camera), (ny511.events, [EVENT], ny511.normalize_event)):
feed.items = [norm(r) for r in rows]
feed.fetched_at, feed._next_attempt = 1.0, float("inf")
r = client.get("/traffic/511", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4})
with _serving(True):
r = client.get("/traffic/511", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4})
assert r.status_code == 200
body = r.json()
assert body["credit"]["attribution"] == "powered by 511NY" and "as is" in body["credit"]["disclaimer"]
assert [c["id"] for c in body["cameras"]] == ["NYSDOT-1"]
assert body["cameras_status"] == {"fetched_at": 1.0, "error": None, "total_in_bbox": 1, "truncated": False}
assert len(body["events"]) == 1
@@ -96,3 +114,24 @@ def test_endpoint_filters_to_bbox_and_reports_status():
def test_endpoint_rejects_inverted_bbox():
r = client.get("/traffic/511", params={"south": 41.4, "west": -74.0, "north": 40.9, "east": -73.4})
assert r.status_code == 400
def test_xml_inventory_maps_onto_legacy_fields():
xml = """<?xml version="1.0"?><cameras xmlns="urn:x">
<camera><ID>NYSDOT-9</ID><Latitude>41.05</Latitude><Longitude>-73.78</Longitude>
<Name>I-287 at Exit 4</Name><RoadwayName>I-287</RoadwayName><DirectionOfTravel>Eastbound</DirectionOfTravel>
<ImageUrl>https://example.invalid/still.jpg</ImageUrl><Disabled>false</Disabled></camera>
<camera id="NYSDOT-10" latitude="41.1" longitude="-73.7"><Disabled>true</Disabled></camera>
</cameras>"""
rows = ny511.parse_inventory_xml(xml)
cams = [ny511.normalize_camera(r) for r in rows]
assert cams[0]["id"] == "NYSDOT-9" and cams[0]["image_url"] == "https://example.invalid/still.jpg"
assert cams[1] is None # disabled flag honoured from XML text
def test_xml_parser_refuses_html_and_entities():
import pytest
with pytest.raises(ValueError, match="HTML"):
ny511.parse_inventory_xml("<!doctype html><html><body>511NY</body></html>")
with pytest.raises(ValueError, match="DTD"):
ny511.parse_inventory_xml('<?xml version="1.0"?><!DOCTYPE x [<!ENTITY a "b">]><x>&a;</x>')
@@ -66,11 +66,14 @@ async def test_sweep_pass_skips_a_call_whose_pipeline_just_started():
"ended_at": _iso(now - timedelta(minutes=1)),
"intelligence_started_at": _iso(now - timedelta(seconds=30)),
}
# Relative to the hold-back, which is now derived from the AI timeouts
# (server-26#153) rather than a fixed 15 minutes.
past_hold = recorrelation_sweep.MIN_MINUTES_SINCE_PIPELINE_START + 4
genuinely_orphaned_call = {
"call_id": "call-genuine-orphan",
"started_at": _iso(now - timedelta(minutes=20)),
"ended_at": _iso(now - timedelta(minutes=19)),
"intelligence_started_at": _iso(now - timedelta(minutes=19)),
"started_at": _iso(now - timedelta(minutes=past_hold + 1)),
"ended_at": _iso(now - timedelta(minutes=past_hold)),
"intelligence_started_at": _iso(now - timedelta(minutes=past_hold)),
}
async def fake_collection_where(collection, clauses):
+10 -2
View File
@@ -3,6 +3,7 @@
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import { ReplayTab } from "@/components/admin/ReplayTab";
import { CameraProvidersTab } from "@/components/admin/CameraProvidersTab";
import { useEffect, useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types";
@@ -1267,10 +1268,11 @@ function SttEvalTab() {
// Main admin page
// ---------------------------------------------------------------------------
type AdminTab = "features" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval";
type AdminTab = "features" | "cameras" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval";
const TAB_LABELS: { key: AdminTab; label: string }[] = [
{ key: "features", label: "AI Features" },
{ key: "cameras", label: "Camera providers" },
{ key: "correlation", label: "Correlation Debug" },
{ key: "replay", label: "Replay" },
{ key: "calls", label: "Calls" },
@@ -1283,6 +1285,11 @@ export default function AdminPage() {
const { user, isAdmin, loading: authLoading } = useAuth();
const router = useRouter();
const [tab, setTab] = useState<AdminTab>("features");
// Deep link to a tab (/admin#cameras), e.g. from the live wall's empty state.
useEffect(() => {
const h = window.location.hash.slice(1);
if (TAB_LABELS.some((t) => t.key === h)) setTab(h as AdminTab);
}, []);
// Wait for the claims to resolve before deciding. isAdmin is false for the
// first render of every cold load (typed URL, hard refresh, bookmark) while
@@ -1297,7 +1304,7 @@ export default function AdminPage() {
if (!isAdmin) return null;
// Users/Audit tabs benefit from full width; everything else is narrow
const wide = tab === "users" || tab === "audit" || tab === "replay";
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras";
return (
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
@@ -1318,6 +1325,7 @@ export default function AdminPage() {
</div>
{tab === "features" && <FeaturesTab />}
{tab === "cameras" && <CameraProvidersTab />}
{tab === "correlation" && <CorrelationDebugTab />}
{tab === "replay" && <ReplayTab />}
{tab === "calls" && <StaleCallsTab />}
+15
View File
@@ -246,3 +246,18 @@ html:not(.dark) .marketing-hero-bg {
.skeleton {
animation: skeleton-pulse 1.6s ease-in-out infinite;
}
/* ── Live ops wall transcript ticker ─────────────────────────────────────────
* The belt holds the items twice, so translating by -50% loops seamlessly.
* Paused on hover/focus; reduced-motion users get a static, scrollable row. */
@keyframes drb-ticker {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.drb-ticker-belt { animation: drb-ticker var(--drb-ticker-duration, 90s) linear infinite; }
.drb-ticker:hover .drb-ticker-belt,
.drb-ticker:focus-within .drb-ticker-belt { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
.drb-ticker-belt { animation: none; }
.drb-ticker { overflow-x: auto; }
}
+1 -1
View File
@@ -197,7 +197,7 @@ export default function NodeDetailPage() {
}
async function handleReject() {
if (!confirm("Reject this node? It will not be able to upload recordings.")) return;
if (!confirm("Reject this node? Any MQTT login and API key it holds are revoked, so it cannot connect or upload. Re-approving later issues a new key.")) return;
setApproving(true);
try {
await c2api.rejectNode(id);
+238 -24
View File
@@ -1,41 +1,217 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type RefObject } from "react";
import type HlsType from "hls.js";
import type { Ny511Camera } from "@/lib/types";
import type { Camera, CameraCredit } from "@/lib/types";
// 511NY stills are served with max-age=60, so refreshing faster buys nothing.
// ── Feed hygiene (design-canvas/gods-eye-view-notes.md §2, top-5 #2) ─────────
// A 24/7 wall must not burn bandwidth on feeds nobody can see:
// * hidden tab or off-screen tile → no fetches, live video torn down;
// * at most MAX_LIVE_HLS live decoders page-wide (GEV uses 2); extra tiles
// show their still with a "live cap" badge (the drill-down gets one extra);
// * a failed refresh keeps the last good frame with a "stale Nm" badge;
// * a 64×36 downsample signature detects a frozen camera (same frame for
// FROZEN_AFTER_MS) without repainting it; a stalled HLS stream falls back.
// Video is played straight from the agency's host (no DRB egress).
// Most stills are cached ~60 s upstream, so refreshing faster buys nothing.
const SNAPSHOT_REFRESH_MS = 60_000;
// A failed stream is not given up on for good: 511NY feeds drop out briefly,
// and a permanent fallback left pinned tiles showing a stale still image.
const VIDEO_RETRY_MS = 30_000;
// In-place recoveries hls.js gets per session before we fall back to the snapshot.
const MAX_HLS_RECOVERIES = 3;
const VIDEO_STALL_MS = 20_000;
const FROZEN_AFTER_MS = 20 * 60_000; // > the slowest provider cadence (Fintraffic ~10 min)
export const MAX_LIVE_HLS = 2;
// ── Page-wide live-stream leases ─────────────────────────────────────────────
const holders = new Set<number>();
const leaseListeners = new Set<() => void>();
let nextLeaseId = 1;
function tryAcquire(id: number, high: boolean): boolean {
if (holders.has(id)) return true;
if (holders.size < MAX_LIVE_HLS + (high ? 1 : 0)) {
holders.add(id);
return true;
}
return false;
}
function release(id: number) {
if (holders.delete(id)) leaseListeners.forEach((l) => l());
}
function useLiveLease(want: boolean, high: boolean): boolean {
const idRef = useRef(0);
if (!idRef.current) idRef.current = nextLeaseId++;
const [granted, setGranted] = useState(false);
useEffect(() => {
const id = idRef.current;
if (!want) { release(id); setGranted(false); return; }
const attempt = () => setGranted(tryAcquire(id, high));
attempt();
leaseListeners.add(attempt);
return () => { leaseListeners.delete(attempt); release(id); };
}, [want, high]);
return granted;
}
// ── Visibility: tab shown AND element on screen ──────────────────────────────
function useOnScreen(ref: RefObject<HTMLElement | null>): boolean {
const [tabVisible, setTabVisible] = useState(true);
const [inView, setInView] = useState(true);
useEffect(() => {
const onVis = () => setTabVisible(!document.hidden);
onVis();
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, []);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver((entries) => setInView(entries.some((e) => e.isIntersecting)), { rootMargin: "100px" });
io.observe(el);
return () => io.disconnect();
}, [ref]);
return tabVisible && inView;
}
// Origins whose stills refuse CORS: shown without crossOrigin, no signature.
const noCorsOrigins = new Set<string>();
function originOf(url: string): string {
try { return new URL(url).origin; } catch { return url; }
}
function withBust(url: string, t: number): string {
return `${url}${url.includes("?") ? "&" : "?"}t=${t}`;
}
function signature(img: HTMLImageElement): number[] | null {
try {
const c = document.createElement("canvas");
c.width = 64;
c.height = 36;
const ctx = c.getContext("2d", { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(img, 0, 0, 64, 36);
const d = ctx.getImageData(0, 0, 64, 36).data;
const out: number[] = [];
for (let i = 0; i < d.length; i += 4) out.push((d[i] + d[i + 1] + d[i + 2]) / 3);
return out;
} catch {
return null; // tainted canvas
}
}
function sameFrame(a: number[] | null, b: number[] | null): boolean {
if (!a || !b || a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
return diff / a.length < 1.5;
}
function ageLabel(ms: number): string {
const m = Math.floor(ms / 60_000);
return m < 1 ? "<1m" : m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60 ? ` ${m % 60}m` : ""}`;
}
function clock(t: number): string {
return new Date(t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
interface Props {
camera: Camera;
className?: string;
/** "still" never starts video (popups, thumbnails). */
mode?: "auto" | "still";
/** The drill-down camera may take one stream past the page-wide cap. */
priority?: "normal" | "high";
/** Required attribution for this provider — rendered on the imagery itself. */
credit?: CameraCredit | null;
}
/**
* One 511NY camera: the live HLS stream when the camera has one (NYSDOT's
* skyvdn hosts send Access-Control-Allow-Origin: *), otherwise its snapshot on
* a timer. A stream that fails falls back to the snapshot rather than a black box,
* and video is retried every VIDEO_RETRY_MS while it stays down.
* One public camera: live HLS when the camera has a stream and a lease is
* free, otherwise its still on a timer — with honest state badges and the
* provider's attribution on the frame. A stream that fails or stalls falls
* back to the still and is retried every VIDEO_RETRY_MS.
*/
export function CameraFeed({ camera, className }: { camera: Ny511Camera; className?: string }) {
export function CameraFeed({ camera, className, mode = "auto", priority = "normal", credit }: Props) {
const rootRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const onScreen = useOnScreen(rootRef);
const [videoFailed, setVideoFailed] = useState(false);
const [tick, setTick] = useState(() => Date.now());
const useVideo = !!camera.video_url && !videoFailed;
const wantsVideo = mode === "auto" && !!camera.video_url && !videoFailed && onScreen;
const leased = useLiveLease(wantsVideo, priority === "high");
const useVideo = wantsVideo && leased;
const capped = wantsVideo && !leased;
// Still state
const [shownSrc, setShownSrc] = useState<string | null>(null);
const [corsOk, setCorsOk] = useState(true);
const [lastGoodAt, setLastGoodAt] = useState<number | null>(null);
const [frameSince, setFrameSince] = useState<number | null>(null); // when the shown frame first appeared
const [stale, setStale] = useState(false);
const [now, setNow] = useState(() => Date.now());
const sigRef = useRef<number[] | null>(null);
// Reset when the camera changes
useEffect(() => {
setShownSrc(null); setLastGoodAt(null); setFrameSince(null); setStale(false); setVideoFailed(false);
sigRef.current = null;
}, [camera.id]);
useEffect(() => {
if (useVideo) return;
const id = setInterval(() => setTick(Date.now()), SNAPSHOT_REFRESH_MS);
const id = setInterval(() => { if (!document.hidden) setNow(Date.now()); }, 15_000);
return () => clearInterval(id);
}, [useVideo]);
}, []);
// Still polling — only while on screen and not playing video
useEffect(() => {
const url = camera.image_url;
if (useVideo || !onScreen || !url) return;
let cancelled = false;
let probe: HTMLImageElement | null = null;
const load = (allowCors: boolean) => {
const origin = originOf(url);
const cors = allowCors && !noCorsOrigins.has(origin);
const src = withBust(url, Date.now());
probe = new Image();
if (cors) probe.crossOrigin = "anonymous";
probe.onload = () => {
if (cancelled || !probe) return;
const t = Date.now();
const sig = cors ? signature(probe) : null;
const unchanged = sameFrame(sig, sigRef.current);
sigRef.current = sig;
setStale(false);
setLastGoodAt(t);
setCorsOk(cors);
if (!unchanged) {
setShownSrc(src);
setFrameSince(t);
}
};
probe.onerror = () => {
if (cancelled) return;
if (cors) { noCorsOrigins.add(origin); load(false); return; }
setStale(true); // keep the last good frame on screen
};
probe.src = src;
};
load(true);
const id = setInterval(() => load(true), SNAPSHOT_REFRESH_MS);
return () => {
cancelled = true;
clearInterval(id);
if (probe) { probe.onload = null; probe.onerror = null; probe.src = ""; } // abort in-flight
};
}, [camera.image_url, useVideo, onScreen]);
// Retry a failed stream later
useEffect(() => {
if (!videoFailed || !camera.video_url) return;
const id = setTimeout(() => setVideoFailed(false), VIDEO_RETRY_MS);
return () => clearTimeout(id);
}, [videoFailed, camera.video_url]);
// HLS player + stall watchdog
useEffect(() => {
const video = videoRef.current;
const src = camera.video_url;
@@ -65,20 +241,58 @@ export function CameraFeed({ camera, className }: { camera: Ny511Camera; classNa
})
.catch(() => setVideoFailed(true));
}
let lastT = -1;
let lastMove = Date.now();
const watchdog = setInterval(() => {
if (video.currentTime !== lastT) { lastT = video.currentTime; lastMove = Date.now(); return; }
if (Date.now() - lastMove > VIDEO_STALL_MS) setVideoFailed(true); // frozen stream → still
}, 5_000);
return () => {
cancelled = true;
clearInterval(watchdog);
hls?.destroy();
video.removeAttribute("src");
video.load(); // stop the download, not just the playback
};
}, [useVideo, camera.video_url]);
if (useVideo) {
return <video ref={videoRef} muted autoPlay playsInline className={className} onError={() => setVideoFailed(true)} />;
}
if (!camera.image_url) {
return <div className={`${className ?? ""} flex items-center justify-center text-[10px] text-ink-muted`}>No image</div>;
}
// eslint-disable-next-line @next/next/no-img-element
return <img src={`${camera.image_url}?t=${tick}`} alt={`Camera: ${camera.name}`} className={className} />;
// ── Badge ──
let badge: { text: string; tone: "live" | "ok" | "warn" | "err" };
if (useVideo) badge = { text: "LIVE", tone: "live" };
else if (!camera.image_url) badge = { text: capped ? "LIVE CAP · STREAM PAUSED" : camera.video_url ? "STREAM DOWN" : "NO IMAGE", tone: "err" };
else if (!shownSrc) badge = { text: stale ? "FRAME · UNAVAILABLE" : "FRAME · LOADING", tone: stale ? "err" : "warn" };
else if (stale && lastGoodAt) badge = { text: `STALE · ${ageLabel(now - lastGoodAt)}`, tone: "warn" };
else if (frameSince && now - frameSince > FROZEN_AFTER_MS) badge = { text: `FROZEN? · unchanged ${ageLabel(now - frameSince)}`, tone: "warn" };
else badge = { text: `STILL · ${frameSince ? clock(frameSince) : "—"}${capped ? " · LIVE CAP" : ""}`, tone: "ok" };
const toneClass = {
live: "bg-red-600/90 text-white",
ok: "bg-black/65 text-white",
warn: "bg-amber-500/90 text-black",
err: "bg-black/75 text-red-300",
}[badge.tone];
return (
<div ref={rootRef} className={`relative overflow-hidden ${className ?? ""}`}>
{useVideo ? (
<video ref={videoRef} muted autoPlay playsInline className="w-full h-full object-cover" onError={() => setVideoFailed(true)} />
) : shownSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={shownSrc} crossOrigin={corsOk ? "anonymous" : undefined} alt={`Camera: ${camera.name}`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-[10px] text-ink-muted bg-black/60">
{camera.image_url ? "…" : "No still for this camera"}
</div>
)}
<span className={`absolute top-1 left-1 px-1 rounded-sm font-mono text-[9px] leading-[14px] tracking-wide ${toneClass}`} title="Frame time is when DRB first saw this frame">
{badge.text}
</span>
{credit?.attribution && (
<span className="absolute bottom-0 left-0 right-0 px-1 bg-black/60 text-white text-[9px] leading-[13px] truncate">
{credit.attribution_url ? (
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a>
) : credit.attribution}
</span>
)}
</div>
);
}
+316 -36
View File
@@ -2,18 +2,39 @@
/**
* Live — the default landing for every authenticated user (UI_REDESIGN.md
* §3, §5.1). Full-bleed map with the incident rail/legend MapView already
* renders (chunk 5), plus a time-density scrubber strip below it.
* §3, §5.1), now the live ops wall (design-canvas/live-ops-wall-mockup.html):
* Region → Incident → Camera drill-down, worst-first incident rail (MapView),
* KPIs, transcript ticker, scrubber with incident bands, auto-tour and deep
* links (#inc=…&cam=…&tour). Every MapView layer stays — nodes, incidents,
* paths, ADS-B, AIS, cameras, viewsheds, traffic events, NEXRAD.
*/
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { useNodes } from "@/lib/useNodes";
import { useActiveCalls, useCalls } from "@/lib/useCalls";
import { useActiveIncidents } from "@/lib/useIncidents";
import { useAircraft } from "@/lib/useAircraft";
import { useVessels } from "@/lib/useVessels";
import { useCamerasInBbox } from "@/lib/useCameras";
import { c2api } from "@/lib/c2api";
import { severityRank } from "@/lib/severity";
import type { Camera, Ny511Response } from "@/lib/types";
import {
NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, onAirIncidentIds,
parseWallHash, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
} from "@/lib/liveWall";
import { TimeScrubber } from "@/components/TimeScrubber";
import { TranscriptTicker } from "@/components/live/TranscriptTicker";
import { CameraDrill } from "@/components/live/CameraDrill";
import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels";
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
const TOUR_MS = 20_000;
const PIN_KEY = "drb.wall.pinnedCameras";
const MAX_PINNED = 6;
const EVENTS_POLL_MS = 2 * 60 * 1000;
function timeAgo(date: Date): string {
const s = Math.floor((Date.now() - date.getTime()) / 1000);
if (s < 60) return `${s}s ago`;
@@ -21,59 +42,318 @@ function timeAgo(date: Date): string {
return `${Math.floor(s / 3600)}h ago`;
}
/** Per-viewer convenience only; the wall works without it. */
function loadPinned(): Camera[] {
try {
const raw = window.localStorage.getItem(PIN_KEY);
const v = raw ? JSON.parse(raw) : [];
return Array.isArray(v) ? v.filter((c) => c && typeof c.id === "string").slice(0, MAX_PINNED) : [];
} catch {
return [];
}
}
function savePinned(cams: Camera[]) {
try { window.localStorage.setItem(PIN_KEY, JSON.stringify(cams)); } catch { /* storage blocked */ }
}
/** 511NY road events around the region (situational context + KPI). */
function useRegionEvents(box: ReturnType<typeof bboxAround>) {
const [data, setData] = useState<Ny511Response>({});
const key = box ? [box.south, box.west, box.north, box.east].map((n) => n.toFixed(2)).join(",") : "";
useEffect(() => {
if (!box) return;
const b = box;
let cancelled = false;
const load = () => {
if (document.hidden) return;
c2api.get511(b, "events").then((r) => { if (!cancelled) setData(r); }).catch((e) => console.error("region events:", e));
};
load();
const id = setInterval(load, EVENTS_POLL_MS);
return () => { cancelled = true; clearInterval(id); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return data;
}
function Kpi({ value, label }: { value: string | number; label: string }) {
return (
<span className="inline-flex items-baseline gap-1 text-xs text-ink-muted whitespace-nowrap">
<b className="text-ink font-semibold tabular-nums">{value}</b>{label}
</span>
);
}
export function LiveView() {
const { nodes, loading: nodesLoading } = useNodes();
const activeCalls = useActiveCalls();
const activeIncidents = useActiveIncidents();
// Wide-ish recent window: feeds both the incident-path polyline (chunk 5)
// and the scrubber's density bars. Not a fixture — real recent calls.
// Wide-ish recent window: feeds incident paths, the call spine, the ticker
// and the scrubber's density bars. Real recent calls, not a fixture.
const { calls: recentCalls, loading: callsLoading } = useCalls(500);
const { aircraft } = useAircraft();
const { vessels } = useVessels();
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [clock, setClock] = useState<string>("");
useEffect(() => {
if (!nodesLoading) setLastUpdated(new Date());
}, [nodes, activeCalls, activeIncidents, nodesLoading]);
useEffect(() => {
const tick = () => setClock(new Date().toLocaleTimeString([], { hour12: false }));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
// ── Wall state, mirrored in the URL hash ──────────────────────────────────
const [link, setLinkState] = useState<WallLink>({ inc: null, cam: null, tour: false });
useEffect(() => {
const read = () => setLinkState(parseWallHash(window.location.hash));
read();
window.addEventListener("hashchange", read);
return () => window.removeEventListener("hashchange", read);
}, []);
const setLink = useCallback((next: WallLink) => {
setLinkState(next);
const h = wallHash(next);
window.history.replaceState(null, "", h || window.location.pathname + window.location.search);
}, []);
const [pinned, setPinned] = useState<Camera[]>([]);
useEffect(() => { setPinned(loadPinned()); }, []);
const pinnedIds = useMemo(() => new Set(pinned.map((c) => c.id)), [pinned]);
const togglePin = useCallback((cam: Camera) => {
setPinned((prev) => {
const next = prev.some((c) => c.id === cam.id) ? prev.filter((c) => c.id !== cam.id) : [...prev, cam].slice(-MAX_PINNED);
savePinned(next);
return next;
});
}, []);
// ── Geography ─────────────────────────────────────────────────────────────
const callsByIncident = useMemo(() => groupCallsByIncident(recentCalls), [recentCalls]);
const sorted = useMemo(() => worstFirst(activeIncidents), [activeIncidents]);
const pointsById = useMemo(() => {
const m = new Map<string, LatLon[]>();
for (const inc of activeIncidents) m.set(inc.incident_id, incidentPoints(inc, callsByIncident.get(inc.incident_id)));
return m;
}, [activeIncidents, callsByIncident]);
const regionBox = useMemo(() => {
const pts = Array.from(pointsById.values()).flat();
return bboxAround(pts.length ? pts : nodes.map((n) => [n.lat, n.lon] as LatLon), NEAR_CAMERA_KM + 0.5);
}, [pointsById, nodes]);
const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox);
const feeds = useMemo(() => new Map(cams.providers.map((p) => [p.id, p])), [cams.providers]);
// Pins live in this browser and carry their image URLs, so a pin must not
// outlive its provider: once an admin switches a provider off (licence
// gating, server-26#198) its pinned cameras stop rendering until it is back.
const servingPinned = useMemo(() => pinned.filter((c) => feeds.has(c.provider)), [pinned, feeds]);
const nearById = useMemo(() => {
const m = new Map<string, NearCamera[]>();
pointsById.forEach((pts, id) => m.set(id, nearestCameras(pts, cams.cameras)));
return m;
}, [pointsById, cams.cameras]);
const camCountByIncident = useMemo(() => new Map(Array.from(nearById.entries()).map(([id, n]) => [id, n.length])), [nearById]);
const events = useRegionEvents(regionBox);
const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]);
const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null;
const selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : [];
const near = selected ? nearById.get(selected.incident_id) ?? [] : [];
const drillCam: Camera | null = link.cam
? cams.cameras.find((c) => c.id === link.cam) ?? servingPinned.find((c) => c.id === link.cam) ?? null
: null;
// Region wall: pinned first, then the nearest camera to each major/moderate incident.
const wallCameras = useMemo(() => {
const out: { cam: Camera; km?: number }[] = servingPinned.map((cam) => ({ cam }));
for (const inc of sorted) {
if (severityRank(inc.severity) < 2) continue;
const first = nearById.get(inc.incident_id)?.[0];
if (first && !out.some((o) => o.cam.id === first.cam.id)) out.push({ cam: first.cam, km: first.km });
}
return out.slice(0, 6);
}, [servingPinned, sorted, nearById]);
// ── Navigation ────────────────────────────────────────────────────────────
const selectIncident = useCallback((id: string) => setLink({ inc: id, cam: null, tour: false }), [setLink]);
const openCamera = useCallback((c: Camera) => setLink({ ...link, cam: c.id, tour: false }), [link, setLink]);
const stepUp = useCallback(() => {
if (link.cam) setLink({ ...link, cam: null, tour: false });
else if (link.inc) setLink({ inc: null, cam: null, tour: false });
else if (link.tour) setLink({ ...link, tour: false });
}, [link, setLink]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") stepUp(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [stepUp]);
// ── Auto-tour: worst-first through major/moderate (else all plotted) ──────
const tourList = useMemo(() => {
const plotted = sorted.filter((i) => (pointsById.get(i.incident_id) ?? []).length);
const serious = plotted.filter((i) => severityRank(i.severity) >= 2);
return (serious.length ? serious : plotted).map((i) => i.incident_id);
}, [sorted, pointsById]);
const [tourStart, setTourStart] = useState(0);
const [tourNow, setTourNow] = useState(0);
useEffect(() => {
if (!link.tour || !tourList.length) return;
const next = () => {
setLinkState((cur) => {
const i = cur.inc ? tourList.indexOf(cur.inc) : -1;
const nxt: WallLink = { inc: tourList[(i + 1) % tourList.length], cam: null, tour: true };
window.history.replaceState(null, "", wallHash(nxt));
return nxt;
});
setTourStart(Date.now());
};
if (!link.inc || !tourList.includes(link.inc)) next();
else setTourStart(Date.now());
const id = setInterval(next, TOUR_MS);
const prog = setInterval(() => setTourNow(Date.now()), 500);
return () => { clearInterval(id); clearInterval(prog); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [link.tour, tourList.join(",")]);
const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / TOUR_MS) * 100) : 0;
// ── KPIs ──────────────────────────────────────────────────────────────────
const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length;
const nodesUp = nodes.filter((n) => n.status !== "offline").length;
const eventsOff = !!events.events_status?.disabled;
const configuredButQuiet = !nodesLoading && nodes.length > 0 && activeIncidents.length === 0;
const mostRecentSeen = configuredButQuiet
? nodes
.map((n) => n.last_seen)
.filter((s): s is string => !!s)
.sort()
.at(-1)
? nodes.map((n) => n.last_seen).filter((s): s is string => !!s).sort().at(-1)
: null;
const crumb = (label: string, active: boolean, enabled: boolean, onClick: () => void) => (
<button type="button" disabled={!enabled} onClick={onClick} aria-current={active ? "true" : undefined}
className={`px-1.5 py-0.5 rounded ${active ? "text-ink font-semibold" : enabled ? "text-ink-2 hover:text-ink" : "text-ink-muted"}`}>
{label}
</button>
);
return (
<div className="flex flex-col h-full">
<div className="relative flex-1 min-h-0">
{nodesLoading || callsLoading ? (
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">
Loading map…
</div>
) : (
<MapView
nodes={nodes}
activeCalls={activeCalls}
incidents={activeIncidents}
calls={recentCalls}
lastUpdated={lastUpdated}
/>
)}
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
Activation screen handles the zero-node case). A node that's
online but has heard nothing is NOT the same empty state as an
org with no equipment. */}
{configuredButQuiet && (
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""}
{/* ── Wall bar: drill crumbs · KPIs · auto-tour · clock ── */}
<div className="shrink-0 flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-1.5 border-b border-line bg-surface/95 text-xs">
<nav aria-label="Drill level" className="flex items-center gap-0.5">
{crumb("Region", !link.inc, true, () => setLink({ inc: null, cam: null, tour: false }))}
<span className="text-ink-muted">›</span>
{crumb(selected?.title ? `Incident: ${selected.title.slice(0, 32)}` : "Incident", !!link.inc && !link.cam, !!link.inc,
() => setLink({ ...link, cam: null, tour: false }))}
<span className="text-ink-muted">›</span>
{crumb("Camera", !!link.cam, !!link.cam, () => undefined)}
</nav>
<div className="hidden md:flex items-center gap-3 flex-wrap">
<Kpi value={count("major")} label="major" />
<Kpi value={count("moderate")} label="moderate" />
<Kpi value={activeIncidents.length} label="active" />
<Kpi value={`${nodesUp}/${nodes.length}`} label="nodes" />
<Kpi value={cams.cameras.length} label="cams" />
<Kpi value={aircraft.length} label="aircraft" />
<Kpi value={vessels.length} label="vessels" />
<Kpi value={eventsOff ? "—" : events.events?.length ?? 0} label="road events" />
</div>
<div className="ml-auto flex items-center gap-3">
<button type="button" aria-pressed={link.tour} onClick={() => setLink({ ...link, cam: null, tour: !link.tour })}
title="Wall mode: cycle through active incidents, worst first"
className={`px-2 py-0.5 rounded border ${link.tour ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`}>
Auto-tour
</button>
{link.tour && (
<span className="w-20 h-1 bg-raised rounded overflow-hidden" aria-hidden="true">
<span className="block h-full bg-accent" style={{ width: `${tourPct}%` }} />
</span>
</div>
)}
)}
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
</div>
</div>
<TimeScrubber calls={recentCalls} />
<div className="flex-1 min-h-0 flex flex-col lg:flex-row">
<div className="relative h-[55vh] lg:h-auto lg:flex-1 min-w-0 shrink-0">
{nodesLoading || callsLoading ? (
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
) : (
<MapView
nodes={nodes}
activeCalls={activeCalls}
incidents={activeIncidents}
calls={recentCalls}
lastUpdated={lastUpdated}
selectedIncidentId={selected?.incident_id ?? null}
onSelectIncident={selectIncident}
nearCameras={near}
onOpenCamera={openCamera}
onAir={onAir}
camCountByIncident={camCountByIncident}
/>
)}
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
Activation screen handles the zero-node case). */}
{configuredButQuiet && (
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""}
</span>
</div>
)}
</div>
<aside className="lg:w-[360px] lg:shrink-0 flex-1 lg:flex-none min-h-0 overflow-y-auto border-t lg:border-t-0 lg:border-l border-line bg-surface"
aria-live="polite" aria-label={selected ? "Incident detail" : "Region overview"}>
{selected ? (
<IncidentPanel
inc={selected}
calls={callsByIncident.get(selected.incident_id) ?? []}
points={selectedPoints}
near={near}
feeds={feeds}
servingCount={camsLoaded ? cams.serving_count : 1}
onAir={onAir.has(selected.incident_id)}
aircraft={aircraft}
vessels={vessels}
events={events.events ?? []}
eventsCredit={{ credit: events.credit, fetched_at: events.events_status?.fetched_at ?? null }}
onOpenCamera={openCamera}
/>
) : (
<RegionPanel
incidents={activeIncidents}
calls={recentCalls}
onAirCount={onAir.size}
nodes={nodes}
wallCameras={wallCameras}
feeds={feeds}
servingCount={camsLoaded ? cams.serving_count : 1}
aircraftCount={aircraft.length}
vesselCount={vessels.length}
events={events.events ?? []}
eventsOff={eventsOff}
pinnedIds={pinnedIds}
onOpenCamera={openCamera}
/>
)}
</aside>
</div>
<TranscriptTicker calls={recentCalls} onSelectIncident={selectIncident} />
<TimeScrubber calls={recentCalls} incidents={activeIncidents} selectedIncidentId={selected?.incident_id ?? null} onSelectIncident={selectIncident} />
{drillCam && (
<CameraDrill
camera={drillCam}
feed={feeds.get(drillCam.provider)}
distanceKm={selectedPoints.length ? Math.min(...selectedPoints.map((p) => km(p, [drillCam.lat, drillCam.lon]))) : null}
incidentTitle={selected?.title}
pinned={pinnedIds.has(drillCam.id)}
onTogglePin={() => togglePin(drillCam)}
onClose={() => setLink({ ...link, cam: null })}
/>
)}
</div>
);
}
+214 -54
View File
@@ -6,7 +6,9 @@ import {
FeatureGroup,
LayersControl,
MapContainer,
Circle,
Marker,
Polygon,
Polyline,
Popup,
TileLayer,
@@ -22,8 +24,11 @@ import { useAircraft } from "@/lib/useAircraft";
import { useAircraftTrail } from "@/lib/useAircraftTrail";
import { useVessels } from "@/lib/useVessels";
import { use511 } from "@/lib/use511";
import type { Ny511Camera, Ny511Event, Ny511FeedStatus } from "@/lib/types";
import { useMapCameras } from "@/lib/useCameras";
import type { Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Event, Ny511FeedStatus } from "@/lib/types";
import { CameraFeed } from "@/components/CameraFeed";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
import { NEAR_CAMERA_KM, groupCallsByIncident, incidentPoints, viewshedWedge, worstFirst } from "@/lib/liveWall";
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
@@ -341,6 +346,7 @@ function RadarLayer() {
// Overlay names double as the event keys for overlayadd/overlayremove.
const OVERLAY_DOT_CAMERAS = "DOT Cameras";
const OVERLAY_TRAFFIC_EVENTS = "Traffic Events";
const OVERLAY_VIEWSHEDS = "Camera Viewsheds (approx.)";
/** True while the named LayersControl overlay is checked. Overlays start unchecked. */
function useOverlayShown(map: L.Map, name: string): boolean {
@@ -450,6 +456,7 @@ function fmtLocal(iso: string | null): string | null {
function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: string; status?: Ny511FeedStatus; fetchError: string | null }) {
let msg: string | null = null;
if (fetchError) msg = `${label}: could not reach server`;
else if (status?.disabled) msg = `${label}: 511NY feed is off — an admin enables it under Admin → Camera providers`;
else if (status?.error) {
const age = status.fetched_at ? `showing data from ${Math.round((Date.now() / 1000 - status.fetched_at) / 60)} min ago` : "no data yet";
msg = `${label}: 511NY feed error, ${age}`;
@@ -461,19 +468,66 @@ function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: st
);
}
/** Provider id → what must render with its imagery. */
function creditsByProvider(providers: CameraProviderFeed[]): Map<string, CameraProviderFeed> {
return new Map(providers.map((p) => [p.id, p]));
}
/** Same honesty as FeedProblem, for the multi-provider camera layer. */
function CameraProblem({ map, data, loaded, fetchError }: { map: L.Map; data: CamerasResponse; loaded: boolean; fetchError: string | null }) {
let msg: string | null = null;
if (fetchError) msg = "Cameras: could not reach server";
else if (loaded && data.serving_count === 0) msg = "Cameras: no camera provider is enabled — Admin → Camera providers";
else {
const bad = data.providers.find((p) => p.error);
const cut = data.providers.find((p) => p.truncated);
if (bad) msg = `${bad.name}: feed error, ${bad.fetched_at ? `showing data from ${fetchedLabel(bad.fetched_at)}` : "no data yet"}`;
else if (cut) msg = `Cameras: too many in view — zoom in`;
}
if (!msg) return null;
return createPortal(
<div className="absolute bottom-8 left-3 z-[1001] bg-surface/90 border border-line rounded px-2 py-1 text-xs text-ink-2 pointer-events-none">{msg}</div>,
map.getContainer(),
);
}
/** Popup body for one camera: still, credit, catalog freshness, actions. */
function CameraPopupBody({ c, feed, onPin, onDock, onOpen }: {
c: Camera; feed?: CameraProviderFeed; onPin: () => void; onDock: () => void; onOpen?: () => void;
}) {
return (
<div className="space-y-1">
<div className="font-semibold">{c.name}</div>
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
{/* Popup content mounts on open, so the still is fetched per open. */}
<button type="button" onClick={onOpen ?? onPin} title="Open this camera" className="block w-full p-0 border-0 bg-transparent cursor-pointer">
<CameraFeed camera={c} mode="still" credit={feed} className="w-full h-[150px] rounded border border-line bg-black" />
</button>
<div className="flex gap-1.5 pt-0.5">
{onOpen && <button type="button" onClick={onOpen} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Open</button>}
<button type="button" onClick={onPin} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Pin to map</button>
<button type="button" onClick={onDock} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Dock</button>
</div>
<div className="text-[10px] text-ink-muted">{c.video_url ? "Live feed available" : "Snapshot"}</div>
<SourceCredit credit={feed} fallbackName={feed?.name} updated={feed ? `camera list ${fetchedLabel(feed.fetched_at) ?? "—"}` : null} />
</div>
);
}
// Open cameras: each is either pinned (a small tile anchored at the camera, so
// several views along one road read against the map) or docked (a larger tile
// in a strip over the map, which stays put while you pan and survives the
// overlay being switched off). Oldest camera drops past the cap.
const MAX_OPEN_CAMERAS = 6;
type CameraMode = "pin" | "dock";
interface OpenCamera { cam: Ny511Camera; mode: CameraMode }
interface OpenCamera { cam: Camera; mode: CameraMode }
const camBtn = "text-ink-muted hover:text-ink text-xs leading-none px-0.5";
function CameraDock({ map, open, onMode, onClose }: {
function CameraDock({ map, open, credits, onMode, onClose }: {
map: L.Map;
open: Ny511Camera[];
open: Camera[];
credits: Map<string, CameraCredit>;
onMode: (id: string, mode: CameraMode) => void;
onClose: (id: string) => void;
}) {
@@ -497,7 +551,7 @@ function CameraDock({ map, open, onMode, onClose }: {
<button type="button" onClick={() => onMode(c.id, "pin")} title="Pin to the map at the camera" aria-label={`Pin ${c.name} to the map`} className={camBtn}>⌖</button>
<button type="button" onClick={() => onClose(c.id)} aria-label={`Close ${c.name}`} className={camBtn}>×</button>
</div>
<CameraFeed camera={c} className="block w-[240px] h-[135px] object-cover bg-black rounded-b-md" />
<CameraFeed camera={c} credit={credits.get(c.provider)} className="block w-[240px] h-[135px] bg-black rounded-b-md" />
</div>
))}
</div>,
@@ -505,20 +559,29 @@ function CameraDock({ map, open, onMode, onClose }: {
);
}
function DotCameraLayer() {
function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }) {
const map = useMap();
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
const zoom = useMapZoom(map);
const { data, error } = use511(map, "cameras", shown);
const [open, setOpen] = useState<OpenCamera[]>([]);
const { data, error, loaded } = useMapCameras(map, shown);
const [openAll, setOpen] = useState<OpenCamera[]>([]);
// Credits from the last answer received while the overlay was on. Hiding the
// overlay empties `data`, but docked players stay up and still need their
// attribution. A pinned/docked camera whose provider an admin has since
// switched off (licence gating, server-26#198) drops out on the next answer.
const [feeds, setFeeds] = useState<Map<string, CameraProviderFeed>>(() => new Map());
useEffect(() => {
if (shown && loaded) setFeeds(creditsByProvider(data.providers));
}, [shown, loaded, data.providers]);
const open = useMemo(() => openAll.filter((o) => feeds.has(o.cam.provider)), [openAll, feeds]);
const pinnedIds = useMemo(() => new Set(open.filter((o) => o.mode === "pin").map((o) => o.cam.id)), [open]);
const cells = useMemo(
() => lodCells((data.cameras ?? []).filter((c) => !pinnedIds.has(c.id)), map, zoom),
() => lodCells(data.cameras.filter((c) => !pinnedIds.has(c.id)), map, zoom),
[data.cameras, pinnedIds, map, zoom],
);
const show = (cam: Ny511Camera, mode: CameraMode) => {
const show = (cam: Camera, mode: CameraMode) => {
map.closePopup();
setOpen((prev) => [...prev.filter((o) => o.cam.id !== cam.id), { cam, mode }].slice(-MAX_OPEN_CAMERAS));
};
@@ -528,7 +591,7 @@ function DotCameraLayer() {
return (
<>
{shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />}
{shown && <CameraProblem map={map} data={data} loaded={loaded} fetchError={error} />}
{cells.map(({ rep: c, count }) => count > 1 ? (
<Marker key={`lod-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon(count)}
title={`${count} cameras — click to zoom in`}
@@ -536,24 +599,8 @@ function DotCameraLayer() {
) : (
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
<Popup minWidth={260} maxWidth={340}>
<div className="space-y-1">
<div className="font-semibold">{c.name}</div>
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
{c.image_url && (
// Popup content mounts on open, so the timestamp busts the cache per open.
<button type="button" onClick={() => show(c, "pin")} title="Pin this camera to the map" className="block w-full p-0 border-0 bg-transparent cursor-pointer">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" />
</button>
)}
<div className="flex gap-1.5 pt-0.5">
<button type="button" onClick={() => show(c, "pin")} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Pin to map</button>
<button type="button" onClick={() => show(c, "dock")} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Dock</button>
</div>
<div className="text-[10px] text-ink-muted">
{c.video_url ? "Live feed" : "Snapshot"} · 511NY / NYSDOT
</div>
</div>
<CameraPopupBody c={c} feed={feeds.get(c.provider)} onPin={() => show(c, "pin")} onDock={() => show(c, "dock")}
onOpen={onOpenCamera ? () => { map.closePopup(); onOpenCamera(c); } : undefined} />
</Popup>
</Marker>
))}
@@ -567,12 +614,70 @@ function DotCameraLayer() {
<button type="button" onClick={() => setMode(c.id, "dock")} title="Dock" aria-label={`Dock ${c.name}`} className={camBtn}>⇲</button>
<button type="button" onClick={() => close(c.id)} aria-label={`Close ${c.name}`} className={camBtn}>×</button>
</div>
<CameraFeed camera={c} className="block w-[192px] h-[108px] object-cover bg-black rounded-b" />
<CameraFeed camera={c} credit={feeds.get(c.provider)} className="block w-[192px] h-[108px] bg-black rounded-b" />
</div>
</Tooltip>
</Marker>
))}
<CameraDock map={map} open={open.filter((o) => o.mode === "dock").map((o) => o.cam)} onMode={setMode} onClose={close} />
<CameraDock map={map} open={open.filter((o) => o.mode === "dock").map((o) => o.cam)} credits={feeds} onMode={setMode} onClose={close} />
</>
);
}
// ── Camera viewsheds — GEV idea #4, coarse 2D cones, APPROXIMATE ─────────────
// Built from the published direction of travel, not a surveyed heading/FOV.
function CameraViewshedLayer({ highlightIds }: { highlightIds: Set<string> }) {
const map = useMap();
const shown = useOverlayShown(map, OVERLAY_VIEWSHEDS);
const zoom = useMapZoom(map);
const { data } = useMapCameras(map, shown && zoom >= 11);
if (!shown) return null;
return (
<>
{data.cameras.map((c) => {
const wedge = viewshedWedge(c);
if (!wedge) return null;
const hot = highlightIds.has(c.id);
return (
<Polygon key={`vs-${c.id}`} positions={wedge}
pathOptions={{ color: hot ? "#3987E5" : "#A5B0C2", weight: hot ? 1 : 0.5, opacity: hot ? 0.8 : 0.4, fillOpacity: hot ? 0.18 : 0.06 }}>
<Tooltip sticky>{c.name} — approximate view (from the road&apos;s direction of travel, not a surveyed heading)</Tooltip>
</Polygon>
);
})}
</>
);
}
// ── Nearest cameras to the selected incident (live ops wall) ─────────────────
// Always drawn while an incident is selected, independent of the DOT Cameras
// overlay, so the wall never hides the cameras it is recommending.
function nearCameraIcon(): L.DivIcon {
return L.divIcon({
className: "",
html: `<div style="width:20px;height:16px;border-radius:4px;background:#0b1f3a;border:2px solid #3987E5;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 3px rgba(57,135,229,.25)"><svg width="12" height="9" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="none" stroke="#8DB9F0" stroke-width="1.5"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#8DB9F0"/></svg></div>`,
iconSize: [20, 16],
iconAnchor: [10, 8],
});
}
function NearCameraLayer({ cameras, head, onOpenCamera }: {
cameras: { cam: Camera; km: number }[];
head: [number, number] | null;
onOpenCamera?: (c: Camera) => void;
}) {
return (
<>
{head && (
<Circle center={head} radius={NEAR_CAMERA_KM * 1000} interactive={false}
pathOptions={{ color: "#3987E5", weight: 1, opacity: 0.6, fill: false, dashArray: "2 5" }} />
)}
{cameras.map(({ cam, km: d }) => (
<Marker key={`near-${cam.id}`} position={[cam.lat, cam.lon]} icon={nearCameraIcon()} zIndexOffset={600}
eventHandlers={{ click: () => onOpenCamera?.(cam) }}>
<Tooltip direction="right" offset={[10, 0]}>{cam.name} · {d.toFixed(1)} km</Tooltip>
</Marker>
))}
</>
);
}
@@ -605,8 +710,10 @@ function TrafficEventLayer() {
<div className="font-semibold">{st.label}{e.subtype ? `: ${e.subtype}` : ""}</div>
<div className="text-xs text-ink-muted">{[e.roadway, e.direction, e.county].filter(Boolean).join(" · ")}</div>
{(start || end) && <div className="text-xs">{start ? `From ${start}` : ""}{end ? ` until ${end}` : ""}</div>}
{/* DAA: shown exactly as NYSDOT published it — never summarised or rewritten. */}
<div className="text-xs whitespace-pre-line">{e.description}</div>
<div className="text-[10px] text-ink-muted">511NY{e.updated_local ? ` · updated ${fmtLocal(e.updated_local)}` : ""}</div>
<SourceCredit credit={data.credit} fallbackName="511NY / NYSDOT"
updated={fmtLocal(e.updated_local) ?? fetchedLabel(data.events_status?.fetched_at)} />
</div>
</Popup>
</Marker>
@@ -816,9 +923,11 @@ function FanNodeLayer({
function FanIncidentLayer({
incidents,
onSelect,
selectedId,
}: {
incidents: IncidentRecord[];
onSelect: (inc: IncidentRecord) => void;
selectedId?: string | null;
}) {
const map = useMap();
const [tick, setTick] = useState(0);
@@ -863,6 +972,8 @@ function FanIncidentLayer({
key={repId}
position={[repPlot.lat, repPlot.lng]}
icon={members.length > 1 ? incidentFanIcon(members) : incidentIcon(repPlot.inc.type, repPlot.inc.severity)}
opacity={selectedId && !members.some((m) => m.incident_id === selectedId) ? 0.45 : 1}
zIndexOffset={members.some((m) => m.incident_id === selectedId) ? 1000 : 0}
eventHandlers={{ click: () => onSelect(repPlot.inc) }}
>
<Popup minWidth={180}>
@@ -964,6 +1075,8 @@ function timeAgo(date: Date): string {
return `${Math.floor(s / 3600)}h ago`;
}
const NO_NEAR: { cam: Camera; km: number }[] = [];
// ── Main MapView ──────────────────────────────────────────────────────────────
interface Props {
nodes: NodeRecord[];
@@ -978,9 +1091,24 @@ interface Props {
*/
calls?: CallRecord[];
lastUpdated?: Date | null;
// ── Live ops wall (all optional; without them MapView behaves as before) ──
/** The incident drilled into; the map fits its path/pin and dims the rest. */
selectedIncidentId?: string | null;
/** When set, rail cards and pins select instead of only flying to the pin. */
onSelectIncident?: (id: string) => void;
/** Nearest cameras to the selected incident — always drawn, highlighted. */
nearCameras?: { cam: Camera; km: number }[];
onOpenCamera?: (c: Camera) => void;
/** Incident ids with a live call on one of their talkgroups. */
onAir?: Set<string>;
/** Cameras within range of each incident (rail "N cams"). */
camCountByIncident?: Map<string, number>;
}
export default function MapView({ nodes, activeCalls, incidents = [], calls = [], lastUpdated }: Props) {
export default function MapView({
nodes, activeCalls, incidents = [], calls = [], lastUpdated,
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
}: Props) {
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [agoClock, setAgoClock] = useState(0);
@@ -1008,17 +1136,13 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
// eslint-disable-next-line react-hooks/exhaustive-deps
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
const callsByIncident = useMemo(() => {
const map = new Map<string, CallRecord[]>();
for (const c of calls) {
const ids = c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : [];
for (const id of ids) {
if (!map.has(id)) map.set(id, []);
map.get(id)!.push(c);
}
}
return map;
}, [calls]);
const callsByIncident = useMemo(() => groupCallsByIncident(calls), [calls]);
const railIncidents = useMemo(() => worstFirst(incidents), [incidents]);
const highlightIds = useMemo(() => new Set(nearCameras.map((n) => n.cam.id)), [nearCameras]);
const selectedPoints = useMemo(() => {
const inc = incidents.find((i) => i.incident_id === selectedIncidentId);
return inc ? incidentPoints(inc, callsByIncident.get(inc.incident_id)) : [];
}, [incidents, selectedIncidentId, callsByIncident]);
const allPositions = useMemo(
() => [
@@ -1050,12 +1174,22 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
const handleIncidentSelect = useCallback(
(inc: IncidentRecord) => {
if (onSelectIncident) { onSelectIncident(inc.incident_id); return; } // the fit happens below
if (!mapInstance || !inc.location_coords) return;
mapInstance.flyTo([inc.location_coords.lat, inc.location_coords.lng], 15, { duration: 1.2 });
},
[mapInstance]
[mapInstance, onSelectIncident]
);
// Wall drill-down: fit the selected incident's whole path (or its pin).
const selectedKey = selectedPoints.map((p) => p.join(",")).join(";");
useEffect(() => {
if (!mapInstance || !selectedPoints.length) return;
if (selectedPoints.length === 1) mapInstance.flyTo(selectedPoints[0], 14, { duration: 1.0 });
else mapInstance.flyToBounds(L.latLngBounds(selectedPoints), { padding: [60, 60], maxZoom: 15, duration: 1.0 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mapInstance, selectedIncidentId, selectedKey]);
const onMapReady = useCallback((m: L.Map) => setMapInstance(m), []);
return (
@@ -1100,7 +1234,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
{/* Overlay: Active Incidents */}
<LayersControl.Overlay checked name="Active Incidents">
<FeatureGroup>
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} />
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} selectedId={selectedIncidentId} />
</FeatureGroup>
</LayersControl.Overlay>
@@ -1128,7 +1262,12 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
<LayersControl.Overlay name={OVERLAY_DOT_CAMERAS}>
<FeatureGroup>
<DotCameraLayer />
<DotCameraLayer onOpenCamera={onOpenCamera} />
</FeatureGroup>
</LayersControl.Overlay>
<LayersControl.Overlay name={OVERLAY_VIEWSHEDS}>
<FeatureGroup>
<CameraViewshedLayer highlightIds={highlightIds} />
</FeatureGroup>
</LayersControl.Overlay>
<LayersControl.Overlay name={OVERLAY_TRAFFIC_EVENTS}>
@@ -1142,6 +1281,10 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
<RadarLayer />
</LayersControl.Overlay>
</LayersControl>
{selectedIncidentId && (
<NearCameraLayer cameras={nearCameras} head={selectedPoints.at(-1) ?? null} onOpenCamera={onOpenCamera} />
)}
</MapContainer>
{/* ── Live timestamp ───────────────────────────────────────────────────── */}
@@ -1236,11 +1379,16 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
</div>
<div className="flex flex-col gap-1.5 overflow-y-auto min-h-0 pointer-events-auto">
{incidents.map((inc) => {
{railIncidents.map((inc) => {
const color = severityColor(inc.severity);
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
const unitCount = inc.units_active?.length ?? inc.units?.length ?? 0;
const baseClass = "w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all";
const units = (inc.units_active?.length ? inc.units_active : inc.units) ?? [];
const selected = inc.incident_id === selectedIncidentId;
const live = onAir?.has(inc.incident_id) ?? false;
const cams = camCountByIncident?.get(inc.incident_id) ?? 0;
const callCount = inc.call_ids?.length ?? 0;
const baseClass = `w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all ${selected ? "ring-2 ring-accent" : ""}`;
const cardBody = (
<>
<div className="flex items-center gap-1.5 mb-0.5">
@@ -1248,6 +1396,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
<span className="uppercase tracking-wide font-semibold text-[10px]" style={{ color }}>
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
</span>
{live && <span className="ml-auto bg-accent text-white rounded px-1 text-[9px] font-semibold tracking-wide">ON AIR</span>}
</div>
<p className="text-ink font-semibold leading-snug truncate">
{inc.title ?? "Incident"}
@@ -1255,22 +1404,33 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
{inc.location && (
<p className="text-ink-muted truncate mt-0.5">{inc.location}</p>
)}
{onSelectIncident && units.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{units.slice(0, 4).map((u) => (
<span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>
))}
{units.length > 4 && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-muted">+{units.length - 4}</span>}
</div>
)}
<div className="flex items-center justify-between mt-0.5">
{age && <span className="text-ink-muted font-mono">{age}</span>}
{unitCount > 0 && (
{onSelectIncident ? (
<span className="text-ink-muted font-mono">{callCount} call{callCount !== 1 ? "s" : ""}{cams ? ` · ${cams} cam${cams !== 1 ? "s" : ""}` : ""}</span>
) : unitCount > 0 && (
<span className="text-ink-muted font-mono">{unitCount} unit{unitCount !== 1 ? "s" : ""}</span>
)}
</div>
{!inc.location_coords && (
{!inc.location_coords && !onSelectIncident && (
<p className="text-[10px] text-accent mt-1">View details →</p>
)}
</>
);
if (inc.location_coords) {
if (inc.location_coords || onSelectIncident) {
return (
<button
key={inc.incident_id}
onClick={() => handleIncidentSelect(inc)}
aria-pressed={selected}
className={baseClass}
style={{ borderColor: color + "55" }}
>
@@ -1305,7 +1465,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
<div className="bg-surface/95 border-t border-line max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
{/* Gate A / A2 (server-26#46) */}
<MachineOutputNotice variant="inline" className="text-[10px] items-start" />
{incidents.map((inc) => {
{railIncidents.map((inc) => {
const color = severityColor(inc.severity);
const label = (
<>
@@ -1316,7 +1476,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
<span className="text-ink">{inc.title ?? "Incident"}</span>
</>
);
if (inc.location_coords) {
if (inc.location_coords || onSelectIncident) {
return (
<button
key={inc.incident_id}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import type { CameraCredit } from "@/lib/types";
/** "3 min ago (14:02)" for an epoch-seconds catalog/feed fetch time. */
export function fetchedLabel(epochS: number | null | undefined): string | null {
if (!epochS) return null;
const ms = epochS * 1000;
const m = Math.round((Date.now() - ms) / 60_000);
const t = new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
return m < 1 ? `just now (${t})` : `${m} min ago (${t})`;
}
/**
* Source credit + freshness + pass-through disclaimer for third-party data
* (511NY DAA: credit as a source credit only, "display the time at which the
* data was published or last updated wherever you present it", and "best
* efforts" to pass on NYSDOT's as-is disclaimer). Render it wherever that
* provider's data or imagery is shown.
*/
export function SourceCredit({
credit,
updated,
fallbackName,
className,
}: {
credit?: CameraCredit | null;
/** Human-readable "updated …" text; required by the DAA wherever 511NY data shows. */
updated?: string | null;
fallbackName?: string;
className?: string;
}) {
const name = credit?.attribution ?? fallbackName ?? null;
return (
<div className={`text-[10px] text-ink-muted space-y-0.5 ${className ?? ""}`}>
<div>
{name && (credit?.attribution_url ? (
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="underline hover:text-ink-2">{name}</a>
) : <span>{name}</span>)}
{name && updated ? " · " : ""}
{updated ? `updated ${updated}` : ""}
</div>
{credit?.disclaimer && (
<details>
<summary className="cursor-pointer select-none">Provided &ldquo;as is&rdquo;, without warranty — details</summary>
<p className="mt-0.5 leading-snug whitespace-normal">{credit.disclaimer}</p>
</details>
)}
</div>
);
}
+43 -4
View File
@@ -10,8 +10,8 @@
* fixture — only the scrub interaction is deferred.
*/
import { useMemo, useState } from "react";
import type { CallRecord } from "@/lib/types";
import { SEVERITY_COLORS, severityRank } from "@/lib/severity";
import type { CallRecord, IncidentRecord } from "@/lib/types";
import { isKnownSeverity, SEVERITY_COLORS, severityRank } from "@/lib/severity";
const RANGES = [
{ label: "1h", ms: 60 * 60 * 1000 },
@@ -22,7 +22,17 @@ const RANGES = [
const BUCKETS = 48;
export function TimeScrubber({ calls }: { calls: CallRecord[] }) {
/**
* `incidents` (optional) adds one band per active incident from its start to
* its last update, tinted by severity — click a band to drill into it (live
* ops wall). The playhead still does not move; see the header.
*/
export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null, onSelectIncident }: {
calls: CallRecord[];
incidents?: IncidentRecord[];
selectedIncidentId?: string | null;
onSelectIncident?: (id: string) => void;
}) {
const [rangeIdx, setRangeIdx] = useState(2); // default 24h
const range = RANGES[rangeIdx];
@@ -46,9 +56,38 @@ export function TimeScrubber({ calls }: { calls: CallRecord[] }) {
const maxCount = Math.max(1, ...buckets.map((b) => b.count));
const bands = useMemo(() => {
const now = Date.now();
const start = now - range.ms;
return incidents
.map((inc) => {
const a = Math.max(start, new Date(inc.started_at).getTime());
const b = Math.min(now, new Date(inc.updated_at || inc.started_at).getTime());
if (Number.isNaN(a) || Number.isNaN(b) || b < start) return null;
return { inc, left: ((a - start) / range.ms) * 100, width: Math.max(0.6, ((Math.max(a, b) - a) / range.ms) * 100) };
})
.filter((x): x is { inc: IncidentRecord; left: number; width: number } => x !== null)
.sort((x, y) => severityRank(x.inc.severity) - severityRank(y.inc.severity));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [incidents, rangeIdx]);
return (
<div className="h-[84px] bg-surface/90 border-t border-line px-4 flex items-center gap-4">
<div className="flex-1 h-12 flex items-end gap-[2px]">
<div className="flex-1 h-12 flex items-end gap-[2px] relative">
{bands.length > 0 && (
<div className="absolute left-0 right-0 -top-3 h-2" aria-label="Incident bands">
{bands.map(({ inc, left, width }) => {
const color = isKnownSeverity(inc.severity) ? SEVERITY_COLORS[inc.severity] : "var(--ink-muted)";
const sel = inc.incident_id === selectedIncidentId;
return (
<button key={inc.incident_id} type="button" title={inc.title ?? "Incident"}
onClick={() => onSelectIncident?.(inc.incident_id)}
className={`absolute top-0 h-2 rounded-sm ${sel ? "ring-1 ring-ink" : ""}`}
style={{ left: `${left}%`, width: `${width}%`, background: color, opacity: sel ? 1 : 0.55 }} />
);
})}
</div>
)}
{buckets.map((b, i) => {
const heightPct = b.count === 0 ? 4 : Math.max(10, (b.count / maxCount) * 100);
const color =
@@ -0,0 +1,150 @@
"use client";
/**
* Admin → Camera providers. Every source in design-canvas/camera-sources.md,
* each with its licence status shown first. All default OFF. A licence-gated
* source (agreement required / unverified / non-commercial / paid) shows a
* blocking warning and needs an explicit confirmation, which c2-core records
* with who/when in config/camera_providers and audit_log. Stubs and sources
* whose terms forbid the use cannot be switched on at all.
*/
import { useEffect, useState } from "react";
import { c2api } from "@/lib/c2api";
import type { CameraProviderAdmin, LicenceStatus } from "@/lib/types";
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" },
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
paid: { label: "Paid licence — no contract", cls: "bg-amber-950 text-amber-300 border-amber-700" },
prohibited: { label: "Terms forbid use", cls: "bg-red-950 text-red-300 border-red-700" },
};
function fmtEpoch(s: number | null | undefined): string {
return s ? new Date(s * 1000).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : "—";
}
export function CameraProvidersTab() {
const [providers, setProviders] = useState<CameraProviderAdmin[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [confirming, setConfirming] = useState<CameraProviderAdmin | null>(null);
const [ackChecked, setAckChecked] = useState(false);
const load = () =>
c2api.listCameraProviders().then((r) => setProviders(r.providers)).catch((e) => setError(String(e)));
useEffect(() => { load(); }, []);
async function toggle(p: CameraProviderAdmin, enabled: boolean, acknowledge = false) {
if (enabled && p.licence.gated && !acknowledge) {
setAckChecked(false);
setConfirming(p);
return;
}
setBusy(p.id);
setError(null);
try {
const updated = await c2api.setCameraProvider(p.id, enabled, acknowledge);
setProviders((cur) => cur?.map((x) => (x.id === p.id ? updated : x)) ?? null);
setConfirming(null);
} catch (e) {
setError(String(e));
} finally {
setBusy(null);
}
}
return (
<section className="space-y-3">
<p className="text-gray-400 text-xs leading-snug">
Public camera sources for the Live wall. Every provider is <b>off</b> until enabled here. Licence status comes from the
camera-sources catalog (2026-10-04); a red or amber licence means DRB does not hold a confirmed right to show that imagery.
Required attributions render on every camera tile.
</p>
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
{!providers ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p>
) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
{providers.map((p) => {
const lic = LICENCE_STYLE[p.licence.status];
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
return (
<div key={p.id} className="px-5 py-4 space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
<p className="text-white text-sm font-semibold">{p.name}</p>
<p className="text-gray-400 text-xs">{p.licence.label}</p>
<p className="text-gray-500 text-xs">{p.region} · {p.formats}</p>
</div>
<div className="shrink-0 text-right space-y-1">
<button
type="button"
role="switch"
aria-checked={p.enabled}
aria-label={`${p.enabled ? "Disable" : "Enable"} ${p.name}`}
disabled={busy === p.id || (!!lockReason && !p.enabled)}
title={lockReason ?? undefined}
onClick={() => toggle(p, !p.enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-40 ${p.enabled ? "bg-indigo-600" : "bg-gray-700"}`}
>
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-gray-500"}`}>
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px] font-mono text-gray-400">
<span>cameras: {p.health ? p.health.count : "—"}</span>
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
<span className={p.key_missing ? "text-amber-400" : ""}>{p.key_setting ? `key: ${p.key_missing ? "missing" : "set"}` : "no key needed"}</span>
</div>
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
{p.licence.attribution && <p className="text-[11px] text-gray-400">Attribution shown with imagery: “{p.licence.attribution}”</p>}
{p.acknowledgement && (
<p className="text-[11px] text-amber-300">
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
</p>
)}
<details className="text-[11px] text-gray-500">
<summary className="cursor-pointer select-none">Terms & adapter notes</summary>
<p className="mt-1">{p.licence.terms}</p>
<p className="mt-1">{p.notes}</p>
<p className="mt-1 space-x-2">
{p.licence.terms_url && <a className="underline" href={p.licence.terms_url} target="_blank" rel="noopener noreferrer">terms</a>}
{p.homepage && <a className="underline" href={p.homepage} target="_blank" rel="noopener noreferrer">source</a>}
{p.licence.issue && <span>tracking: {p.licence.issue}</span>}
</p>
</details>
</div>
);
})}
</div>
)}
{confirming && (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="cam-ack-title">
<div className="bg-gray-900 border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
<h2 id="cam-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
<p className="text-gray-300 text-sm leading-snug">{confirming.licence.warning}</p>
<label className="flex items-start gap-2 text-sm text-gray-200">
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
<span>I confirm DRB holds the right to show this source&apos;s imagery. This confirmation is recorded with my account and the time in the audit log.</span>
</label>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-gray-700 text-gray-300">Cancel</button>
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
</div>
</div>
</div>
)}
</section>
);
}
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useRef } from "react";
import type { Camera, CameraProviderFeed } from "@/lib/types";
import { CameraFeed } from "@/components/CameraFeed";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
/**
* Level 3 of the wall: one camera, large. Esc / Back steps up to the incident.
*
* Camera detections (the mockup's boxes + per-class counts) are NOT built:
* blocked on server-26#199 (NYSDOT's written answer on imagery analytics) and
* #200 (CISO ruling on retention). The seam is `CameraSignalSink` in c2-core
* (app/internal/cameras/base.py); this panel only states that plainly.
*/
export function CameraDrill({
camera, feed, distanceKm, incidentTitle, pinned, onTogglePin, onClose,
}: {
camera: Camera;
feed?: CameraProviderFeed;
distanceKm?: number | null;
incidentTitle?: string | null;
pinned: boolean;
onTogglePin: () => void;
onClose: () => void;
}) {
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => { closeRef.current?.focus(); }, [camera.id]);
return (
<div className="fixed inset-0 z-[2000] bg-black/70 flex items-center justify-center p-4" role="dialog" aria-modal="true"
aria-labelledby="drb-drill-title" onClick={onClose}>
<div className="bg-surface border border-line-strong rounded-xl shadow-2xl w-full max-w-5xl max-h-full overflow-y-auto grid md:grid-cols-[minmax(0,1fr)_300px]"
onClick={(e) => e.stopPropagation()}>
<CameraFeed camera={camera} priority="high" credit={feed} className="w-full aspect-video bg-black md:rounded-l-xl" />
<div className="p-4 space-y-3 text-sm">
<div className="flex items-start gap-2">
<h2 id="drb-drill-title" className="flex-1 font-semibold text-ink leading-snug">{camera.name || camera.id}</h2>
<button ref={closeRef} type="button" onClick={onClose} className="text-xs border border-line rounded px-2 py-1 hover:bg-raised" aria-label="Close camera">Esc</button>
</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt className="text-ink-muted">Source</dt><dd className="text-ink-2">{feed?.name ?? camera.provider}</dd>
{camera.roadway && (<><dt className="text-ink-muted">Road</dt><dd className="text-ink-2">{camera.roadway}</dd></>)}
{camera.direction && (<><dt className="text-ink-muted">Direction</dt><dd className="text-ink-2">{camera.direction}{camera.heading_deg != null ? " (approx. view)" : ""}</dd></>)}
{distanceKm != null && (<><dt className="text-ink-muted">Distance</dt><dd className="text-ink-2">{distanceKm.toFixed(1)} km from {incidentTitle ?? "the incident"}</dd></>)}
<dt className="text-ink-muted">Feed</dt><dd className="text-ink-2">{camera.video_url ? "Live stream (falls back to still)" : "Still image"}</dd>
</dl>
<SourceCredit credit={feed} fallbackName={feed?.name} updated={feed ? `camera list ${fetchedLabel(feed.fetched_at) ?? "—"}` : null} />
<div className="border border-dashed border-line rounded-md p-2.5 text-xs text-ink-muted space-y-1">
<p className="font-medium text-ink-2">Camera signals · future</p>
<p>Object-class counts from camera stills (vehicle, truck, person, smoke) are not built. Blocked on
server-26#199 (NYSDOT written answer on imagery analytics) and #200 (CISO ruling on retention).
No plate or face recognition will ever be part of it.</p>
</div>
<div className="flex gap-2 flex-wrap">
<button type="button" onClick={onTogglePin} className="text-xs rounded px-3 py-1.5 bg-accent text-white">{pinned ? "Unpin from wall" : "Pin to wall"}</button>
<button type="button" onClick={onClose} className="text-xs border border-line rounded px-3 py-1.5 hover:bg-raised">Back</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,62 @@
"use client";
import { useMemo, type CSSProperties } from "react";
import type { CallRecord } from "@/lib/types";
const WINDOW_MS = 60 * 60 * 1000;
const MAX_ITEMS = 25;
function hhmm(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
}
/**
* Live radio transcripts, newest first, as a slow belt along the bottom of
* the wall (mockup "Radio" ticker). Clicking an incident id drills into it.
* Transcripts are machine output — the same text the call list already shows.
*/
export function TranscriptTicker({ calls, onSelectIncident }: { calls: CallRecord[]; onSelectIncident: (id: string) => void }) {
const items = useMemo(() => {
const cutoff = Date.now() - WINDOW_MS;
return calls
.filter((c) => !c.duplicate_of && (c.transcript_corrected || c.transcript) && new Date(c.started_at).getTime() >= cutoff)
.sort((a, b) => b.started_at.localeCompare(a.started_at))
.slice(0, MAX_ITEMS);
}, [calls]);
const belt = (copy: number) =>
items.map((c) => {
const text = (c.transcript_corrected || c.transcript || "").replace(/\s+/g, " ").trim();
const inc = c.incident_ids?.[0] ?? c.incident_id ?? null;
return (
<span key={`${copy}-${c.call_id}`} className="inline-flex items-center gap-1.5 mr-8 whitespace-nowrap" aria-hidden={copy > 0}>
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span>
{c.talkgroup_name && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{c.talkgroup_name}</span>}
{inc && (
<button type="button" tabIndex={copy > 0 ? -1 : 0} onClick={() => onSelectIncident(inc)}
className="font-mono text-[10px] text-accent hover:underline">{inc.slice(0, 8)}</button>
)}
<span className="text-ink-2">{text.length > 160 ? `${text.slice(0, 157)}…` : text}</span>
</span>
);
});
return (
<div className="h-9 shrink-0 flex items-center border-t border-line bg-surface/95 text-xs" aria-label="Live radio transcripts">
<div className="px-3 shrink-0 font-semibold text-ink-2 uppercase tracking-wide text-[10px] flex items-center gap-1">
<span className="text-accent">●</span>Radio
</div>
<div className="drb-ticker flex-1 min-w-0 overflow-hidden">
{items.length === 0 ? (
<span className="text-ink-muted">No transcribed calls in the last hour.</span>
) : (
<div className="drb-ticker-belt inline-flex" style={{ "--drb-ticker-duration": `${Math.max(40, items.length * 8)}s` } as CSSProperties}>
{belt(0)}
{belt(1)}
</div>
)}
</div>
<span className="px-3 shrink-0 text-[10px] text-ink-muted hidden md:inline">machine transcripts · unverified</span>
</div>
);
}
+300
View File
@@ -0,0 +1,300 @@
"use client";
/**
* Live ops wall side panel — Level 1 (region) and Level 2 (incident) of the
* approved mockup (design-canvas/live-ops-wall-mockup.html).
*/
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import type {
AircraftTrack, CallRecord, Camera, CameraCredit, CameraProviderFeed, IncidentRecord, Ny511Event, NodeRecord, VesselTrack,
} from "@/lib/types";
import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL } from "@/lib/severity";
import { useAuth } from "@/components/AuthProvider";
import { CameraFeed } from "@/components/CameraFeed";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
import { CONTEXT_KM, NEAR_CAMERA_KM, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall";
function sevColor(s: string | null | undefined) {
return isKnownSeverity(s) ? SEVERITY_COLORS[s] : "var(--ink-muted)";
}
function sevLabel(s: string | null | undefined) {
return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown";
}
function elapsed(iso: string): string {
const m = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60_000));
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
}
function hhmm(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
}
const H3 = "text-[11px] uppercase tracking-wide text-ink-muted font-medium mb-1.5";
const SEC = "px-4 py-3 border-b border-line";
export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: CameraProviderFeed; km?: number; onOpen: () => void }) {
return (
<button type="button" onClick={onOpen} title={cam.name} className="relative block w-full text-left rounded-md overflow-hidden border border-line bg-black group">
<CameraFeed camera={cam} credit={feed} className="w-full aspect-video" />
{km != null && <span className="absolute top-1 right-1 bg-black/65 text-white font-mono text-[9px] px-1 rounded-sm">{km.toFixed(1)} km</span>}
<span className="block px-1.5 py-0.5 text-[10px] text-ink-2 bg-surface truncate group-hover:text-ink">{cam.name || cam.id}</span>
</button>
);
}
function NoCameras({ servingCount, radiusKm }: { servingCount: number; radiusKm: number }) {
// Only admins can open /admin (everyone else is bounced to /), so only they get the link.
const { isAdmin } = useAuth();
return (
<p className="text-xs text-ink-muted">
{servingCount !== 0
? `No public camera within ${radiusKm} km.`
: isAdmin
? <>No camera provider is enabled. Turn one on under <Link href="/admin#cameras" className="underline">Admin → Camera providers</Link>.</>
: "No camera provider is enabled. A platform admin can turn them on."}
</p>
);
}
// ── Level 1: region ──────────────────────────────────────────────────────────
export function RegionPanel({
incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount,
events, eventsOff, pinnedIds, onOpenCamera,
}: {
incidents: IncidentRecord[];
calls: CallRecord[];
onAirCount: number;
nodes: NodeRecord[];
wallCameras: { cam: Camera; km?: number }[];
feeds: Map<string, CameraProviderFeed>;
servingCount: number;
aircraftCount: number;
vesselCount: number;
events: Ny511Event[];
eventsOff: boolean;
pinnedIds: Set<string>;
onOpenCamera: (c: Camera) => void;
}) {
const calls6h = useMemo(() => {
const cutoff = Date.now() - 6 * 3600_000;
return calls.filter((c) => new Date(c.started_at).getTime() >= cutoff).length;
}, [calls]);
const providers = Array.from(feeds.values());
return (
<div>
<div className={SEC}>
<h2 className="text-sm font-semibold text-ink mb-2">Region</h2>
<div className="grid grid-cols-3 gap-2 text-center">
{[[incidents.length, "active incidents"], [calls6h, "calls · last 6 h"], [onAirCount, "on air now"]].map(([v, l]) => (
<div key={String(l)} className="bg-raised rounded-md py-2">
<div className="text-lg font-semibold text-ink tabular-nums">{v}</div>
<div className="text-[10px] text-ink-muted">{l}</div>
</div>
))}
</div>
</div>
<div className={SEC}>
<h3 className={H3}>Camera wall · pinned + nearest to major/moderate incidents</h3>
{wallCameras.length ? (
<div className="grid grid-cols-2 gap-2">
{wallCameras.map(({ cam, km }) => (
<div key={cam.id} className="relative">
<CameraTile cam={cam} feed={feeds.get(cam.provider)} km={km} onOpen={() => onOpenCamera(cam)} />
{pinnedIds.has(cam.id) && <span className="absolute bottom-6 right-1 bg-accent text-white text-[9px] px-1 rounded-sm">PINNED</span>}
</div>
))}
</div>
) : (
<NoCameras servingCount={servingCount} radiusKm={NEAR_CAMERA_KM} />
)}
<p className="text-[10px] text-ink-muted mt-2">Off-screen and hidden-tab tiles stop fetching; at most two live streams play at once.</p>
</div>
<div className={SEC}>
<h3 className={H3}>Feeds</h3>
<ul className="space-y-1 text-xs text-ink-2">
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>{aircraftCount} aircraft heard by your nodes</li>
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">AIS</span>{vesselCount} vessels / aids to navigation</li>
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">511NY</span>{eventsOff ? "road events off (Admin → Camera providers)" : `${events.length} road events in region`}</li>
{providers.map((p) => (
<li key={p.id}><span className="font-mono text-[10px] text-ink-muted mr-1.5">CAM</span>{p.name}: {p.total_in_bbox} in region{p.error ? " · feed error" : ""}{p.fetched_at ? ` · list ${fetchedLabel(p.fetched_at)}` : ""}</li>
))}
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>Weather Radar layer (map menu); sampled per incident</li>
</ul>
</div>
<div className={SEC}>
<h3 className={H3}>Nodes</h3>
<ul className="space-y-1 text-xs">
{nodes.map((n) => (
<li key={n.node_id} className="flex justify-between gap-2">
<span className="text-ink-2 truncate">{n.name}</span>
<span className="font-mono text-ink-muted capitalize">{n.status}</span>
</li>
))}
</ul>
</div>
<p className="px-4 py-3 text-[10px] text-ink-muted">
Region → click an incident for its path, summary, units and nearest cameras → click a camera to open it. Esc steps back up.
Bookmark a view with <span className="font-mono">#inc=…&amp;cam=…&amp;tour</span>.
</p>
</div>
);
}
// ── Level 2: incident ────────────────────────────────────────────────────────
function useRadarDbz(head: LatLon | null): { dbz: number | null; done: boolean } {
const [state, setState] = useState<{ dbz: number | null; done: boolean }>({ dbz: null, done: false });
const key = head ? `${head[0].toFixed(3)},${head[1].toFixed(3)}` : "";
useEffect(() => {
if (!head) return;
let cancelled = false;
setState({ dbz: null, done: false });
sampleRadarDbz(head[0], head[1]).then((dbz) => { if (!cancelled) setState({ dbz, done: true }); });
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return state;
}
export function IncidentPanel({
inc, calls, points, near, feeds, servingCount, onAir, aircraft, vessels, events, eventsCredit, onOpenCamera,
}: {
inc: IncidentRecord;
calls: CallRecord[];
points: LatLon[];
near: NearCamera[];
feeds: Map<string, CameraProviderFeed>;
servingCount: number;
onAir: boolean;
aircraft: AircraftTrack[];
vessels: VesselTrack[];
events: Ny511Event[];
eventsCredit: { credit?: CameraCredit; fetched_at: number | null };
onOpenCamera: (c: Camera) => void;
}) {
const color = sevColor(inc.severity);
const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]);
const radar = useRadarDbz(points.at(-1) ?? null);
// Stop numbers share the index with the map path: geocoded calls, oldest first.
const stopOf = useMemo(() => {
const m = new Map<string, number>();
calls.filter((c) => c.location_coords).slice().sort((a, b) => a.started_at.localeCompare(b.started_at))
.forEach((c, i) => m.set(c.call_id, i + 1));
return m;
}, [calls]);
const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls]);
const unitsOn = inc.units_active?.length ? inc.units_active : inc.units ?? [];
const cleared = inc.units_cleared ?? [];
return (
<div>
<div className={SEC}>
<div className="flex items-center gap-2 text-[11px] mb-1">
<span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span>
<span className="text-ink-muted">{inc.type ?? "other"}</span>
{onAir ? <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold">ON AIR</span> : <span className="text-ink-muted">active</span>}
<span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span>
</div>
<h2 className="text-base font-semibold text-ink leading-snug">{inc.title ?? "Incident"}</h2>
<div className="text-xs text-ink-muted mt-1 flex flex-wrap gap-x-3">
{inc.location && <span>{inc.location}</span>}
<span className="font-mono">{elapsed(inc.started_at)} elapsed</span>
<span>{inc.call_ids?.length ?? 0} calls</span>
</div>
<Link href={`/incidents/${inc.incident_id}`} className="text-xs text-accent hover:underline mt-1 inline-block">Open incident page →</Link>
</div>
<div className={SEC}>
<MachineOutputNotice variant="inline" className="text-[10px] mb-1.5 items-start" />
{inc.summary ? <p className="text-sm text-ink-2 leading-relaxed">{inc.summary}</p> : <p className="text-xs text-ink-muted">No AI summary yet.</p>}
</div>
<div className={SEC}>
<h3 className={H3}>Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}</h3>
{near.length ? (
<div className="grid grid-cols-2 gap-2">
{near.map(({ cam, km }) => (
<CameraTile key={cam.id} cam={cam} feed={feeds.get(cam.provider)} km={km} onOpen={() => onOpenCamera(cam)} />
))}
</div>
) : points.length ? (
<NoCameras servingCount={servingCount} radiusKm={NEAR_CAMERA_KM} />
) : (
<p className="text-xs text-ink-muted">This incident has no geocoded location, so no cameras can be matched.</p>
)}
<div className="mt-2 border border-dashed border-line rounded-md px-2 py-1.5 text-[10px] text-ink-muted">
Camera signals (detections as incident evidence) · future — blocked on server-26#199 and #200.
</div>
</div>
<div className={SEC}>
<h3 className={H3}>Situational context · fused layers</h3>
<ul className="space-y-1 text-xs text-ink-2">
{ctx.aircraft.map(({ a, km }) => (
<li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>
<b>{a.callsign || a.icao}</b>{a.altitude_ft != null ? ` · ${Math.round(a.altitude_ft).toLocaleString()} ft` : ""} · {km.toFixed(1)} km</li>
))}
{ctx.vessels.map(({ v, km }) => (
<li key={v.mmsi}><span className="font-mono text-[10px] text-ink-muted mr-1.5">AIS</span>
<b>{v.name || v.mmsi}</b>{v.speed_kt != null ? ` · ${Math.round(v.speed_kt)} kt` : ""} · {km.toFixed(1)} km</li>
))}
{ctx.road.map(({ e, km }) => (
<li key={e.id}>
<span className="font-mono text-[10px] text-ink-muted mr-1.5">511NY</span>
{/* DAA: NYSDOT's text as published, with its update time and source credit. */}
<span className="whitespace-pre-line">{e.description}</span> · {km.toFixed(1)} km
<SourceCredit credit={eventsCredit.credit} fallbackName="511NY / NYSDOT"
updated={e.updated_local ? new Date(e.updated_local).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : fetchedLabel(eventsCredit.fetched_at)} />
</li>
))}
<li>
<span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>
{!points.length ? "—" : !radar.done ? "sampling…" : radar.dbz == null ? "radar sample unavailable"
: radar.dbz === 0 ? "no precipitation echo within ~5 km" : `up to ~${radar.dbz} dBZ within ~5 km (approx., from the radar tile)`}
</li>
</ul>
{!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && (
<p className="text-[10px] text-ink-muted mt-1">No aircraft within {CONTEXT_KM.aircraft} km, vessels within {CONTEXT_KM.vessel} km, or road events within {CONTEXT_KM.road} km.</p>
)}
</div>
<div className={`${SEC} grid grid-cols-2 gap-3`}>
<div>
<h3 className={H3}>On scene</h3>
<div className="flex flex-wrap gap-1">{unitsOn.length ? unitsOn.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div>
</div>
<div>
<h3 className={H3}>Cleared</h3>
<div className="flex flex-wrap gap-1">{cleared.length ? cleared.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-muted">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div>
</div>
</div>
<div className={SEC}>
<h3 className={H3}>Call spine · newest first</h3>
{spine.length ? (
<ol className="space-y-2">
{spine.slice(0, 30).map((c) => {
const stop = stopOf.get(c.call_id);
const text = c.transcript_corrected || c.transcript;
return (
<li key={c.call_id} className="flex gap-2 text-xs">
<span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]"
style={stop ? { background: color, color: "var(--page)" } : { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? "·"}</span>
<div className="min-w-0">
<div className="flex gap-1.5 items-center">
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span>
{c.talkgroup_name && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2 truncate">{c.talkgroup_name}</span>}
</div>
<p className="text-ink-2 leading-snug">{text || <span className="text-ink-muted">no transcript</span>}</p>
</div>
</li>
);
})}
</ol>
) : (
<p className="text-xs text-ink-muted">No calls for this incident in the loaded window.</p>
)}
</div>
</div>
);
}
+16 -1
View File
@@ -1,5 +1,5 @@
import { auth } from "@/lib/firebase";
import type { AreaContext, Ny511Response, TalkgroupPending } from "@/lib/types";
import type { AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, TalkgroupPending } from "@/lib/types";
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
@@ -390,6 +390,21 @@ export const c2api = {
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east), layers,
})}`),
// Public cameras from every admin-enabled provider inside a bbox (live ops wall)
getCameras: (bbox: { south: number; west: number; north: number; east: number }) =>
request<CamerasResponse>(`/cameras?${new URLSearchParams({
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east),
})}`),
// Admin → Camera providers. A licence-gated provider answers 409 with
// `requires_acknowledgement` until `acknowledge_licence` is sent.
listCameraProviders: () => request<{ providers: CameraProviderAdmin[] }>("/admin/camera-providers"),
setCameraProvider: (id: string, enabled: boolean, acknowledgeLicence = false) =>
request<CameraProviderAdmin>(`/admin/camera-providers/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
}),
// Public waitlist — no auth, see routers/waitlist.py
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
+244
View File
@@ -0,0 +1,244 @@
/**
* Live ops wall — pure helpers (design-canvas/live-ops-wall-mockup.html,
* gods-eye-view-notes.md top 5). No React here, so the logic is testable in
* isolation and shared by MapView and LiveView.
*/
import type { AircraftTrack, CallRecord, Camera, IncidentRecord, Ny511Event, VesselTrack } from "@/lib/types";
import { severityRank } from "@/lib/severity";
export type LatLon = [number, number];
export function km(a: LatLon, b: LatLon): number {
const R = 6371;
const dLa = ((b[0] - a[0]) * Math.PI) / 180;
const dLo = ((b[1] - a[1]) * Math.PI) / 180;
const x =
Math.sin(dLa / 2) ** 2 +
Math.cos((a[0] * Math.PI) / 180) * Math.cos((b[0] * Math.PI) / 180) * Math.sin(dLo / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(x));
}
/** Calls grouped by incident id (multi-scene calls count toward each incident). */
export function groupCallsByIncident(calls: CallRecord[]): Map<string, CallRecord[]> {
const map = new Map<string, CallRecord[]>();
for (const c of calls) {
const ids = c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : [];
for (const id of ids) {
if (!map.has(id)) map.set(id, []);
map.get(id)!.push(c);
}
}
return map;
}
/**
* An incident's geography, oldest first: its geocoded call stops (the path —
* same order as the call spine) or, failing that, its pin. Empty = not plotted.
*/
export function incidentPoints(inc: IncidentRecord, calls: CallRecord[] | undefined): LatLon[] {
const stops = (calls ?? [])
.filter((c) => c.location_coords)
.slice()
.sort((a, b) => a.started_at.localeCompare(b.started_at))
.map((c) => [c.location_coords!.lat, c.location_coords!.lng] as LatLon);
if (stops.length >= 2) return stops;
if (inc.location_coords) return [[inc.location_coords.lat, inc.location_coords.lng]];
return stops;
}
/** Worst-first, then most recent (UI_REDESIGN.md §5.1). */
export function worstFirst(incidents: IncidentRecord[]): IncidentRecord[] {
return incidents
.slice()
.sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at));
}
export const NEAR_CAMERA_KM = 3.5;
export interface NearCamera { cam: Camera; km: number }
/**
* Nearest cameras to an incident. For a path, distance is to ANY stop but
* weighted toward the newest one, so a pursuit surfaces the cameras at its
* head first (GEV "NEAREST", path-aware as in the mockup). `km` is the plain
* distance to the nearest stop/pin; ranking uses the weighted score.
*/
export function nearestCameras(points: LatLon[], cameras: Camera[], k = 4, maxKm = NEAR_CAMERA_KM): NearCamera[] {
if (!points.length) return [];
const last = points.length - 1;
return cameras
.map((cam) => {
let raw = Infinity;
let score = Infinity;
points.forEach((p, i) => {
const d = km(p, [cam.lat, cam.lon]);
raw = Math.min(raw, d);
score = Math.min(score, d * (1 + (last - i) * 0.5));
});
return { cam, km: raw, score };
})
.filter((x) => x.score < maxKm)
.sort((a, b) => a.score - b.score)
.slice(0, k)
.map(({ cam, km: d }) => ({ cam, km: d }));
}
/**
* Coarse 2D viewshed cone (GEV idea #4). APPROXIMATE: built from the
* published direction of travel, not a surveyed heading/FOV — label it so
* wherever it is drawn. Cameras with no usable direction get no cone rather
* than a made-up one.
*/
export function viewshedWedge(cam: Camera, rangeKm = 0.8, fovDeg = 50): LatLon[] | null {
if (cam.heading_deg == null) return null;
const pts: LatLon[] = [[cam.lat, cam.lon]];
const cosLat = Math.cos((cam.lat * Math.PI) / 180);
for (let a = -fovDeg / 2; a <= fovDeg / 2; a += 5) {
const b = ((cam.heading_deg + a) * Math.PI) / 180;
pts.push([cam.lat + (rangeKm / 111) * Math.cos(b), cam.lon + (rangeKm / (111 * cosLat)) * Math.sin(b)]);
}
return pts;
}
// ── Fused situational context ────────────────────────────────────────────────
export const CONTEXT_KM = { aircraft: 4, vessel: 3, road: 2 };
export interface IncidentContext {
aircraft: { a: AircraftTrack; km: number }[];
vessels: { v: VesselTrack; km: number }[];
road: { e: Ny511Event; km: number }[];
}
function nearestKm(points: LatLon[], lat: number, lon: number): number {
return points.reduce((m, p) => Math.min(m, km(p, [lat, lon])), Infinity);
}
export function contextFor(
points: LatLon[],
aircraft: AircraftTrack[],
vessels: VesselTrack[],
events: Ny511Event[],
): IncidentContext {
const near = <T,>(items: T[], pos: (t: T) => LatLon | null, r: number) =>
items
.map((t) => {
const p = pos(t);
return { t, km: p ? nearestKm(points, p[0], p[1]) : Infinity };
})
.filter((x) => x.km <= r)
.sort((a, b) => a.km - b.km);
return {
aircraft: near(aircraft, (a) => (a.lat != null && a.lon != null ? [a.lat, a.lon] : null), CONTEXT_KM.aircraft)
.map(({ t, km: d }) => ({ a: t, km: d })),
vessels: near(vessels, (v) => (v.lat != null && v.lon != null ? [v.lat, v.lon] : null), CONTEXT_KM.vessel)
.map(({ t, km: d }) => ({ v: t, km: d })),
road: near(events, (e) => [e.lat, e.lon], CONTEXT_KM.road).map(({ t, km: d }) => ({ e: t, km: d })),
};
}
/** Bbox around a set of points, padded by `padKm`. */
export function bboxAround(points: LatLon[], padKm: number) {
if (!points.length) return null;
const lats = points.map((p) => p[0]);
const lons = points.map((p) => p[1]);
const dLat = padKm / 111;
const midLat = (Math.min(...lats) + Math.max(...lats)) / 2;
const dLon = padKm / (111 * Math.max(0.2, Math.cos((midLat * Math.PI) / 180)));
return {
south: Math.max(-90, Math.min(...lats) - dLat),
north: Math.min(90, Math.max(...lats) + dLat),
west: Math.max(-180, Math.min(...lons) - dLon),
east: Math.min(180, Math.max(...lons) + dLon),
};
}
// ── Deep links: #inc=<id>&cam=<cameraId>&tour ───────────────────────────────
export interface WallLink { inc: string | null; cam: string | null; tour: boolean }
export function parseWallHash(hash: string): WallLink {
const p = new URLSearchParams(hash.replace(/^#/, ""));
return { inc: p.get("inc") || null, cam: p.get("cam") || null, tour: p.has("tour") };
}
export function wallHash(link: WallLink): string {
const parts: string[] = [];
if (link.inc) parts.push(`inc=${encodeURIComponent(link.inc)}`);
if (link.cam) parts.push(`cam=${encodeURIComponent(link.cam)}`);
if (link.tour) parts.push("tour");
return parts.length ? `#${parts.join("&")}` : "";
}
/** "On air": a live call is on one of the incident's talkgroups right now. */
export function onAirIncidentIds(incidents: IncidentRecord[], activeCalls: CallRecord[]): Set<string> {
const live = new Set(activeCalls.map((c) => c.talkgroup_id).filter((t): t is number => t != null).map(String));
return new Set(
incidents.filter((i) => (i.talkgroup_ids ?? []).some((t) => live.has(String(t)))).map((i) => i.incident_id),
);
}
// ── NEXRAD sample near an incident ───────────────────────────────────────────
// Reads the same IEM n0r composite tile the Weather Radar layer draws (IEM
// sends ACAO: *), and maps pixel colours back to the NWS reflectivity scale.
// Approximate by construction — label it so.
const N0R_PALETTE: [number, number, number, number][] = [
[5, 4, 233, 231], [10, 1, 159, 244], [15, 3, 0, 244], [20, 2, 253, 2], [25, 1, 197, 1], [30, 0, 142, 0],
[35, 253, 248, 2], [40, 229, 188, 0], [45, 253, 149, 0], [50, 253, 0, 0], [55, 212, 0, 0], [60, 188, 0, 0],
[65, 248, 0, 253], [70, 152, 84, 198], [75, 253, 253, 253],
];
const RADAR_TILE = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913";
function pixelDbz(r: number, g: number, b: number): number {
let best = 0;
let bestD = Infinity;
for (const [dbz, pr, pg, pb] of N0R_PALETTE) {
const d = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2;
if (d < bestD) { bestD = d; best = dbz; }
}
return bestD < 60 * 60 ? best : 0;
}
/** Max reflectivity (dBZ) within ~radiusKm of a point, or null if it can't be read. */
export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Promise<number | null> {
const z = 9;
const n = 2 ** z;
const xf = ((lon + 180) / 360) * n;
const latR = (lat * Math.PI) / 180;
const yf = ((1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2) * n;
const tx = Math.floor(xf);
const ty = Math.floor(yf);
const img = new Image();
img.crossOrigin = "anonymous";
img.src = `${RADAR_TILE}/${z}/${tx}/${ty}.png?_=${Math.floor(Date.now() / 300000)}`;
try {
await img.decode();
} catch {
return null;
}
const canvas = document.createElement("canvas");
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.drawImage(img, 0, 0);
const px = Math.floor((xf - tx) * 256);
const py = Math.floor((yf - ty) * 256);
const kmPerPx = (40075 * Math.cos(latR)) / (n * 256);
const rad = Math.max(1, Math.round(radiusKm / kmPerPx));
const x0 = Math.max(0, px - rad);
const y0 = Math.max(0, py - rad);
const w = Math.min(256, px + rad) - x0;
const h = Math.min(256, py + rad) - y0;
if (w <= 0 || h <= 0) return null;
let data: Uint8ClampedArray;
try {
data = ctx.getImageData(x0, y0, w, h).data;
} catch {
return null; // tainted canvas — CORS changed upstream
}
let max = 0;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] < 32) continue;
max = Math.max(max, pixelDbz(data[i], data[i + 1], data[i + 2]));
}
return max;
}
+73 -2
View File
@@ -139,16 +139,83 @@ export interface VesselTrack {
// 511NY traffic layer (server-26#183) — GET /traffic/511, cached server-side,
// not Firestore. *_local times are New York local, no offset: display only.
export interface Ny511Camera {
/**
* One public camera from any enabled provider (GET /cameras, live ops wall).
* `id` is provider-prefixed ("ny511:NYSDOT-1"). `heading_deg` is APPROXIMATE —
* most sources publish the road's direction of travel, not the camera heading.
*/
export interface Camera {
id: string;
provider: string;
lat: number;
lon: number;
name: string;
roadway: string;
direction: string;
heading_deg: number | null;
image_url: string | null;
video_url: string | null;
}
/** Kept for older call sites; every camera is now a provider camera. */
export type Ny511Camera = Camera;
/** What must travel with a provider's imagery: credit, link, pass-through disclaimer. */
export interface CameraCredit {
attribution: string | null;
attribution_url: string | null;
disclaimer: string | null;
}
export interface CameraProviderFeed extends CameraCredit {
id: string;
name: string;
fetched_at: number | null; // epoch s of the provider's last successful catalog fetch
error: string | null;
total_in_bbox: number;
truncated: boolean;
}
export interface CamerasResponse {
cameras: Camera[];
providers: CameraProviderFeed[];
serving_count: number; // providers enabled anywhere (0 → "none enabled" empty state)
}
export type LicenceStatus =
| "commercial_ok" | "attribution_required" | "agreement_required"
| "non_commercial" | "unverified" | "paid" | "prohibited";
/** Admin → Camera providers (GET /admin/camera-providers). */
export interface CameraProviderAdmin {
id: string;
name: string;
region: string;
formats: string;
implemented: boolean;
notes: string;
homepage: string | null;
licence: {
status: LicenceStatus;
label: string;
terms: string;
terms_url: string | null;
attribution: string | null;
attribution_url: string | null;
disclaimer: string | null;
issue: string | null;
gated: boolean;
warning: string | null;
};
enabled: boolean;
serving: boolean;
not_serving_reason: string | null;
acknowledgement: { by_uid: string; by_email: string; at: string; licence_status: string; warning: string } | null;
updated_at: string | null;
updated_by: { uid: string; email: string } | null;
key_setting: string | null;
key_missing: boolean;
health: { fetched_at: number | null; last_attempt_at: number | null; error: string | null; count: number } | null;
}
export interface Ny511Event {
id: string;
@@ -171,13 +238,17 @@ export interface Ny511FeedStatus {
error: string | null; // set when the latest refresh failed (data is then stale)
total_in_bbox: number;
truncated: boolean;
/** True when an admin has not enabled the 511NY provider (default OFF, DAA-gated). */
disabled?: boolean;
}
export interface Ny511Response {
cameras?: Ny511Camera[];
cameras?: Camera[];
cameras_status?: Ny511FeedStatus;
events?: Ny511Event[];
events_status?: Ny511FeedStatus;
/** DAA: source credit + NYSDOT as-is disclaimer, present when the feed is served. */
credit?: CameraCredit;
}
export interface VocabularyPendingTerm {
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type L from "leaflet";
import { c2api } from "@/lib/c2api";
import type { CamerasResponse } from "@/lib/types";
// Public cameras from every provider an admin has enabled (GET /cameras).
// Catalogs are cached server-side (15 min); this only re-asks for the bbox.
const POLL_MS = 5 * 60 * 1000;
const MOVE_DEBOUNCE_MS = 400;
const BBOX_PAD = 0.2;
export interface BBox { south: number; west: number; north: number; east: number }
const EMPTY: CamerasResponse = { cameras: [], providers: [], serving_count: 0 };
function useCameraFetch(getBox: (() => BBox | null) | null, deps: unknown[], subscribe?: (reload: () => void) => () => void) {
const [data, setData] = useState<CamerasResponse>(EMPTY);
const [error, setError] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const seq = useRef(0);
useEffect(() => {
if (!getBox) { setData(EMPTY); setLoaded(false); return; }
const boxFn = getBox;
const load = async () => {
const box = boxFn();
if (!box) return;
const mine = ++seq.current;
try {
const res = await c2api.getCameras(box);
if (mine !== seq.current) return;
setData(res);
setError(null);
setLoaded(true);
} catch (e) {
if (mine !== seq.current) return;
console.error("useCameras:", e);
setError(e instanceof Error ? e.message : String(e));
setLoaded(true);
}
};
load();
const unsub = subscribe ? subscribe(load) : undefined;
const poll = setInterval(() => { if (!document.hidden) load(); }, POLL_MS);
return () => {
unsub?.();
clearInterval(poll);
seq.current++;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return { data, error, loaded };
}
/** Cameras for the current map view, refetched on pan/zoom while `enabled`. */
export function useMapCameras(map: L.Map, enabled: boolean) {
return useCameraFetch(
enabled
? () => {
// Clamp: zoomed out (or panned past the antimeridian) Leaflet bounds run
// past ±180/±90, and GET /cameras answers 422 to an out-of-range bbox.
const b = map.getBounds().pad(BBOX_PAD);
const clamp = (v: number, lim: number) => Math.max(-lim, Math.min(lim, v));
return { south: clamp(b.getSouth(), 90), west: clamp(b.getWest(), 180), north: clamp(b.getNorth(), 90), east: clamp(b.getEast(), 180) };
}
: null,
[map, enabled],
(reload) => {
let t: ReturnType<typeof setTimeout> | undefined;
const onMove = () => { clearTimeout(t); t = setTimeout(reload, MOVE_DEBOUNCE_MS); };
map.on("moveend", onMove);
return () => { map.off("moveend", onMove); clearTimeout(t); };
},
);
}
/** Cameras inside a fixed bbox (e.g. around the active incidents). `null` = don't fetch. */
export function useCamerasInBbox(box: BBox | null) {
const key = box ? `${box.south.toFixed(3)},${box.west.toFixed(3)},${box.north.toFixed(3)},${box.east.toFixed(3)}` : "";
return useCameraFetch(box ? () => box : null, [key]);
}