Skip to content

pi/hitl/phone: wire the iOS-native app-driver lane + dynamic C6 port discovery - #227

Draft
fughilli wants to merge 28 commits into
mainfrom
claude/ios-native-driver-wiring
Draft

fughilli wants to merge 28 commits into
mainfrom
claude/ios-native-driver-wiring

Conversation

@fughilli

@fughilli fughilli commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Stands up the Mac mini iOS HITL bench (Phase D) and makes the iOS app-driver journey lane run fully reservation-driven — one command from the container/CI, zero manual Mac steps — and gets smoke PASSING on the real iPhone.

Verified

bazel run //pi/hitl/phone:ios_reservation -- --server http://mac-mini:8087 --journeys smoke --ble-mode virtual from the container drives the whole chain and ends in [phone] PASS smoke / ALL JOURNEYS PASSED: reserve ios-phone → darwin-runner ssh → ship the harness into the reserved session → run phone_e2e on the Mac → the launchd build-server (a user LaunchAgent) cap-syncs + signs + installs + launches the Capacitor app on the physical iPhone → the app loads the station URL, enters driver mode, connects back to the driver WS, runs the journey. Unit tests + builds green (//pi/hitl/tests:hitl_test, //pi/hitl/phone:phone_target_test, //pi/hitl/phone:ios_reservation_test).

What it adds

Native iOS driver wiring — a native WKWebView loads the bundled web/dist with no query string, so the ?driver=…&ble=real seam (web/src/ui/app/main.ts) was unreachable. Point Capacitor's server.url at the station-served app URL (which carries the query) via CAP_SERVER_URL, so the WebView enters driver mode; native BLE stays on the Capacitor Improv plugin, and the C6's self-signed wss is handled by the existing @splanc/wss-bridge plugin. phone_target.py's iOS client hits the real tools/ios_build_server.py /run?task= endpoint. launcher.serve_dir binds 0.0.0.0; ATS NSAllowsLocalNetworking.

Reservation-driven run path — pi/hitl/phone/ios_reservation.py (container orchestrator) reserves the unit, ships the harness+app+solver+firmware as one dereferenced tar, discovers the C6 port dynamically (serial_discovery.py, Espressif VID 0x303a, by adapter serial), and runs phone_e2e on the Mac. hitl-darwin.nix makes every piece a flake-provided service.

The macOS bring-up (each commit fixes a distinct, live-diagnosed blocker):

  • reservation user materialized non-blocking via dscl (sysadminctl hangs on the FDE dialog), with a proper AuthenticationAuthority, and added to the com.apple.access_ssh SACL;
  • reserved-session ssh reads the runner's root:0600 authorized_keys via AuthorizedKeysCommand … AuthorizedKeysCommandUser root (macOS sshd reads it as the user, which can't);
  • build server runs as a LaunchAgent in the operator's login session (headless signing/devicectl need it) with system.primaryUser, logs to ~/Library/Logs, a hermetic tool PATH, and a PathState gate; workspace on an internal path (a launchd daemon can't read /Volumes — TCC);
  • signing env + keychain in a build-user-readable path, with set-key-partition-list for non-interactive key access;
  • reserved-session python is the nix pyEnv (websockets + pyserial).

Known follow-up

connect/config/mapping_* are the next journeys — they need the C6 flashed + provisioned over real BLE and the netstack staying associated on a reachable AP (the same constraint the Android lane hit). This PR delivers the bench + the native driver lane + smoke-green.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 29, 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-227/

Built to branch gh-pages at 2026-10-03 03:26 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

claude added 5 commits October 3, 2026 02:27
…discovery

