Skip to content

Diffuse-fixture LED capture: strided blink + adaptive blob detector - #152

Open
fughilli wants to merge 2 commits into
mainfrom
diffuse_capture
Open

fughilli wants to merge 2 commits into
mainfrom
diffuse_capture

Conversation

@fughilli

@fughilli fughilli commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Diffuse-fixture LED capture

Maps LED fixtures that sit behind a diffuser. Today's capture lights every LED every frame and carries identity in hue — on a diffuser adjacent spots bleed together (merged blobs, washed chroma) and the diffuser flattens each spot's luma derivative, so the blob detector under-segments and the decode fails.

Two-pronged fix.

Stage A — model the problem, then beat it (fully verified)

A headless image-space simulator (web/src/sim/render.ts) renders real camera frames through an energy-conserving diffuser convolution (reproduces both symptoms — color bleed and the lowered luma derivative) and runs the production detector core (reducedToBlobs, extracted from detect.ts) → CvPipeline. web/tests/diffuse_sim.test.ts asserts a 2×2 ablation:

default detector + adaptive detector
all-lit 0.00 0.00
strided 0.00 1.00

Clean (no diffuser) floor = 1.00 → striding and the adaptive detector are each necessary and jointly sufficient. The detector fix is a local-contrast top-hat (large-σ background subtract + low threshold = local adaptive detection), hill-climbed against the sim.

Stage B — wire it, firmware-first (behind ?diffuse=1)

  • Stride schedule (web/src/code/stride.ts, mirrored in the Rust pattern crate, pinned by a shared golden): S uniform coverage phases + S-1 sparse bridge phases → per-phase spacing ≥ S, full coverage, and a depth-2 registration star so partial per-phase maps fuse with bounded error.
  • Protocol: stride_spacing / anchor_density / stride_phase on StartMappingOptions / ConfigureOptions / CodeParams (proto + JSON schemas + regenerated Rust / Python / TS / buf bindings).
  • Firmware player: masks non-lit LEDs (returns black, not None) and advances the phase on Configure, keeping spacing/anchor.
  • Detector: detect.ts GL fragment-shader top-hat, gated off by default.
  • Capture screen: ?diffuse=1&stride=N&anchor=M enables the prefilter + low threshold and rotates the stride phase across epochs.

Try the preview

Open the capture screen with ?diffuse=1&stride=4&anchor=3 (tune with ?lcgain=, ?threshold=). Without the flag, behavior is unchanged (stride params unset ⇒ legacy all-lit).

Verified

//web:unit_tests (incl. stride + diffuse_sim), //firmware/pattern:golden_stride_test (Rust↔TS parity), //firmware/player:session_test (masking + phase rotation), //shared/protocol/rust:conformance_test, web app typecheck, and //firmware/player_app:esp32c6 builds. On-device tuning of S/A/σ/gain/threshold against a real diffuser is the remaining step. Native-iOS diffuse and Pi-player diffuse are follow-ups.

Based on #151 (in the merge queue); until it lands, this PR's diff includes its two netstack commits — they drop out automatically once #151 merges.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://fughilli.github.io/splanc/pr-preview/pr-152/

Built to branch gh-pages at 2026-09-30 17:38 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

fughilli pushed a commit that referenced this pull request Sep 21, 2026
…152 decoder infra)

The mapping journey needs a camera; under the app-driver (?driver=) there is none.
SyntheticCaptureSource renders a KNOWN fixture's LEDs — projected through a pinhole
camera on an arc (geom/pinhole.project) and coloured with the SAME hue-code the
firmware drives (code/gray.colorForFrame) at the SAME frame timing
(code/timing.frameIndexAt) — into a pre-reduced RGBA frame (alpha = lit mask, RGB =
colour), exactly the "virtual image → decoder" convention the synthetic pipeline
test is built on (web/tests/pipeline_synthetic.test.ts). So the real detector CCLs
the blobs and the real decoder recovers LED indices, with no camera and no scene.

capture.ts takes a driverActive() branch (mirrors the iOS native reduced-frame path:
offscreen GL context, no-op exposure target) and hands the source the negotiated
code-book (CodeParams + epoch + clock) right after startMapping resolves, so the
render matches what the decoder expects.

