Compare commits

...
Author SHA1 Message Date
Said 664c18d97a Merge latest CCTV foundations and retain reviewed follow-up fixes 2026-10-19 23:50:21 +01:00
Said Turksever de2138e84c Merge pull request #1233 from yaka0007/fix/tarktee-datex-catalog-hardening
fix(cctv): harden the Tarktee DATEX catalog fetches and dedupe before the cap
2026-10-19 23:45:41 +01:00
Said Turksever f0e0ea42ce test(cctv): verify DEM placement in the keyless globe gate 2026-10-19 23:31:52 +01:00
Said Turksever 89ef90af04 Merge remote-tracking branch 'upstream/main' into codex/pr1233-tarktee-cleanup 2026-10-19 23:27:29 +01:00
Said Turksever 5597c81e7c Merge pull request #945 from trygvehhh/feat/cctv-vegvesen-kartverket-heights
feat: Kartverket ground heights for Statens vegvesen cameras
2026-10-19 23:24:35 +01:00
Said fb3506cc20 Record refreshed Kartverket PR ancestry without changing the tested tree 2026-10-19 23:19:13 +01:00
Said f927ef288a Check tracking rotation on the first visible frame within fleet cadence 2026-10-19 23:16:02 +01:00
Said Turksever 97630a598a fix(cctv): release Tarktee sibling on fetch rejection 2026-10-19 23:15:43 +01:00
Said Turksever 3744a97f00 Merge branch 'main' into feat/cctv-vegvesen-kartverket-heights 2026-10-19 23:15:41 +01:00
Said d90bae49b6 Record refreshed Tarktee PR ancestry with reviewed fixes preserved 2026-10-19 23:06:45 +01:00
Said ea09a59877 Validate CCTV terrain regime and frame calibration drag fixture 2026-10-19 23:05:38 +01:00
Said 5b4f5ed4a0 Fix CCTV provider cancellation and credential-safe errors 2026-10-19 23:05:37 +01:00
Said 1bccc295f0 Merge remote-tracking branch 'origin/pr945' into codex/cctv-shared-batch-20261019 2026-10-19 22:50:20 +01:00
Said 4416a491d2 Merge remote-tracking branch 'origin/pr1228' into codex/cctv-shared-batch-20261019 2026-10-19 22:50:20 +01:00
Said 4171ebb6a8 Merge current upstream CCTV fixes into review candidate 2026-10-19 22:49:17 +01:00
Said TurkseverandClaude Opus 5.5 59646e52f9 Merge upstream main into fix/tarktee-datex-catalog-hardening
Resolve conflicts with #1234: keep redirect: 'manual' on both DATEX
fetches and keep main's 30 s TARKTEE_FETCH_TIMEOUT_MS. Drop this
branch's 15 s constant and its import, which auto-merged into a
duplicate export. Keep both sets of Estonia tests and drop the 15 s
claim from the changelog fragment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:48:03 +01:00
Said Turksever 38e037d3a7 Merge pull request #1234 from quanticsoul4772/upstream/cctv-tarktee-refresh
fix(cctv): serve the camera catalog while it refreshes, and give Tarktee's slow feeds 30 s
2026-10-19 22:39:54 +01:00
Said Turksever ca71b27791 Merge branch 'main' into upstream/cctv-tarktee-refresh 2026-10-19 22:24:44 +01:00
Said Turksever b7fe29b5cc Merge pull request #1133 from asturksever/fix/qa-cctv-v2-headful
fix(qa-cctv-v2): keep long headful waits under puppeteer's protocolTimeout
2026-10-19 22:24:12 +01:00
Said TurkseverandClaude Opus 5.5 12b8fd0c1d fix(qa-cctv-v2): poll waits from Node so a hidden window can't stall them
page.waitForFunction polls on requestAnimationFrame by default. In a
minimised or hidden headful window rAF stops while timers keep running, so
each 120 s slice only checked the predicate when it started: a drain that
finished in the last slice was recorded as a FAIL on the sign-off path.

- waitForPredicate replaces waitForFunctionSliced and the other three
  waitForFunction calls: one short page.evaluate per tick (1 s for the
  drain, 250 ms elsewhere), a monotonic performance.now() deadline, retry
  across a context swap, and a throwing predicate fails the run instead of
  silently spending the budget.
- One in-page window.__qaRenderFrames(count, capMs) helper serves both the
  gizmo-drag and the sibling-owner frame waits.
- The sibling pick counts as a stalled renderer only at 0 of 3 frames, so a
  slow SwiftShader frame still reports a real miss; the record flattens and
  prints the frame count once.
- Review screenshots log and continue when a capture fails.
- Launch with pipe: true (no localhost CDP port) and protocolTimeout
  300_000 like the other harnesses; drop the occlusion flag that puppeteer
  already passes by default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:07:34 +01:00
Said TurkseverandClaude Opus 5.5 474d44c4db fix(qa-cctv-v2): report a stalled renderer apart from a missed sibling pick
When the sibling-owner pick misses after fewer than 3 rendered frames, the
cause is a stalled renderer, not a pick regression. Record it as a failure
on the headful sign-off path and as inconclusive under headless GL, with
"renderer stalled: N/3 frames" in the detail. A missed pick after 3 frames
still fails in both modes. Also corrects the catalog-size comment (~3,700).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:07:34 +01:00
Said TurkseverandClaude Opus 5.5 05f668dfa7 fix(qa-cctv-v2): keep long headful waits under puppeteer's protocolTimeout
The headful drain budget is uncapped (camCount * 800 + 30000 ms, ~50 min
at N≈3,700), but each page.waitForFunction is one CDP call that puppeteer
aborts after its 180 s default protocolTimeout. The .catch then recorded
"timed out waiting for drain on the real-GPU sign-off path" after ~3 min.

- Poll the drain in <=120 s slices (waitForFunctionSliced) so no single
  CDP call outlives protocolTimeout, whatever the catalog size.
- Launch with protocolTimeout 240 s, headroom above the largest slice and
  the 60 s fixed waits.
- Bound the sibling-owner 3-frame postRender wait at 10 s and report the
  frame count, so a renderer that stops producing frames fails with
  evidence instead of hanging the evaluate.
- List --disable-backgrounding-occluded-windows explicitly. Puppeteer
  25.10 already passes it by default, so this documents the headful
  contract rather than changing behaviour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:07:34 +01:00
Sameh Khamis 93c1f6cd45 Merge pull request #1220 from manjunath22466/codex/openai-typed-send-rejection
fix replies after rejected openai typed commands
2026-10-19 14:06:19 -07:00
Sameh Khamis 3e39bb2cf4 Merge branch 'main' into codex/openai-typed-send-rejection 2026-10-19 13:51:48 -07:00
Sameh Khamis 194a547955 docs(changelog): match fragment style for #1220 2026-10-19 13:51:07 -07:00
quanticsoul4772 4e3c1628b2 Merge branch 'main' into upstream/cctv-tarktee-refresh 2026-10-19 09:30:58 -07:00
Said TurkseverandClaude Opus 5.5 c808bfe609 fix(cctv): harden the Kartverket height lookup for Vegvesen cameras
- Default backoff is 30 min, longer than the 15-minute catalog refresh,
  so a failing Kartverket costs one attempt per backoff, not per refresh.
- A point a 200 answer leaves out is cached as no height (6 h TTL)
  instead of being asked again, and waited on, every refresh.
- Only 408, 429 and 5xx stop the lookup; any other 4xx marks that
  request's points as no height and the lookup carries on.
- Each request has its own 10 s timeout inside the shared 30 s deadline;
  a lookup cut short only by the deadline resumes without a backoff.
- The lane waits only on a cold cache (>10% of cameras missing); a few
  new cameras or expired misses are looked up in the background.
- applyVegvesenGroundHeights skips cameras without finite coordinates,
  so it really never throws.
- clearKartverketHeightCache bumps a generation, so a lookup started
  before a clear cannot write to the cache, backoff or pending slot.
- Credit and DATA_SOURCES note the modification (water set to 0 m,
  rounded to 0.1 m) as CC BY 4.0 asks; docs match the stop rules.
- Tests for the deadline being shared, per-request timeouts, 503,
  oversized and non-JSON bodies, 4xx, left-out points, Retry-After end
  to end, overlapping refreshes, the warm-cache wait, bad coordinates,
  the cache-clear generation and the loader's wait budget.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:03:50 +01:00
Said TurkseverandClaude Opus 5.5 b66d02df7c fix(cctv): space out failed background refreshes; test the stale paths
- A background catalog refresh that rejects now keeps the old catalog for
  another TTL, as an empty refresh already did, so a refresh that keeps
  failing is retried once per TTL instead of on every request.
- Trim notes are keyed by the source list they describe, and /sources asks
  for the list it served, so a request answered during a refresh never pairs
  the old cameras with the new list's notes.
- Tests: a failed background refresh (one warning, no unhandled rejection,
  no retry before the TTL, trim notes stay with their list) and an empty
  background refresh (old catalog kept, no upstream fetch before the TTL).
  The race assertion's message now names the failure; its timer is cleared.
- The Tarktee test no longer imports the new constant, so on main it fails
  on its own assertion instead of taking the whole file down.
- precompute-cctv-heights waits 60 s for a cold catalog, past Tarktee's 30 s.
- CURRENT-STATE notes the 30 s bound and the retry spacing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:02:06 +01:00
Said TurkseverandClaude Opus 5.5 0561277dcb test(cctv): pin Ontario 511 key handling against regressions
- Assert the key never reaches the camera records sent to the browser or
  the "Loaded" log line.
- Count fetches in the keyless test instead of relying on assert.fail
  inside the mock, which the loader's catch would swallow; a
  whitespace-only key is treated as missing.
- Cover the key hint for 400/401/403 and its absence for 429/500/503,
  and log the failure as one string without a trailing empty argument.
- Add _resetOntarioKeyWarningForTest so the logged-once check no longer
  depends on test order.
- Read CCTV_ONTARIO_MAX_SOURCES from the same env as the key, and keep
  console output out of the suite.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:59:52 +01:00