The Mac iOS bench (#189) stood up the reservation daemon, signing and a bare
app install, but the app-driver JOURNEY lane could not actually run on a real
iPhone: a native Capacitor WKWebView loads the bundled web/dist at
capacitor://localhost/ with no query string, so the `?driver=…&ble=real` seam
(web/src/ui/app/main.ts) — which starts the journeys — was unreachable. And the
iOS client posted to a build server that doesn't exist (:8765 /device-*?url=)
instead of the real tools/ios_build_server.py (:8099 /run?task=…).

Drive the iPhone the SAME way as Android by pointing Capacitor's server.url at
the station-served app URL (which carries the query) so the WKWebView loads it
over the LAN and enters driver mode; native BLE keeps bridging through the
Capacitor Improv plugin.

- web/capacitor.config.ts: set server.url from CAP_SERVER_URL when present
  (unset = load the bundle, as before).
- tools/ios_build_server.py: accept a `server_url` param on /run and pass it as
  CAP_SERVER_URL into the task env so `cap sync` bakes it (env only, never argv;
  validated to an http(s) URL).
- pi/hitl/phone/phone_target.py: IosBuildClient now hits the real /run?task=
  endpoint on :8099; the iOS targets thread the app URL through server_url on a
  cap-sync→build→install→launch chain (not a devicectl launch url=, which can't
  open a URL on a real device). Default bundle id fixed to dev.splanc.app.
- pi/hitl/phone/launcher.py: serve the app on 0.0.0.0 so an off-box phone can
  fetch it (+ SO_REUSEADDR); the returned base stays loopback for the browser lane.
- web/ios-config/apply.sh: NSAllowsLocalNetworking so ATS permits cleartext to
  the LAN station + device (narrower than arbitrary loads; App-Store-acceptable).

Dynamic C6 serial-port discovery (owner requirement — never hardcode the
renumbering /dev/cu.usbmodem*): pi/hitl/harness/serial_discovery.py resolves the
port from the stable USB identity (Espressif VID 0x303a), matching the reserved
board by its HITL_ADAPTER_SERIAL when several C6s are attached. Reusable on the
Mac bench and the Linux rigs; a py_binary CLI prints the path for
`esptool --port "$(…)"`. Pure selection logic is dependency-injected and
unit-tested without pyserial/hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…+ container orchestrator)

Follow-up to the driver wiring: drive the Mac iOS bench the same way as the Linux
rig DUTs — reserve from the container, execute host-natively on the Mac via the
darwin runner — with zero hand-run Mac commands.

hitl-darwin.nix (flake-provided hooks, so nothing is started by hand):
- launchd `ios-build-server` service: runs tools/ios_build_server.py on loopback
  :8099 as the operator account (Xcode + signing keychain) against a Mac checkout.
  Added when the host sets iosBuildUser + iosBuildWorkspace; signing/ASC secrets are
  sourced at launch from ${stateDir}/ios-build.env (outside the nix store), never
  committed. The reserved session drives it over 127.0.0.1.
- reserved session's python is now python3 + websockets (pyEnv), which the shipped
  phone_e2e driver_server needs; the rest of the iOS lane is stdlib (real BLE runs
  on the phone via the Capacitor plugin, so no bleak here).
- header documents the reservation-driven flow + the genuinely one-time human setup.

ios_reservation.py (container/CI orchestrator, //pi/hitl/phone:ios_reservation):
reserves the `ios-phone` unit, ships the harness + app + solver + C6 firmware bundle
into the reserved session as one tar, resolves the C6 serial port dynamically
in-session (serial_discovery), and runs phone_e2e ON THE MAC — HITL_STATION_IP =
the Mac's own LAN IP, IOS_BUILD_SERVER = the loopback launchd service, app dirs via
env (no bazel/runfiles on the Mac). Device identifiers stay out of the repo — the
UDID/adapter serial ride in from the reservation env. Pure payload/command
construction is unit-tested offline (ios_reservation_test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…session SSH)

The reserved-session SSH grant was rejected on the live Mac because the `hitl`
account never existed: nix-darwin declares users.users.hitl but does NOT create a
fresh macOS account (dscl -> eDSRecordNotFound), so sshd could never authenticate a
non-existent user (its password/keyboard-interactive offer was a generic
anti-enumeration response, not a real fallback). Everything else was correct — the
sshd drop-in (Match User hitl -> the managed AuthorizedKeysFile, PermitUserEnvironment
yes), StrictModes, and /var/lib/hitl perms all check out.

Add an idempotent postActivation script that creates the account with sysadminctl
(a proper auth-authority-bearing standard user, so pubkey SSH works; a random unused
password keeps it key-only), hidden from the login window, uid 555, staff group, a
STABLE /bin/bash shell (not a GC-able nix-store bash), home + ~/.ssh (0700) for the
signing-secret env seam. No-op if the record already exists. One `darwin-rebuild
switch` now creates the user AND deploys the launchd ios-build-server + websockets.

Also flag the bind-mount hazard: /workspace is a bind-mount of the operator's
day-to-day checkout, whose branch flips as container agents `git checkout`, so
iosBuildWorkspace MUST be a DEDICATED clone pinned to a stable ref — the build server
would otherwise build a random agent's branch. (The app content the iPhone loads is
served from the tar the reserved session ships, so this checkout's web/dist staleness
is irrelevant; it supplies only the native iOS project + config + plugins + signing.)

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

