Skip to content

Accessories - #482

Draft
KacperKozak wants to merge 22 commits into
devfrom
accessories
Draft

Accessories#482
KacperKozak wants to merge 22 commits into
devfrom
accessories

Conversation

@KacperKozak

@KacperKozak KacperKozak commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

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.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

@KacperKozak KacperKozak added area:accessories Accessory discovery, protocol, configuration, and Board bindings area:native Touches native side (modules/vesc-ble, Swift/Kotlin) area:db Touches database / persistent storage complexity:high Critical paths, subtle correctness, native pipelines. Use opus. labels Sep 13, 2026
@KacperKozak KacperKozak changed the title Add custom accessories Accessories Sep 13, 2026
KacperKozak and others added 12 commits September 13, 2026 17:40
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.
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:accessories Accessory discovery, protocol, configuration, and Board bindings area:db Touches database / persistent storage area:native Touches native side (modules/vesc-ble, Swift/Kotlin) complexity:high Critical paths, subtle correctness, native pipelines. Use opus.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant