Live ops wall + pluggable camera providers with licence gating (server-26#198)
Backend (c2-core):
- app/internal/cameras/: provider interface, licence model, per-provider
catalog cache; one adapter per source in design-canvas/camera-sources.md.
Implemented: 511NY (legacy shape + tolerant XML inventory parser, 511x
endpoints UNVERIFIED), Caltrans, DelDOT, Ontario 511, DriveBC, Calgary,
TfL, NSW, Fintraffic. Stubs: NYC DOT, Westchester DPW, TRANSCOM, CTroads,
511NJ, Austin, TxDOT, MD CHART, WSDOT, Tallinn, Tarktee, Warendorf,
TrafficLand, Windy, EarthCam.
- Every provider defaults OFF (config/camera_providers). Gated licences
(agreement required / unverified / non-commercial / paid) need an explicit
acknowledgement stored with who/when and written to audit_log; the audit
write is a precondition. Stubs and prohibited sources cannot be enabled.
Ack is bound to the licence status it was given for.
- GET /cameras (signed-in), GET/PUT /admin/camera-providers (admin only).
- /traffic/511 follows the ny511 provider switch and returns the DAA credit
+ NYSDOT as-is disclaimer; 511NY event descriptions no longer truncated
("may not alter the substance of a report").
Frontend:
- LiveView becomes the ops wall: Region > Incident > Camera drill-down,
worst-first rail (MapView), KPIs, transcript ticker, scrubber incident
bands, auto-tour, deep links (#inc=&cam=&tour), Esc steps up.
- Incident panel: nearest cameras (path-weighted), fused situational context
(ADS-B, AIS, 511NY events, NEXRAD tile sample), units, call spine.
- MapView keeps every layer; adds Camera Viewsheds (approx.) overlay and a
nearest-camera layer for the selected incident.
- CameraFeed hygiene: hidden-tab/off-screen gating, 2 concurrent HLS cap,
last good frame + stale badge, frozen-frame signature, HLS stall watchdog,
provider attribution rendered on the imagery.
- Camera/event popups show source credit, update time and the disclaimer.
- Admin > Camera providers tab with licence status, health, gated confirm.
- Camera detection is not built (blocked on #199/#200); seam only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d57738a8f6
commit
aa5b71d853
@@ -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>')
|
||||
|
||||
Reference in New Issue
Block a user