The dedicated build checkout may live on an external volume (e.g.
/Volumes/MacMiniExt/Projects/splanc-iosbuild) that mounts late at boot or detaches.
A plain KeepAlive=true would crash-loop while the exec path is missing. Gate
KeepAlive on PathState of the server script instead: launchd runs the job only while
that path exists — starting it when the volume mounts and stopping it cleanly when
the volume is absent. RunAtLoad stays for the mounted-at-boot case.

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

Three hardening fixes surfaced during the Mac bring-up:

1. User materialization no longer hangs. sysadminctl -addUser blocks on a GUI
   secure-token/FDE authorization dialog when run non-interactively on a FileVault
   Mac (it stalled the first switch until the operator clicked an invisible prompt;
   a headless/CI activation would hang forever, and macOS has no `timeout` to bound
   it). Create the account with plain `dscl` instead — no FDE dance, non-blocking —
   and `dscl -passwd` (root, no old password) for a proper pubkey-usable auth record
   (random unused password; password login stays disabled). Guard on UniqueID; every
   step `|| true` so activation can't be aborted.

2. Hermetic build-server PATH. Reference the nix tools (node/pnpm/cocoapods/git/
   bazelisk/jq) by absolute store path (lib.makeBinPath), prepended — so a launchd
   env without /run/current-system/sw/bin can't cause `command not found`. bazelisk
   (only needed here for the one-time web-build) lives in this service PATH, not
   systemPackages; Xcode's xcodebuild/devicectl stay via /usr/bin + DEVELOPER_DIR.

3. ios_build_server fails LOUDLY on a port conflict. If :8099 is already held (e.g.
   a stale hand-started server from another checkout squatting the port and
   answering with the wrong workspace/PATH), print a clear message naming the
   `lsof -nP -iTCP:8099 -sTCP:LISTEN` check and exit non-zero, instead of a bare
   traceback / crash-loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 20 commits October 3, 2026 02:27
…ar/log

The launchd service never started: state "spawn scheduled", active count 0, and no
log file at all. Root cause: /var/log is 0755 root:wheel, so a UserName=kevin
LaunchDaemon cannot create its StandardOut/ErrorPath there — launchd fails to set up
the job and never spawns it (the missing log file is the tell; a TCC/exec error would
have produced a log). Verified the code itself is fine: running the exact command as
kevin binds and serves :8099.

Point the logs at the service user's ~/Library/Logs (writable by them). No other
service path needs /var write: the bazel cache is ~/.cache and web/dist + web/ios are
written inside the (kevin-owned) workspace.

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

The hitl user existed on the live Mac (UID 555, home, shell) but had NO
AuthenticationAuthority — the earlier hung sysadminctl created a partial record and
never finished the auth setup. macOS rejects EVERY login for such an account, pubkey
included, so the reserved-session ssh kept failing ("pubkey offered → Permission
denied") even though the user "exists". The dscl create-guard skipped the existing
user, so it never repaired it.

Move the auth-record materialization OUT of the create guard: whenever
AuthenticationAuthority is missing (a partial sysadminctl account OR a fresh dscl one),
`dscl -passwd` a random unused password to create ShadowHashData + the auth authority.
Idempotent (only when missing), non-blocking, password login stays unused.

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

Verified on the live Mac: `dscl -passwd` populated ShadowHashData (a password) but
left the account with NO AuthenticationAuthority key, while a working account (kevin)
has `;ShadowHash;HASHLIST:…`. Without that pointer macOS authenticates nothing for the
account. Create `AuthenticationAuthority ";ShadowHash;"` before setting the password.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…h blocker)

Reserved-session ssh failed even with a valid per-reservation key, correct
authorized_keys perms, and a complete auth record. Root cause: macOS Remote Login
is gated by the `com.apple.access_ssh` SACL group (which nests admin), and the
non-admin `hitl` service account was not a member — so sshd rejects it at login
regardless of the key. Add it to that group in the activation (idempotent), which
grants ssh access without making the account an admin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(macOS ssh fix)

Definitive root cause from the live Mac sshd-session log:
  Could not open user 'hitl' authorized keys '/var/lib/hitl/authorized_keys': Permission denied

macOS sshd reads AuthorizedKeysFile AS THE TARGET USER, but the darwin runner writes
the managed key file root:0600 inside the 0700 root state dir — so the hitl user
can't open its own key file, and every reserved-session ssh was rejected. (On Linux
sshd reads it as root, so this was never hit.) The runner rewrites the file 0600 on
every reserve, so loosening file perms won't hold.

Fix: in the Match User block, read the file via `AuthorizedKeysCommand /bin/cat …`
with `AuthorizedKeysCommandUser root` — root can read the 0600 file, no world-readable
perms needed, and command output isn't subject to the per-uid AuthorizedKeysFile read
or StrictModes. This also overrides nix-darwin's global AuthorizedKeysCommand (which
points at a per-user file we don't populate). AuthorizedKeysFile is kept as a
root-read fallback/doc.

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

The reserved Mac session's PATH lists /usr/bin before /run/current-system/sw/bin, so
bare `python3` is the system interpreter (no websockets) — phone_e2e's driver_server
import would fail. Prepend the nix system profile bin so `python3` is the nix-darwin
pyEnv (websockets present).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ing seam)

The device build found "No Accounts"/no signing certificate because the launchd
build server (runs as the build user) couldn't source its signing env: the file was
in ${stateDir} (/var/lib/hitl, 0700 root), unreadable by that user, so HITL_SIGN_*
was empty and signing fell back to a nonexistent Apple ID account. Same root-only
path trap as the ssh key file. Point iosBuildEnvFile at the build user's own
~/.config/hitl/ios-build.env (where setup-ios-signing.sh already keeps the keychain
pass + ASC key). The signing keychain HITL_SIGN_KEYCHAIN must likewise live in a
build-user-readable path, not /var/lib/hitl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reserved session's serial_discovery needs pyserial to enumerate the C6's port
(connect/config flashing). Add it to the nix pyEnv, and have discover_c6_cmd invoke
the pyEnv python explicitly (the reserved session's bare python3 is the system one
without pyserial). Non-fatal for the device-free smoke, required for the device lanes.

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

The device build unlocked the signing keychain but never granted apple codesigning
tools non-interactive access to the private key. That works in an interactive login
session (a direct build signed fine) but NOT from the headless launchd build server,
which then failed "No signing certificate … with a private key found" — the cert is
visible but the key access is ACL/partition-list gated. Add the same
`security set-key-partition-list -S apple-tool:,apple:` the TestFlight prep already
does, right after the unlock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… login session)

Code-signing (xcodebuild automatic signing / -allowProvisioningUpdates) and devicectl
only work from the operator's login (Aqua) GUI session. Verified live: the exact
device-build command signs in an interactive session ("BUILD SUCCEEDED") but the
system LaunchDaemon (Background session) fails "No signing certificate … with a
private key" for the same command, keychain, and env — the Background session can't
resolve the signing identity. Move the build server from launchd.daemons (system,
Background) to launchd.user.agents (the logged-in user's gui domain). Drop UserName
(an agent already runs as that user). Deploy now also runs `<sys>/activate-user` as
the operator to load the agent. Requires the operator stays logged in — fine for a
dedicated always-on bench Mac.

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

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

The smoke drove the app on the iPhone but then hit FileNotFoundError on a journey
file: a prior run's /tmp payload tree has READ-ONLY dirs (web/dist + solver come from
read-only bazel runfiles), so `rm -rf` couldn't clear them ("Can't unlink … Permission
denied") and the next tar extraction was incomplete. chmod -R u+w before removing, and
again after extraction so the next run can clean up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… are symlinks)

