mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 11:54:37 +02:00
feat(state_511_atis_cameras): Castle Rock 511 traffic cameras telemetry (v0.9.6)
New CENTRAL_TRAFFIC_CAMERAS stream + state_511_atis_cameras adapter. Telemetry
half of Castle Rock (events shipped in v0.9.2). Each Idaho camera -> one
telemetry event on /telemetry; detail drawer renders <img> direct from the
source (no blob storage / proxy in Central -- URL only).
supervisor + gui + ARCHIVE restart (NEW event-bearing stream
central.traffic_cameras.>). Ships disabled; public-unauth (no api key). Idaho only.
- Full camera list via POST /List/GetData/Cameras (DataTables), PAGINATED at
100/page (Idaho ~455 = 5 pages). GetUserCameras was a red herring (4 default
cams). The 100-row page cap also means v0.9.2 state_511_atis silently
truncates its 114-row Construction layer -> separate v0.9.7 fix.
- Subject central.traffic_cameras.{state}.{camera_id}; category
camera.state_511_atis_cameras -> GUI event_type "camera". data_class=telemetry.
- Per-UTC-day dedup {state}:cam:{id}:{YYYY-MM-DD}: one event per camera per day
-- always shows today's cameras, no per-poll flooding, no retention
coordination. Inherits the v0.9.1 dedup mixin.
- All sources included (Idaho511/ITDNET/RWIS/UDOT/ODOT/WYDOT/MTD border cameras);
source surfaced in data + the drawer for provenance. WKT POINT (lon lat) -> geo.
- No upstream image-capture timestamp (lastUpdated is config-edit time); drawer
shows no false "Captured" line. Cadence 600s. Severity 1 (telemetry).
Full suite: 829 passed, 1 skipped (central and unprivileged zvx, 3x each).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d241bfea26
commit
02bc692bda
12 changed files with 461 additions and 3 deletions
1
tests/fixtures/state_511_atis_cameras_sample.json
vendored
Normal file
1
tests/fixtures/state_511_atis_cameras_sample.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"recordsTotal":2,"recordsFiltered":2,"data":[{"DT_RowId":"1","tooltipUrl":"/tooltip/Cameras/1?lang=%7Blang%7D&noCss=true","agencyLogoEnabled":false,"visible":true,"isDefault":false,"images":[{"id":1,"cameraSiteId":1,"sortOrder":1,"description":"N/A","imageUrl":"/map/Cctv/1","imageType":0,"isVideoAuthRequired":false,"videoDisabled":false,"disabled":false,"blocked":false,"language":"en"}],"id":1,"sourceId":"10.C1","source":"UDOT","type":"UDOT","areaId":null,"area":null,"sortOrder":1,"roadway":"I-15","direction":"Unknown","location":"I-15 UT/ID State Line UT","latLng":{"geography":{"coordinateSystemId":4326,"wellKnownText":"POINT (-112.198 42.0011)"}},"linkId1":"112984999F","linkId2":"114769197T","created":"2023-01-23T13:51:47.3533333+00:00","lastUpdated":"2023-01-23T13:51:47.3533333+00:00","lastEditedBy":"zeeshawn.ahmad@gmail.com","defaultCameraSite":false,"nickname":null,"language":"en","jsonData":{},"jsonDataSerialized":null,"region":null,"state":null,"county":null,"city":null,"dotDistrict":null},{"DT_RowId":"2","tooltipUrl":"/tooltip/Cameras/2?lang=%7Blang%7D&noCss=true","agencyLogoEnabled":false,"visible":true,"isDefault":false,"images":[{"id":2,"cameraSiteId":2,"sortOrder":325,"imageUrl":"/map/Cctv/2","imageType":0,"refreshRateMs":300000,"isVideoAuthRequired":false,"videoDisabled":false,"disabled":false,"blocked":false,"language":"en"},{"id":3,"cameraSiteId":2,"sortOrder":325,"imageUrl":"/map/Cctv/3","imageType":0,"refreshRateMs":300000,"isVideoAuthRequired":false,"videoDisabled":false,"disabled":false,"blocked":false,"language":"en"},{"id":4,"cameraSiteId":2,"sortOrder":325,"imageUrl":"/map/Cctv/4","imageType":0,"refreshRateMs":300000,"isVideoAuthRequired":false,"videoDisabled":false,"disabled":false,"blocked":false,"language":"en"}],"id":2,"sourceId":"100.C1","source":"RWIS","type":"RWIS","areaId":null,"area":null,"sortOrder":325,"roadway":"SH-75","direction":"Unknown","location":"SH-75 Wood River","latLng":{"geography":{"coordinateSystemId":4326,"wellKnownText":"POINT (-114.345 43.5946)"}},"linkId1":"41105278T","linkId2":"851613715T","created":"2024-08-12T07:57:34.1833333+00:00","lastUpdated":"2024-08-12T07:57:34.1833333+00:00","lastEditedBy":"barton.phelps@itd.idaho.gov","defaultCameraSite":false,"nickname":null,"language":"en","jsonData":{"rwisType":"Normal","status":"Normal"},"jsonDataSerialized":"{\"RwisType\":\"Normal\",\"Status\":\"Normal\"}","region":null,"state":null,"county":null,"city":null,"dotDistrict":null}]}
|
||||
|
|
@ -1146,6 +1146,7 @@ _SAMPLE_INNER = {
|
|||
"state_511_atis": {"layer": "Incidents", "roadway_name": "US-95", "location_description": "Ponderosa Mobile Home Park"},
|
||||
"tomtom_flow": {"road_category": "primary", "relative_speed": 0.11},
|
||||
"tomtom_incidents": {"description": "Roadworks", "from": "Early Road", "to": "Slade Road"},
|
||||
"state_511_atis_cameras": {"location_description": "I-84 Mountain Home", "camera_id": 42},
|
||||
}
|
||||
|
||||
# Exact expected subjects for the deterministic adapters. swpc_alerts is omitted
|
||||
|
|
@ -1167,6 +1168,7 @@ _EXPECTED_SUBJECT = {
|
|||
"wzdx": "Work zone on I-80 eastbound",
|
||||
"tomtom_flow": "Traffic flow (primary) — 11% of free-flow",
|
||||
"tomtom_incidents": "Roadworks on Early Road → Slade Road",
|
||||
"state_511_atis_cameras": "Camera: I-84 Mountain Home",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
134
tests/test_state_511_atis_cameras.py
Normal file
134
tests/test_state_511_atis_cameras.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""Tests for the state_511_atis_cameras adapter (v0.9.6).
|
||||
|
||||
Fixture is a real /List/GetData/Cameras capture (2 cameras: one single-image
|
||||
UDOT border camera, one multi-image RWIS camera):
|
||||
tests/fixtures/state_511_atis_cameras_sample.json
|
||||
|
||||
No conftest entry: dedup uses the supervisor-injected cursors.db (inherited
|
||||
mixin); polling is stateless.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from central.adapter import SourceAdapter
|
||||
from central.adapters.state_511_atis_cameras import (
|
||||
State511ATISCamerasAdapter,
|
||||
StateConfig,
|
||||
_parse_wkt,
|
||||
)
|
||||
from central.config_models import AdapterConfig
|
||||
|
||||
FIX = json.loads((Path(__file__).parent / "fixtures" / "state_511_atis_cameras_sample.json").read_text())
|
||||
CAMS = FIX["data"]
|
||||
ID = StateConfig(code="ID", base_url="https://511.idaho.gov")
|
||||
TODAY = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _cfg():
|
||||
return AdapterConfig(
|
||||
name="state_511_atis_cameras", enabled=True, cadence_s=600,
|
||||
settings={"states": [ID.model_dump()]}, updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(tmp_path):
|
||||
return State511ATISCamerasAdapter(_cfg(), MagicMock(), tmp_path / "cursors.db")
|
||||
|
||||
|
||||
def test_wkt_parse():
|
||||
assert _parse_wkt("POINT (-112.198 42.0011)") == (42.0011, -112.198) # (lat, lon)
|
||||
assert _parse_wkt(None) == (None, None)
|
||||
assert _parse_wkt("nonsense") == (None, None)
|
||||
|
||||
|
||||
def test_dedup_key_shape(adapter):
|
||||
e = adapter._build_event(CAMS[0], ID)
|
||||
assert e.id == f"ID:cam:{CAMS[0]['id']}:{TODAY}" # per-UTC-day bucketing
|
||||
|
||||
|
||||
def test_build_event_with_image_url(adapter):
|
||||
e = adapter._build_event(CAMS[0], ID)
|
||||
assert e.category == "camera.state_511_atis_cameras"
|
||||
assert e.severity == 1
|
||||
assert e.data["image_url"] == "https://511.idaho.gov" + CAMS[0]["images"][0]["imageUrl"]
|
||||
assert e.data["source"] == CAMS[0]["source"] # provenance surfaced
|
||||
assert e.data["roadway_name"] == CAMS[0]["roadway"]
|
||||
assert e.data["latitude"] is not None and e.data["longitude"] is not None
|
||||
|
||||
|
||||
def test_build_event_multi_image(adapter):
|
||||
e = adapter._build_event(CAMS[1], ID)
|
||||
assert e.data["image_count"] == len(CAMS[1]["images"])
|
||||
assert e.data["image_count"] >= 2
|
||||
|
||||
|
||||
def test_no_image_url_handled_gracefully(adapter):
|
||||
cam = {"id": 999, "roadway": "US-95", "location": "US-95 Somewhere", "source": "ITDNET",
|
||||
"direction": "Unknown", "images": [],
|
||||
"latLng": {"geography": {"wellKnownText": "POINT (-116.5 46.4)"}}}
|
||||
e = adapter._build_event(cam, ID)
|
||||
assert e is not None
|
||||
assert e.data["image_url"] is None and e.data["image_count"] == 0
|
||||
assert e.data["location_description"] == "US-95 Somewhere"
|
||||
|
||||
|
||||
def test_subject_for_state_id(adapter):
|
||||
e = adapter._build_event(CAMS[0], ID)
|
||||
assert adapter.subject_for(e) == f"central.traffic_cameras.id.{CAMS[0]['id']}"
|
||||
|
||||
|
||||
def test_subject_for_unknown_state(adapter):
|
||||
e = adapter._build_event(CAMS[0], StateConfig(code="", base_url="https://x"))
|
||||
assert adapter.subject_for(e) == f"central.traffic_cameras.unknown.{CAMS[0]['id']}"
|
||||
|
||||
|
||||
def test_summary_partial_renders():
|
||||
from central.gui.routes import _derive_subject
|
||||
inner = {"location_description": "I-84 Mountain Home", "camera_id": 42}
|
||||
row = {"adapter": "state_511_atis_cameras", "data": {"data": {"data": inner}}}
|
||||
assert _derive_subject(row) == "Camera: I-84 Mountain Home"
|
||||
|
||||
|
||||
def test_rows_partial_includes_img_tag():
|
||||
from central.gui.routes import _get_templates
|
||||
inner = {"image_url": "https://511.idaho.gov/map/Cctv/1", "roadway_name": "I-84",
|
||||
"location_description": "I-84 Mountain Home", "source": "ITDNET", "image_count": 1}
|
||||
row = {"data": {"data": {"data": inner}}}
|
||||
html = _get_templates().env.get_template("_event_rows/state_511_atis_cameras.html").render(event=row)
|
||||
assert "<img" in html
|
||||
assert "https://511.idaho.gov/map/Cctv/1" in html
|
||||
assert "ITDNET" in html # source provenance rendered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_paginates(tmp_path):
|
||||
a = State511ATISCamerasAdapter(_cfg(), MagicMock(), tmp_path / "cursors.db")
|
||||
await a.startup()
|
||||
|
||||
def _cam(i):
|
||||
return {"id": i, "roadway": "I-84", "location": f"loc {i}", "direction": "N",
|
||||
"source": "ITDNET", "images": [{"imageUrl": f"/map/Cctv/{i}"}],
|
||||
"latLng": {"geography": {"wellKnownText": "POINT (-116.2 43.6)"}}}
|
||||
|
||||
pages = {0: {"recordsTotal": 150, "data": [_cam(i) for i in range(100)]},
|
||||
100: {"recordsTotal": 150, "data": [_cam(i) for i in range(100, 150)]}}
|
||||
|
||||
async def fake_page(base_url, start):
|
||||
return pages[start]
|
||||
|
||||
a._fetch_page = fake_page
|
||||
events = [e async for e in a.poll()]
|
||||
await a.shutdown()
|
||||
assert len(events) == 150 # all 150 fetched across 2 pages, NOT truncated at the 100-row cap
|
||||
|
||||
|
||||
def test_inherits_dedup_mixin():
|
||||
for m in ("is_published", "mark_published", "sweep_old_ids"):
|
||||
assert m not in State511ATISCamerasAdapter.__dict__, f"redefines {m}"
|
||||
assert getattr(State511ATISCamerasAdapter, m) is getattr(SourceAdapter, m)
|
||||
|
|
@ -11,7 +11,7 @@ from central.adapter_discovery import discover_adapters
|
|||
from central.gui import routes
|
||||
|
||||
# Adapters with data_class="telemetry" (the pinned split; grow as telemetry adapters land).
|
||||
_TELEMETRY = ["nwis", "tomtom_flow"]
|
||||
_TELEMETRY = ["nwis", "state_511_atis_cameras", "tomtom_flow"]
|
||||
|
||||
|
||||
# --- data_class defaults / registry split -----------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue