Skip to content

V3 Map Service: Worker + device fetch/render + app rebuild button - #1

Merged
selmapi merged 12 commits into
mainfrom
claude/v3-map-service-radar-wqa0ip
Jul 6, 2026
Merged

V3 Map Service: Worker + device fetch/render + app rebuild button#1
selmapi merged 12 commits into
mainfrom
claude/v3-map-service-radar-wqa0ip

Conversation

@selmapi

@selmapi selmapi commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Summary

Full V3 "Map Service" feature (see docs/superpowers/specs/2026-07-05-v3-map-service-design.md), landed as three reviewed layers in one branch:

Phase 1 — worker/: Cloudflare Worker OSM pipeline port

  • GET /map?lat&lon&radius returns a binary blob (magic/version/center/counts + verts/spans/towns) per docs/superpowers/plans/2026-07-05-v3-map-service-phase1.md.
  • Pipeline ported 1:1 from scripts/build_region_map.py: Overpass fetch, way stitching, water-noise filters, Douglas-Peucker with the same budget ladder, int16 quantization, interstates-only owner decision.
  • Workers Cache API (3-week TTL) + in-isolate single-flight guard + coarse per-IP rate limiting. 34 tests, no live network calls.
  • An independent review fixed 4 bugs before merge: rate limiter reset-under-load, non-ASCII town-label corruption, concurrent-fetch WAF risk, wrong HTTP status code.