The payload is resolved from bazel runfiles, where the journey JSONs are SYMLINKS into
the container tree. tar preserved them as symlinks, so on the Mac they were dangling
-> phone_e2e hit FileNotFoundError: journeys/config.json (after the app had already
launched + connected). Open the tar with dereference=True so real content ships.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-Fi AP (connect)

For the connect/config/mapping journeys: add --flash-c6 (extract the shipped netstack
flashbundle and esptool the C6 at the dynamically-discovered port, host-native on the
Mac) and --wifi-ssid/--wifi-pass (forwarded to phone_e2e so the phone provisions the
C6 onto that AP over real BLE). Intended for the Mac's Internet Sharing AP, which both
the C6 and the iPhone join so the phone can reach the C6's wss.

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

The connect journey failed on iOS: the driver's provisionBle called
requestImprovDevice() (Web Bluetooth), which doesn't exist in the WKWebView
("Web Bluetooth is unavailable in this browser"). Production code branches on
isNativePlatform() to use the Capacitor BLE plugin (addDevice.ts →
chooseImprovDevice); the driver didn't. Add a HEADLESS native pick (no UI chooser):
scan the Capacitor BLE plugin for Improv and take the strongest-RSSI device (the DUT
next to the phone on the bench), adapted to the shared provisionViaBle seam. Browser/
emulator lane keeps Web Bluetooth.

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

Two security hardenings for the Mac iOS bench's provisioning AP:

1. AP creds off the repo/CLI. phone_e2e no longer defaults HITL_WIFI_SSID to a
   hard-coded 'FugLink' SSID (which could silently join a stray network); it reads
   the SSID/pass from the reservation-session env (host-managed ~/.ssh/environment,
   the same seam the signing secrets ride) or an explicit --wifi-*, and fails loudly
   on a real-BLE provisioning journey when neither is set. ios_reservation's
   --wifi-* are now optional + discouraged in favour of the env. Documented the
   HITL_WIFI_SSID/HITL_WIFI_PASS seam in hitl-darwin.nix.