Verified: //web:web_ts_typecheck_test + //web:web_tests_js compile. Loaded only
behind the guard → zero production bundle impact. Next: a device backend (mock WSS
server or the rig C6) to run the browser smoke end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fughilli added a commit that referenced this pull request Sep 21, 2026
… camera/IMU + Android Nix lane) (#177)

* pi/hitl/phone,web: app-driver WebSocket control channel + virtual BLE (phone HITL MVP)

Phase 0 of the phone-in-the-loop HITL station: drive the web/PWA app through the
basic user journeys (scan/connect, mapping, gamma/hardware config) over a WebSocket,
so we can debug the pairing/connection/mapping flow with a phone (emulator now, real
device + gantry later) in the loop.

Web app (guarded by ?driver=<ws-url>, dynamic-imported → zero production cost, mirrors
the shipped ?demo= seam):
- web/src/driver/{guard,harness}.ts — the app-driver: opens a WS to the station,
  streams appState transitions + milestones, and dispatches JSON commands to the exact
  production functions a user's taps invoke (appState.connect, provisionViaBle,
  client.startMapping/stopMapping/setHardwareConfig/setColorCorrection, router.navigate).
- web/src/net/virtualBle.ts — virtual Improv + player BLE devices (promoted from the
  in-line test fakes), swapped in behind the guard at requestImprovDevice()/
  requestBleDevice() so journeys run headless with no radio.
- ui/app/main.ts — the ?driver= branch, installs the guard before any screen mounts.