Phase 2 — device: fetch, store, render

  • include/ui/region_map_blob.h: native-testable, Arduino-free wire-format decoder mirroring worker/src/encode.ts.
  • include/ui/region_map_source.h/.cpp: indirection layer between the renderer and its data — prefers /map.bin on LittleFS when present/valid, falls back to the compile-time-baked arrays otherwise. Streams records from flash rather than loading a fetched blob into RAM (the ESP32-C3's ~70KB free heap can't hold the wire format's 96KB budget).
  • src/ui/region_map.cpp rewired through the new source accessors (chunked reads) instead of the raw baked externs.
  • src/services/map_service.h/.cpp: HTTPS fetch mirroring adsb_client.cpp's poll-during-blocking-I/O idiom; streams to /map.bin.tmp, validates, atomically renames into place. Worker URL is NVS-backed, empty by default (no hardcoded Cloudflare account).
  • 7 new native tests for the wire-format decoder (54 total, all green).

Phase 3 — app: rebuild button

  • POST /api/map/rebuild; /api/aircraft gains mapSource/mapServiceUrl; /api/settings accepts mapServiceUrl.
  • webapp/index.html: map-service-URL input, "rebuild map for my location" button with inline status, map-source readout.

An independent review of Phase 2+3 found and fixed two real bugs before this push: rebuildForLocation() was deleting the old /map.bin before attempting the rename (a rename failure would've left no map at all instead of the old one), and saveServiceUrl() was dead code with no caller anywhere — the rebuild button could never have succeeded end-to-end without a firmware rebuild to hardcode a URL. Both are fixed.

Verification

  • Worker: cd worker && npm test (34/34) + npx tsc --noEmit — run directly, both clean.
  • Firmware: this sandbox's egress policy blocks PlatformIO's package registry (confirmed via the proxy status endpoint — 403 on api.registry.platformio.org), so pio test -e native / pio run -e supermini cannot run here. Verified instead via a hand-built harness that compiles/links test/native/* exactly like [env:native] would — 54/54 tests pass, independently re-run during review, not just trusted from the implementation pass. The webapp's generated include/web/webapp_gz.h was independently regenerated (bypassing the blocked PlatformIO pre-script by running the same gzip logic directly) and confirmed byte-identical. Arduino-bound files (map_service.cpp, web_app.cpp, region_map_source.cpp) were syntax-checked against hand-written stub headers matching the real ESP32 Arduino APIs where feasible; full LovyanGFX/WiFiManager stubbing was out of scope for the time budget, so those two files' edits (small, pattern-matching existing adjacent code) were verified by manual line-by-line review instead of a compiler.
  • No real hardware was touched — no -t upload, per the working agreement of always warning Selma before flashing her physical device.

Explicitly deferred

  • Actual Worker deployment (needs a real Cloudflare account/credentials, not available in this environment — worker/README.md documents wrangler deploy for whoever hosts it).
  • NVS-configurable Worker URL exposed in the WiFiManager portal itself (only the app//api/settings path is wired up).
  • Field-testing against a deployed Worker + a real device.

Test plan

  • cd worker && npm test — 34/34 pass
  • cd worker && npx tsc --noEmit — clean
  • Native firmware tests (hand-built harness, since pio test can't reach its registry here) — 54/54 pass
  • Deploy the Worker to a real Cloudflare account and hit it from a real device (not done here — no credentials/hardware in this session)
  • pio test -e native / pio run -e supermini in an environment with PlatformIO registry access, to confirm the sandbox workaround didn't miss anything

claude added 4 commits July 5, 2026 21:46
Scopes phase 1 of the parked v3 map-service spec to a self-contained
Cloudflare Worker: ports build_region_map.py's OSM pipeline to TS and
defines the binary wire format the device will later parse. Firmware
and app changes are deferred to later phases per the working agreement
of one reviewed diff per layer.
Ports scripts/build_region_map.py's Overpass fetch -> stitch -> filter ->
Douglas-Peucker -> quantize pipeline to a Cloudflare Worker (TypeScript)
serving GET /map?lat&lon&radius as a binary blob per the wire format in
docs/superpowers/plans/2026-07-05-v3-map-service-phase1.md. Includes
Workers Cache API caching, per-IP rate limiting, and 34 unit/integration
tests with mocked Overpass responses (no live network calls).

Independent review (code-review skill, high effort) surfaced and fixed:
- Rate limiter's overflow safety valve wiped all tracked IPs instead of
  sweeping expired ones, resetting counters exactly under high load.
- Town labels were mangled for non-ASCII names (byte-truncated UTF-16
  code units instead of UTF-8 encoding).
- Layer fetches ran concurrently instead of matching the Python original's
  serialized+2s-delay Overpass politeness pattern (CLAUDE.md landmine MatixYo#7);
  now serialized with an injectable delay, plus an in-isolate single-flight
  guard so concurrent requests for the same location share one pipeline run.
- BudgetExceededError (a caller-controllable outcome) returned 502 instead
  of 400.

Deployment, firmware LittleFS integration, and the app's rebuild-map
button are deferred to later phases per the working agreement of one
reviewed diff per layer (see the phase-1 plan doc).
Records the device-side streaming-read architecture decision (no full
blob in RAM given the ESP32-C3's ~70KB free heap vs. the wire format's
96KB budget) before implementation starts.
Wires the device up to the Phase 1 Cloudflare Worker (PR #1, worker/):

- include/ui/region_map_blob.h: native-testable, Arduino-free wire-format
  decoder (mirrors worker/src/encode.ts field-by-field).
- include/ui/region_map_source.h + src/ui/region_map_source.cpp: indirection
  layer between the renderer and its data source. Prefers /map.bin on
  LittleFS when present and valid, falls back to the compile-time-baked
  arrays otherwise. Never loads a fetched blob fully into RAM (the ESP32-C3's
  ~70KB free heap can't hold the wire format's 96KB budget) -- accessors
  seek+read only the records the renderer asks for.
- src/ui/region_map.cpp: drawRegionMap()/drawSpan() rewired through the new
  source accessors (64-vert chunked reads) instead of the raw baked externs.
- src/services/map_service.{h,cpp}: HTTPS fetch mirroring adsb_client.cpp's
  poll-during-blocking-I/O idiom; streams the response straight to
  /map.bin.tmp, validates the header/size, then atomically renames into
  place. Worker URL is NVS-backed (services::map::saveServiceUrl), empty by
  default -- this fork doesn't bake in a specific Cloudflare account's URL.
- src/services/web_app.cpp: POST /api/map/rebuild; /api/aircraft gains
  mapSource ("baked"/"fetched"/"hidden") and mapServiceUrl fields;
  /api/settings accepts a mapServiceUrl field.
- webapp/index.html: map service URL input, "rebuild map for my location"
  button with inline status, and a map-source readout in the drawer.
- test/native/test_map_blob: 7 tests for the wire-format decoder.

Verification note: this sandbox's egress policy blocks PlatformIO's package
registry (confirmed via the proxy status endpoint), so `pio test`/`pio run`
themselves can't run here. Verified instead via a hand-built harness that
compiles/links test/native/* exactly like [env:native] would (all 54 tests
pass, including the new ones) plus manual review/syntax-checking of the
Arduino-bound files against stub headers matching the real APIs. Two bugs
found in review and fixed before this commit: rebuildForLocation() was
deleting the old /map.bin before attempting the rename (a rename failure
would've left no map at all instead of the old one), and saveServiceUrl()
was dead code with no caller (the rebuild button could never have worked
end-to-end without a firmware rebuild to hardcode a URL).

Real hardware was never touched -- no `-t upload`, per the working
agreement of always warning before flashing Selma's physical device.
@selmapi selmapi changed the title V3 Map Service — Phase 1: Cloudflare Worker OSM pipeline port V3 Map Service: Worker + device fetch/render + app rebuild button Jul 6, 2026
selmapi and others added 7 commits July 6, 2026 00:28
…r the live 2026-07-06 deploy)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A cold Worker request can take 37s-3min, far past the device's 15s HTTP
timeout, so first-time rebuilds always failed. The Worker now returns 202
immediately on a cache miss and builds in the background; add
RebuildResult::kBuilding for that case so the caller can retry later
instead of treating it as a network error. Timeout stays at 15s -- 202
arrives right away, and cache hits measure ~1.3s on-device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kBuilding now surfaces as {"ok":false,"building":true,"error":"..."} so
the phone app can distinguish "still building, retry" from a hard
failure. Other results unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When /api/map/rebuild responds with building:true, show inline status
("building map on server... attempt N of 8") and automatically re-POST
every 45s, up to 8 attempts. Stops early on ok:true (success + map-source
refresh) or on a non-building error. Vanilla JS, matches the existing
terminal-style status conventions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- include/config.h: kDefaultMapServiceUrl now points at the owner's
  deployed Worker (opened for public use by this fork), instead of
  shipping empty/disabled by default.
- scripts/build_webapp.py: pin the gzip header's OS byte to 0xFF.
  Python 3.14 changed its default from 0x03 to 0xFF, which alone made
  the committed webapp_gz.h non-byte-stable across Python versions
  (landmine MatixYo#6). Regenerated the header (also picks up the new
  auto-poll JS); verified byte-identical across two consecutive
  regenerations.
- README.md: document the map service (public URL, ~1-2min first build
  with automatic retry, or self-host from worker/).
- CLAUDE.md: note the OS-byte pin under landmine MatixYo#6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cache-miss requests no longer hold the connection for the 37s+ (sometimes
multi-minute, when Overpass throttles Cloudflare egress IPs) pipeline the
device's 15s HTTP timeout can never survive. Instead a miss starts (or
joins) the single-flight background build, registers the full
pipeline->cache.put chain via ctx.waitUntil, and immediately returns
202 {"status":"building","retryAfterSeconds":45} with Retry-After: 45.
Cache hits still return the 200 blob unchanged; 400/404/405/429 behavior
is unchanged. Pipeline failures clear the in-flight entry so the next
poll retries fresh -- deliberately no persistent failure state (no KV/DO).

Documented honestly (code comment + README): Cloudflare docs say waitUntil
extends an invocation at most 30s past the response -- on paper too short
for this pipeline -- but observed live behavior (2026-07-05) is that
disconnected-client builds run to completion; each 45s poll also re-arms
the extension on a fresh ExecutionContext as a hedge.

wrangler.toml: [observability] enabled = false -- the service is being
opened to other users of this fork and request URLs contain their home
coordinates; do not retain request logs.

Tests (plain vitest, injected deps preserved): 202-then-200 flow via a
captured waitUntil promise, in-flight joiner dedup, failure-clears-flight
retry, plus a test-only resetInFlightState() to isolate module state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@selmapi
selmapi marked this pull request as ready for review July 6, 2026 04:46
@selmapi

selmapi commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

Independent review (Opus): APPROVE — no findings above Low. Verified: wire format encoder/decoder field-by-field parity, LittleFS tmp/rename lifecycle, 202-vs-15s-timeout path, worker single-flight lifecycle (no leaks/races), webapp poll loop (no timer stacking), landmines #1/MatixYo#5/MatixYo#6/MatixYo#8/MatixYo#10 compliance, Overpass UA+politeness preserved. Suites: worker 35/35 + tsc clean, native 54/54, supermini build SUCCESS with clean tree after regeneration.

🤖 Generated with Claude Code

rebuildForLocation() streams a new map to /map.bin.tmp and renames it
over /map.bin, but region_map_source.cpp keeps /map.bin open for the
process lifetime so the renderer can seek/read records on demand.
LittleFS can't rename over (or reliably remove) an open file, so every
rebuild with an existing map failed with kWriteError even after a
successful fetch.

Add mapSourceRelease() to close the handle and fall back to the baked
arrays, called as late as possible (right before the rename block) so
the old map stays drawable through the whole streaming phase. Both the
success and the still-failing-after-retry kWriteError paths now call
mapSourceInit() so the source is always re-attached rather than left
detached until reboot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@selmapi
selmapi merged commit 706bff6 into main Jul 6, 2026
@selmapi
selmapi deleted the claude/v3-map-service-radar-wqa0ip branch July 8, 2026 00:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants