Accessories - #482
Draft
KacperKozak wants to merge 22 commits into
Draft
Conversation
KacperKozak
force-pushed
the
accessories
branch
from
September 13, 2026 04:33
3707ea0 to
6456792
Compare
The Board selector becomes the way into both domains: separate Boards and Accessories sections, an Add accessory action, and accessory rows carrying a link status that open their configuration screen. Accessories are listed flat rather than under a Board, because a binding targets whichever Board is connected — nesting them would promise a per-Board setting that does not exist. Native owns the whole protocol. Scanning matches the Vescape Accessory service UUID and never a name: a name is a label the rider can change and other hardware can copy, so it identifies nothing. Picking one writes a single `hello`, reads the manifest, and disconnects; there is no path on that class that can emit an operational message, so finding an accessory cannot start a measurement or drive a light. Compatibility is native's verdict, not something JS re-derives. It separates "we share no protocol version" from "the version is fine but nothing it offers is a type this app can drive", and a recognized type is still not a usable one — a ground clearance in millimetres, with an inverted range, or offering no rate is reported unsupported rather than guessed at. Unknown capability types are shown beside the recognized ones instead of being filtered away. Framing is bounded by construction on both platforms: the byte that would cross 4096 fails the stream instead of being appended, so a peer that never sends a line feed costs a fixed 4 KB rather than growing until something dies. UTF-8 is validated per reassembled line, never per chunk, because a BLE notification splits multi-byte characters wherever it likes. `shared/fixtures/accessory-protocol/` is the contract in executable form — framing cases as hex so a split can land mid-character, and every manifest the parser must accept with a verdict or refuse outright. Kotlin, Swift and the ESP32 firmware all run it, and the cases marked canonical are compared against the bytes the firmware actually emits. Accessories found this way are session-scoped: enrollment, saved identities and auto-connect are native durable truth and land with the slices that need them. Firmware: vescape-app/vescape-accessories@4a29e4e
A Codex review of the discovery slice found nine problems; all of them were real. Stuck state. A handshake outlives the scan screen by up to its timeout, and a late success used to call `router.replace` on a rider who had already navigated elsewhere — the screen now cancels on unmount and drops results that arrive after it. On iOS an inspection requested while Bluetooth was off waited for a state change that was never coming: only `.unknown` and `.resetting` are transient, everything else is answered immediately, the transient wait is bounded, and cancelling resolves the promise instead of dropping its callback. Lying status. `devices` outlived the scan that gathered them, so every accessory read "Nearby" forever; the list is cleared when the scan stops. A failed handshake now outranks a sighting too — an accessory that advertises and then refuses to answer is not reachable, whatever the radio says. Scan intent. The store gated its native stop on its own `scanning` flag, which a native scan error had already cleared, leaving the intent armed on iOS to restart a listener-less scan when Bluetooth returned. The stop is unconditional. Concurrency. `inspectAccessory` is an Expo `AsyncFunction` and does not arrive on the queue CoreBluetooth delivers on, so iOS discovery now funnels every entry point through main; Android does the same through its handler, and the GATT callbacks post before reading any field rather than after. In the store a second tap took `inspecting` away from the running handshake and hid its spinner; it is refused instead, and only the owning request clears the flag. Parser divergence. `toInt()` and `intValue` truncate, so `protocolVersion: 1.9` passed as the v1 we speak; both sides now require an integral finite number. Android's `optInt`/`optJSONArray`/`optJSONObject` coerced strings and treated a wrongly-typed field as an absent one, accepting manifests iOS rejected. Presentation. Capability types come off the wire, so an accessory advertising `constructor` resolved an inherited `Object.prototype` value — truthy, with no icon, and a render crash behind it. The lookup is a `Map`.
An Accessory the rider adds is now durable, and native connects to it on its own from then on — with the app backgrounded, the screen locked, or the JS runtime never started. Durable truth is a new `accessories` table (Room 43->44, GRDB `v44_accessories`) keyed on the manifest's persistent accessory id. That primary key is the whole duplicate defence: a renamed, re-flashed unit seen on a new BLE handle updates one row rather than adding a second. `device_id` is a reconnect hint and nothing more, and `capabilities_json` is the set validated at the last handshake, so a capability whose limits moved is flagged instead of silently accepted. Enrollment reads the manifest natively. JS hands over a device handle, never an identity, so an enrollment can only record what the hardware actually said — and a device that merely advertises nearby is never added on its own. Sessions are native and outlive JS. Android hosts them in `CoreForegroundService`, started from `AutoConnectProvider` only when something is enrolled, and live Accessory sessions now keep the service alive the way a Board Session or GPS does. iOS uses a second central with its own restore identifier, created in the launch hook so CoreBluetooth can relaunch the app for an Accessory link. Neither is gated on a selected Board, the Board auto-connect setting, or a manual Board stop. Every reconnect is a fresh protocol session: a new session id, request ids from the start, the desired state re-sent from scratch. Commands are absolute and leased — one outstanding request, one retry with the *same* id so a duplicate cannot apply twice, renewal every 500 ms, and the accessory's own fallback when renewals stop. Timers run on monotonic clocks on both platforms; a lease measured on wall clock is a light that goes dark at midnight. The session's baseline per capability is the protocol's neutral state — a clearance sensor in measurement standby, a light told Board telemetry is unavailable. Nothing capability-specific is rendered yet; the point is that the lease, the retry and the expiry are observable before #478 and #480 replace these with the rider's actual demand. `shared/fixtures/accessory-protocol/session.json` pins the exact command bytes, the response parsing, and the peer's request-id rules. Kotlin, Swift and the ESP32 firmware all run it. The Board selector's Accessories section lists saved Accessories with native's link phase; tapping one opens its configuration, where it can be forgotten. Board selection is untouched and no hardware import reaches Board components.
Cross-agent review of the slice. Every fix below is a real path, not a preference. Links could get permanently stranded. On iOS a link whose first connect was refused for a powered-off radio was already `started`, so the `.poweredOn` callback's `start()` declined it and nothing ever retried; it now has an explicit resume hook plus Android's retry backstop. On both platforms re-pointing a link at a new BLE handle tore the old connection down and then returned early, leaving it armed with neither a connection nor a timer. Hosting was wrong in three places. The first enrollment started a link with no foreground service, because process-start auto-connect had already looked and found nothing saved. `stopGpsMonitoring` and `stopGroupRideObserve` each carried their own copy of the "is anything still running" test and both forgot accessories, so stopping GPS killed independently enrolled hardware. Revalidation could resurrect a forgotten accessory: it upserted from an IO coroutine that could land after the delete. It is now a single UPDATE that no-ops on a missing row. The capability baseline is no longer overwritten on revalidation. It is what "declared limits changed" is derived from, so rewriting it meant the warning vanished on the next launch — and made the in-memory flag unnecessary. Every ack pumped the next command immediately, so renewal ran at BLE round-trip rate rather than the 500 ms the protocol asks for. Commands now carry a dirty flag and a per-capability due time; an ack drains what is actually owed, which also stops one capability starving another. `getAccessories()` iterated live maps from the JS thread while the main looper mutated them. It reads a published immutable snapshot now. JS: the mirror re-reads native on foreground, because nothing is emitted to the bridge while backgrounded. A failed forget no longer navigates away as if it succeeded. iOS enrollment passed `Any??` to the capability encoder, which would have recorded an empty capability set for every enrollment.
Read the ground-clearance reading stream, hold it to an explicit status, and
save the rider's calibration durably.
A sample carries its status and never borrows a number: an ok with no value, a
null, a string, a status this build does not know, and anything outside the
declared window all resolve to error or out_of_range with no value. None of them
becomes the maximum of the range, which is the reading that would tell a board it
is safe to tilt.
Measurement demand is arbitrated natively as the union of an open configuration
screen and riding a board the capability is calibrated for; dropping both sends
configure{enabled:false}, which stops the accessory measuring while BLE stays up.
Riding comes from the Board Session's own engagement predicate, now shared with
Idle Pause instead of copied.
Calibration is a new table keyed on accessory id plus capability id (Room 44->45,
GRDB v45_accessory_ground_clearance), saved when complete and valid with no Save
step, forgotten with its accessory in one transaction. Saving one that fits the
current manifest is also how the rider accepts declared limits that moved, and
the only thing that rewrites the frozen capability baseline.
GroundClearance is pure and clock-free on both platforms, asserted against
shared/fixtures/accessory-protocol/session.json. groundClearanceInput is the seam
#479 consumes: a scaled signed input, or a named reason to release.
Six findings from a Codex review of 41cbdbd, all real. Riding state leaked in three places. iOS never released it on stopPolling, so a Board that disconnected mid-ride left sensors measuring indefinitely; and on both platforms a Refloat fault frame returned before saying anything, so a faulting board kept the last engaged sample standing — measuring, and eligible to drive tilt, for as long as it kept faulting. A fault frame carries zeroed metrics and no engagement; it now ends riding. Calibration writes on Android fanned out across the IO pool, so a save dispatched before a clear could finish after it and put the row back. They now run on a single-parallelism scope in intent order, and each mutation carries a sequence number so an older completion cannot apply its result over a newer one. Forget bumps it too. iOS already wrote these on the main queue and was ordered. Preview demand outlived the JS runtime: a reload or crash with the screen open left the accessory measuring forever, since native keeps renewing on the screen's behalf and the lease never lapses. Both modules now release every preview on destroy. Riding demand is untouched — it belongs to the Board Session. The screen showed a frozen distance as a live one when the stream stalled. Every sample now carries native's staleAfterMs, and the readout drops the number and says the sensor stopped answering. A stale reading presented as current is the same lie as an invalid one shown as maximum range, just slower. Leaving the editor inside the 400 ms debounce discarded the rider's last edit on a screen that promises to save on its own. Exit now flushes instead of cancelling.
Connect calibrated ground-clearance readings to native Remote Tilt while riding a Board this app is entitled to command. One arbiter now owns the Board's single remote-input slot. The rider's pad, Board Move and the sensor all reach the tilt/move controllers only through it, so "who is driving the board" has one answer instead of three writers with no referee. Ownership is derived from the streams themselves, so it cannot outlive the stream that claimed it. Arming: a saved calibration that still fits the live manifest, on a connected Accessory session, with a fresh in-range reading, while the Board is Connected, Trusted, answering, and engaged. Anything else releases through the pad's existing smooth return — never a snap, and never a held last value. Board-side release reasons are new to this slice (board-untrusted, board-stale, contested, board-move, manual-tilt); #478 deliberately judged only the sensor. Two calibrated capabilities release rather than resolve: picking one would be picking a correction direction on the rider's behalf. Sensor commands are slew-limited to the same bounded rate a cancel eases at, so neither arming mid-ride nor a pothole's one-sample swing hands the firmware an instant full-range angle error. Steady state still follows the readings exactly. Board Move drops a pending sensor decay to neutral rather than sharing the slot with it, and is refused outright while a sensor is actively correcting. The tilt pad renders read-only while a configured sensor is connected, with the release reason spelled out; the showcase previews the variant.
KacperKozak
force-pushed
the
accessories
branch
from
September 13, 2026 15:42
ff54325 to
daaccfe
Compare
… resume Cross-agent review of the slice found two ways the arbiter could still hand the firmware a step or lose the slot for a whole session. A sensor re-engaging while a release was still easing down restarted its ramp from neutral. The board was being told 234 and the next write said 129 — the full unfinished decay delivered as one step, which is exactly the surge a snapped cancel causes and the reason nothing here is allowed to step. One bad reading followed by a good one is an ordinary minute of riding. The ramp now starts from what the board is actually being told, and only falls back to neutral when no stream is running. Manual input was refused by consulting the current owner, but a binding that is bound and not yet driving — parked, or between readings — owns nothing. A manual command arriving in that window was accepted, and a manual *lock* never ends on its own. The pad is read-only by then, so its Cancel is gone and the rider has no way to give the slot back: the binding stays refused for the rest of the session. The arbiter now asks whether a binding is bound, not only who is streaming, and the binding re-releases a manual tilt on every tick rather than only on the arming one — `releaseManual` no-ops once an ease is running, so repeating it cannot shrink the return toward zero. `getGroundClearanceTilt` also ran off the main queue on both platforms while its neighbours run on it, reading binding state the tick mutates.
…dicting them Cross-agent review of the slice. Sampling changed `lightMode` and cleared a running preview without publishing, so the settings screen kept saying "Preview active" while the hardware had already gone back to automatic, and a mode change mid-ride never reached JS at all. `sample` now reports whether anything a screen renders changed and the snapshot goes out when it did — a steady-speed ride still publishes nothing, which is why this was not a `publish()` on every telemetry sample. The docs claimed brake-light behaviour was unimplemented immediately above the section describing its implementation, and the protocol doc still said the capability was held at its neutral state. Both now point at the PoC defaults and keep only the validation that really is outstanding. The GRDB schema doc comment had been separated from the function it documents by the new brake-light table.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR adds custom accessories that connect automatically and work while riding with the phone locked. Ground-clearance sensors control Remote Tilt, and a separate brake light responds to slowing Board speed through a shared versioned protocol.
Warning
Risk: High — sensor input commands Board tilt and must release correctly on stale data or connection loss.
Complexity: High — coordinated ESP32 firmware, Android/iOS native sessions, and shared control ownership.
DB: Data change — persist accessory enrollment and per-capability calibration/preferences.
Tasks
All app implementation work is tracked in these issues and can merge back into this branch. Firmware changes are coordinated in vescape-accessories.
Description
Accessories appear in their own section of the Board selector, with Add accessory, connection status, and detail screens. Valid calibration and preferences save automatically. Bindings follow the currently connected Board.
Native owns background connections, measurement demand, ground-clearance correction, and speed-based braking detection. Firmware advertises typed capabilities, publishes explicit reading statuses, and renders semantic light states. Commands expire, reconnects start fresh sessions, and stale sensor input uses the existing smooth tilt cancellation. Board Move remains available while parked; the Remote Tilt pad displays sensor-commanded input read-only.
This PoC supports ground_clearance and brake_light only. Front/rear arbitration and production authenticated enrollment are deferred. Protocol timings require hardware validation. App and firmware implementation for both capabilities is committed here (#476-#480). On-hardware validation (#481) is outstanding: no ESP32 has been flashed, no device build has run, and every timing below is reasoned from fixtures and driver source rather than measured.
Prior sensor spike: #441. Follow the protocol draft and feature design.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.