Station (pi/hitl/phone/, uses websockets like the rest of the harness):
- driver_server.py — the WS server + async driving API (command/query/wait_event).
- journeys.py — the three journeys asserting on the app's real replies.
- launcher.py — headless-Chromium (serves //web:dist) + Android-emulator lanes.
- phone_e2e.py + BUILD.bazel — the runner.

Verified: //web:web_ts_typecheck_test + the BLE unit tests pass; //pi/hitl/phone:phone
builds. Next: synthetic camera source (mapping journey) + a browser smoke run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: synthetic capture source for the phone HITL harness (reuses the #152 decoder infra)

The mapping journey needs a camera; under the app-driver (?driver=) there is none.
SyntheticCaptureSource renders a KNOWN fixture's LEDs — projected through a pinhole
camera on an arc (geom/pinhole.project) and coloured with the SAME hue-code the
firmware drives (code/gray.colorForFrame) at the SAME frame timing
(code/timing.frameIndexAt) — into a pre-reduced RGBA frame (alpha = lit mask, RGB =
colour), exactly the "virtual image → decoder" convention the synthetic pipeline
test is built on (web/tests/pipeline_synthetic.test.ts). So the real detector CCLs
the blobs and the real decoder recovers LED indices, with no camera and no scene.

capture.ts takes a driverActive() branch (mirrors the iOS native reduced-frame path:
offscreen GL context, no-op exposure target) and hands the source the negotiated
code-book (CodeParams + epoch + clock) right after startMapping resolves, so the
render matches what the decoder expects.

Verified: //web:web_ts_typecheck_test + //web:web_tests_js compile. Loaded only
behind the guard → zero production bundle impact. Next: a device backend (mock WSS
server or the rig C6) to run the browser smoke end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* pi/hitl/phone: runnable browser lane — headless Chromium drives the app-driver loop

The station now brings the real web app up in headless Chromium (Playwright, async)
serving //web:dist at ?driver=, waits for it to connect back, and runs journeys. A
device-free `smoke` journey exercises the whole loop with NO backend: navigate +
virtual-BLE provisionBle (the virtual Improv peripheral answers the RPC in-app), so
it validates the app-driver + guard + virtual BLE end to end.

VERIFIED end to end: `bazel run //pi/hitl/phone:phone_e2e -- --browser --journeys smoke`
loads the app, the app connects back over the driver WS ("app ready"), and the station
drives it to a provisioning redirect ("PASS smoke", "ALL JOURNEYS PASSED").

- launcher.py: ensure_chromium() (one-time Playwright install, like the docs capturer)
  + async open_chromium(); dropped the raw-subprocess launcher.
- phone_e2e.py: Playwright browser lane + a journeys map runner.
- journeys.py: journey_smoke (device-free).
- BUILD: + playwright dep.
- .claude-container-overlay: record the Chromium runtime libs (libnss3/libgbm1/… t64)
  so the browser lane is reproducible across container relaunches; + the apt-install
  helper it was recorded with.

The connect/mapping/config journeys still need a device backend (--device-ws to a
rig-forwarded C6 or a mock) — next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* pi/hitl/phone: mock device backend — connect + config journeys green in-container

A self-contained mock splanc device (mock_device.py) speaking the ledmapper.v1 wire
via the real Pi-server codec (pi/server proto_wire): hello->welcome, time_sync,
get/set_hardware_config->hardware_config_state, set_color_correction->welcome,
start/stop_mapping + uploads. It handles the firmware-only config messages the real
Pi reconstruction server doesn't, so the config journey needs no ESP32-C6; point
--device-ws at a rig C6 to exercise real firmware instead.

phone_e2e gains --mock-device (spins the mock up in-process, points the app at it).

VERIFIED end to end in headless Chromium:
  bazel run //pi/hitl/phone:phone_e2e -- --browser --mock-device --journeys connect,config
  → PASS connect (virtual BLE provision → connect → welcome MAC)
  → PASS config  (setHardwareConfig → hardware_config_state; setColorCorrection → welcome)
  → ALL JOURNEYS PASSED

journey_config drives the client config RPCs directly rather than mounting the
Hardware Setup screen — that screen fires its own getHardwareConfig which would
collide with setHardwareConfig on the same reply type (one pending request per reply).

Remaining: the mapping journey (the capture-screen lifecycle drives start/stop itself +
solve placement) is the harder follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* pi/hitl/phone: user journeys as declarative JSON data (inputs + expectations)

Move the three user journeys from imperative Python to STRUCTURED DATA — Maestro-style
(declarative flows + reusable subflows) but over our semantic app-driver protocol, not
DOM selectors (surveyed Maestro / Chrome DevTools Recorder / Puppeteer Replay; all are
DOM-oriented, so we borrow the shape, not the schema).

- journeys/*.json — each journey is {name, description, inputs, steps}. A step is
  {"do"|"query": <method>, "with": {...}, "expect": {...}} or {"await": "connected"|
  "milestone:<name>"|"state:<s>"} or {"runFlow": "<journey>"} (reusable subflow).
  `with` values interpolate ${var} from inputs+runtime context (an exact "${var}"
  preserves the value's type). Expect DSL: present / nonempty / equals / contains,
  with dotted paths into nested replies.
- journey_runner.py — loads the JSON, interpolates, dispatches to the app-driver,
  and asserts each step's expectations. Replaces the imperative journeys.py.
- phone_e2e wires the registry + a runtime context (device_ws / ssid / led_count).

VERIFIED: `--browser --mock-device --journeys smoke,config` → PASS (config runFlow's
connect, so it's self-contained). The format maps 1:1 onto the driver, so new journeys
are data, not code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* pi/hitl/phone: real rig-reservation device backend (journeys vs real firmware)

Adds --reservation alongside --mock-device so the phone journeys run against a REAL
ESP32-C6 on the HITL rig, not just the mock. reservation_backend.py reserves a C6,
flashes the netstack player bundle, provisions it onto the rig AP over BLE Improv, and
res.forward()s its wss:443 to localhost — reusing the existing harness (hitl_client.
Reservation + provision.provision_dut/dut_target). It's a sync context manager entered
before the async run; the tunnel + heartbeat live in their own subprocess/thread so
they survive asyncio.run.

VERIFIED end to end on hitl-rig-2 (real c6-003f08): reserve → flash → BLE-provision
(DUT at 10.42.0.28) → forward → headless Chromium connects to the real C6 over wss →
config journey PASSES against real firmware (welcome MAC, set_hardware_config ->
hardware_config_state, set_color_correction -> welcome), then releases cleanly.

So the same declarative journeys now run against either backend:
  --browser --mock-device   --journeys smoke,config      (self-contained, no rig)
  --browser --reservation    --journeys config            (real firmware via the rig)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* pi/hitl/phone: station README + MVP BOM

Document the phone-in-the-loop HITL station in one place (until now it lived only in
commit messages): quickstart for each lane/backend, how the app-driver + virtual BLE +
synthetic camera fit together, the declarative journey-JSON format + app-driver method
reference, and the MVP bill of materials (Android/Linux station first, iOS/Mac later,
shared rig) with a forward-looking gantry/ambient-light note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* pi/hitl/phone: Android emulator lane — SDK + adb via Nix in the build system

Bring the Android debug tools into the build via the Bazel-pinned nixpkgs (25.05), no
ad-hoc SDK install:

- @android_tools (//pi/hitl/phone:adb) — adb / platform-tools. Cross-platform; VERIFIED
  building AND running on the aarch64 container (Android Debug Bridge 1.0.41).
- @android_emulator (//pi/hitl/phone:android_emulator) — an androidenv composed SDK
  (emulator + Google-APIs system image + cmdline-tools) from pi/hitl/phone/nix/
  android-emulator.nix, native-ABI system image per host (arm64-v8a on Apple Silicon,
  x86_64 on x86_64). The Android SDK ships the emulator for macOS (Intel + Apple Silicon)
  and linux-x86_64 but NOT linux-aarch64 (the derivation reports "no sources for os=linux,
  arch=aarch64"), so the target is target_compatible_with {macOS, linux-x86_64} and cleanly
  skipped as incompatible on linux-aarch64 (this container). Also tagged manual (a multi-GB
  SDK download only this lane needs) so it stays out of `bazel build //...`.

launcher.py resolves adb/emulator/avdmanager from the Nix SDK ($ANDROID_SDK_ROOT, PATH
fallback), creates the AVD from the bundled system image, boots the emulator headless, and
opens the served app in the emulator at 10.0.2.2 (its host-loopback alias — the mock/rig
device URL is rewritten to it too). phone_e2e gains the --android branch.

Verified (aarch64): `bazel build //pi/hitl/phone/...` green (emulator skipped); adb builds
+ runs; explicit emulator build is cleanly incompatible (not a nix failure). Booting the
emulator needs a station with a hypervisor — an Apple-silicon Mac (which can host BOTH the
Android and iOS lanes) or a linux-x86_64 box with /dev/kvm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* pi/hitl/phone: custom Nix derivation for the arm64-Linux Android emulator

Upstream nixpkgs' androidenv ships the emulator for macOS (Intel + Apple Silicon) and
linux-x86_64 but NOT linux-aarch64 ("no sources for os=linux, arch=aarch64") — yet the
arm64-Linux emulator exists (Asahi etc.). So provide it via a custom derivation, and widen
the //pi/hitl/phone:android_emulator target to macOS + Linux (x86_64 + aarch64).

android-emulator.nix: on aarch64-linux, compose the emulator-less SDK (cmdline-tools +
arm64-v8a system image, arch-agnostic) and merge in a custom emulator built from Google's CI
(ci.android.com — the only source). Reuses nixpkgs emulator.nix's exact buildInputs +
autoPatchelf + LD_LIBRARY_PATH wrap.

Sourcing it reproducibly is the subtle part: ci.android.com serves the emulator only via a
TEMPORARY signed storage.googleapis.com URL (Expires=…&Signature=…) and garbage-collects old
builds. A plain fetchurl can't pin that, so the source is a FIXED-OUTPUT derivation (pinned by
content hash) whose builder scrapes the CI page, resolves the fresh signed URL, and downloads
— verified working end to end here (scrape → signed URL → download → nix hash-check). The one
thing that needs a maintainer/station: a CURRENT emulatorBuild + its hash (the checked-in
example build is GC'd → NoSuchKey); outputHash is lib.fakeHash with a PIN MAINTENANCE note.

Verified (aarch64): `bazel build //pi/hitl/phone/...` green (emulator `manual` → excluded);
nix file parses; the fetch machinery downloads + hash-verifies. Booting still needs a
hypervisor host (KVM / Hypervisor.framework).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* pi/hitl/phone: fix the aarch64 emulator fetcher — use the CI download API, not HTML scrape

The previous fetcher grepped the ci.android.com artifact page for a storage.googleapis.com
URL. That no longer works (and my earlier "verified end to end" claim was wrong): the current
Artifact Viewer page ships `artifactUrl:""` and resolves the signed URL at runtime via the
build API — the HTML contains no URL to grep. Replace it with the actual mechanism:

    androidbuildinternal.googleapis.com/.../builds/<id>/emulator-linux_aarch64/attempts/latest/
      artifacts/sdk-repo-linux_aarch64-emulator-<id>.zip/url?redirect=true

which 302-redirects to the temporary signed URL. Anonymous access is allowed for public builds
— VERIFIED reachable here (the endpoint returns semantic 404s, not auth errors). `curl -fL`
follows the redirect; a `PK` magic-byte check fails the build loudly on a purged/missing build
instead of hashing a JSON error page.

Also corrected, honestly:
- The build id ships UNSET (`0`) rather than a purged example that looks real; the derivation
  fails loudly until a current `aosp-emu-master-dev` id + `outputHash` are pinned.
- PIN MAINTENANCE now says the id must be read off the emulator grid IN A BROWSER: the
  build-LIST REST API is anonymously rate-limited + deprecated ("migrate to Build API v4"),
  and the grid renders its list via JS — so scripted discovery is unreliable (the *download*
  path is fine anonymously; only listing is walled).
- Noted that dl.google.com's SDK channel carries only emulator-linux_x64 + emulator-darwin_
  aarch64 (verified against repository2-*.xml) — its build numbers have no linux_aarch64
  target, so they can't be reused as the pin.

Verified (aarch64): nix file parses; `bazel build //pi/hitl/phone/...` green (emulator is
`manual` → excluded). The emulator itself still can't build here until a live id is pinned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* pi/hitl/phone: deep mapping journey — drive the real capture screen through decode

The mapping journey only drove the start/stop-mapping RPCs; the synthetic camera → real
detector → real decoder path was never exercised through the actual app. Now it is.

- web/src/driver/guard.ts: a capture-controller registry (stats() + finish()) the capture
  screen populates under ?driver=.
- web/src/ui/screens/capture.ts: under the driver, register that controller on mount /
  clear on unmount; publish live decode stats each frame; settle the harness's finish()
  when the solve completes / fails (incl. the "nothing to solve" + unmount paths, so it
  can't hang).
- web/src/driver/harness.ts: openCapture (navigate to /capture — it owns start-mapping +
  the synthetic scene), captureStats / awaitDecode (block until N LED ids are recovered),
  finishCapture (end + VIO solve). finishCapture is timeout-bounded: solve() has no timeout
  of its own and the VIO solver can stall on a scene with no inertial data.
- pi/hitl/phone/journeys/mapping_capture.json: connect → openCapture → awaitDecode, asserting
  every LED id is decoded end to end through the app (not the RPC, not a unit test).
- pi/hitl/phone/phone_e2e.py + BUILD: serve the solver deployment (//solver:solver_web —
  wasm + worker) at /solver/ like the Pi server does, so the on-device solve path can load;
  merged serve root assembled via symlinks.
- pi/hitl/phone/launcher.py: pipe the page console + uncaught errors to stderr — the only
  window into the in-page journey when headless (this is how the decode/solver issues were
  found).

Verified (in-container, headless Chromium vs the mock): smoke + connect + config + mapping +
mapping_capture all green; the synthetic camera decodes 30/30 LED ids through the real
capture screen. The final VIO solve is wired (finishCapture) but left out of the default
journey — the visual-inertial solver needs real motion/IMU the headless synthetic scene
doesn't yet synthesize (planar fixture, no DeviceMotion); that synthesis is the follow-up.
Web typecheck + 76 unit tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* pi/hitl/phone: synthetic IMU + parallax — the real VIO solve now converges

mapping_capture proved decode; the full visual-inertial solve couldn't run because the
synthetic scene had no inertial data (and solve() has no timeout, so it hung). Fix both ends:

- web/src/xr/syntheticCaptureSource.ts: emit a synthetic IMU stream consistent with the pose
  track — body-frame angular velocity (so3_log of the rotation finite-difference) and specific
  force Rᵀ·(a_world − g) from the second difference of position, at 60 Hz, stamped in the
  frames' performance.now clock so the solver aligns inertial + visual by timestamp. Widen the
  camera path to the benchmark's radius-1.8 sweep for real parallax. This is a port of the
  solver's own synth.rs (its solvable canned problem), with the web geom/pinhole conventions
  (quatToRotMat = camera-to-world). The source now also serves as the capture screen's IMU
  flusher under the driver.
- web/src/ui/screens/capture.ts: use the synthetic source as the imuFlusher under ?driver=
  (no DeviceMotion in a headless browser); driver-only solve diagnostics.
- web/src/driver/harness.ts: finishCapture asserts the solve recovered ≥ half the fixture.
- pi/hitl/phone/mock_device.py: reply `mapping_stopped` to stop_mapping{solveOnHost:false} —
  the phone-solve path awaits that ack, not `result_ready`. THIS was what wedged finishCapture
  (before the solve was even reached); not the IMU.
- pi/hitl/phone/journeys/mapping_solve.json: connect → openCapture → finishCapture, asserting
  a solved map.

Verified (headless Chromium vs the mock): the VIO solve converges — 960 detections + 516 IMU
samples, gravity correctly ~9.8 m/s² in body-Y, progress 0→80% with reprojection RMS 1.0→0.6px,
all LEDs recovered. All 6 journeys green (smoke, connect, config, mapping, mapping_capture,
mapping_solve); web typecheck + 76 unit tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* pi/hitl/phone: real-BLE lane — software Improv peripheral (Bumble) + app toggle

The real-BLE lane replaces the app-seam virtual-BLE mock with a real OS BLE stack pairing with
a software Improv peripheral — so we exercise real pairing/provisioning, not the mock.

- pi/hitl/phone/ble_peripheral.py: a Bumble Improv GATT server (the same service a real
  ESP32-C6 advertises). Mirrors web/src/net/improv.ts (UUIDs + wire) and virtualBle.ts's
  behaviour: on a wifi-settings RPC it answers RPC_RESULT with a redirect URL and steps
  CURRENT_STATE → PROVISIONED. run()/main() advertise it on a real Bumble transport
  (android-netsim / hci-socket / RootCanal) for the station.
- pi/hitl/phone/ble_peripheral_test.py: a Bumble CENTRAL drives the full Improv handshake over
  a LocalLink — two complete BLE stacks (LL/L2CAP/ATT/GATT), no radio, no emulator. Verified
  in-container incl. the MTU-negotiation gotcha (the default 23-byte MTU truncates the redirect
  notification — real clients negotiate up) and the Improv error path.
- App real-BLE toggle: `?driver=…&ble=real` makes the improv/bleTransport swaps fall through to
  real Web Bluetooth instead of the virtual mock (new driverUsesVirtualBle() predicate +
  setDriverBleMode in main.ts).
- requirements: add `bumble` (+ transitive deps: cryptography, pyusb, …). BUILD: py_library
  `ble_peripheral`, py_test `ble_peripheral_test`, py_binary `ble_peripheral_server`.

Verified (in-container): ble_peripheral_test green under Bazel (cryptography does NOT SIGILL
under the hermetic interpreter here); web typecheck + 76 unit tests green; the virtual-BLE
journeys (smoke/connect/config) still pass after the guard-predicate refactor.

Station-only remainder (needs a hypervisor): boot the emulator, attach the peripheral over
Netsim, and drive the Web Bluetooth chooser under automation — the peripheral + protocol (the
portable, hard part) are done and CI-tested; the emulator boot + chooser is the spike.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* pi/hitl/phone: record that the arm64-Linux emulator is discontinued upstream

Chasing a pinnable aosp-emu-master-dev build turned up a dead end: the branch is FROZEN — last
green build 13278466 (2025-03-28) — and even that build's linux_aarch64 zip is garbage-collected
(getdownloadurl → "attempt not found"; a maintainer confirmed the grid download 404s). No
successor branch publishes a linux_aarch64 target, and the current released emulator has none
either. So there is nothing live to pin from Google's CI.

Update the PIN MAINTENANCE note (nix), the Android-lane section + Status (README), and the
BUILD comment to say so plainly and point to the working path: run the emulator lane on a
macOS (Apple Silicon/Intel) or linux-x86_64 station, where nixpkgs ships the emulator upstream
with NO pin. Only a native arm64-LINUX station hits this gap, and its sole remaining route is
building the emulator from source. The custom derivation + fetch machinery stay in place (still
verified reachable) in case Google republishes; `emulatorBuild` stays UNSET so it fails loudly.

No code change — docs/comments only. nix parses; `bazel build //pi/hitl/phone/...` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Agent <k4757026@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a diffused fixture, lighting every LED every frame blends adjacent spots
(color bleed) and the diffuser lowers the luma derivative, so the blob
detector under-segments and the per-LED hue decode is corrupted.

Two-pronged fix.

Stage A — model + validate. A headless image-space simulator (web/src/sim)
renders real camera frames through an energy-conserving diffuser convolution
(reproduces both color bleed AND the lowered luma derivative) and drives the
PRODUCTION detector core (reducedToBlobs, extracted from detect.ts) +
CvPipeline. A 2x2 ablation (web/tests/diffuse_sim) proves striding AND a
local-contrast top-hat detector are each necessary and jointly sufficient:
decode yield 0 / 0 / 0 / 1.0 across (all-lit,strided)x(default,adaptive) vs a
clean 1.0 floor.

Stage B — wire it (firmware-first), behind ?diffuse=1:
- Stride schedule authority (web/src/code/stride.ts, mirrored in the Rust
  pattern crate; pinned by a shared golden). S uniform coverage phases + S-1
  sparse bridge phases => per-phase spacing >= S, full coverage, and a
  depth-2 registration star so partial per-phase maps fuse with bounded error.
- Protocol: stride_spacing / anchor_density / stride_phase on
  StartMappingOptions / ConfigureOptions / CodeParams (proto + JSON schemas +
  regenerated Rust/Python/TS/buf bindings).
- Firmware player masks non-lit LEDs (returns BLACK, not None) and advances
  the phase on Configure, keeping spacing/anchor.
- detect.ts GL fragment-shader top-hat prefilter, gated off by default.
- Capture screen: ?diffuse=1&stride=N&anchor=M enables the prefilter + a low
  threshold and rotates the stride phase across epochs.

Verified: web unit tests, firmware pattern/player tests, proto conformance,
and the esp32c6 image builds. On-device tuning of S/A/sigma/gain/threshold
against a real diffuser is pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-investigated the "firmware doesn't report hardware config" note: the
hw-config report path IS present and reached in a matching-revision c6/c3 build
(not target-gated) and seeds two channels from NVS at boot, so a fresh device
reports >=1 channel and never 0 (#102 is an ancestor of HEAD, never reverted).
The note appeared only because getHardwareConfig() had no deadline: a firmware
too old to answer get_hardware_config (or a wedged link / mis-flashed image)
left the request pending forever and the pre-rendered note stuck with no error.
Not a firmware bug.

Robustness (client.ts + hardwareSetup.ts):
- request() takes an opt-in per-call timeout; getHardwareConfig uses it and
  rejects with a new RequestTimeoutError instead of hanging. Long-running calls
  (map solve -> result_ready) pass no timeout and keep waiting forever.
- Hardware Setup now tells apart not-connected / firmware-didn't-answer (version-
  aware "out of date" wording, enriched with the welcome's fw version + commit) /
  answered-but-zero-channels, instead of one conflated "doesn't report" note.

Capture-config UI, persistent (was only reachable via ?diffuse=1 and friends):
- Inline diffuse/strided toggle on the capture screen, persisted to Behavior
  Settings so re-mapping never needs the URL param again.
- Per-device stride on Hardware Setup (tracks a fixture's LED pitch/diffuser),
  persisted on the device record.
- Anchor density, local-contrast gain, threshold, downscale, flip-V in Behavior
  Settings (app-global).
- capture.ts resolves the effective config from these (per-device stride from
  the device; the rest from Behavior Settings); URL params remain per-run
  overrides.

Tests: request timeout + first-settle-wins, per-device stride clamp/persist,
diffuse params/toggle persistence. web typecheck + unit suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

This branch had an error being deployed

1 failed deployment
HITL — 929b366f Deployed Oct 4, 2026 by fughilli via hitl_tests #697
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