Said Turksever e9a234094b Merge remote-tracking branch 'upstream/main' into pr-1228-fix
# Conflicts:
#	README.md
2026-10-19 16:56:57 +01:00
manjunath bd087d6096 Merge upstream main for typed-send compatibility 2026-10-19 21:12:47 +05:30
manjunath 5fd5d688dc Merge upstream main for typed-send compatibility 2026-10-19 20:39:51 +05:30
Trygve Hemminghytt 4999f6dc60 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/CURRENT-STATE.md
2026-10-19 16:16:37 +02:00
Said 7cdaf40fd0 docs(cctv): retain Ontario licence attribution in batch candidate 2026-10-19 14:00:35 +01:00
Said 0a4d520e79 Merge remote-tracking branch 'origin/pr1200' into codex/cctv-shared-batch-20261019 2026-10-19 13:51:26 +01:00
Said 695cce967d Merge remote-tracking branch 'origin/pr1201' into codex/cctv-shared-batch-20261019 2026-10-19 13:51:25 +01:00
Said 42c95a8a24 Merge remote-tracking branch 'origin/pr945' into codex/cctv-shared-batch-20261019 2026-10-19 13:51:25 +01:00
Said 29d8fb4101 Merge remote-tracking branch 'origin/pr1228' into codex/cctv-shared-batch-20261019 2026-10-19 13:51:17 +01:00
Said 7a31ef5e8a Integrate PR #1233 while preserving the shared 30-second timeout 2026-10-19 13:51:17 +01:00
Said 7dfecd7d98 Merge remote-tracking branch 'origin/pr1234' into codex/cctv-shared-batch-20261019 2026-10-19 13:50:20 +01:00
Said ea06c9fc64 Merge remote-tracking branch 'origin/pr1133' into codex/cctv-shared-batch-20261019 2026-10-19 13:50:20 +01:00
quanticsoul4772 cd7e29c635 Merge branch 'main' into upstream/cctv-tarktee-refresh 2026-10-19 05:34:17 -07:00
quanticsoul4772andClaude Opus 5.5 5b12f51e98 fix(cctv): warn once per failed background refresh; test the single flight
Attach the background-refresh warning where the refresh starts, so a
failed refresh logs once instead of once per request served meanwhile.
The test now checks that requests during a refresh share it, and turns
the hang off before releasing the fetches so a retrying loader can't
stall it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 05:28:56 -07:00
quanticsoul4772andClaude Opus 5.5 ca9dbea060 docs(changelog): fragment for #1234
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:13:36 -07:00
yaka0007 9b400622af docs(changelog): add fragment for #1233 2026-10-19 01:13:30 -04:00
yaka0007 3792d7377e fix(cctv): harden Tarktee DATEX catalog fetches and dedupe before cap 2026-10-19 01:12:18 -04:00
quanticsoul4772andClaude Opus 5.5 1b66d284c4 docs(cctv): CURRENT-STATE describes the background catalog refresh and Tarktee's 30 s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:12:13 -07:00
quanticsoul4772andClaude Opus 5.5 f26586bb3e fix(cctv): serve the camera catalog while it refreshes
An expired catalog made every /api/cctv request (camera list and frames)
wait for the full refresh, which lasts as long as the slowest of the live
packs: Tarktee's DATEX feeds took 2 to 16.6 s across ten requests. With
Tarktee's timeout raised to 30 s, that wait could reach 30 s at each
15-minute refresh. The cached list is now returned while a single-flight
refresh replaces it in the background; only a first load, with nothing
to serve, waits. A background refresh that throws is logged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:08:24 -07:00
quanticsoul4772andClaude Opus 5.5 6b3f7a3ec1 fix(cctv): give Tarktee's slow DATEX feeds 30 s instead of the shared 15 s
The Estonian Transpordiamet feeds answered in 2 to 16.6 s across ten
requests (120 and 160 KB), and both must arrive for any camera to load,
so the shared 15 s catalog timeout dropped the whole pack on about a
third of loads ("Tarktee DATEX download error: The operation was aborted
due to timeout"), for the 15 minutes until the next catalog refresh.
TARKTEE_FETCH_TIMEOUT_MS gives the two requests 30 s; other packs keep
15 s.

Live: 175 cameras in 3.3 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:04:52 -07:00
quanticsoul4772andClaude Opus 5.5 83b9940733 docs(cctv): Ontario 511 docs match the key requirement and the developer agreement
Review fixes for #1228: CURRENT-STATE.md and the constants comment no
longer call Ontario 511 keyless. DATA_SOURCES.md notes Ontario 511's
Developer's Access Agreement (511on.ca/developers/resources): the data
stays under the Open Government Licence - Ontario, key requests are
reviewed case by case, attribution by link is encouraged and the logo
rules don't apply because the app shows none, and the "as is" disclaimer
should be passed on, which the credit now does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:48:02 -07:00
quanticsoul4772andClaude Opus 5.5 91461381ee fix(cctv): point a rejected Ontario key at ONTARIO_511_API_KEY; README notes the key
A wrong or revoked key logged only "download failed: 400"; a 400, 401 or
403 now adds "(check ONTARIO_511_API_KEY)". The README's CCTV row listed
Ontario among the default cameras with no mention of the key it now
needs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:43:15 -07:00
quanticsoul4772andClaude Opus 5.5 cd0956eef7 docs(changelog): fragment for #1228
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:46:13 -07:00
quanticsoul4772andClaude Opus 5.5 fa085cbd64 docs(cctv): point to the 511on.ca pages that exist for the Ontario key
511on.ca/developers is a 404. Sign up at 511on.ca/my511/register, then
request the key while logged in at 511on.ca/developers/doc (both checked).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:40:22 -07:00
quanticsoul4772andClaude Opus 5.5 48c8a64c97 fix(cctv): Ontario 511 cameras take the developer key the API now requires
The Ontario 511 camera list answered 400 "Invalid Key" on every catalog load
("[CCTV] Ontario 511 camera download failed: 400"), so the pack loaded
nothing. 511on.ca's developer docs now say the API "Requires a developer
key" (free, from 511on.ca/developers; 10 calls a minute). The loader reads
ONTARIO_511_API_KEY and sends it as `key`; without it the pack is skipped
with one log line instead of a failed request on every load. The key is
never logged. .env.example and DATA_SOURCES no longer call the endpoint
keyless.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:40:22 -07:00
Trygve Hemminghytt 59db4ac227 docs(cctv): state the measured cold lookup time and what a slow first run leaves 2026-10-18 20:22:21 +02:00
Trygve Hemminghytt 15208b5c40 docs: move the changelog entry to changelog.d 2026-10-18 20:03:24 +02:00
Trygve Hemminghytt 80a0954030 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-18 20:02:40 +02:00
manjunath 8d3c929740 docs: add numbered changelog for typed send rejection 2026-10-18 16:16:39 +05:30
manjunath 7688e53424 fix(voice): skip replies for rejected typed sends 2026-10-18 16:15:06 +05:30
Trygve Hemminghytt 2bec3d521b Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/CURRENT-STATE.md
#	server/providers/cctv/sources.js
2026-10-16 07:21:30 +02:00
Trygve Hemminghytt e2dacd92ea Merge remote-tracking branch 'origin/main' into HEAD 2026-10-15 21:15:57 +02:00
trygvehhh 2aa0ae851f Merge branch 'main' into feat/cctv-vegvesen-kartverket-heights 2026-10-14 21:18:04 +02:00
Said Turksever 77dfa718a9 Merge branch 'main' into fix/qa-cctv-v2-headful 2026-10-14 17:44:50 +01:00
Said Turksever 436ffbb21c Merge branch 'main' into fix/qa-cctv-v2-headful 2026-10-14 16:55:46 +01:00
Trygve Hemminghytt 6f054408da Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	DATA_SOURCES.md
#	docs/CURRENT-STATE.md
#	server/providers/cctv/constants.js
#	server/providers/cctv/sources.js
2026-10-14 16:00:43 +02:00
Said TurkseverandClaude Opus 5.5 1a0b9c8b0f fix(qa-cctv-v2): keep long headful waits under puppeteer's protocolTimeout
The headful drain budget is uncapped (camCount * 800 + 30000 ms, ~50 min
at N≈3,700), but each page.waitForFunction is one CDP call that puppeteer
aborts after its 180 s default protocolTimeout. The .catch then recorded
"timed out waiting for drain on the real-GPU sign-off path" after ~3 min.

- Poll the drain in <=120 s slices (waitForFunctionSliced) so no single
  CDP call outlives protocolTimeout, whatever the catalog size.
- Launch with protocolTimeout 240 s, headroom above the largest slice and
  the 60 s fixed waits.
- Bound the sibling-owner 3-frame postRender wait at 10 s and report the
  frame count, so a renderer that stops producing frames fails with
  evidence instead of hanging the evaluate.
- List --disable-backgrounding-occluded-windows explicitly. Puppeteer
  25.10 already passes it by default, so this documents the headful
  contract rather than changing behaviour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 13:08:35 +01:00
trygvehhh a15747ca61 Merge branch 'main' into feat/cctv-vegvesen-kartverket-heights 2026-10-11 13:23:17 +02:00
Trygve Hemminghytt 939264d30e Merge remote-tracking branch 'origin/main' into feat/cctv-vegvesen-kartverket-heights 2026-10-07 21:37:41 +02:00
Trygve Hemminghytt 1e1ddfca0f Merge remote-tracking branch 'origin/main' into feat/cctv-vegvesen-kartverket-heights
# Conflicts:
#	CHANGELOG.md
2026-10-07 15:44:26 +02:00
Trygve HemminghyttandClaude Opus 5.5 34d53edc91 fix: address review on Kartverket ground heights
- Send 20 points per request, not 50: above ~20 Kartverket answers the
  extra points from contour lines (hoydekurver) or not at all. At 20,
  nearly every camera resolves from dtm1, so the null-retry pass is gone.
  Eight requests in flight keep a cold lookup ~6 s.
- Cache misses: positions with no height are not asked again for 6 h.
- Transport failures: the first 429, 5xx or network error stops the
  lookup with no retries, and no lookup starts until the backoff ends
  (Retry-After when sent, else 10 min; bounded to 1 min-1 h).
- Budget the 12 s wait from the start of the loader, catalog fetch
  included.
- Record answers only for the points each request asked about.
- Send the CCTV User-Agent; link CC BY 4.0 in the credit.
- Docs: bridge cameras sit at the water surface, below the deck.
- Tests: batching, 8-in-flight cap, request count on failure, the shared
  deadline, Retry-After bounds, null TTL, backoff, the -20 m bound and
  each water marker on its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 19:21:27 +02:00
Trygve HemminghyttandClaude Opus 5.5 c43f58dc3f feat: Kartverket ground heights for Statens vegvesen cameras
The Vegvesen camera feed is 2D, so every camera shared one flat 150 m
ground prior and mountain-pass cameras could sit hundreds of metres below
the terrain where neither the point-height prior nor the ground snap
resolves. Each camera now gets its terrain height from Kartverket's
keyless point-height API (CC BY 4.0): ~840 of ~845 cameras.

- 50 points per request, six in flight; points a full batch answers null
  for are retried once in batches of 10 (the API drops points from large
  batches).
- Sea-floor depths (dybdekurver / Havflate) under bridges and quays
  count as the water surface, 0 m.
- Heights are cached by position, so the 15-minute refresh only asks
  about new or moved cameras. A catalog load waits at most 12 s; a slower
  lookup keeps filling the cache for the next refresh.
- CCTV_VEGVESEN_HEIGHTS=0 turns it off; Kartverket is credited beside
  Statens vegvesen in the attribution popover.

Follow-up to #929.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 21:46:20 +02:00
26 changed files with 2606 additions and 198 deletions

No files matched your search

+2
View File
@@ -207,6 +207,7 @@ AISSTREAM_API_KEY=
# CCTV_TFL_ENABLED=1 # set 0 to disable Transport for London JamCams
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
# CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras
# ONTARIO_511_API_KEY= # free developer key: sign up at 511on.ca/my511/register, then request it at 511on.ca/developers/doc
# CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511
# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack
# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog
@@ -234,6 +235,7 @@ AISSTREAM_API_KEY=
# CCTV_VEGVESEN_ENABLED=1 # set 0 to disable the Statens vegvesen (Norway) road cameras
# CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000)
# CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras
# CCTV_VEGVESEN_HEIGHTS=1 # set 0 to skip Kartverket ground heights for Statens vegvesen cameras (flat prior instead)
# CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog URL
# CCTV_NYC_ENABLED= # off by default; set 1 only if you hold a NYC DOT data-sharing agreement ([email protected]) for the camera feed
# CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first)
+3 -1
View File
@@ -64,6 +64,7 @@ How to read this:
| **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) |
| **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) |
| **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" |
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
@@ -153,7 +154,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
- **Ontario 511 CCTV.** The camera list comes from the keyless `511on.ca/api/v2/get/cameras` endpoint; frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario.
- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does.
- **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms.
- **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch.
- **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/<id>.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel.
@@ -168,6 +169,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`.
- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here.
- **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/<cameraId>`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public/<cameraId>/manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover.
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
+1 -1
View File
@@ -381,7 +381,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
@@ -0,0 +1,6 @@
- Make CCTV QA ground-sampling checks respect the active terrain regime and
frame the actual calibration gizmo for the drag regression, preserving the
photoreal completion floor and all drag invariants.
- Measure the tracking arrival regression on the first visible frame within
250 ms, accounting for the 80 ms fleet cadence while rejecting a stale
first-visible orientation instead of retrying until it is correct.
+3
View File
@@ -0,0 +1,3 @@
- Avoid starting or queuing a reply when the local channel rejects a typed OpenAI
command, while preserving an earlier accepted deferred input
(Manjunath, #1220).
+6
View File
@@ -0,0 +1,6 @@
- Load Ontario 511 cameras again. 511on.ca's camera endpoint now requires a
free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is
otherwise skipped with one log line saying where to get a key
(quanticsoul4772, #1228).
- Refuse Ontario catalog redirects and keep credential-bearing URLs and provider
exception text out of failure logs; retain the Ontario licence attribution.
@@ -0,0 +1,5 @@
- Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects,
abort pending siblings and cancel received or late response streams when either
request fails before headers or overflows the 4 MB
byte cap, retain the shared 30 s dual-XML fetch deadline, and deduplicate camera
IDs before per-pack prioritization (yaka0007, #1233).
@@ -0,0 +1,5 @@
- Serve the camera catalog while it refreshes. An expired catalog made every
camera request wait for the slowest pack to reload; it is now answered at
once while one background refresh runs, so only the first load waits. Estonia's
slow Tarktee feeds get 30 s instead of 15 s, so the pack no longer drops out
on about a third of loads (quanticsoul4772, #1234).
@@ -0,0 +1,17 @@
- Give each Statens vegvesen camera its own ground height from Kartverket's
keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so
mountain-pass cameras no longer sit hundreds of metres below the terrain
where neither the point-height prior nor the ground snap resolves. About 850
of ~855 cameras get a height; the rest keep the old prior. Requests carry 20
points, the most the API answers from its 1 m terrain model, eight at a
time; a cold lookup takes about 6–13 s. Cameras over water use the water
surface rather than the sea-floor depth the API returns there; for bridge
cameras that is still below the deck. Heights are cached for good and misses
for 6 h, so a refresh only asks about new or moved cameras. After a
failing request (a 408, 429 or 5xx, a network error, a request over 10 s or
an unreadable body) the lookup stops and backs off (Retry-After, else
30 min, longer than the refresh). The lane waits at most 12 s from its
start, and only on a cold cache; a slower first lookup leaves the cameras it
had not reached on the flat prior until the next 15-minute refresh, and
keeps filling the cache meanwhile.
`CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to #929 (trygvehhh, #945).
+24 -6
View File
@@ -3448,7 +3448,7 @@ its criteria cannot be silently ignored.
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + FL511 (FL, opt-in) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + FL511 (FL, opt-in) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
@@ -4441,16 +4441,34 @@ silently demoting every later lookup for the session.
default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of
1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego,
Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default
sources. Ontario 511 (2026-09-12) adds keyless highway cameras including Kitchener-area routes
(cap 1,000, all enabled rows from the current ~944-camera catalog). All sources are RAW PRIOR
sources. Ontario 511 (2026-09-12) adds highway cameras including Kitchener-area routes
(cap 1,000, all enabled rows from the current ~944-camera catalog). The 511on.ca API now
requires a developer key, so the pack needs `ONTARIO_511_API_KEY` and is skipped without one
(#1228). All sources are RAW PRIOR
poses, and the layer is stills-first.
sources. Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`,
Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`,
kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list
covering the whole country, where one _preset_ (a station's fixed view) is one camera — 806
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin
across packs, so a lower global cap thins every region instead of starving the last pack.
~1,100 cameras total, all RAW PRIOR poses, stills-first.
~1,100 cameras total, all RAW PRIOR poses, stills-first. The catalog lives for 15 minutes; once
it expires, `/api/cctv` keeps answering from it while one background refresh reloads every
pack, so only the very first load waits for the slowest pack (a live first load took about
22 s, and is bounded by Tarktee's 30 s). A background refresh that fails or comes back
empty keeps the old catalog and is retried after another 15 minutes, not on the next
request. Estonia's Tarktee DATEX feeds, which answer in 2 to 16.6 s, get 30 s
(`TARKTEE_FETCH_TIMEOUT_MS`) where other packs get 15 s.
Ontario catalog requests refuse redirects and log only a fixed diagnostic for
transport/JSON failures, so reflected request URLs or keys cannot reach logs.
Tarktee owns both XML requests with one 30-second deadline and shared abort;
a failed header or body read cancels its sibling, including a response arriving
after cleanup.
Both Tarktee XML catalogs refuse redirects and have a 4 MiB per-document
limit, enforced against declared length and streamed bytes. A rejected fetch,
unsuccessful response, or body-read failure releases both available response
bodies; read failures also cancel an active sibling reader. Cameras sharing
an image number are deduplicated before the Tarktee pack cap is applied.
- **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field
validated 2026-07-21): the COVERAGE toggle is a
tri-state cycle `OFF → ON → VIEWSHED`; viewshed mode renders each visible camera's frustum
@@ -4767,7 +4785,7 @@ and unreachable upstream (502/504) separately from road geometry.
- **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame.
- **Context-mode vocabulary is symmetric** (2026-08-21). `set_context_mode` accepts `contacts`; the internal id is `flights`. Every model-readable field (`mode`, `entering`, `priorMode`, nested `context`/`contextRollback`, and the transition diagnostic text) is reported in the accepted vocabulary, with the internal id preserved as `<field>Internal`; an absent secondary mode stays `null` rather than claiming to be `off`. Mapping lives in `src/contextModePolicy.js` so UI text and voice payloads cannot drift. Reporting the internal id made the model read `mode:'flights'` as "Contacts is off" and refuse to answer from the `contactsWindow` counts in the same payload.
- **Analyst → track handoff carries a key, not just a label** (2026-08-21). `analyst_query` items include `icao24`/`mmsi` alongside the display `id`, and contact lookup uses the shared tiered ranking in `src/data/contactMatch.js`: hex exact → callsign exact → registration exact → callsign prefix → registration prefix → callsign substring → registration substring. The tiers keep an exact callsign ahead of a colliding registration regardless of feed order, and registrations compare separator-insensitively (`G-ABCD`/`GABCD`, `05-8152`/`058152`, `N123AB`/`N-123AB`).
- **A typed command supersedes the turn it interrupts** (2026-08-21). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items.
- **A typed command supersedes the turn it interrupts** (2026-08-21; hardened 2026-10-17). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items. If the local channel rejects the user item, that command requests no response and adds no deferred answer; earlier accepted deferred input remains queued. Interruption and pointer preparation still happen before the send attempt.
- **Subject reconciliation across satellite catalog rebuilds** (2026-08-21). A dense↔core toggle or TLE refresh clears and repopulates the catalog, so the published subject is re-resolved against the new satrec; a subject that did NOT survive releases the slot. The per-frame refresh cannot do this itself — `_getTrackedFramePosition` returns early once the satellite has no catalog entry — so the reconcile runs at rebuild completion. An empty catalog is a rebuild in flight, not a disappearance.
- **One aircraft-proximity snapshot** (2026-08-22; hardened 2026-09-30). `collectAircraftProximityWindow` (`src/data/militaryAwareness.js`) computes the Contacts panel cohort once. The voice analyst reads the exact retained snapshot and evaluation timestamp instead of re-scanning live layers, so the panel readout and spoken count for one centre cannot cross snapshots. **Invariant: do not re-derive a proximity count anywhere else.** Each layer retains 20,000 rows and samples one row beyond the cap; a saturated cohort reports `complete:false`, `truncated:true`, and is spoken as a lower bound. They diverged before because the panel read live billboard positions while the analyst used last-fix coordinates over a 2,000-record slice — 111 on screen, 15 spoken. Explicit regions, arbitrary points, and a radius different from the panel radius deliberately keep the general record engine. Entity-centred results carry `window: {engine:'contacts-window', centeredOn, radiusKm, flights, military, aircraft, complete, evaluatedAt}`.
- **Centre precedence for nearby asks**: explicit place in the question > Contacts subject (a selected non-contact entity never silently becomes the centre) > an entity the user names > the current view, said aloud. Contacts active with no subject uses the view rather than reading an empty panel.
+3 -1
View File
@@ -326,8 +326,10 @@ async function main() {
'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json',
);
let sidecar = await loadSidecar(output);
// A cold catalog waits for its slowest pack (Tarktee gets 30 s), so allow
// more than that.
const response = await fetch(`${base}/api/cctv/sources`, {
signal: AbortSignal.timeout(30_000),
signal: AbortSignal.timeout(60_000),
});
if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`);
const catalog = await response.json();
+269 -107
View File
@@ -11,8 +11,10 @@
* exactly once per camera ACTIVATION (the §9.1 obstruction probe) and
* never again in steady state; shared mesh-floor sampling fires at most
* once per coarse cell during the staggered geometry-load drain, and
* actually COMPLETES once tiles load (>=1 real sample — the
* ceiling alone would pass trivially at Δ=0 if the snap never finished),
* actually COMPLETES once tiles load in photoreal mode (>=1 real sample
* — the ceiling alone would pass trivially at Δ=0 if the snap never
* finished). Globe mode instead proves the rendered mount uses a real
* terrain height with zero mesh samples. Sampling
* stays flat while idle, and stays flat for non-positional pose edits.
* 2. Geometry contract: the 5-polyline frustum wireframe + far-cap plane
* exist for the active camera, the plane's corners coincide with the
@@ -139,6 +141,56 @@ function record(name, ok, detail) {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Each page.evaluate is bounded by puppeteer's `protocolTimeout` (default
// 180s). Waits never come close: they poll from Node (below). The headroom is
// for the few long evaluates, such as enabling the full ~3,700-camera catalog
// on a real GPU, and matches the other qa-*.mjs harnesses.
const PROTOCOL_TIMEOUT_MS = 300_000;
/**
* Polls an in-page predicate from Node, one short page.evaluate per tick,
* until it holds or the budget runs out. Used instead of page.waitForFunction
* for two reasons: its default rAF polling stops in a minimised or hidden
* headful window (timers keep running, rAF does not), and its result is one
* CDP call that protocolTimeout cuts off, while the headful drain budget
* reaches ~50 minutes. A predicate that throws fails the run instead of
* silently spending the budget.
* @param {import('puppeteer').Page} page
* @param {Function} predicate - In-page predicate.
* @param {object} opts
* @param {number} opts.timeoutMs - Overall budget.
* @param {number} [opts.intervalMs=250] - Delay between checks.
* @param {unknown[]} [opts.args=[]] - Arguments passed to the predicate.
* The predicate must return a boolean: its result crosses CDP by value.
* @returns {Promise<boolean>} True if the predicate held within the budget.
*/
async function waitForPredicate(
page,
predicate,
{ timeoutMs, intervalMs = 250, args = [] },
) {
// performance.now() is monotonic and doesn't advance while the machine
// sleeps, so neither a clock change nor a laptop sleep eats the budget.
const deadline = performance.now() + timeoutMs;
for (;;) {
try {
if (await page.evaluate(predicate, ...args)) return true;
} catch (err) {
// A navigation or Vite reload replaces the execution context: poll again.
if (
!/Execution context was destroyed|Cannot find context/.test(
err?.message ?? '',
)
) {
throw err;
}
}
const remaining = deadline - performance.now();
if (remaining <= 0) return false;
await sleep(Math.min(intervalMs, remaining));
}
}
// SwiftShader can spend tens of seconds servicing the hover drillPick emitted
// by page.mouse.move(), before the LEFT_DOWN under test even runs. Position the
// pointer while ADJUST is disabled, then re-enable it and explicitly render a
@@ -155,29 +207,58 @@ async function positionPointerForGizmoDrag(page, point, frameCount = 3) {
gev.dataManager.layers
.get('cctv')
.module.setParams({ calibrationMode: true });
const scene = gev.viewer.scene;
let rendered = 0;
return new Promise((resolve) => {
let settled = false;
const finish = (complete) => {
if (settled) return;
settled = true;
clearTimeout(timer);
remove();
resolve(complete);
};
const remove = scene.postRender.addEventListener(() => {
rendered += 1;
if (rendered >= count) {
finish(true);
return;
}
return (await window.__qaRenderFrames(count, 5000)) >= count;
}, frameCount);
}
/**
* Saves a screenshot for human review. These are never a gate, so a capture
* that fails (for example when the renderer stalls in a hidden headful window)
* is logged instead of ending the run.
* @param {import('puppeteer').Page} page
* @param {string} file
* @param {string} label
*/
async function saveReviewShot(page, file, label) {
try {
await page.screenshot({ path: file });
console.log(` screenshot (${label}) → ${path.relative(REPO_ROOT, file)}`);
} catch (err) {
console.warn(
` screenshot skipped (${label}): ${path.relative(REPO_ROOT, file)}: ${err?.message ?? err}`,
);
}
}
/**
* Installs window.__qaRenderFrames(count, capMs) in the page: requests renders
* until `count` frames have passed scene.postRender or capMs runs out, and
* resolves with the number of frames rendered. The cap keeps a stalled
* renderer (headful window asleep or hidden) from hanging the CDP call.
* @param {import('puppeteer').Page} page
*/
function installRenderFramesHelper(page) {
return page.evaluate(() => {
window.__qaRenderFrames = (count, capMs) =>
new Promise((resolve) => {
const scene = window.__godsEyeView.viewer.scene;
let frames = 0;
const finish = () => {
clearTimeout(timer);
remove();
resolve(frames);
};
const remove = scene.postRender.addEventListener(() => {
if (++frames >= count) {
finish();
return;
}
scene.requestRender();
});
const timer = setTimeout(finish, capMs);
scene.requestRender();
});
const timer = setTimeout(() => finish(false), 5000);
scene.requestRender();
});
}, frameCount);
});
}
// In-page helper (stringified and re-defined inside each evaluate that needs
@@ -280,23 +361,21 @@ const BEARING_SHIFT_SRC = `
* @returns {Promise<boolean>} True if tilesLoaded was observed within the timeout.
*/
function waitForTilesLoaded(page, timeoutMs = 15000) {
return page
.waitForFunction(
() => {
const scene = window.__godsEyeView.viewer.scene;
const prims = scene.primitives;
for (let i = 0; i < prims.length; i++) {
const p = prims.get(i);
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
return p.tilesLoaded === true;
}
return waitForPredicate(
page,
() => {
const scene = window.__godsEyeView.viewer.scene;
const prims = scene.primitives;
for (let i = 0; i < prims.length; i++) {
const p = prims.get(i);
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
return p.tilesLoaded === true;
}
return true; // no tileset in the scene — nothing to wait for
},
{ timeout: timeoutMs },
)
.then(() => true)
.catch(() => false);
}
return true; // no tileset in the scene — nothing to wait for
},
{ timeoutMs },
);
}
/**
@@ -364,6 +443,10 @@ async function main() {
const chromeExecutable = findChromeExecutable();
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
protocolTimeout: PROTOCOL_TIMEOUT_MS,
// Talk CDP over a pipe, so no debugging port stays open on localhost for
// the length of a headful run.
pipe: true,
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
args: [
'--no-sandbox',
@@ -397,13 +480,18 @@ async function main() {
});
console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
const appReady = await waitForPredicate(
page,
() =>
window.__godsEyeView &&
window.__godsEyeView.viewer &&
window.__godsEyeView.dataManager,
{ timeout: 60000 },
Boolean(
window.__godsEyeView &&
window.__godsEyeView.viewer &&
window.__godsEyeView.dataManager,
),
{ timeoutMs: 60000 },
);
if (!appReady) throw new Error('app did not initialise within 60s');
await installRenderFramesHelper(page);
// Let the initial fly-to Austin and first tiles settle.
await sleep(4000);
@@ -475,18 +563,15 @@ async function main() {
console.log(
`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`,
);
const drained = await page
.waitForFunction(
() => {
const mod =
window.__godsEyeView.dataManager.layers.get('cctv').module;
const ui = mod.getUIState();
return ui.loading && ui.loading.active === false;
},
{ timeout: drainBudgetMs },
)
.then(() => true)
.catch(() => false);
const drained = await waitForPredicate(
page,
() => {
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
const ui = mod.getUIState();
return Boolean(ui.loading) && ui.loading.active === false;
},
{ timeoutMs: drainBudgetMs, intervalMs: 1000 },
);
record(
`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`,
drained ? true : HEADFUL ? false : null,
@@ -575,27 +660,87 @@ async function main() {
// (existence, not just ceiling). The wait below tolerates the update-tick
// latency of the completion pass (update interval is 10s).
// -----------------------------------------------------------------------
console.log('Waiting for tiles + the one-shot completion pass...');
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
let sampleFloorOk = false;
if (tilesSeenLoaded) {
sampleFloorOk = await page
.waitForFunction(
(base) => window.__qaCounters.sampleHeight - base >= 1,
{ timeout: 30000 },
c0.sampleHeight,
)
.then(() => true)
.catch(() => false);
}
const cFloor = await readCounters();
record(
'>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
tilesSeenLoaded ? sampleFloorOk : null,
tilesSeenLoaded
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
: 'tilesLoaded never observed within 45s — floor not assessable this run',
const globeMode = await page.evaluate(
() => window.__godsEyeView.viewer.scene.globe?.show !== false,
);
if (globeMode) {
// Globe stacks intentionally perform no scene.sampleHeight query.
// Require independently fetched DEM heights and the actual rendered
// mount, so a catalog/geoid fallback cannot turn zero samples into a pass.
console.log(
'Checking terrain-globe completion against live DEM heights...',
);
const terrain = await page.evaluate(async () => {
const camera = window.__godsEyeView.dataManager.layers
.get('cctv')
.module.getUIState().activeCamera;
const exact = `${camera.lon.toFixed(5)},${camera.lat.toFixed(5)}`;
const cell = `${camera.lon.toFixed(3)},${camera.lat.toFixed(3)}`;
const response = await fetch(
`/api/terrain/heights?points=${encodeURIComponent(`${exact};${cell}`)}`,
{ signal: AbortSignal.timeout(30000) },
);
const body = await response.json();
return { ok: response.ok, results: body.results };
});
const heights = terrain.results?.map((result) => result?.ellipsoid);
const terrainReady =
terrain.ok && heights?.length === 2 && heights.every(Number.isFinite);
const mountResolved =
terrainReady &&
(await waitForPredicate(
page,
(expected, base) => {
const { viewer, dataManager } = window.__godsEyeView;
const camera = dataManager.layers
.get('cctv')
.module.getUIState().activeCamera;
const ray = viewer.entities.getById(`cctv-${camera.id}-ray-tl`);
const mount = ray?.polyline?.positions.getValue(
viewer.clock.currentTime,
)?.[0];
const height =
mount &&
viewer.scene.globe.ellipsoid.cartesianToCartographic(mount)
.height;
return (
Number.isFinite(camera.groundPriorM) &&
Math.abs(camera.groundPriorM - expected[0]) < 0.01 &&
Number.isFinite(height) &&
expected.some(
(ground) =>
Math.abs(height - ground - camera.mountHeightM) < 0.05,
) &&
window.__qaCounters.sampleHeight === base
);
},
{ timeoutMs: 30000, args: [heights, c0.sampleHeight] },
));
record(
'terrain-globe completion uses a real DEM height at the rendered mount without mesh samples',
Boolean(mountResolved),
`terrain=${JSON.stringify(heights)}, mesh sample Δ=${(await readCounters()).sampleHeight - c0.sampleHeight}`,
);
} else {
console.log('Waiting for tiles + the one-shot completion pass...');
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
let sampleFloorOk = false;
if (tilesSeenLoaded) {
sampleFloorOk = await waitForPredicate(
page,
(base) => window.__qaCounters.sampleHeight - base >= 1,
{ timeoutMs: 30000, args: [c0.sampleHeight] },
);
}
const cFloor = await readCounters();
record(
'>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
tilesSeenLoaded ? sampleFloorOk : null,
tilesSeenLoaded
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
: 'tilesLoaded never observed within 45s — floor not assessable this run',
);
}
// Let the completion pass finish before the steady-state window: require
// the sampleHeight counter quiet for 12s (> the 10s update interval, so
@@ -1358,18 +1503,7 @@ async function main() {
},
});
window.__qaCctvSiblingOwner = owner;
await new Promise((resolve) => {
let frames = 0;
const remove = scene.postRender.addEventListener(() => {
if (++frames < 3) {
scene.requestRender();
return;
}
remove();
resolve();
});
scene.requestRender();
});
const framesRendered = await window.__qaRenderFrames(3, 10000);
const rect = scene.canvas.getBoundingClientRect();
const picked = scene.pick(canvasPoint);
const pickedId = picked?.id?.id ?? picked?.id ?? null;
@@ -1382,26 +1516,33 @@ async function main() {
typeof pickedId === 'string' ? pickedId : (pickedId?.id ?? null),
ownerId: owner.id,
ownsExactObject: picked?.id === owner,
framesRendered,
projectionDelta: Math.hypot(
projected.x - canvasPoint.x,
projected.y - canvasPoint.y,
),
};
}, emptyClickPoint);
const siblingOwnsPick =
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
siblingTarget.ownsExactObject;
// No frame at all within the 10s cap points at a stalled renderer rather
// than at picking, though the harness can't rule out both. Fail it on the
// real-GPU sign-off path; leave it inconclusive under headless GL. A slow
// SwiftShader frame (1-2 of 3) still counts as a real miss.
const siblingRendererStalled =
siblingTarget && !siblingOwnsPick && siblingTarget.framesRendered === 0;
record(
'sibling test object owns its canvas pick',
!siblingTarget
? null
: siblingOwnsPick ||
(siblingRendererStalled && !HEADFUL ? null : false),
siblingTarget
? siblingTarget.pickedId === 'qa-cctv-sibling-owner' &&
siblingTarget.ownsExactObject
: null,
siblingTarget
? `picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
? `${siblingRendererStalled ? 'renderer stalled: ' : ''}frames=${siblingTarget.framesRendered}/3 picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
: 'no previously verified empty canvas ray was available',
);
if (
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
siblingTarget.ownsExactObject
) {
if (siblingOwnsPick) {
await page.mouse.click(siblingTarget.x, siblingTarget.y);
await sleep(250);
}
@@ -1426,8 +1567,7 @@ async function main() {
});
record(
'sibling canvas click passes through without CCTV selection or deselection',
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
siblingTarget.ownsExactObject
siblingOwnsPick
? siblingClick.activeId === clickEvidenceSetup.activeId &&
siblingClick.transitions === siblingClick.baselineTransitions &&
siblingClick.focusEvents === 0 &&
@@ -1626,10 +1766,7 @@ async function main() {
);
const viewshedShot = path.join(SHOTS_DIR, 'cctv-v3-viewshed.png');
await page.screenshot({ path: viewshedShot });
console.log(
` screenshot (visual review only) → ${path.relative(REPO_ROOT, viewshedShot)}`,
);
await saveReviewShot(page, viewshedShot, 'visual review only');
// =========================================================================
// Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag)
@@ -1660,6 +1797,34 @@ async function main() {
gizmoStates.map((s, i) => `${GIZMO_PARTS[i]}=${s}`).join(' '),
);
// Frame the actual gizmo for the drag fixture. Default autofocus can leave
// its shaft below the 10px interaction threshold; that distant view is not
// the drag-usability scenario this regression exercises.
const dragFraming = await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
const e = viewer.entities.getById('cctv-gizmo-move-east');
if (!e) return null;
const pts = e.polyline.positions.getValue(viewer.clock.currentTime);
const target = pts?.[0];
if (!target) return null;
const measure = () => {
const a = viewer.scene.cartesianToCanvasCoordinates(pts[0]);
const b = viewer.scene.cartesianToCanvasCoordinates(pts[pts.length - 1]);
return {
shaftPx: a && b ? Math.hypot(b.x - a.x, b.y - a.y) : null,
rangeM: target.constructor.distance(viewer.camera.positionWC, target),
};
};
const before = measure();
viewer.camera.cancelFlight();
viewer.camera.lookAt(target, new target.constructor(0, -200, 180));
viewer.camera.lookAtTransform(viewer.camera.transform.constructor.IDENTITY);
viewer.scene.requestRender();
const frames = await window.__qaRenderFrames(3, 10000);
return { before, after: measure(), frames };
});
console.log('Drag fixture framing:', JSON.stringify(dragFraming));
// Synthetic CDP drag on the EAST arrow — the owner's field-test
// regression. Sample effective elevation after every mouse move: it must
// stay frozen until release, with no transient sampleHeight calls.
@@ -1963,10 +2128,7 @@ async function main() {
// -----------------------------------------------------------------------
await sleep(1000);
const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png');
await page.screenshot({ path: shot });
console.log(
` screenshot (visual review only, not a gate) → ${path.relative(REPO_ROOT, shot)}`,
);
await saveReviewShot(page, shot, 'visual review only, not a gate');
// No console errors during the whole exercise.
record(
+53 -26
View File
@@ -2696,9 +2696,9 @@ async function main() {
// Staged deterministically: park the camera nadir over the synthetic
// cluster, let rotations settle, TAMPER a billboard's rotation, then
// trigger each path with the camera idle. The idle-drift catch-up
// (ROTATION_REFRESH_MS = 1 s) can coincidentally mask a regression in a
// ~300 ms SwiftShader frame window, so a regression shows as an
// intermittent (not guaranteed) failure — never a false failure.
// (ROTATION_REFRESH_MS = 1 s) must not repair a stale first-visible nose
// before we inspect it: capture that first postRender, with a 250 ms
// deadline that admits the 80 ms fleet cadence without waiting a second.
// ============================================================
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
const arrival = await evalPage(async () => {
@@ -2762,6 +2762,43 @@ async function main() {
});
await nextFrames(3);
const angDiff = (a, b) => Math.abs(Math.atan2(Math.sin(a - b), Math.cos(a - b)));
// Render frames are not fleet ticks: at 60/120 Hz four frames can
// precede the next 80 ms fleet pass. Observe elapsed time instead, and
// freeze the angle on the FIRST visible frame, even when it is wrong.
const observeFirstReveal = (bb, course, budgetMs = 250) => new Promise((resolve) => {
const startedAt = performance.now();
let frames = 0;
let settled = false;
const finish = (shown, elapsedMs) => {
if (settled) return;
settled = true;
clearTimeout(timer);
remove();
const expected = shown
? screenProjectedRotation(v.scene, bb.position, course, null)
: null;
resolve({
shown,
flipFrame: shown ? frames : -1,
revealElapsedMs: elapsedMs,
revealWithinBudget: shown && elapsedMs <= budgetMs,
dReveal: shown && Number.isFinite(expected) ? angDiff(bb.rotation, expected) : null,
});
};
const remove = v.scene.postRender.addEventListener(() => {
frames += 1;
const elapsedMs = performance.now() - startedAt;
if (bb.show) {
finish(true, elapsedMs);
} else if (elapsedMs >= budgetMs) {
finish(false, elapsedMs);
} else {
v.scene.requestRender();
}
});
const timer = setTimeout(() => finish(false, performance.now() - startedAt), budgetMs);
v.scene.requestRender();
});
const probe = async (id, course) => {
const bb = findBB(id);
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
@@ -2779,25 +2816,17 @@ async function main() {
const expectedMoveEnd = screenProjectedRotation(v.scene, bb.position, course, null);
// (b) reveal pass: tamper + hide — the next fleet tick must flip it
// visible AND correct the nose in that same tick (camera still idle,
// no moveEnd raised). Diagnostic fields (round 5): record WHICH frame
// the flip landed on (flipFrame; -1 = never within the extended
// window) and whether a 3D model owns this id — distinguishes "tick
// slow under load" from "occluder says hidden" from "model handoff".
// no moveEnd raised). Record frame count as diagnostics only: the
// first visible postRender must already have the corrected rotation.
bb.rotation = r0 + 1.5;
bb.show = false;
let flipFrame = -1;
for (let f = 1; f <= 6; f++) {
await nextFrames(1);
if (bb.show && flipFrame === -1) flipFrame = f;
if (f >= 2 && flipFrame !== -1) break;
}
const expectedReveal = screenProjectedRotation(v.scene, bb.position, course, null);
const reveal = await observeFirstReveal(bb, course);
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
return {
r0,
dMoveEnd: angDiff(afterMoveEnd, expectedMoveEnd),
dReveal: angDiff(bb.rotation, expectedReveal),
shown: bb.show, flipFrame, hasModel,
...reveal,
hasModel,
};
};
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
@@ -2808,20 +2837,18 @@ async function main() {
};
});
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? a.error
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} hasModel=${a.hasModel}`);
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${a.dReveal === null ? 'unobserved' : (a.dReveal * 180 / Math.PI).toFixed(1) + '°'} shown=${a.shown} flipFrame=${a.flipFrame} revealMs=${a.revealElapsedMs.toFixed(1)} hasModel=${a.hasModel}`);
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
// Reveal budget re-pinned 2 → 4 frames (round 5): the diagnostic probe
// shows the military flip consistently lands on frame 2 — the exact edge
// of the old budget — so any load hiccup flaked it (flights lands frame
// 1). The regression this protects against is a reveal that waits for
// the 1 s idle catch-up (~30-60 frames) or never re-aims; 4 frames still
// catches that with a wide margin, with zero-headroom flakes gone.
// 250 ms spans multiple 80 ms fleet opportunities while remaining well
// below the 1 s idle catch-up. Never retry until the angle becomes correct:
// dReveal is the immutable first-visible-frame observation above.
const arrOk = (a) => !!a && !a.error && a.shown
&& (a.flipFrame === -1 ? false : a.flipFrame <= 4)
&& a.revealWithinBudget && a.revealElapsedMs <= 250
&& a.dReveal !== null
&& a.dMoveEnd * 180 / Math.PI < 10 && a.dReveal * 180 / Math.PI < 10;
record(`arrival: moveEnd + reveal both re-aim within 4 frames, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
record(`arrival: moveEnd re-aims and first reveal is correct within 250 ms, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrOk(arrival.flights), fmtArr(arrival.flights));
record(`arrival: moveEnd + reveal both re-aim within 4 frames, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
record(`arrival: moveEnd re-aims and first reveal is correct within 250 ms, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrOk(arrival.military), fmtArr(arrival.military));
// ============================================================
+1 -1
View File
@@ -143,7 +143,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
if (url.pathname === '/sources') {
const body = {
trimmedPacks: getCctvSources.trimmedPacks?.() ?? [],
trimmedPacks: getCctvSources.trimmedPacks?.(sources) ?? [],
sources: sources.map((source) => ({
id: source.id,
pack: source.pack,
+38 -12
View File
@@ -204,16 +204,19 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
/** @type {Promise<Array<object>>|null} In-flight refresh, shared by concurrent
* callers so a post-TTL burst launches ONE refetch, not one per request. */
let _cctvSourceInflight = null;
/** @type {Array<{pack: string, available: number, served: number}>} Packs
* that serve fewer cameras than they offer. */
let _cctvTrimmedPacks = [];
/** @type {WeakMap<Array<object>, Array<{pack: string, available: number, served: number}>>}
* Packs that serve fewer cameras than they offer, keyed by the source list
* they describe, so a request served the old list during a background
* refresh never pairs it with the new list's trim notes. */
const _cctvTrimmedPacks = new WeakMap();
/**
* Assemble and cache the merged CCTV source list.
*
* Merges every source pack (live open-data packs, local file, env
* variable), deduplicates by ID, shares the catalog cap fairly across
* packs, and caches for CCTV_SOURCE_CACHE_MS.
* packs, and caches for CCTV_SOURCE_CACHE_MS. Past that, the cached list is
* returned while a background refresh replaces it; only a first load waits.
*
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
*/
@@ -228,11 +231,31 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
// Single-flight: a burst of requests arriving past the TTL shares ONE refresh
// instead of each launching the full multi-provider refetch. The `.finally`
// clears the ref so the next post-TTL cycle starts fresh.
if (_cctvSourceInflight) return _cctvSourceInflight;
_cctvSourceInflight = refreshCctvSources().finally(() => {
_cctvSourceInflight = null;
});
return _cctvSourceInflight;
if (!_cctvSourceInflight) {
_cctvSourceInflight = refreshCctvSources().finally(() => {
_cctvSourceInflight = null;
});
// A background refresh has no caller waiting on it: report its failure
// here, once per refresh rather than once per request it serves. Keep
// the old catalog for another TTL, as an empty refresh does, so a refresh
// that keeps failing is retried once per TTL, not back to back.
if (_cctvSourceCache.length) {
_cctvSourceInflight.catch((error) => {
_cctvSourceCacheAt = Date.now();
console.warn(
'[CCTV] background catalog refresh failed; serving',
_cctvSourceCache.length,
'cameras from the last catalog:',
error?.message || error,
);
});
}
}
// With a catalog in hand, answer with it while the refresh runs: the
// refresh lasts as long as the slowest pack (Tarktee's DATEX feeds take up
// to its 30 s timeout), and every /api/cctv request would otherwise wait
// for it. Only the first load, with nothing to serve yet, waits.
return _cctvSourceCache.length ? _cctvSourceCache : _cctvSourceInflight;
}
/**
@@ -322,7 +345,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
}
if (capped.length > 0 || _cctvSourceCache.length === 0) {
_cctvSourceCache = capped;
_cctvTrimmedPacks = trimmedPacks;
_cctvTrimmedPacks.set(capped, trimmedPacks);
} else {
// Every source came back empty (all live packs timed out / upstream outage)
// but a good catalog is already cached — serve it stale rather than blanking
@@ -336,7 +359,10 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
return _cctvSourceCache;
}
/** Packs serving fewer cameras than they offer, as of the last refresh. */
getCctvSources.trimmedPacks = () => _cctvTrimmedPacks;
/** Packs serving fewer cameras than they offer in `sources` (by default the
* current catalog). Pass the list a request was served to keep the two in
* step while a background refresh swaps the catalog. */
getCctvSources.trimmedPacks = (sources = _cctvSourceCache) =>
_cctvTrimmedPacks.get(sources) ?? [];
return getCctvSources;
}
+50 -1
View File
@@ -140,7 +140,7 @@ export const TFL_IMAGE_ORIGIN =
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
export const DEFAULT_TFL_MAX_SOURCES = 250;
export const LONDON_CENTER = { lat: 51.5074, lon: -0.1278 };
/** Ontario 511: keyless CARS/511 camera catalog; frame URLs are still images. */
/** Ontario 511: CARS/511 camera catalog, which needs a developer key (ONTARIO_511_API_KEY); frame URLs are still images. */
export const ONTARIO_511_CAMERAS_URL =
'https://511on.ca/api/v2/get/cameras?format=json&lang=en';
export const ONTARIO_511_IMAGE_ORIGIN = 'https://511on.ca/map/Cctv/';
@@ -209,6 +209,8 @@ export const TARKTEE_IMAGES_URL =
'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages';
export const TARKTEE_IMAGE_ORIGIN = 'https://tarktee.transpordiamet.ee/images/';
export const DEFAULT_TARKTEE_MAX_SOURCES = 179;
/** Hard ceiling per Tarktee DATEX XML document (~120–165 KB live). */
export const TARKTEE_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
export const TARKTEE_ANCHORS = [
{ lat: 59.437, lon: 24.753 }, // Tallinn
{ lat: 58.378, lon: 26.729 }, // Tartu
@@ -338,6 +340,49 @@ export const NORWAY_ANCHORS = [
{ lat: 69.6492, lon: 18.9553 }, // Tromsø
];
/** Kartverket's keyless point-height API (CC BY 4.0): terrain height above
* sea level (NN2000), used as the Vegvesen cameras' ground prior because the
* camera feed's points are 2D. */
export const KARTVERKET_HEIGHT_URL =
'https://ws.geonorge.no/hoydedata/v1/punkt';
/** Points per request. The API takes up to 50, but above ~20 it answers the
* extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not
* at all; at 20 nearly every camera resolves from the 1 m terrain model. */
export const KARTVERKET_MAX_POINTS = 20;
/** Requests in flight at once. The whole country is ~36 requests of
* 0.6-9.5 s each, so a cold lookup takes ~6-13 s. */
export const KARTVERKET_CONCURRENCY = 8;
/** Deadline for one complete lookup, shared by every request. A lookup cut
* short by it is resumed on the next refresh, without a backoff. */
export const KARTVERKET_LOOKUP_TIMEOUT_MS = 30 * 1000;
/** One request that takes longer than this counts as a failing service. */
export const KARTVERKET_REQUEST_TIMEOUT_MS = 10 * 1000;
/** The Vegvesen lane's budget, measured from the start of the loader (catalog
* fetch included), for waiting on heights. Under the 15 s per-provider fetch
* timeout so this pack is never the catalog's slowest lane. A lookup still
* running after it keeps filling the cache for the next refresh. */
export const KARTVERKET_CATALOG_WAIT_MS = 12 * 1000;
/** The lane only waits when more than this share of its cameras has no
* answer yet (a cold cache). A few new cameras or expired misses are looked
* up in the background instead of holding the refresh. */
export const KARTVERKET_WAIT_MISSING_SHARE = 0.1;
/** A point Kartverket has no height for (some ferry quays and bridges) is not
* asked about again for this long. */
export const KARTVERKET_NULL_TTL_MS = 6 * 60 * 60 * 1000;
/** After a failing request (408, 429, 5xx, a network error, a request
* timeout or an unreadable body), no lookup starts for this long, or for the
* Retry-After the service sent, bounded to [1 min, 1 h]. Longer than the
* 15-minute catalog refresh (CCTV_SOURCE_CACHE_MS), so a failing service
* costs one attempt per backoff, not one per refresh. */
export const KARTVERKET_FAILURE_BACKOFF_MS = 30 * 60 * 1000;
export const KARTVERKET_MAX_BACKOFF_MS = 60 * 60 * 1000;
export const KARTVERKET_MAX_RESPONSE_BYTES = 256 * 1024;
/** Plausible Norwegian terrain: below sea level only at the shore, and no road
* camera sits above ~1,500 m. Anything outside is treated as no height. */
export const KARTVERKET_HEIGHT_RANGE_M = Object.freeze({ min: -20, max: 2500 });
/** Same identity the other CCTV upstream requests send. */
export const KARTVERKET_USER_AGENT = 'gods-eye-view-cctv-proxy/1.0';
/** NYC DOT Traffic Management Center cameras: one keyless JSON list for the
* five boroughs (~980 cameras). Frames are stills synthesized from each
* camera UUID on the city's webcam host — never taken from the payload. */
@@ -460,6 +505,10 @@ export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
* pending forever — a hung fetch aborts, the loader returns [], and
* serve-stale/other packs take over. */
export const CCTV_SOURCE_FETCH_TIMEOUT_MS = 15 * 1000;
/** Tarktee's two DATEX feeds (120 and 160 KB) answered in 2 to 16.6 s across
* ten requests on 2026-10-18, so the shared 15 s timeout dropped the Estonian
* pack on about a third of loads. Both must arrive for any camera to load. */
export const TARKTEE_FETCH_TIMEOUT_MS = 30 * 1000;
/** Individual CCTV image fetches must settle before the active 10-second
* client refresh cadence. A bounded miss can fall through to Street View or
* the synthetic frame instead of leaving the browser preview pending. */
+169
View File
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
_resetOntarioKeyWarningForTest,
loadOntarioSourcesFromOpenData,
} from './sources.js';
const ROW = {
Id: '42',
Latitude: 43.6532,
Longitude: -79.3832,
Location: 'Hwy 401 near Yonge St',
Direction: 'Eastbound',
Views: [
{
Status: 'Enabled',
Url: 'https://511on.ca/map/Cctv/4201',
Description: 'Eastbound',
},
],
};
function captureConsole(t) {
const lines = [];
const capture = (...args) => lines.push(args.join(' '));
t.mock.method(console, 'warn', capture);
t.mock.method(console, 'log', capture);
return lines;
}
test.beforeEach(() => _resetOntarioKeyWarningForTest());
test('without ONTARIO_511_API_KEY the Ontario pack is skipped, logged once', async (t) => {
const lines = captureConsole(t);
let fetches = 0;
t.mock.method(globalThis, 'fetch', async () => {
fetches += 1;
return Response.json([ROW]);
});
assert.deepEqual(await loadOntarioSourcesFromOpenData({}, {}), []);
assert.deepEqual(
await loadOntarioSourcesFromOpenData({}, { ONTARIO_511_API_KEY: ' ' }),
[],
);
assert.equal(fetches, 0);
assert.equal(lines.length, 1);
assert.match(lines[0], /ONTARIO_511_API_KEY/);
});
test('with a key the camera list is requested with it and parsed', async (t) => {
const lines = captureConsole(t);
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json([ROW]);
});
const cameras = await loadOntarioSourcesFromOpenData(
{ sourceRoot: '/ignored' },
{ ONTARIO_511_API_KEY: ' test-key ' },
);
assert.equal(requested.length, 1);
const url = new URL(requested[0]);
assert.equal(url.host, '511on.ca');
assert.equal(url.searchParams.get('key'), 'test-key');
assert.equal(url.searchParams.get('format'), 'json');
assert.equal(cameras.length, 1);
assert.equal(cameras[0].id, 'on-42');
assert.equal(cameras[0].url, 'https://511on.ca/map/Cctv/4201');
// The records go to the browser through /api/cctv; the key must stay on
// the server-side catalog request.
assert.doesNotMatch(JSON.stringify(cameras), /test-key/);
assert.doesNotMatch(lines.join('\n'), /test-key/);
});
test('the source cap is read from the same environment as the key', async (t) => {
captureConsole(t);
const rows = Array.from({ length: 12 }, (_, i) => ({
...ROW,
Id: String(100 + i),
Latitude: ROW.Latitude + i * 0.01,
Views: [{ ...ROW.Views[0], Url: `https://511on.ca/map/Cctv/${100 + i}` }],
}));
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
const cameras = await loadOntarioSourcesFromOpenData(
{},
{ ONTARIO_511_API_KEY: 'test-key', CCTV_ONTARIO_MAX_SOURCES: '8' },
);
assert.equal(cameras.length, 8);
});
for (const status of [400, 401, 403]) {
test(`a ${status} points at the key by name, never by value`, async (t) => {
const lines = captureConsole(t);
t.mock.method(globalThis, 'fetch', async () =>
Response.json({ Message: 'Invalid Key' }, { status }),
);
const cameras = await loadOntarioSourcesFromOpenData(
{},
{ ONTARIO_511_API_KEY: 'secret-key-value' },
);
assert.deepEqual(cameras, []);
assert.match(
lines.join('\n'),
new RegExp(`failed: ${status} \\(check ONTARIO_511_API_KEY\\)`),
);
assert.doesNotMatch(lines.join('\n'), /secret-key-value/);
});
}
for (const status of [429, 500, 503]) {
test(`a ${status} is reported without blaming the key`, async (t) => {
const lines = captureConsole(t);
t.mock.method(globalThis, 'fetch', async () =>
Response.json({}, { status }),
);
const cameras = await loadOntarioSourcesFromOpenData(
{},
{ ONTARIO_511_API_KEY: 'secret-key-value' },
);
assert.deepEqual(cameras, []);
assert.equal(lines.length, 1);
assert.equal(
lines[0],
`[CCTV] Ontario 511 camera download failed: ${status}`,
);
assert.doesNotMatch(lines[0], /secret-key-value/);
});
}
for (const failure of ['transport', 'invalid-json']) {
test(`Ontario ${failure} failures cannot log reflected keys or request URLs`, async (t) => {
const lines = captureConsole(t);
const key = 'synthetic secret&/=%value';
t.mock.method(globalThis, 'fetch', async (url) => {
if (failure === 'transport')
throw new Error(`request failed: ${url} ${key}`);
return new Response(`not JSON: ${url} ${key}`);
});
assert.deepEqual(
await loadOntarioSourcesFromOpenData({}, { ONTARIO_511_API_KEY: key }),
[],
);
assert.equal(lines.length, 1);
assert.match(lines[0], /Ontario 511 camera download error/);
assert.equal(lines[0].includes(key), false);
assert.equal(lines[0].includes('synthetic'), false);
assert.equal(lines[0].includes('https://'), false);
});
}
test('Ontario encodes reserved key characters and refuses credential-bearing redirects', async (t) => {
const lines = captureConsole(t);
const key = 'synthetic &/=% value';
let request;
t.mock.method(globalThis, 'fetch', async (url, init) => {
request = { url: new URL(url), init };
return Response.json([ROW]);
});
const cameras = await loadOntarioSourcesFromOpenData(
{},
{ ONTARIO_511_API_KEY: key },
);
assert.equal(request.url.searchParams.get('key'), key);
assert.equal(request.init.redirect, 'error');
assert.equal(cameras.length, 1);
assert.equal(JSON.stringify(cameras).includes('synthetic'), false);
assert.equal(lines.join(' ').includes('synthetic'), false);
});
+433 -37
View File
@@ -42,6 +42,8 @@ import {
TARKTEE_IMAGES_URL,
TARKTEE_IMAGE_ORIGIN,
DEFAULT_TARKTEE_MAX_SOURCES,
TARKTEE_MAX_CATALOG_BYTES,
TARKTEE_FETCH_TIMEOUT_MS,
TARKTEE_ANCHORS,
DEFAULT_WARENDORF_SOURCE_FILE,
WARENDORF_IMAGE_ORIGINS,
@@ -95,6 +97,19 @@ import {
DEFAULT_VEGVESEN_MAX_SOURCES,
VEGVESEN_MAX_CATALOG_BYTES,
NORWAY_ANCHORS,
KARTVERKET_HEIGHT_URL,
KARTVERKET_MAX_POINTS,
KARTVERKET_CONCURRENCY,
KARTVERKET_LOOKUP_TIMEOUT_MS,
KARTVERKET_REQUEST_TIMEOUT_MS,
KARTVERKET_WAIT_MISSING_SHARE,
KARTVERKET_CATALOG_WAIT_MS,
KARTVERKET_NULL_TTL_MS,
KARTVERKET_FAILURE_BACKOFF_MS,
KARTVERKET_MAX_BACKOFF_MS,
KARTVERKET_USER_AGENT,
KARTVERKET_MAX_RESPONSE_BYTES,
KARTVERKET_HEIGHT_RANGE_M,
NYC_CAMERAS_URL,
NYC_IMAGE_URL,
NYC_CAMERA_ID_RE,
@@ -509,22 +524,57 @@ function pickOntarioCctvView(views) {
);
}
let ontarioKeyMissingLogged = false;
/** Test seam: let the next keyless call log the missing-key line again. */
export function _resetOntarioKeyWarningForTest() {
ontarioKeyMissingLogged = false;
}
/**
* Fetch Ontario 511 CCTV cameras. Keyless: the catalog is exposed by the
* public 511 API, while frame URLs are stable still-image endpoints under
* 511on.ca/map/Cctv/. Only rows with finite Ontario coords and at least one
* enabled official still view are kept.
* Fetch Ontario 511 CCTV cameras. The camera list comes from the 511 API,
* which now requires a free developer key (511on.ca/developers/doc, "Requires a
* developer key"; unkeyed calls answer 400 "Invalid Key"), read from
* ONTARIO_511_API_KEY. Without one the pack is skipped with a single log line.
* Frame URLs are stable still-image endpoints under 511on.ca/map/Cctv/. Only
* rows with finite Ontario coords and at least one enabled official still
* view are kept.
*
* @param {object} [_options] The catalog's loader options (unused).
* @param {object} [env] Environment to read the key and the source cap from.
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadOntarioSourcesFromOpenData() {
export async function loadOntarioSourcesFromOpenData(
_options = {},
env = process.env,
) {
const key = String(env.ONTARIO_511_API_KEY ?? '').trim();
if (!key) {
if (!ontarioKeyMissingLogged) {
ontarioKeyMissingLogged = true;
console.warn(
'[CCTV] Ontario 511 cameras skipped: the 511on.ca API needs a free developer key (set ONTARIO_511_API_KEY)',
);
}
return [];
}
try {
const resp = await fetch(ONTARIO_511_CAMERAS_URL, {
const url = new URL(ONTARIO_511_CAMERAS_URL);
url.searchParams.set('key', key);
const resp = await fetch(url, {
headers: { Accept: 'application/json' },
redirect: 'error',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
if (!resp.ok) {
console.warn('[CCTV] Ontario 511 camera download failed:', resp.status);
// The API answers 400 "Invalid Key" to a wrong or revoked key.
const keyHint =
resp.status === 400 || resp.status === 401 || resp.status === 403
? ' (check ONTARIO_511_API_KEY)'
: '';
console.warn(
`[CCTV] Ontario 511 camera download failed: ${resp.status}${keyHint}`,
);
return [];
}
const rows = await resp.json();
@@ -587,7 +637,7 @@ export async function loadOntarioSourcesFromOpenData() {
new Map(cameras.map((camera) => [camera.id, camera])).values(),
);
const maxRaw = Number(
process.env.CCTV_ONTARIO_MAX_SOURCES || DEFAULT_ONTARIO_MAX_SOURCES,
env.CCTV_ONTARIO_MAX_SOURCES || DEFAULT_ONTARIO_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(1000, Math.floor(maxRaw)))
@@ -597,10 +647,11 @@ export async function loadOntarioSourcesFromOpenData() {
`[CCTV] Loaded Ontario 511 camera sources: ${unique.length} enabled (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
} catch {
// Transport and JSON errors may reflect the credential-bearing request.
// Do not log upstream exception text, response bodies or URLs.
console.warn(
'[CCTV] Ontario 511 camera download error:',
error?.message || error,
'[CCTV] Ontario 511 camera download error (network or invalid response)',
);
return [];
}
@@ -1197,28 +1248,49 @@ export function parseTarkteeDatexImages(xml) {
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadTarkteeSourcesFromDatex() {
// Own both transports before either can reject. A response that arrives
// after cleanup must also be discarded instead of escaping Promise.all.
const abort = new AbortController();
const signal = AbortSignal.any([
abort.signal,
AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
]);
const responses = [];
const discardBoth = async () => {
abort.abort();
await Promise.allSettled(
responses.map((response) => response.body?.cancel()),
);
return [];
};
const fetchXml = async (url) => {
const response = await fetch(url, {
headers: { Accept: 'application/xml,text/xml,*/*' },
redirect: 'manual',
signal,
});
if (signal.aborted) {
await Promise.allSettled([response.body?.cancel()]);
signal.throwIfAborted();
}
responses.push(response);
if (!response.ok) {
throw new Error(
response.status >= 300 && response.status < 400
? 'catalog redirected; redirects are not followed'
: `catalog HTTP ${response.status}`,
);
}
return response;
};
try {
const [locResp, imgResp] = await Promise.all([
fetch(TARKTEE_LOCATIONS_URL, {
headers: { Accept: 'application/xml,text/xml,*/*' },
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
}),
fetch(TARKTEE_IMAGES_URL, {
headers: { Accept: 'application/xml,text/xml,*/*' },
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
}),
fetchXml(TARKTEE_LOCATIONS_URL),
fetchXml(TARKTEE_IMAGES_URL),
]);
if (!locResp.ok) {
console.warn('[CCTV] Tarktee locations download failed:', locResp.status);
return [];
}
if (!imgResp.ok) {
console.warn('[CCTV] Tarktee images download failed:', imgResp.status);
return [];
}
const [locXml, imgXml] = await Promise.all([
locResp.text(),
imgResp.text(),
readResponseTextCapped(locResp, TARKTEE_MAX_CATALOG_BYTES, signal),
readResponseTextCapped(imgResp, TARKTEE_MAX_CATALOG_BYTES, signal),
]);
const locations = parseTarkteeDatexLocations(locXml);
const images = parseTarkteeDatexImages(imgXml);
@@ -1231,6 +1303,7 @@ export async function loadTarkteeSourcesFromDatex() {
}
const cameras = [];
const seen = new Set();
for (const [locationId, loc] of locations.entries()) {
const imageUrl = images.get(locationId);
if (!imageUrl) continue;
@@ -1242,6 +1315,8 @@ export async function loadTarkteeSourcesFromDatex() {
const cameraId = numMatch
? `ee-tarktee-${numMatch[1]}`
: `ee-tarktee-${locationId}`;
if (seen.has(cameraId)) continue;
seen.add(cameraId);
cameras.push({
id: cameraId,
name: loc.name,
@@ -1277,6 +1352,7 @@ export async function loadTarkteeSourcesFromDatex() {
);
return prioritized;
} catch (error) {
await discardBoth();
console.warn(
'[CCTV] Tarktee DATEX download error:',
error?.message || error,
@@ -2393,14 +2469,11 @@ export function vegvesenCameraToSource(feature) {
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
// KNOWN LIMITATION: the feed's coordinates are 2D, so this is one flat
// prior for the whole country, while cameras run from sea level to
// mountain passes near 1,000 m (Haukelifjell, Sjonfjellet, Rugeldalen).
// The point-height prior and the client's one-shot ground snap correct it
// where they resolve; on a stack where neither does (no 3D tiles), a pass
// camera stays hundreds of metres below the terrain, the same risk the
// Caltrans pack documents. A per-camera height (e.g. Kartverket's keyless
// point-height API) would remove it.
// The feed's coordinates are 2D. The loader replaces this flat prior with
// Kartverket's terrain height for each camera (applyVegvesenGroundHeights);
// it only survives when that lookup fails, and then a mountain-pass camera
// can sit below the terrain on a stack without 3D tiles, the same risk the
// Caltrans pack documents.
groundElevationM: 150,
feedType: videoUrl ? 'hls' : 'image',
url: videoUrl || imageUrl,
@@ -2420,6 +2493,7 @@ export function vegvesenCameraToSource(feature) {
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadVegvesenSourcesFromOpenData() {
const startedAt = Date.now();
try {
const endpoint = process.env.CCTV_VEGVESEN_URL || DEFAULT_VEGVESEN_CCTV_URL;
const resp = await fetch(endpoint, {
@@ -2465,6 +2539,12 @@ export async function loadVegvesenSourcesFromOpenData() {
? Math.max(8, Math.min(1000, Math.floor(maxRaw)))
: DEFAULT_VEGVESEN_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, NORWAY_ANCHORS);
await applyVegvesenGroundHeights(prioritized, {
waitMs: Math.max(
0,
KARTVERKET_CATALOG_WAIT_MS - (Date.now() - startedAt),
),
});
console.log(
`[CCTV] Loaded Vegvesen camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
@@ -2478,6 +2558,322 @@ export async function loadVegvesenSourcesFromOpenData() {
}
}
/**
* Kartverket answers by rounded position: `{z, expires}`, where z is metres or
* null for "no height there". Heights never expire (camera positions barely
* change); nulls expire after KARTVERKET_NULL_TTL_MS. The 15-minute catalog
* refresh therefore only asks about new or moved cameras. Bounded well above
* the ~900 cameras the pack can load.
*/
const kartverketHeightCache = new Map();
const KARTVERKET_CACHE_LIMIT = 5000;
/** The lookup in flight, so overlapping catalog loads never start a second. */
let kartverketPending = null;
/** No lookup starts before this time (epoch ms) after a transport failure. */
let kartverketBackoffUntil = 0;
/** Bumped by clearKartverketHeightCache, so a lookup started before a clear
* can no longer write to the cache, the backoff or the pending slot. */
let kartverketGeneration = 0;
/** Cache key for one position, at the ~0.1 m precision the request sends. */
const kartverketKey = (lon, lat) => `${lon.toFixed(6)},${lat.toFixed(6)}`;
/** Statuses that say the service, not the request, is failing. */
const isKartverketTransportStatus = (status) =>
status === 408 || status === 429 || status >= 500;
/** Forget cached heights, the backoff and any lookup in flight. Tests only. */
export function clearKartverketHeightCache() {
kartverketGeneration += 1;
kartverketHeightCache.clear();
kartverketPending = null;
kartverketBackoffUntil = 0;
}
/**
* How long to back off after a failed request: the Retry-After the service
* sent (seconds or an HTTP date), else the default, bounded to [1 min, 1 h].
*
* @param {?string} retryAfter - The Retry-After header, if any.
* @param {number} now - Epoch ms.
* @returns {number} Milliseconds.
*/
export function kartverketBackoffMs(retryAfter, now = Date.now()) {
let ms = KARTVERKET_FAILURE_BACKOFF_MS;
const text = String(retryAfter ?? '').trim();
if (/^\d+$/.test(text)) ms = Number(text) * 1000;
else if (text && Number.isFinite(Date.parse(text))) {
ms = Date.parse(text) - now;
}
return Math.max(60 * 1000, Math.min(KARTVERKET_MAX_BACKOFF_MS, ms));
}
/**
* Terrain heights above sea level (NN2000) from Kartverket's keyless
* point-height API, KARTVERKET_MAX_POINTS points per request. Each answer is
* matched back by the coordinates the API echoes, and only for points that
* request asked about, then reported through `into.set(key, z)` as its
* request lands, so a caller that stops waiting keeps what has arrived. `z` is
* metres, or null when the API has no usable height there. A point a 200
* answer leaves out is reported as null too, so it is not asked again on
* every refresh.
*
* Over water `/punkt` returns the depth of the sea floor (`datakilde:
* dybdekurver`, `terreng: Havflate`; −547 m under Nordhordlandsbrua). Either
* marker counts as 0 m, the water surface. For the ~14 bridge cameras that is
* below the deck, but far closer than the sea floor or a flat prior.
*
* The first transport failure (a 408, 429 or 5xx, a network error, a request
* past KARTVERKET_REQUEST_TIMEOUT_MS, an unreadable or oversized body, or the
* shared deadline) stops the lookup: no further requests start, and the
* failure is returned so the caller can back off. Points in failed requests
* are not reported, so they are asked again later. Any other 4xx rejects that
* request's points, not the service: they are reported as null and the lookup
* carries on.
*
* @param {Array<{lon:number, lat:number}>} points
* @param {{into?: {set: Function}, timeoutMs?: number, requestTimeoutMs?: number}} [options]
* @returns {Promise<{heights: Map<string, ?number>, failure: ?{status: ?number, retryAfter: ?string, deadline: boolean}}>}
* `heights` is `into` when it is a Map, else a Map of what was reported.
* `failure.deadline` is true when only the shared deadline ran out.
*/
export async function fetchKartverketHeights(
points,
{
into = new Map(),
timeoutMs = KARTVERKET_LOOKUP_TIMEOUT_MS,
requestTimeoutMs = KARTVERKET_REQUEST_TIMEOUT_MS,
} = {},
) {
const heights = into instanceof Map ? into : new Map();
const report = (key, z) => {
if (heights !== into) heights.set(key, z);
into.set(key, z);
};
const unique = Array.from(
new Map(
(points || [])
.filter((p) => isLikelyNorwayCoordinate(p?.lat, p?.lon))
.map((p) => [kartverketKey(p.lon, p.lat), p]),
).values(),
);
let failure = null;
if (!unique.length) return { heights, failure };
const batches = [];
for (let i = 0; i < unique.length; i += KARTVERKET_MAX_POINTS) {
batches.push(unique.slice(i, i + KARTVERKET_MAX_POINTS));
}
const deadline = AbortSignal.timeout(timeoutMs);
const fetchBatch = async (batch) => {
const asked = new Set(batch.map((p) => kartverketKey(p.lon, p.lat)));
const url = new URL(KARTVERKET_HEIGHT_URL);
url.searchParams.set(
'punkter',
JSON.stringify(
batch.map((p) => [Number(p.lon.toFixed(6)), Number(p.lat.toFixed(6))]),
),
);
url.searchParams.set('koordsys', '4258');
const resp = await fetch(url, {
headers: {
Accept: 'application/json',
'User-Agent': KARTVERKET_USER_AGENT,
},
redirect: 'error',
signal: AbortSignal.any([
deadline,
AbortSignal.timeout(requestTimeoutMs),
]),
});
if (!resp.ok) {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
if (!isKartverketTransportStatus(resp.status)) {
console.warn(
`[CCTV] Kartverket rejected ${asked.size} points: HTTP ${resp.status}`,
);
for (const key of asked) report(key, null);
return;
}
const error = new Error(`HTTP ${resp.status}`);
error.status = resp.status;
error.retryAfter = resp.headers.get('retry-after');
throw error;
}
const payload = await readResponseJsonCapped(
resp,
KARTVERKET_MAX_RESPONSE_BYTES,
);
for (const point of Array.isArray(payload?.punkter)
? payload.punkter
: []) {
const x = toFiniteNumber(point?.x);
const y = toFiniteNumber(point?.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
const key = kartverketKey(x, y);
// Only the points this request asked about: an odd answer can neither
// grow the cache nor overwrite another camera.
if (!asked.has(key)) continue;
asked.delete(key);
// `z: null` means no height there; Number(null) would read 0.
const raw = point?.z;
let z =
raw === null || raw === undefined || raw === ''
? NaN
: toFiniteNumber(raw);
if (
Number.isFinite(z) &&
(point?.datakilde === 'dybdekurver' || point?.terreng === 'Havflate')
) {
z = 0;
}
const usable =
Number.isFinite(z) &&
z >= KARTVERKET_HEIGHT_RANGE_M.min &&
z <= KARTVERKET_HEIGHT_RANGE_M.max;
report(key, usable ? z : null);
}
// A 200 that leaves a point out has no height for it either.
for (const key of asked) report(key, null);
};
let next = 0;
const worker = async () => {
while (next < batches.length && !failure) {
const batch = batches[next++];
try {
await fetchBatch(batch);
} catch (error) {
if (!failure) {
failure = {
status: Number.isFinite(error?.status) ? error.status : null,
retryAfter: error?.retryAfter ?? null,
deadline: deadline.aborted,
};
console.warn(
'[CCTV] Kartverket height lookup failed:',
error?.message || error,
);
}
}
}
};
await Promise.all(
Array.from(
{ length: Math.min(KARTVERKET_CONCURRENCY, batches.length) },
worker,
),
);
return { heights, failure };
}
/**
* Give each Vegvesen camera its own terrain height as the ground prior, in
* place. Waits at most `waitMs` for positions it has no answer for, and only
* when more than KARTVERKET_WAIT_MISSING_SHARE of the cameras are missing
* (a cold cache); a few new cameras or expired misses are looked up in the
* background. A lookup still running when the wait ends keeps filling the
* cache for the next catalog refresh. Cameras without a height keep the flat
* prior. After a transport failure no lookup starts until the backoff ends,
* which outlasts the 15-minute catalog refresh, so a failing Kartverket costs
* one attempt per backoff, not one per refresh. A lookup cut short only by its
* shared deadline is resumed on the next refresh without a backoff. Cameras
* without finite coordinates are skipped. Never throws.
* CCTV_VEGVESEN_HEIGHTS=0 turns it off.
*
* @param {Array<object>} cameras - Sources from vegvesenCameraToSource().
* @param {{waitMs?: number, now?: () => number, timeoutMs?: number}} [options]
* `timeoutMs` is the lookup's shared deadline.
* @returns {Promise<number>} How many cameras got a terrain height.
*/
export async function applyVegvesenGroundHeights(
cameras,
{
waitMs = KARTVERKET_CATALOG_WAIT_MS,
now = Date.now,
timeoutMs = KARTVERKET_LOOKUP_TIMEOUT_MS,
} = {},
) {
if (String(process.env.CCTV_VEGVESEN_HEIGHTS || '1').trim() === '0') return 0;
const list = (Array.isArray(cameras) ? cameras : []).filter(
(c) => Number.isFinite(c?.lon) && Number.isFinite(c?.lat),
);
const startedAt = now();
const missing = list.filter((c) => {
const entry = kartverketHeightCache.get(kartverketKey(c.lon, c.lat));
return !entry || entry.expires <= startedAt;
});
if (
missing.length &&
!kartverketPending &&
startedAt >= kartverketBackoffUntil
) {
if (kartverketHeightCache.size + missing.length > KARTVERKET_CACHE_LIMIT) {
kartverketHeightCache.clear();
}
const generation = kartverketGeneration;
const current = () => generation === kartverketGeneration;
const sink = {
set: (key, z) => {
if (!current()) return;
kartverketHeightCache.set(key, {
z,
expires: z === null ? now() + KARTVERKET_NULL_TTL_MS : Infinity,
});
},
};
const pending = fetchKartverketHeights(missing, { into: sink, timeoutMs })
.then(({ failure }) => {
if (failure && !failure.deadline && current()) {
kartverketBackoffUntil =
now() + kartverketBackoffMs(failure.retryAfter, now());
}
})
.catch((error) => {
if (current()) {
kartverketBackoffUntil = now() + KARTVERKET_FAILURE_BACKOFF_MS;
}
console.warn(
'[CCTV] Kartverket height lookup error:',
error?.message || error,
);
})
.finally(() => {
if (kartverketPending === pending) kartverketPending = null;
});
kartverketPending = pending;
}
const cold = missing.length > list.length * KARTVERKET_WAIT_MISSING_SHARE;
if (cold && kartverketPending && waitMs > 0) {
let timer;
await Promise.race([
kartverketPending,
new Promise((resolve) => {
timer = setTimeout(resolve, waitMs);
timer.unref?.();
}),
]);
clearTimeout(timer);
}
let applied = 0;
for (const camera of list) {
const z = kartverketHeightCache.get(
kartverketKey(camera.lon, camera.lat),
)?.z;
if (!Number.isFinite(z)) continue;
camera.groundElevationM = Math.round(z * 10) / 10;
applied += 1;
}
if (list.length) {
console.log(
`[CCTV] Kartverket ground heights: ${applied}/${list.length} Vegvesen cameras`,
);
}
return applied;
}
const NYC_BOROUGHS = new Set([
'Manhattan',
'Brooklyn',
+222
View File
@@ -6,6 +6,7 @@ import {
} from '../../server/providers/cctv/cap.js';
import {
CCTV_MAX_SOURCES_CEILING,
CCTV_SOURCE_CACHE_MS,
DEFAULT_CCTV_MAX_SOURCES,
} from '../../server/providers/cctv/constants.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
@@ -160,3 +161,224 @@ test('a pack trimmed to its nearest cameras reports how many it offered', async
assert.equal(kept.available, 5);
assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 });
});
/** A CCTV_SOURCES_JSON value with `count` cameras named `<prefix>-<i>`. */
const envSources = (prefix, count = 3) =>
JSON.stringify(
Array.from({ length: count }, (_, i) => ({
id: `${prefix}-${i}`,
name: `${prefix} ${i}`,
lat: 30 + i * 0.01,
lon: -97,
url: `https://example.test/${prefix}-${i}.jpg`,
})),
);
/** Let a background refresh that needs no timers run to completion. */
const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
/** Restore process.env to a snapshot taken with `{ ...process.env }`. */
const restoreEnv = (saved) => {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
};
test('an expired catalog is served at once while a slow pack refreshes in the background', async (t) => {
const saved = { ...process.env };
try {
// Live packs run alongside the env pack, so one slow upstream can hold
// the refresh open.
process.env.CCTV_FORCE_AUSTIN = '1';
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
process.env.CCTV_SOURCES_JSON = envSources('old');
let now = 1_000_000;
t.mock.method(Date, 'now', () => now);
t.mock.method(console, 'warn', () => {});
t.mock.method(console, 'log', () => {});
const hung = [];
let hang = false;
t.mock.method(globalThis, 'fetch', () =>
hang
? new Promise((_, reject) => {
hung.push(() => reject(new Error('upstream gave up')));
})
: Promise.reject(new Error('offline')),
);
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
assert.deepEqual(
(await getSources()).map((s) => s.id),
['old-0', 'old-1', 'old-2'],
);
// Past the TTL, with every live pack's upstream hanging.
now += CCTV_SOURCE_CACHE_MS + 1;
hang = true;
process.env.CCTV_SOURCES_JSON = envSources('new');
let timer;
const served = await Promise.race([
getSources(),
new Promise((resolve) => {
timer = setTimeout(() => resolve('waited'), 200);
}),
]);
clearTimeout(timer);
assert.notEqual(
served,
'waited',
'a stale request waited for the background refresh',
);
assert.deepEqual(
served.map((s) => s.id),
['old-0', 'old-1', 'old-2'],
);
// Requests during the refresh share it rather than starting another.
const pause = () => new Promise((resolve) => setTimeout(resolve, 50));
await pause();
const inFlight = hung.length;
assert.ok(inFlight > 0, 'the refresh reached the hanging upstreams');
await getSources();
await getSources();
await pause();
assert.equal(hung.length, inFlight, 'only one refresh ran');
// When the slow pack gives up, the refreshed catalog replaces the old one.
hang = false;
for (const giveUp of hung) giveUp();
for (let i = 0; i < 50; i += 1) {
const ids = (await getSources()).map((s) => s.id);
if (ids[0] === 'new-0') break;
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(
(await getSources()).map((s) => s.id),
['new-0', 'new-1', 'new-2'],
);
} finally {
restoreEnv(saved);
}
});
test('a failed background refresh keeps the catalog, warns once, and waits a TTL to retry', async (t) => {
const saved = { ...process.env };
const unhandled = [];
const onUnhandled = (reason) => unhandled.push(reason);
process.on('unhandledRejection', onUnhandled);
try {
process.env.CCTV_PREFER_AUSTIN = '0';
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
process.env.CCTV_SOURCES_JSON = envSources('old', 9);
process.env.CCTV_MAX_SOURCES = '8';
let now = 1_000_000;
t.mock.method(Date, 'now', () => now);
// Nine cameras over a cap of eight make every refresh warn; throwing from
// that warning is how this test makes a refresh reject.
let failRefresh = false;
let failedRefreshes = 0;
const warnings = [];
t.mock.method(console, 'warn', (...args) => {
const text = args.join(' ');
if (failRefresh && text.includes('exceeds cap')) {
failedRefreshes += 1;
throw new Error('refresh broke');
}
warnings.push(text);
});
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
const old = await getSources();
assert.equal(old.length, 8);
// Past the TTL the refresh throws: the old catalog is still served.
now += CCTV_SOURCE_CACHE_MS + 1;
failRefresh = true;
assert.equal(await getSources(), old);
await settle();
assert.equal(failedRefreshes, 1);
assert.equal(
warnings.filter((w) => w.includes('background catalog refresh failed'))
.length,
1,
);
// The failure is not retried on every request until the next TTL.
assert.equal(await getSources(), old);
assert.equal(await getSources(), old);
await settle();
assert.equal(failedRefreshes, 1, 'a failed refresh was retried at once');
// After it, a refresh that works replaces the catalog. With no live packs
// the refresh finishes before the request that started it returns.
now += CCTV_SOURCE_CACHE_MS + 1;
failRefresh = false;
process.env.CCTV_SOURCES_JSON = envSources('new');
await getSources();
await settle();
const fresh = await getSources();
assert.deepEqual(
fresh.map((s) => s.id),
['new-0', 'new-1', 'new-2'],
);
// Trim notes stay with the list they describe, so a request served the
// old list during the refresh never reports the new list's notes.
assert.deepEqual(
getSources.trimmedPacks(old).map((p) => [p.pack, p.available, p.served]),
[['env', 9, 8]],
);
assert.deepEqual(getSources.trimmedPacks(fresh), []);
assert.deepEqual(getSources.trimmedPacks(), []);
assert.deepEqual(unhandled, []);
} finally {
process.off('unhandledRejection', onUnhandled);
restoreEnv(saved);
}
});
test('an empty background refresh keeps the catalog and waits a TTL before fetching again', async (t) => {
const saved = { ...process.env };
try {
process.env.CCTV_FORCE_AUSTIN = '1';
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
process.env.CCTV_SOURCES_JSON = envSources('old');
let now = 1_000_000;
t.mock.method(Date, 'now', () => now);
t.mock.method(console, 'log', () => {});
const warnings = [];
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
let fetches = 0;
t.mock.method(globalThis, 'fetch', () => {
fetches += 1;
return Promise.reject(new Error('offline'));
});
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
const old = await getSources();
// Past the TTL every pack comes back empty, as in an upstream outage.
now += CCTV_SOURCE_CACHE_MS + 1;
process.env.CCTV_SOURCES_JSON = '[]';
assert.equal(await getSources(), old);
for (let i = 0; i < 50; i += 1) {
if (warnings.some((w) => w.includes('source refresh returned empty')))
break;
await settle();
}
assert.ok(
warnings.some((w) => w.includes('serving 3 stale cameras')),
'the empty refresh kept the old catalog',
);
// Until the next TTL, requests are served without touching any upstream.
const fetchesAfterRefresh = fetches;
assert.equal(await getSources(), old);
assert.equal(await getSources(), old);
await settle();
assert.equal(
fetches,
fetchesAfterRefresh,
'an empty refresh reran at once',
);
} finally {
restoreEnv(saved);
}
});
+315
View File
@@ -1,11 +1,24 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
TARKTEE_IMAGES_URL,
TARKTEE_LOCATIONS_URL,
TARKTEE_MAX_CATALOG_BYTES,
TARKTEE_FETCH_TIMEOUT_MS,
} from '../../server/providers/cctv/constants.js';
import {
loadTallinnSourcesFromCatalog,
loadTarkteeSourcesFromDatex,
parseTarkteeDatexImages,
parseTarkteeDatexLocations,
} from '../../server/providers/cctv/sources.js';
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
<payloadPublication>
@@ -84,3 +97,305 @@ test('Tarktee DATEX image parser rejects non-official hosts', () => {
const images = parseTarkteeDatexImages(xml);
assert.equal(images.size, 0);
});
test('Tarktee DATEX requests wait 30 s, past the slow answers measured live', async (t) => {
const timeouts = [];
const timeout = AbortSignal.timeout;
t.mock.method(AbortSignal, 'timeout', (ms) => {
timeouts.push(ms);
return timeout.call(AbortSignal, ms);
});
t.mock.method(console, 'log', () => {});
t.mock.method(
globalThis,
'fetch',
async (url) =>
new Response(
String(url).includes('roadCameraLocations')
? SAMPLE_LOCATIONS_XML
: SAMPLE_IMAGES_XML,
),
);
const cameras = await loadTarkteeSourcesFromDatex();
assert.deepEqual(timeouts, [30_000]);
assert.equal(cameras.length, 1);
});
test('Tarktee loader deduplicates cameras sharing an image number before the cap', async (t) => {
quiet(t);
const dupLocationsXml = `<?xml version="1.0" encoding="UTF-8"?>
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
<payloadPublication>
<predefinedLocationContainer id="TRAFFIC_CAMERAS" version="0">
<predefinedLocation id="loc-1" version="0">
<predefinedLocationName><values><value lang="et">Tallinn Ringtee</value></values></predefinedLocationName>
<location><pointByCoordinates><pointCoordinates><latitude>59.4370</latitude><longitude>24.7530</longitude></pointCoordinates></pointByCoordinates></location>
</predefinedLocation>
<predefinedLocation id="loc-2" version="0">
<predefinedLocationName><values><value lang="et">Tallinn Ringtee duplicate</value></values></predefinedLocationName>
<location><pointByCoordinates><pointCoordinates><latitude>59.4371</latitude><longitude>24.7531</longitude></pointCoordinates></pointByCoordinates></location>
</predefinedLocation>
</predefinedLocationContainer>
</payloadPublication>
</d2LogicalModel>`;
const dupImagesXml = `<?xml version="1.0" encoding="UTF-8"?>
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
<payloadPublication>
<trafficView id="1"><linearTrafficView id="1"><linearPredefinedLocationReference id="loc-1"/><trafficViewRecord id="1-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_1.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
<trafficView id="2"><linearTrafficView id="2"><linearPredefinedLocationReference id="loc-2"/><trafficViewRecord id="2-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_2.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
</payloadPublication>
</d2LogicalModel>`;
const seen = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init?.redirect]);
const body =
String(url) === TARKTEE_LOCATIONS_URL ? dupLocationsXml : dupImagesXml;
return new Response(body, {
status: 200,
headers: { 'Content-Type': 'application/xml' },
});
});
const cameras = await loadTarkteeSourcesFromDatex();
assert.deepEqual(seen, [
[TARKTEE_LOCATIONS_URL, 'manual'],
[TARKTEE_IMAGES_URL, 'manual'],
]);
assert.equal(cameras.length, 1);
assert.equal(cameras[0].id, 'ee-tarktee-42');
assert.equal(cameras[0].name, 'Tallinn Ringtee');
});
test('Tarktee loader refuses a redirect and cancels both response bodies', async (t) => {
quiet(t);
const cancelled = { locations: false, images: false };
t.mock.method(globalThis, 'fetch', async (url) => {
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('<xml/>'));
},
cancel() {
if (isLocations) cancelled.locations = true;
else cancelled.images = true;
},
});
return new Response(body, {
status: isLocations ? 302 : 200,
headers: isLocations ? { location: 'https://evil.example/loc.xml' } : {},
});
});
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.equal(cancelled.locations, true, 'redirected body is cancelled');
assert.equal(cancelled.images, true, 'sibling 200 body is also cancelled');
});
test('Tarktee loader cancels fulfilled bodies when either fetch rejects', async (t) => {
for (const failedUrl of [TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL]) {
for (const delayedSibling of [false, true]) {
await t.test(
`${failedUrl}, delayed sibling: ${delayedSibling}`,
async (t) => {
quiet(t);
let cancelled = false;
const response = new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('<xml/>'));
},
cancel() {
cancelled = true;
},
}),
);
t.mock.method(globalThis, 'fetch', async (url) => {
if (String(url) === failedUrl) throw new TypeError('fetch failed');
if (delayedSibling) await new Promise(setImmediate);
return response;
});
try {
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
// An aborted mock transport may still deliver its queued response.
if (delayedSibling) await new Promise(setImmediate);
assert.equal(
cancelled,
true,
'fulfilled sibling body is cancelled',
);
} finally {
await response.body.cancel();
}
},
);
}
}
});
test('Tarktee loader cancels the sibling body when one feed fails or overflows the byte cap', async (t) => {
quiet(t);
let siblingCancelled = false;
t.mock.method(globalThis, 'fetch', async (url) => {
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
if (isLocations) {
return new Response('', { status: 503 });
}
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
},
cancel() {
siblingCancelled = true;
},
});
return new Response(body, { status: 200 });
});
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.equal(
siblingCancelled,
true,
'sibling 200 stream is cancelled on 503',
);
let overflowSiblingCancelled = false;
t.mock.method(globalThis, 'fetch', async (url) => {
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
if (isLocations) {
const padded =
SAMPLE_LOCATIONS_XML + ' '.repeat(TARKTEE_MAX_CATALOG_BYTES);
return new Response(padded, {
status: 200,
headers: { 'Content-Length': String(padded.length) },
});
}
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
},
cancel() {
overflowSiblingCancelled = true;
},
});
return new Response(body, { status: 200 });
});
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.equal(
overflowSiblingCancelled,
true,
'sibling stream is cancelled when the other feed exceeds the byte cap',
);
});
for (const failingUrl of [TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL]) {
for (const lateSibling of [false, true]) {
test(`Tarktee cancels a ${lateSibling ? 'late' : 'received'} sibling when ${failingUrl} rejects before headers`, async (t) => {
quiet(t);
let cancelled = false;
let siblingSignal;
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
if (String(url) === failingUrl) throw new Error('headers unavailable');
siblingSignal = init.signal;
if (lateSibling) await gate;
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('<xml/>'));
},
cancel() {
cancelled = true;
},
}),
);
});
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
// Also exercise a response arriving after abort, as a queued transport
// completion can race the rejection owner even when fetch honors abort.
if (lateSibling) {
release();
await new Promise((resolve) => setImmediate(resolve));
}
assert.equal(cancelled, true, 'every obtained body must be cancelled');
assert.equal(
siblingSignal.aborted,
true,
'a pending transport is aborted immediately',
);
});
}
test(`Tarktee aborts a pending sibling when ${failingUrl} rejects`, async (t) => {
quiet(t);
let aborted = false;
t.mock.method(globalThis, 'fetch', async (url, init) => {
if (String(url) === failingUrl) throw new Error('headers unavailable');
return new Promise((resolve, reject) => {
init.signal.addEventListener(
'abort',
() => {
aborted = true;
reject(init.signal.reason);
},
{ once: true },
);
});
});
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.equal(aborted, true, 'do not wait for the 30-second deadline');
});
}
test('Tarktee cancels both lengthless streams when one exceeds the byte cap', async (t) => {
quiet(t);
const cancelled = [];
t.mock.method(
globalThis,
'fetch',
async (url) =>
new Response(
new ReadableStream({
start(controller) {
const oversized = String(url) === TARKTEE_LOCATIONS_URL;
controller.enqueue(
new Uint8Array(oversized ? TARKTEE_MAX_CATALOG_BYTES + 1 : 1),
);
},
cancel() {
cancelled.push(String(url));
},
}),
),
);
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.deepEqual(
cancelled.sort(),
[TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL].sort(),
);
});
test('Tarktee aborts pending headers as soon as its sibling returns an HTTP error', async (t) => {
quiet(t);
let aborted = false;
t.mock.method(globalThis, 'fetch', async (url, init) => {
if (String(url) === TARKTEE_LOCATIONS_URL)
return new Response('unavailable', { status: 503 });
return new Promise((resolve, reject) => {
init.signal.addEventListener(
'abort',
() => {
aborted = true;
reject(init.signal.reason);
},
{ once: true },
);
});
});
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.equal(aborted, true);
});
+621 -1
View File
@@ -2,11 +2,24 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
applyVegvesenGroundHeights,
clearKartverketHeightCache,
fetchKartverketHeights,
kartverketBackoffMs,
loadVegvesenSourcesFromOpenData,
vegvesenCameraToSource,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_VEGVESEN_CCTV_URL,
KARTVERKET_HEIGHT_URL,
KARTVERKET_CATALOG_WAIT_MS,
KARTVERKET_CONCURRENCY,
KARTVERKET_FAILURE_BACKOFF_MS,
KARTVERKET_MAX_POINTS,
KARTVERKET_MAX_RESPONSE_BYTES,
KARTVERKET_NULL_TTL_MS,
KARTVERKET_USER_AGENT,
CCTV_SOURCE_CACHE_MS,
VEGVESEN_IMAGE_ORIGIN,
} from '../../server/providers/cctv/constants.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
@@ -104,6 +117,8 @@ test('faulty cameras, off-host frames and bad geometry are dropped', () => {
});
test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
clearKartverketHeightCache();
t.after(clearKartverketHeightCache);
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const requested = [];
@@ -127,7 +142,11 @@ test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
});
});
const cameras = await loadVegvesenSourcesFromOpenData();
assert.deepEqual(requested, [DEFAULT_VEGVESEN_CCTV_URL]);
// The catalog first; the rest is the Kartverket height lookup.
assert.equal(requested[0], DEFAULT_VEGVESEN_CCTV_URL);
// Two unique positions: one Kartverket request.
assert.equal(requested.length, 2);
assert.ok(requested[1].startsWith(KARTVERKET_HEIGHT_URL));
// Nearest-to-anchor first: Oslo leads.
assert.deepEqual(
cameras.map((c) => c.id),
@@ -200,3 +219,604 @@ test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => {
);
});
});
/** Answer a Kartverket request the way the live API does: echo every point,
* with the height `heightFor(x, y)` returns (null = outside coverage). */
const kartverketResponse = (url, heightFor) => {
const points = JSON.parse(new URL(url).searchParams.get('punkter'));
return Response.json({
koordsys: 4258,
punkter: points.map(([x, y]) => ({ x, y, z: heightFor(x, y) })),
});
};
/** Cameras at distinct Norwegian positions. */
const cameraAt = (i) => ({
id: `no-vegvesen-${i}`,
lat: 60 + i * 0.001,
lon: 7 + i * 0.001,
groundElevationM: 150,
});
const withHeightsEnv = (t) => {
const saved = process.env.CCTV_VEGVESEN_HEIGHTS;
delete process.env.CCTV_VEGVESEN_HEIGHTS;
clearKartverketHeightCache();
t.after(() => {
clearKartverketHeightCache();
if (saved === undefined) delete process.env.CCTV_VEGVESEN_HEIGHTS;
else process.env.CCTV_VEGVESEN_HEIGHTS = saved;
});
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
};
const sentPoints = (url) =>
JSON.parse(new URL(url).searchParams.get('punkter'));
test('Kartverket heights come in batches of 20, matched by echoed coordinates', async (t) => {
withHeightsEnv(t);
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push(String(url));
assert.ok(String(url).startsWith(KARTVERKET_HEIGHT_URL));
assert.equal(init.headers['User-Agent'], KARTVERKET_USER_AGENT);
// Redirects are refused, so a request can never leave the fixed host.
assert.equal(init.redirect, 'error');
// Reply in reverse order: matching must not depend on list position.
const response = kartverketResponse(url, (x) =>
x > 7.04 ? null : Math.round((x - 7) * 10000),
);
const body = await response.json();
body.punkter.reverse();
return Response.json(body);
});
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 50 }, (_, i) => cameraAt(i)),
);
assert.equal(failure, null);
// One pass, no retries: 20 + 20 + 10.
assert.deepEqual(
requested.map((url) => sentPoints(url).length),
[KARTVERKET_MAX_POINTS, 20, 10],
);
for (const url of requested) {
assert.equal(new URL(url).searchParams.get('koordsys'), '4258');
}
assert.equal(heights.size, 50);
assert.equal(heights.get('7.010000,60.010000'), 100);
// Past x = 7.04 the API has no height: recorded as null, never as 0.
assert.equal(heights.get('7.049000,60.049000'), null);
});
test('only the points a request asked about are recorded', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) => {
const points = sentPoints(url);
return Response.json({
koordsys: 4258,
punkter: [
...points.map(([x, y]) => ({ x, y, z: 10 })),
{ x: 7.5, y: 60.5, z: 999 }, // never asked
],
});
});
const { heights } = await fetchKartverketHeights([cameraAt(0)]);
assert.deepEqual([...heights.entries()], [['7.000000,60.000000', 10]]);
});
test('either water marker counts as the surface', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) => {
const [a, b, c] = sentPoints(url);
return Response.json({
koordsys: 4258,
punkter: [
{
x: a[0],
y: a[1],
z: -547.1,
datakilde: 'dybdekurver',
terreng: null,
},
{ x: b[0], y: b[1], z: -9.2, datakilde: 'dtm1', terreng: 'Havflate' },
{
x: c[0],
y: c[1],
z: -0.2,
datakilde: 'dtm1',
terreng: 'ÅpentOmråde',
},
],
});
});
const { heights } = await fetchKartverketHeights([
cameraAt(0),
cameraAt(1),
cameraAt(2),
]);
assert.equal(heights.get('7.000000,60.000000'), 0);
assert.equal(heights.get('7.001000,60.001000'), 0);
// A quay just below the datum on land data stays as measured.
assert.equal(heights.get('7.002000,60.002000'), -0.2);
});
test('heights outside -20..2500 m and points outside Norway are not used', async (t) => {
withHeightsEnv(t);
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
const zs = [-20, -20.5, 2500, 2500.5];
const points = sentPoints(url);
return Response.json({
koordsys: 4258,
punkter: points.map(([x, y], i) => ({ x, y, z: zs[i] })),
});
});
const { heights } = await fetchKartverketHeights([
cameraAt(0),
cameraAt(1),
cameraAt(2),
cameraAt(3),
{ lat: 55.68, lon: 12.57 }, // Copenhagen: never sent
]);
assert.equal(requested.length, 1);
assert.equal(sentPoints(requested[0]).length, 4);
assert.deepEqual(
[0, 1, 2, 3].map((i) =>
heights.get(
`${(7 + i * 0.001).toFixed(6)},${(60 + i * 0.001).toFixed(6)}`,
),
),
[-20, null, 2500, null],
);
});
test('no more than eight requests are in flight', async (t) => {
withHeightsEnv(t);
let inFlight = 0;
let peak = 0;
t.mock.method(globalThis, 'fetch', async (url) => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight -= 1;
return kartverketResponse(url, () => 1);
});
const { heights } = await fetchKartverketHeights(
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
);
assert.equal(heights.size, 400);
assert.equal(peak, KARTVERKET_CONCURRENCY);
});
test('a transport failure stops the lookup: no retries, no new requests', async (t) => {
withHeightsEnv(t);
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return new Response('busy', {
status: 429,
headers: { 'Retry-After': '120' },
});
});
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
);
// The first wave is already in flight when the first failure lands; nothing
// after it starts, and no point is resent.
assert.equal(calls, KARTVERKET_CONCURRENCY);
assert.equal(heights.size, 0);
assert.deepEqual(failure, {
status: 429,
retryAfter: '120',
deadline: false,
});
});
/** A fetch that never answers, and rejects only when its signal aborts. */
const hangingFetch = (url, init) => {
assert.ok(init.signal instanceof AbortSignal);
return new Promise((_, reject) => {
init.signal.addEventListener('abort', () => reject(init.signal.reason));
});
};
test('the lookup deadline bounds a hanging service', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', hangingFetch);
const started = Date.now();
const { heights, failure } = await fetchKartverketHeights([cameraAt(0)], {
timeoutMs: 30,
});
const elapsed = Date.now() - started;
assert.ok(elapsed >= 25 && elapsed < 2000, `elapsed ${elapsed} ms`);
assert.equal(heights.size, 0);
assert.deepEqual(failure, { status: null, retryAfter: null, deadline: true });
});
test('one hanging request times out on its own, as a failing service', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', hangingFetch);
const { failure } = await fetchKartverketHeights([cameraAt(0)], {
timeoutMs: 60_000,
requestTimeoutMs: 20,
});
assert.deepEqual(failure, {
status: null,
retryAfter: null,
deadline: false,
});
});
test('the deadline is shared: later requests do not get a fresh one', async (t) => {
withHeightsEnv(t);
let calls = 0;
t.mock.method(globalThis, 'fetch', (url, init) => {
calls += 1;
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => resolve(kartverketResponse(url, () => 1)),
40,
);
init.signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(init.signal.reason);
});
});
});
// Five waves of 40 ms against a 100 ms deadline for the whole lookup.
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 5 * KARTVERKET_CONCURRENCY * 20 }, (_, i) =>
cameraAt(i),
),
{ timeoutMs: 100 },
);
assert.equal(failure?.deadline, true);
assert.ok(heights.size < 5 * KARTVERKET_CONCURRENCY * 20);
assert.ok(calls < 5 * KARTVERKET_CONCURRENCY, `calls ${calls}`);
});
test('a 503, an unreadable or an oversized body stops the lookup', async (t) => {
withHeightsEnv(t);
const answers = {
503: () => new Response('down', { status: 503 }),
'not json': () =>
new Response('<html>maintenance</html>', {
headers: { 'Content-Type': 'text/html' },
}),
oversized: () =>
Response.json({
punkter: [],
pad: 'x'.repeat(KARTVERKET_MAX_RESPONSE_BYTES + 1),
}),
};
for (const [label, answer] of Object.entries(answers)) {
clearKartverketHeightCache();
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return answer();
});
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
);
assert.equal(calls, KARTVERKET_CONCURRENCY, label);
assert.equal(heights.size, 0, label);
assert.equal(failure?.deadline, false, label);
assert.equal(failure?.status, label === '503' ? 503 : null, label);
}
});
test('a 4xx rejects that request only: its points get no height, the rest go on', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) =>
sentPoints(url)[0][0] === 7
? new Response('bad point', { status: 400 })
: kartverketResponse(url, () => 42),
);
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 40 }, (_, i) => cameraAt(i)),
);
assert.equal(failure, null);
assert.equal(heights.size, 40);
assert.equal(heights.get('7.000000,60.000000'), null);
assert.equal(heights.get('7.020000,60.020000'), 42);
});
test('a point a 200 answer leaves out is cached as no height', async (t) => {
withHeightsEnv(t);
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
const points = sentPoints(url).slice(1); // drop the first point
return Response.json({
koordsys: 4258,
punkter: points.map(([x, y]) => ({ x, y, z: 7 })),
});
});
const cameras = [cameraAt(0), cameraAt(1)];
assert.equal(await applyVegvesenGroundHeights(cameras), 1);
assert.equal(cameras[0].groundElevationM, 150);
// The next refresh asks nothing: the left-out point is a cached miss.
assert.equal(await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1)]), 1);
assert.equal(fetchMock.mock.callCount(), 1);
});
test('Retry-After sets the backoff, bounded to [1 min, 1 h]', () => {
const now = Date.parse('2026-10-06T12:00:00Z');
assert.equal(kartverketBackoffMs('120', now), 120_000);
assert.equal(kartverketBackoffMs('5', now), 60_000);
assert.equal(kartverketBackoffMs('86400', now), 3_600_000);
assert.equal(
kartverketBackoffMs('Mon, 06 Oct 2026 12:05:00 GMT', now),
300_000,
);
assert.equal(kartverketBackoffMs(null, now), KARTVERKET_FAILURE_BACKOFF_MS);
assert.equal(kartverketBackoffMs('soon', now), KARTVERKET_FAILURE_BACKOFF_MS);
});
test('heights are cached, and misses are not asked again until they expire', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
const asked = [];
t.mock.method(globalThis, 'fetch', async (url) => {
asked.push(sentPoints(url).length);
return kartverketResponse(url, (x) => (x < 7.002 ? 1043.26 : null));
});
const cameras = [cameraAt(0), cameraAt(1), cameraAt(5)];
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 2);
assert.deepEqual(
cameras.map((c) => c.groundElevationM),
[1043.3, 1043.3, 150],
);
// The next refresh asks nothing: heights and the miss are both cached.
assert.equal(
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1), cameraAt(5)], {
now,
}),
2,
);
assert.deepEqual(asked, [3]);
// Once the miss expires, only that point is asked again.
clock += KARTVERKET_NULL_TTL_MS + 1;
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(5)], { now });
assert.deepEqual(asked, [3, 1]);
});
test('after a failure no lookup starts until the backoff ends', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
throw new Error('offline');
});
const cameras = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 0);
assert.equal(cameras[0].groundElevationM, 150);
assert.equal(fetchMock.mock.callCount(), 1);
// The default backoff outlasts the catalog refresh, so the next refresh
// and every one inside the backoff cost nothing.
assert.ok(KARTVERKET_FAILURE_BACKOFF_MS > CCTV_SOURCE_CACHE_MS);
clock += CCTV_SOURCE_CACHE_MS;
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
clock += KARTVERKET_FAILURE_BACKOFF_MS - CCTV_SOURCE_CACHE_MS - 1;
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
assert.equal(fetchMock.mock.callCount(), 1);
// After it, the failed point is asked again.
clock += 2;
fetchMock.mock.mockImplementation(async (url) =>
kartverketResponse(url, () => 321),
);
const later = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(later, { now }), 1);
assert.equal(later[0].groundElevationM, 321);
assert.equal(fetchMock.mock.callCount(), 2);
});
test('a 429 backs off for its Retry-After, not the default', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
const retryAfterS = 50 * 60;
const fetchMock = t.mock.method(
globalThis,
'fetch',
async () =>
new Response('busy', {
status: 429,
headers: { 'Retry-After': String(retryAfterS) },
}),
);
await applyVegvesenGroundHeights([cameraAt(0)], { now });
assert.equal(fetchMock.mock.callCount(), 1);
clock += KARTVERKET_FAILURE_BACKOFF_MS + 1;
await applyVegvesenGroundHeights([cameraAt(0)], { now });
assert.equal(fetchMock.mock.callCount(), 1);
clock += retryAfterS * 1000 - KARTVERKET_FAILURE_BACKOFF_MS;
await applyVegvesenGroundHeights([cameraAt(0)], { now });
assert.equal(fetchMock.mock.callCount(), 2);
});
test('a lookup cut short by its deadline resumes on the next refresh', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
t.mock.method(globalThis, 'fetch', hangingFetch);
// The lookup runs out of time; that is not a failing service.
assert.equal(
await applyVegvesenGroundHeights([cameraAt(0)], {
now,
timeoutMs: 10,
waitMs: 1000,
}),
0,
);
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 64),
);
clock += CCTV_SOURCE_CACHE_MS;
const cameras = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 1);
assert.equal(cameras[0].groundElevationM, 64);
assert.equal(fetchMock.mock.callCount(), 1);
});
test('overlapping refreshes share one lookup', async (t) => {
withHeightsEnv(t);
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
await gate;
return kartverketResponse(url, () => 3);
});
await Promise.all([
applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 }),
applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 }),
]);
assert.equal(fetchMock.mock.callCount(), 1);
release();
});
test('a warm cache does not wait for a few new cameras', async (t) => {
withHeightsEnv(t);
const cameras = Array.from({ length: 20 }, (_, i) => cameraAt(i));
t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 500),
);
assert.equal(await applyVegvesenGroundHeights(cameras), 20);
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
await gate;
return kartverketResponse(url, () => 600);
});
const refresh = [...cameras, cameraAt(20)];
const started = Date.now();
assert.equal(
await applyVegvesenGroundHeights(refresh, { waitMs: 5_000 }),
20,
);
assert.ok(Date.now() - started < 1000);
assert.equal(fetchMock.mock.callCount(), 1);
// The background lookup fills the cache for the next refresh.
release();
await new Promise((resolve) => setTimeout(resolve, 20));
const next = [...cameras, cameraAt(20)];
assert.equal(await applyVegvesenGroundHeights(next), 21);
assert.equal(next[20].groundElevationM, 600);
});
test('cameras without numeric coordinates are skipped, never thrown on', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 9),
);
const cameras = [
{ id: 'no-coordinates' },
null,
{ id: 'strings', lat: '60', lon: '7' },
cameraAt(0),
];
assert.equal(await applyVegvesenGroundHeights(cameras), 1);
assert.equal(cameras[3].groundElevationM, 9);
});
test('a lookup started before a cache clear cannot write after it', async (t) => {
withHeightsEnv(t);
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
t.mock.method(globalThis, 'fetch', async (url) => {
await gate;
return kartverketResponse(url, () => 111);
});
await applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 });
clearKartverketHeightCache();
t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 222),
);
const fresh = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(fresh), 1);
assert.equal(fresh[0].groundElevationM, 222);
release();
await new Promise((resolve) => setTimeout(resolve, 20));
const later = [cameraAt(0)];
await applyVegvesenGroundHeights(later, { waitMs: 0 });
assert.equal(later[0].groundElevationM, 222);
});
test('CCTV_VEGVESEN_HEIGHTS=0 skips the lookup', async (t) => {
withHeightsEnv(t);
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 5),
);
process.env.CCTV_VEGVESEN_HEIGHTS = '0';
const cameras = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(cameras), 0);
assert.equal(cameras[0].groundElevationM, 150);
assert.equal(fetchMock.mock.callCount(), 0);
});
test('a slow lookup does not hold the catalog; the next refresh applies it', async (t) => {
withHeightsEnv(t);
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
t.mock.method(globalThis, 'fetch', async (url) => {
await gate;
return kartverketResponse(url, () => 812);
});
const first = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(first, { waitMs: 20 }), 0);
assert.equal(first[0].groundElevationM, 150);
release();
// The lookup kept running and filled the cache for the next refresh.
await new Promise((resolve) => setTimeout(resolve, 20));
const next = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(next, { waitMs: 20 }), 1);
assert.equal(next[0].groundElevationM, 812);
});
test('the loader applies Kartverket heights to the cameras it returns', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) => {
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
return kartverketResponse(url, () => 987.6);
}
return Response.json({ type: 'FeatureCollection', features: [feature()] });
});
const cameras = await loadVegvesenSourcesFromOpenData();
assert.deepEqual(
cameras.map((c) => [c.id, c.groundElevationM]),
[['no-vegvesen-3000063_1', 987.6]],
);
});
test('the loader budgets the height wait from its own start', async (t) => {
withHeightsEnv(t);
const realNow = Date.now.bind(Date);
let offset = 0;
t.mock.method(Date, 'now', () => realNow() + offset);
t.mock.method(globalThis, 'fetch', (url, init) => {
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
return hangingFetch(url, init);
}
// The catalog fetch uses up all but 10 ms of the lane's budget.
offset += KARTVERKET_CATALOG_WAIT_MS - 10;
return Promise.resolve(
Response.json({ type: 'FeatureCollection', features: [feature()] }),
);
});
const started = performance.now();
const cameras = await loadVegvesenSourcesFromOpenData();
assert.ok(performance.now() - started < 1000);
assert.equal(cameras[0].groundElevationM, 150);
});
+7 -3
View File
@@ -238,8 +238,9 @@ export const DATA_CREDITS = [
key: 'ontario-511-cctv',
html:
'CCTV cameras &amp; frames (Ontario): ' +
'<a href="https://511on.ca/" target="_blank" rel="noopener">Ontario 511</a> ' +
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>)',
'<a href="https://511on.ca/" target="_blank" rel="noopener">Ontario 511</a>. ' +
'Contains information licensed under the <a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence – Ontario</a>. ' +
'Provided as is, without warranty; actual road conditions may differ.',
},
{
key: 'fintraffic-cctv',
@@ -293,7 +294,10 @@ export const DATA_CREDITS = [
html:
'Road cameras (Norway): contains data under the ' +
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
'distributed by Statens vegvesen',
'distributed by Statens vegvesen · camera ground heights © ' +
'<a href="https://kartverket.no/en/api-and-data/terrengdata" target="_blank" rel="noopener">Kartverket</a> ' +
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>); ' +
'modified: heights over water set to 0 m and rounded to 0.1 m',
},
{
key: 'nyc-cctv',
+31
View File
@@ -139,3 +139,34 @@ test('same pixels at a new aspect redraw at the new fit', () => {
]);
assert.equal(runtime.canvasStamp, 2);
});
test('same pixels at a new resolution with the same fit skip the redraw', () => {
const { runtime, load } = stillHarness();
load(640, 480);
assert.deepEqual(load(1280, 960), []);
assert.equal(runtime.canvasStamp, 1);
assert.equal(runtime.drawnImageStamp, 2);
});
test('changed pixels redraw even when the fitted rectangle is unchanged', () => {
const { runtime, load } = stillHarness();
load(640, 480);
const changedPixels = new Uint8ClampedArray(
FRAME_SIGNATURE_W * FRAME_SIGNATURE_H * 4,
).fill(90);
runtime.signatureCtx.getImageData = () => ({ data: changedPixels });
const calls = load(640, 480);
assert.equal(calls[0][0], 'fillRect');
assert.deepEqual(calls[1], ['drawImage', 240, 0, 1440, 1080]);
assert.equal(runtime.canvasStamp, 2);
});
test('a failed pixel signature redraws rather than assuming equal content', () => {
const { runtime, load } = stillHarness();
load(640, 480);
runtime.signatureCtx.getImageData = () => {
throw new Error('tainted canvas');
};
assert.deepEqual(load(640, 480).at(-1), ['drawImage', 240, 0, 1440, 1080]);
assert.equal(runtime.canvasStamp, 2);
});
+53
View File
@@ -71,3 +71,56 @@ test('focus de-emphasis fades each icon from its own color', () => {
);
assert.equal(state._activeFocusStyleCount, 3);
});
test('focus release restores video hue and alpha after the style throttle', () => {
let target = {};
const records = ['mp4', 'webm'].map((feedType) => ({
camera: { id: feedType, feedType },
billboard: { show: true, color: LIVE_CAMERA_COLOR },
}));
const state = {
_enabled: true,
_viewer: { scene: {}, camera: {} },
_records: records,
_activeCameraId: null,
_activeFocusStyleCount: 0,
_lastFocusStyleAt: -Infinity,
};
const focus = {
focusNowMs: (nowMs) => nowMs,
getFocusTarget: () => target,
focusPassIsNeeded: (value, count) => !!value || count > 0,
advanceSpriteFocus: () => ({
factor: target ? FOCUS_FACTOR : 1,
active: !!target,
transitioning: false,
}),
focusAlphaNeedsWrite: (before, after) => before !== after,
};
const { refreshCctvFocusStyles } = createRendering({
state,
services: { focus },
parts: { model: { isVideoFeedType } },
source: {},
});
refreshCctvFocusStyles(1000);
for (const record of records) {
assertFadedFrom(
record.billboard.color,
LIVE_CAMERA_COLOR,
record.camera.id,
);
}
const dimmed = records.map((record) => record.billboard.color);
target = null;
refreshCctvFocusStyles(1050);
for (let i = 0; i < records.length; i += 1) {
assert.equal(records[i].billboard.color, dimmed[i]);
}
refreshCctvFocusStyles(1100);
for (const record of records) {
assert.deepEqual(record.billboard.color, LIVE_CAMERA_COLOR);
}
assert.equal(state._activeFocusStyleCount, 0);
});
+1 -1
View File
@@ -137,7 +137,7 @@ export class RealtimeTurns {
content: [{ type: 'input_text', text: cleanText }],
},
};
this.sendRealtimeEvent(itemEvent, 'client.user_text');
if (!this.sendRealtimeEvent(itemEvent, 'client.user_text')) return;
this.requestUserTextResponse();
}
+268
View File
@@ -0,0 +1,268 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { GevRealtimeController } from './realtimeController.js';
function fixture({ pointer = null, onUserSend = () => {} } = {}) {
const sent = [];
const executed = [];
const rejectedTexts = new Set();
let rejectResponse = false;
const controller = new GevRealtimeController({
runner: async (name) => {
executed.push(name);
return { ok: true };
},
backend: {},
debugSink: null,
pointer,
ui: {
root: {
dataset: {},
classList: { remove() {} },
querySelectorAll: () => [],
},
status: {},
detail: {},
},
});
controller.dc = {
readyState: 'open',
send(body) {
const value = JSON.parse(body);
const text = value.item?.role === 'user' && value.item.content?.[0]?.text;
if (value.item?.role === 'user') onUserSend();
if (
rejectedTexts.has(text) ||
(rejectResponse && value.type === 'response.create')
)
throw new Error('Synthetic channel rejection');
sent.push(value);
},
close() {
this.readyState = 'closed';
},
};
controller.status = 'listening';
controller._turns.narration = null;
const event = (payload) =>
controller.handleRealtimeEvent({ data: JSON.stringify(payload) });
const created = (id) => event({ type: 'response.created', response: { id } });
const done = (id) =>
event({
type: 'response.done',
response: { id, status: 'completed', output: [] },
});
const creates = () =>
sent.filter(
(x) =>
x.type === 'response.create' &&
x.response?.metadata?.gev !== 'progress',
);
const texts = () =>
sent
.filter((x) => x.item?.role === 'user')
.map((x) => x.item.content[0].text);
return {
controller,
sent,
executed,
rejectedTexts,
event,
created,
done,
creates,
texts,
rejectResponses(value) {
rejectResponse = value;
},
};
}
test('rejected idle text schedules no answer, and a successful successor answers once', async () => {
const f = fixture();
try {
f.rejectedTexts.add('rejected');
assert.equal(f.controller.sendTextCommand('rejected'), undefined);
assert.equal(f.creates().length, 0);
assert.equal(f.controller.pendingUserTextResponse, false);
assert.equal(f.controller.responseCreatePending, false);
assert.equal(f.controller.sendTextCommand('successor'), undefined);
assert.deepEqual(f.texts(), ['successor']);
assert.equal(f.creates().length, 1);
await f.created('successor');
await f.done('successor');
assert.equal(f.creates().length, 1);
} finally {
f.controller.stop();
}
});
test('rejected text behind an active response cannot queue an answer', async () => {
const f = fixture();
try {
await f.created('active');
f.controller.queueResponseCreate('Old tool follow-up');
assert.equal(
f.controller.pendingResponseInstructions,
'Old tool follow-up',
);
const runningTool = new AbortController();
f.controller._turns.activeToolAbortControllers.add(runningTool);
f.rejectedTexts.add('rejected');
f.controller.sendTextCommand('rejected');
assert.equal(f.controller.pendingUserTextResponse, false);
assert.equal(f.controller.pendingResponseInstructions, null);
assert.equal(f.controller._turns.isSupersededResponse('active'), true);
assert.equal(runningTool.signal.aborted, true);
const staleCall = {
type: 'response.function_call_arguments.done',
response_id: 'active',
call_id: 'stale-call',
name: 'fly_to_location',
arguments: '{"query":"Paris"}',
};
await f.event(staleCall);
await f.event(staleCall);
assert.deepEqual(f.executed, []);
const outputs = f.sent.filter(
(x) => x.item?.type === 'function_call_output',
);
assert.equal(outputs.length, 1);
assert.equal(outputs[0].item.call_id, 'stale-call');
assert.equal(JSON.parse(outputs[0].item.output).superseded, true);
await f.done('active');
assert.equal(f.creates().length, 0);
} finally {
f.controller.stop();
}
});
test('rejected text still prepares narration and pointer context before sending', () => {
const steps = [];
const f = fixture({
pointer: {
beginTurn() {
steps.push('pointer');
return null;
},
clear() {},
},
onUserSend() {
steps.push('send');
},
});
f.controller._turns.narration = {
cancel() {
steps.push('cancel');
},
userTurnEnded() {
steps.push('user-turn-ended');
},
};
try {
f.rejectedTexts.add('rejected');
f.controller.sendTextCommand('rejected');
assert.deepEqual(steps, ['cancel', 'user-turn-ended', 'pointer', 'send']);
assert.equal(f.creates().length, 0);
} finally {
f.controller.stop();
}
});
test('earlier accepted deferred input survives a newer failed send and answers once', async () => {
const f = fixture();
try {
await f.created('active');
f.controller.sendTextCommand('accepted');
f.rejectedTexts.add('rejected');
f.controller.sendTextCommand('rejected');
assert.deepEqual(f.texts(), ['accepted']);
assert.equal(f.controller.pendingUserTextResponse, true);
assert.equal(f.creates().length, 0);
await f.done('active');
assert.equal(f.creates().length, 1);
await f.done('active');
assert.equal(f.creates().length, 1);
} finally {
f.controller.stop();
}
});
test('rejected text preserves an outstanding create without adding deferred input', async () => {
const f = fixture();
try {
f.controller.sendTextCommand('accepted');
f.rejectedTexts.add('rejected');
f.controller.sendTextCommand('rejected');
assert.equal(f.controller.responseCreatePending, true);
assert.equal(f.controller.pendingUserTextResponse, false);
await f.created('accepted');
await f.done('accepted');
assert.equal(f.creates().length, 1);
} finally {
f.controller.stop();
}
});
for (const acceptedFirst of [false, true]) {
test(`progress completion answers only accepted deferred input (accepted first=${acceptedFirst})`, async () => {
const f = fixture();
try {
assert.equal(
f.controller._turns.speakProgressLine('Checking the map.'),
true,
);
await f.event({
type: 'response.created',
response: { id: 'progress', metadata: { gev: 'progress' } },
});
if (acceptedFirst) f.controller.sendTextCommand('accepted');
f.rejectedTexts.add('rejected');
f.controller.sendTextCommand('rejected');
assert.equal(f.controller.pendingUserTextResponse, acceptedFirst);
await f.event({
type: 'response.done',
response: {
id: 'progress',
status: 'cancelled',
metadata: { gev: 'progress' },
},
});
assert.equal(f.creates().length, acceptedFirst ? 1 : 0);
} finally {
f.controller.stop();
}
});
}
test('accepted text with a rejected response.create releases the pending latch for a successor', async () => {
const f = fixture();
try {
f.rejectResponses(true);
f.controller.sendTextCommand('accepted without response');
assert.equal(f.controller.responseCreatePending, false);
assert.equal(f.creates().length, 0);
f.rejectResponses(false);
f.controller.sendTextCommand('successor');
assert.deepEqual(f.texts(), ['accepted without response', 'successor']);
assert.equal(f.creates().length, 1);
} finally {
f.controller.stop();
}
});
test('successful deferred typed burst still retains both items and coalesces one answer', async () => {
const f = fixture();
try {
await f.created('active');
f.controller.sendTextCommand('first');
f.controller.sendTextCommand('second');
assert.deepEqual(f.texts(), ['first', 'second']);
assert.equal(f.creates().length, 0);
await f.done('active');
await f.done('active');
assert.equal(f.creates().length, 1);
} finally {
f.controller.stop();
}
});