diff --git a/docs/CONSUMER-INTEGRATION.md b/docs/CONSUMER-INTEGRATION.md
index eaea094..964d608 100644
--- a/docs/CONSUMER-INTEGRATION.md
+++ b/docs/CONSUMER-INTEGRATION.md
@@ -133,6 +133,7 @@ Central's archive.
| `CENTRAL_HYDRO` | `central.hydro.>` | 7 | 1 GiB | ✓ | ✓ |
| `CENTRAL_TRAFFIC` | `central.traffic.>` | 7 | 1 GiB | ✓ | ✓ |
| `CENTRAL_TRAFFIC_FLOW` | `central.traffic_flow.>` | 7 | 1 GiB | ✓ | ✓ |
+| `CENTRAL_TRAFFIC_CAMERAS` | `central.traffic_cameras.>` | 7 | 1 GiB | ✓ | ✓ |
| `CENTRAL_META` | `central.meta.>` | 1 | 1 GiB | — | ✓ |
Retention and storage caps are migration-seeded defaults visible in `config.streams`;
@@ -1518,6 +1519,44 @@ road name, description, county, severity). Verified for Idaho only.
- **Removal semantics:** none in v1. Events age out of the upstream feed; the
14-day dedup sweep expires stale ids.
+### state_511_atis_cameras — State 511 traffic cameras (Castle Rock ATIS, telemetry)
+
+State-DOT 511 traffic cameras (Idaho). One telemetry event per camera per UTC day;
+the `/telemetry` detail drawer renders the live image inline (`
` fetched
+direct from the source -- Central stores the URL, never the image bytes). Pairs
+with state_511_atis incidents: see an incident, click a nearby camera, see road
+conditions.
+
+- **Stream:** `CENTRAL_TRAFFIC_CAMERAS` (telemetry; `/telemetry` tab).
+- **Subject pattern:** `central.traffic_cameras..` -- subscribe to
+ one camera or `central.traffic_cameras.id.>` for all Idaho.
+- **GUI event_type:** `camera` (from `category = "camera.state_511_atis_cameras"`).
+- **Source:** full state list via `POST /List/GetData/Cameras` (DataTables,
+ **paginated** at 100/page; Idaho ~455). Public-unauth. **Cadence 600s.**
+- **Dedup key shape:** `:cam::` -- one event per
+ camera per UTC day. The table always shows today's cameras; no per-poll flooding
+ and no dedup-window/retention coordination needed.
+- **Event.data fields:**
+
+ | key | type | nullable | description |
+ |---|---|---|---|
+ | `camera_id` | int | no | Stable upstream id |
+ | `roadway_name` | str | yes | e.g. `I-84` |
+ | `location_description` | str | yes | e.g. `I-84 Mountain Home` (embeds the roadway) |
+ | `direction` | str | yes | `North` / `Unknown` / ... |
+ | `source` | str | yes | Owning agency: `Idaho511`, `ITDNET`, `RWIS`, `UDOT`, `ODOT`, ... (border cameras Idaho 511 surfaces) |
+ | `image_url` | str | yes | Full live image URL (`/map/Cctv/`); browser fetches direct |
+ | `image_count` | int | no | Number of camera angles (1-4) |
+ | `record_updated` | str | yes | Camera config edit time (NOT image-capture time) |
+ | `state_code` | str | no | Routing |
+ | `latitude` / `longitude` | float | yes | From the WKT `POINT (lon lat)` |
+
+- **Severity:** always `1` (cameras have no severity signal; telemetry styling).
+- **Decipherable as-is:** yes -- location + source are user-ready; the live image
+ is one click away. **No image-capture timestamp** is available upstream (the
+ `lastUpdated` field is camera-config time), so the drawer shows no "captured at".
+- **Removal semantics:** none; offline cameras serve an empty image but stay listed.
+
### tomtom_incidents — TomTom real-time traffic incidents (commercial coverage)
Real-time incidents (closures, jams, hazards, road work, accidents) from TomTom
diff --git a/docs/PRODUCER-INTEGRATION.md b/docs/PRODUCER-INTEGRATION.md
index 6276df4..adc0b2d 100644
--- a/docs/PRODUCER-INTEGRATION.md
+++ b/docs/PRODUCER-INTEGRATION.md
@@ -362,7 +362,7 @@ central..[....]
```
- `` is one of `wx`, `fire`, `quake`, `space`, `disaster`, `hydro`,
- `traffic`, `traffic_flow`, `meta` (the current set — see [§8](#8-the-streamentry-registry) for adding
+ `traffic`, `traffic_flow`, `traffic_cameras`, `meta` (the current set — see [§8](#8-the-streamentry-registry) for adding
one). Operators MUST be able to subscribe to all of one domain with
`central..>`.
- `` is adapter-driven and identifies the event category within the
@@ -552,6 +552,7 @@ STREAMS: list[StreamEntry] = [
StreamEntry("CENTRAL_HYDRO", "central.hydro.>"),
StreamEntry("CENTRAL_TRAFFIC", "central.traffic.>"),
StreamEntry("CENTRAL_TRAFFIC_FLOW", "central.traffic_flow.>"),
+ StreamEntry("CENTRAL_TRAFFIC_CAMERAS", "central.traffic_cameras.>"),
StreamEntry("CENTRAL_META", "central.meta.>", event_bearing=False),
]
```
diff --git a/sql/migrations/029_add_state_511_atis_cameras_adapter.sql b/sql/migrations/029_add_state_511_atis_cameras_adapter.sql
new file mode 100644
index 0000000..d422fc3
--- /dev/null
+++ b/sql/migrations/029_add_state_511_atis_cameras_adapter.sql
@@ -0,0 +1,19 @@
+-- Migration: 029_add_state_511_atis_cameras_adapter
+-- Adds the CENTRAL_TRAFFIC_CAMERAS JetStream stream (telemetry; central.traffic_cameras.>)
+-- AND the state_511_atis_cameras adapter row. NEW event-bearing stream ->
+-- central-archive restart required at deploy (feedback_new_stream_needs_archive_restart).
+-- 7-day retention. Ships disabled; public-unauth (no api key). Idaho only.
+-- Additive-only: idempotent via ON CONFLICT DO NOTHING.
+
+INSERT INTO config.streams (name, max_age_s, max_bytes)
+VALUES ('CENTRAL_TRAFFIC_CAMERAS', 604800, 1073741824)
+ON CONFLICT (name) DO NOTHING;
+
+INSERT INTO config.adapters (name, enabled, cadence_s, settings)
+VALUES (
+ 'state_511_atis_cameras',
+ false,
+ 600,
+ '{"states": [{"code": "ID", "base_url": "https://511.idaho.gov"}]}'::jsonb
+)
+ON CONFLICT (name) DO NOTHING;
diff --git a/src/central/adapters/state_511_atis_cameras.py b/src/central/adapters/state_511_atis_cameras.py
new file mode 100644
index 0000000..6ff7e91
--- /dev/null
+++ b/src/central/adapters/state_511_atis_cameras.py
@@ -0,0 +1,249 @@
+"""State 511 (Castle Rock ATIS) cameras adapter — telemetry.
+
+Polls the full state camera directory via POST /List/GetData/Cameras (paginated,
+100/page), emitting one telemetry Event per camera to CENTRAL_TRAFFIC_CAMERAS
+(subject central.traffic_cameras.{state}.{camera_id}). data_class="telemetry" ->
+the /telemetry tab. The detail drawer renders
straight from the upstream
+image URL -- no blob storage or proxy in Central. Idaho-only; templatized per
+state via settings, same shape as state_511_atis.
+
+Dedup is per-UTC-day ({state}:cam:{id}:{YYYY-MM-DD}): one event per camera per day
+-- the table always shows today's cameras, with no dedup-window/retention
+coordination and no per-poll flooding. Inherits the v0.9.1 dedup mixin.
+"""
+
+import asyncio
+import logging
+import re
+import sqlite3
+from collections.abc import AsyncIterator
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+import aiohttp
+from pydantic import BaseModel
+from tenacity import (
+ retry,
+ retry_if_exception_type,
+ stop_after_attempt,
+ wait_exponential_jitter,
+)
+
+from central.adapter import SourceAdapter
+from central.config_models import AdapterConfig
+from central.config_store import ConfigStore
+from central.models import Event, Geo
+
+logger = logging.getLogger(__name__)
+
+_PAGE_LENGTH = 100 # Castle Rock caps the DataTables page at 100 rows
+_MAX_PAGES = 20 # defensive ceiling (~2,000 cameras)
+_XHR = {"X-Requested-With": "XMLHttpRequest"}
+_FETCH_CONCURRENCY = 4
+_FETCH_TIMEOUT_S = 30
+_WKT_POINT = re.compile(r"POINT \(([-0-9.]+) ([-0-9.]+)\)")
+
+_DEDUP_DDL = (
+ "CREATE TABLE IF NOT EXISTS published_ids ("
+ "adapter TEXT NOT NULL, event_id TEXT NOT NULL, "
+ "first_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "
+ "last_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "
+ "PRIMARY KEY (adapter, event_id))"
+)
+
+
+def _parse_wkt(wkt: str | None) -> tuple[float | None, float | None]:
+ """'POINT (lon lat)' -> (lat, lon); (None, None) on failure."""
+ if not wkt:
+ return (None, None)
+ m = _WKT_POINT.match(wkt)
+ if not m:
+ return (None, None)
+ try:
+ return (float(m.group(2)), float(m.group(1)))
+ except ValueError:
+ return (None, None)
+
+
+class StateConfig(BaseModel):
+ code: str
+ base_url: str
+
+
+class State511CamerasSettings(BaseModel):
+ """states: Castle Rock 511 deployments to poll for cameras."""
+
+ states: list[StateConfig] = []
+
+
+class State511ATISCamerasAdapter(SourceAdapter):
+ """Castle Rock ATIS 511 camera directory adapter (telemetry)."""
+
+ name = "state_511_atis_cameras"
+ display_name = "State 511 Cameras (Castle Rock ATIS)"
+ description = (
+ "State DOT 511 traffic cameras from the Castle Rock ATIS platform. One "
+ "telemetry event per camera (per UTC day); the detail drawer shows the live "
+ "image direct from the source. Verified for Idaho."
+ )
+ settings_schema = State511CamerasSettings
+ requires_api_key = None
+ api_key_field = None
+ wizard_order = None # Ships disabled
+ default_cadence_s = 600
+ data_class = "telemetry"
+ enrichment_locations = [("latitude", "longitude")]
+
+ def __init__(
+ self,
+ config: AdapterConfig,
+ config_store: ConfigStore,
+ cursor_db_path: Path,
+ ) -> None:
+ self._config_store = config_store
+ self._cursor_db_path = cursor_db_path
+ self._session: aiohttp.ClientSession | None = None
+ self._db: sqlite3.Connection | None = None
+ self._states: list[StateConfig] = self._read_states(config)
+
+ @staticmethod
+ def _read_states(config: AdapterConfig) -> list[StateConfig]:
+ return [StateConfig(**s) for s in (config.settings.get("states") or [])]
+
+ async def startup(self) -> None:
+ self._session = aiohttp.ClientSession(
+ timeout=aiohttp.ClientTimeout(total=_FETCH_TIMEOUT_S),
+ headers={"User-Agent": "Central/0.9 (+state_511_atis_cameras)"},
+ )
+ self._db = sqlite3.connect(self._cursor_db_path)
+ self._db.execute(_DEDUP_DDL)
+ self._db.execute("CREATE INDEX IF NOT EXISTS published_ids_last_seen ON published_ids (last_seen)")
+ self._db.commit()
+ logger.info("state_511_atis_cameras adapter started",
+ extra={"states": [s.code for s in self._states]})
+
+ async def shutdown(self) -> None:
+ if self._session:
+ await self._session.close()
+ self._session = None
+ if self._db:
+ self._db.close()
+ self._db = None
+
+ async def apply_config(self, new_config: AdapterConfig) -> None:
+ self._states = self._read_states(new_config)
+ logger.info("state_511_atis_cameras config updated",
+ extra={"states": [s.code for s in self._states]})
+
+ @retry(
+ stop=stop_after_attempt(3),
+ wait=wait_exponential_jitter(initial=1, max=30),
+ retry=retry_if_exception_type((aiohttp.ClientError, TimeoutError)),
+ )
+ async def _fetch_page(self, base_url: str, start: int) -> dict[str, Any]:
+ assert self._session is not None
+ body = {
+ "draw": "1", "start": str(start), "length": str(_PAGE_LENGTH),
+ "columns[0][data]": "0", "order[0][column]": "0",
+ "order[0][dir]": "asc", "search[value]": "",
+ }
+ async with self._session.post(
+ f"{base_url}/List/GetData/Cameras", data=body, headers=_XHR
+ ) as resp:
+ resp.raise_for_status()
+ return await resp.json(content_type=None)
+
+ async def _fetch_all(self, base_url: str) -> list[dict[str, Any]]:
+ """Paginate the camera directory (100/page) -> all rows. [] on failure."""
+ try:
+ first = await self._fetch_page(base_url, 0)
+ except (aiohttp.ClientError, TimeoutError, asyncio.TimeoutError, ValueError) as exc:
+ logger.warning("state_511_atis_cameras page fetch failed",
+ extra={"base_url": base_url, "start": 0, "error": str(exc)})
+ return []
+ total = first.get("recordsTotal") or 0
+ rows = list(first.get("data") or [])
+ start = _PAGE_LENGTH
+ pages = 1
+ while start < total and pages < _MAX_PAGES:
+ try:
+ page = await self._fetch_page(base_url, start)
+ except (aiohttp.ClientError, TimeoutError, asyncio.TimeoutError, ValueError) as exc:
+ logger.warning("state_511_atis_cameras page fetch failed",
+ extra={"base_url": base_url, "start": start, "error": str(exc)})
+ break
+ rows.extend(page.get("data") or [])
+ start += _PAGE_LENGTH
+ pages += 1
+ return rows
+
+ def _build_event(self, cam: dict[str, Any], state: StateConfig) -> Event | None:
+ cam_id = cam.get("id")
+ if cam_id is None:
+ return None
+ lat, lon = _parse_wkt((cam.get("latLng") or {}).get("geography", {}).get("wellKnownText"))
+ images = cam.get("images") or []
+ image_url = None
+ if images and images[0].get("imageUrl"):
+ image_url = state.base_url + images[0]["imageUrl"]
+ day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+ return Event(
+ id=f"{state.code}:cam:{cam_id}:{day}",
+ adapter=self.name,
+ category="camera.state_511_atis_cameras",
+ time=datetime.now(timezone.utc),
+ severity=1, # telemetry; cameras have no severity signal
+ geo=Geo(
+ centroid=(lon, lat) if lat is not None and lon is not None else None,
+ regions=[f"US-{state.code}"],
+ primary_region=f"US-{state.code}",
+ ),
+ data={
+ "camera_id": cam_id,
+ "roadway_name": cam.get("roadway"),
+ "location_description": cam.get("location"),
+ "direction": cam.get("direction"),
+ "source": cam.get("source"),
+ "image_url": image_url,
+ "image_count": len(images),
+ "record_updated": cam.get("lastUpdated"), # camera config edit time (not capture time)
+ "state_code": state.code,
+ "latitude": lat,
+ "longitude": lon,
+ },
+ )
+
+ async def poll(self) -> AsyncIterator[Event]:
+ if not self._session:
+ raise RuntimeError("Session not initialized")
+ sem = asyncio.Semaphore(_FETCH_CONCURRENCY)
+
+ async def _one(state: StateConfig) -> list[Event]:
+ async with sem:
+ cams = await self._fetch_all(state.base_url)
+ out: list[Event] = []
+ for cam in cams:
+ try:
+ ev = self._build_event(cam, state)
+ except Exception:
+ logger.exception("state_511_atis_cameras parse failed", extra={"state": state.code})
+ continue
+ if ev is not None:
+ out.append(ev)
+ return out
+
+ results = await asyncio.gather(*[_one(s) for s in self._states])
+ yielded = 0
+ for evs in results:
+ for ev in evs:
+ yield ev
+ yielded += 1
+
+ self.sweep_old_ids()
+ logger.info("state_511_atis_cameras poll completed", extra={"events_yielded": yielded})
+
+ def subject_for(self, event: Event) -> str:
+ d = event.data
+ code = (d.get("state_code") or "").lower() or "unknown"
+ return f"central.traffic_cameras.{code}.{d.get('camera_id')}"
diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py
index 007fdda..8566b0e 100644
--- a/src/central/gui/routes.py
+++ b/src/central/gui/routes.py
@@ -2658,7 +2658,7 @@ ADAPTER_GROUPS = {
"Space": ["swpc_alerts", "swpc_kindex", "swpc_protons"],
"Geophysical": ["usgs_quake", "nwis"],
"Earth Observation": ["eonet"],
- "Transportation": ["wzdx", "state_511_atis", "tomtom_flow", "tomtom_incidents"],
+ "Transportation": ["wzdx", "state_511_atis", "tomtom_flow", "tomtom_incidents", "state_511_atis_cameras"],
}
# Same palette the map legend uses, indexed by sorted-adapter position.
EVENTS_PALETTE = [
diff --git a/src/central/gui/templates/_event_rows/state_511_atis_cameras.html b/src/central/gui/templates/_event_rows/state_511_atis_cameras.html
new file mode 100644
index 0000000..37cff1e
--- /dev/null
+++ b/src/central/gui/templates/_event_rows/state_511_atis_cameras.html
@@ -0,0 +1,8 @@
+{# State 511 camera detail. Live
fetched direct from the source (no proxy /
+ blob storage in Central). Fields from payload->data->data. #}
+{% set d = (event.data.get('data') or {}).get('data') or {} %}
+{% if d.get('image_url') %}View
{% endif %}
+{% if d.get('roadway_name') %}Road{{ d.roadway_name }}{% if d.get('direction') and d.direction not in ['Unknown', 'None'] %} ({{ d.direction }}){% endif %}{% endif %}
+{% if d.get('location_description') %}Location{{ d.location_description }}{% endif %}
+{% if d.get('source') %}Source{{ d.source }}{% endif %}
+{% if d.get('image_count') and d.image_count > 1 %}Views{{ d.image_count }} camera angles{% endif %}
diff --git a/src/central/gui/templates/_event_summaries/state_511_atis_cameras.html b/src/central/gui/templates/_event_summaries/state_511_atis_cameras.html
new file mode 100644
index 0000000..6974dc7
--- /dev/null
+++ b/src/central/gui/templates/_event_summaries/state_511_atis_cameras.html
@@ -0,0 +1,4 @@
+{# State 511 camera one-line subject. The `location` field already embeds the
+ roadway (e.g. "I-84 Mountain Home"), so it stands alone. Fields from payload->data->data. #}
+{% set d = (event.data.get('data') or {}).get('data') or {} %}
+Camera: {{ d.get('location_description') or d.get('roadway_name') or ('#' ~ d.get('camera_id')) }}
diff --git a/src/central/streams.py b/src/central/streams.py
index 98af7d4..ec2b97e 100644
--- a/src/central/streams.py
+++ b/src/central/streams.py
@@ -31,5 +31,6 @@ STREAMS: list[StreamEntry] = [
StreamEntry("CENTRAL_HYDRO", "central.hydro.>"),
StreamEntry("CENTRAL_TRAFFIC", "central.traffic.>"),
StreamEntry("CENTRAL_TRAFFIC_FLOW", "central.traffic_flow.>"),
+ StreamEntry("CENTRAL_TRAFFIC_CAMERAS", "central.traffic_cameras.>"),
StreamEntry("CENTRAL_META", "central.meta.>", event_bearing=False),
]
diff --git a/tests/fixtures/state_511_atis_cameras_sample.json b/tests/fixtures/state_511_atis_cameras_sample.json
new file mode 100644
index 0000000..5990691
--- /dev/null
+++ b/tests/fixtures/state_511_atis_cameras_sample.json
@@ -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}]}
\ No newline at end of file
diff --git a/tests/test_events_feed_frontend.py b/tests/test_events_feed_frontend.py
index 177853c..de8ed66 100644
--- a/tests/test_events_feed_frontend.py
+++ b/tests/test_events_feed_frontend.py
@@ -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",
}
diff --git a/tests/test_state_511_atis_cameras.py b/tests/test_state_511_atis_cameras.py
new file mode 100644
index 0000000..52b3a09
--- /dev/null
+++ b/tests/test_state_511_atis_cameras.py
@@ -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 "![]()