2. Isolated-NAT pf rules. A launchd job loads a com.apple/* pf sub-anchor that lets
   the Internet Sharing subnet (bridge100/192.168.2.0/24 = DUT+phone) reach only
   itself, DHCP, and mDNS/broadcast, and drops bench->home-LAN (192.168.68.0/24) +
   bench->tailnet (100.64.0.0/10) plus all other off-subnet unicast, so a provisioned
   DUT or the phone can't pivot off the bench. Re-applied on load + every 60s since
   Internet Sharing flushes pf on toggle. To keep the phone->station path intra-subnet
   (and thus allowed), ios_reservation now advertises the station at the bridge IP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ard + connect to it)

The iOS connect journey couldn't reach the reserved C6. Concurrent serial capture
(new tooling below) showed the reserved board was NEVER connected over BLE by the
phone — two harness bugs, not the WiFi stack (the wired join is clean):

- Stray-board pick: the headless iOS Improv picker took the strongest-RSSI advertiser
  with no pinning, so on a bench with other rigs' C6s in BLE range the phone
  provisioned a stray board. iOS/CoreBluetooth exposes no BLE MAC (only an opaque
  per-app UUID), so we pin by the advertised NAME instead: ios_reservation reads the
  reserved C6's Improv name from its serial ("[ble] advertising <name> as <mac>") and
  threads it through phone_e2e -> the connect journey -> pickImprovDeviceHeadless,
  which now accepts only that name (fails loudly rather than provision a stray). Empty
  name keeps strongest-RSSI (single-device lanes / real users with a visible picker).

- Connect not wired to the provision result: on the iOS bench the phone reaches the
  C6 directly, so the wss URL is the address the device just reported over Improv, not
  a rig-forwarded input. The harness now remembers the last provisioned wss and the
  connect command falls back to it when the journey passes no explicit device_ws.

Also adds the diagnostic that found this: ios_serial_diag.py (macOS pyserial wired
PROV + monitor-only capture; DTR-gated USBCDC) and ios_reservation --serial-monitor,
which captures the C6 console during a phone BLE provision (BLE/WiFi coex diagnosis).

Tests: ios_reservation_test (improv-name forwarding) + web typecheck/unit all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…obust scan, log progress

Three fixes toward lights-out iOS journeys on a shared, crowded bench:

- Drop BLE as soon as the Improv redirect arrives (net/improv.ts). The player shares
  one 2.4GHz radio between BLE and its WiFi/TLS stack and is heap-tight; holding the
  BLE central open past provisioning starved the wss TLS handshake on :443, so the
  app hung forever in "connecting" after a successful provision. Disconnecting the
  moment we have the redirect frees the radio for the very next step (wss://<ip>/ws).
  This is a SHIPPING-app fix (shared provisionViaBle), not just HITL.

- Poll the BLE scan until the pinned board appears (driver/harness.ts). With 8+
  "Led Widget <hex>" boards advertising at once on the bench, our reserved board can
  take several scan cycles to surface; the old fixed 5s window spuriously reported
  "not found". Now we scan up to 25s and select as soon as the pinned name shows.

- Surface the app's driver events (driver_server.py): print status / error /
  milestone events so a failed run shows exactly which device the phone picked, how
  far the provision got, and the redirect URL it then dials.

Tests: web typecheck + 78 web unit tests green.
Harden the iOS Improv BLE scan/enumeration path so a reserved board can be
picked by its advertised name reliably. This is the scan-side half of the iOS
HITL `connect` blocker; it is NOT RF/range (every DUT is in range, and RSSI 127
is CoreBluetooth's "unavailable" sentinel, not distance).

The heapless-netstack Improv peripheral advertises the 128-bit Improv service
UUID + Flags in the PRIMARY advertisement (which fills the 31-byte legacy PDU),
so the device NAME can only ride the SCAN RESPONSE. `scanImprovNative` scanned
with the plugin default `allowDuplicates: false`, so CoreBluetooth reported each
matching peripheral exactly ONCE — and that single discovery callback routinely
fires before the scan response arrives, so `localName` is empty and the board
surfaces nameless. A name-pinned headless pick then misses it.

- requestLEScan now passes `allowDuplicates: true`, so iOS re-reports each
  peripheral on later advertising events (which carry the merged scan-response
  name), letting the real name arrive within the scan window.
- scanImprovNative reports "" (not a "Splanc device" placeholder) when a sighting
  carries no name, and callers keep the BEST name seen (never clobber a real name
  with a not-yet-named sighting) and supply their own display fallback:
    * harness.ts (HITL headless pick): merge-best-name; the "not found" error now
      also logs each sighting's RSSI for diagnosis.
    * blePicker.ts (shipping native chooser): refresh a row's label when the name
      arrives on a later sighting.

Verified on the real iPhone + real C6 bench: with the fix the co-located Improv
boards surface WITH their names (880AAF, CA2BFE, 31687A, E2F5EF, CDFD25, 9C9E07,
2x ledmapper), each at a real RSSI.

KNOWN RESIDUAL (needs a firmware change, tracked separately): the reserved iOS-
bench C6 still surfaces nameless (as "(unnamed)@-60" — a real RSSI, i.e. actively
scanned) because its scan-response TX is starved by BLE/WiFi coex while it is the
radio-busy unprovisioned board (continuous Wi-Fi scan / DHCP). The netstack
exposes no GAP Device Name (0x2a00), so the name lives on-air only in the coex-
fragile scan response and there is no connect-to-identify fallback. The durable
fix is firmware: carry the name in the PRIMARY adv via BLE extended advertising,
or add a GAP 0x2a00 characteristic (+ a scanner connect-and-read fallback), or
give scan-response TX coex priority while advertising/unprovisioned.

Tests: //web:unit_tests 77/77, //web:web_ts_typecheck_test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@fughilli
fughilli force-pushed the claude/ios-native-driver-wiring branch from 155aa6a to 65ce0f9 Compare October 3, 2026 03:15
claude added 3 commits October 4, 2026 06:07
…ver GATT

The reserved iOS-bench C6 surfaces to the iPhone at real RSSI but its
scan-response NAME never reaches iOS: the name rides the scan response (the
primary ADV is full with the Improv UUID) and BLE/WiFi coex on the busy
unprovisioned board starves the SCAN_RSP TX reply window, so the board surfaces
UNNAMED and the name-pinned pick can never match it. There was no GATT fallback
for the name.

Durable firmware fix: expose the name over GATT.
- netstack/improv.rs: add a GAP Generic Access service (0x1800) with a Device
  Name characteristic (0x2A00, read) to the Improv GATT DB; set_device_name()
  updates it. iOS reads 0x2A00 on connect to populate CBPeripheral.name, so the
  name resolves independent of the coex-fragile scan response.
- blehost/ble_ffi.rs: ns_ble_set_name now stores the live name and writes it to
  0x2A00 as well as the scan response, and re-applies it whenever the service is
  rebuilt on a fresh connection (so a reconnect doesn't revert to the default).
- Builds for esp32c6 and esp32c3; new host unit test for the 0x2A00 read.

Scanner connect-and-read fallback (web/iOS):
- capacitorImprov.ts: readGapDeviceName() connects to an unnamed peripheral and
  reads GAP 0x2A00 (recovering CBPeripheral.name on iOS, which reserves the GAP
  service), then disconnects.
- driver/harness.ts: when no sighting carries the pinned name, connect-and-read
  the strongest unnamed candidate and fold the resolved name back into the match.
- ui/screens/blePicker.ts: relabel a nameless row from its GATT name.

Robust live-name resolution:
- ios_serial_diag.py: add --app-reset (RTS/EN pulse, boot held high) that reboots
  into the application so the boot banner prints — unlike --reset, which can land
  the board in USB download mode where it never advertises.
- ios_reservation.py: resolve_improv_name forces an app-reset and takes the LAST
  banner, so it reads the board's CURRENT NVS name every run (the shared bench
  board gets renamed by other activity; never hardcode it).

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

resolve_improv_name's app-reset pulsed RTS (EN) to reboot the C6 into the app
but left DTR deasserted. The Arduino USB-CDC gates its TX on DTR, so the boot
`[ble] advertising "<name>"` banner was produced but never transmitted — the
capture read empty and the name resolver returned "". On the live bench that
dropped the phone to the strongest-RSSI fallback, which provisioned a STRAY
board ("Led Widget CA2BFE") instead of the reserved c6-c, and the journey timed
out joining.

DTR is not a reset strap after the reset edge, so re-assert it once the chip is
running. Verified on the bench: the capture now reads the banner and resolves
the reserved board's live name ("HITL Test 97911", BLE addr d2:fe:ff:49:fd:8c,
build 170b337).

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

On the live bench the reserved c6-c (busy, unprovisioned, new firmware) surfaced
to the iPhone as the one UNNAMED board among 8 named idle boards — exactly the
coex-starved scan-response. The connect-and-read fallback fired on it but came
back empty: iOS reads GAP 0x2A00 ITSELF on connect and populates
CBPeripheral.name LAZILY (it refuses an app read of the reserved GAP service), so
a single immediate read raced ahead of it.

readGapDeviceName now polls for a few seconds after connect — attempting the
direct 0x2A00 read (which also triggers iOS service discovery → the GAP fetch)
and the cached connected-device name each round — and the harness re-probes an
unnamed device up to 4 times instead of once.

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

This branch was successfully deployed

1 active (outdated) deployment
HITL — 65ce0f9e Deployed Oct 3, 2026 by fughilli via hitl_tests #731
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