From e0cb112b8b4e8b57bece3dfeee0114e35487b71b Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sun, 13 Sep 2026 06:23:30 +0200 Subject: [PATCH 01/26] Plan accessories #475 --- CONTEXT.md | 12 ++ docs/accessories-prd-draft.md | 201 ++++++++++++++++++++++++++++++ docs/accessories.md | 78 ++++++++++++ docs/accessory-protocol.md | 227 ++++++++++++++++++++++++++++++++++ docs/agents/issue-tracker.md | 1 + docs/index.md | 3 + 6 files changed, 522 insertions(+) create mode 100644 docs/accessories-prd-draft.md create mode 100644 docs/accessories.md create mode 100644 docs/accessory-protocol.md diff --git a/CONTEXT.md b/CONTEXT.md index b078234d..833c859b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -4,6 +4,18 @@ This context defines the shared language for the Vescape app. The app centers on ## Language +**Accessory**: +A saved external hardware unit that provides inputs, receives outputs, or reports its own telemetry to Vescape. +_Avoid_: Board, sensor module (when referring to accessories generally) + +**Accessory Binding**: +A rider-configured relationship that maps an Accessory input or Board telemetry to an Accessory output or Board action. +_Avoid_: Sync, hardware mapping + +**Sensor Tilt Calibration**: +The near and far ground clearances in centimetres, correction direction, and maximum Remote Tilt input defining a board-mounted distance sensor's Accessory Binding, with less clearance producing stronger correction. +_Avoid_: Sensor sensitivity (for the full calibration) + **Board**: A saved rideable device that can be connected over BLE and may expose one motor controller through CAN. _Avoid_: Device, controller, scooter diff --git a/docs/accessories-prd-draft.md b/docs/accessories-prd-draft.md new file mode 100644 index 00000000..2031ed0a --- /dev/null +++ b/docs/accessories-prd-draft.md @@ -0,0 +1,201 @@ +# [PRD][Accessories] Accessory protocol PoC + +Approved and published as [PRD #475](https://github.com/vescape-app/vescape/issues/475), with implementation issues [#476](https://github.com/vescape-app/vescape/issues/476), [#477](https://github.com/vescape-app/vescape/issues/477), [#478](https://github.com/vescape-app/vescape/issues/478), [#479](https://github.com/vescape-app/vescape/issues/479), [#480](https://github.com/vescape-app/vescape/issues/480), and [#481](https://github.com/vescape-app/vescape/issues/481). Area: `[Accessories]`, `area:accessories`. GitHub is the implementation tracker; this file preserves the reviewed proposal. Relevant slices also require changes in `vescape-app/vescape-hardware`. + +## Problem Statement + +Riders need custom hardware to work with Vescape while riding with the phone locked. The current sensor spike shows readings, but has no reusable accessory discovery contract, durable enrollment, ground-clearance tilt binding, or brake-light output. Firmware builders need a small protocol that separates hardware implementation from app-owned behavior. + +## Solution + +Introduce saved Accessories with stable, typed capabilities and a versioned JSON/BLE protocol. The PoC supports a ground-clearance sensor and a separate brake-light accessory. The rider adds an accessory once, configures its behavior on its detail screen, and it operates automatically with the currently connected Board. Native owns connections, settings, and binding execution; firmware measures sensors and renders light states. + +## User Stories + +1. As a rider, I want to discover compatible nearby accessories, so that I can add my hardware without knowing BLE identifiers. +2. As a rider, I want to explicitly add an accessory, so that another rider's hardware does not activate my Board controls. +3. As a rider, I want saved accessories to reconnect when Vescape starts, so that setup is not repeated each ride. +4. As a rider, I want accessories to work while the phone is locked or backgrounded, so that I can ride with the phone in my pocket. +5. As a rider, I want saved identity and settings to survive accessory reboot and app restart, so that normal power cycles need no recalibration. +6. As a rider, I want incompatible firmware and unsupported capabilities explained, so that I understand why an accessory cannot operate. +7. As a rider, I want live clearance in centimetres on the accessory screen, so that I can calibrate its actual mounting. +8. As a rider, I want near/far distances, correction direction, and strength, so that correction matches my Board and mounting position. +9. As a rider, I want valid calibration edits saved automatically, so that there is no Save or Apply workflow. +10. As a rider, I want an initially unconfigured sensor to wait for calibration, so that mounting assumptions do not command tilt. +11. As a rider, I want less ground clearance to produce stronger correction, so that the nose or tail is lifted as configured. +12. As a rider, I want sensor-driven tilt only while riding, so that parking or carrying the Board does not generate input. +13. As a rider, I want sensor measurements to stop while parked but BLE to stay connected, so that the accessory saves power and can resume promptly. +14. As a rider, I want opening the sensor screen to resume readings while parked, so that calibration does not require riding. +15. As a rider, I want missing, erroneous, or stale readings to release tilt smoothly, so that the last correction is not held indefinitely. +16. As a rider, I want the existing tilt pad to display the commanded input without accepting competing gestures, so that I can see the sensor's action. +17. As a rider, I want Board Move to remain available while not riding, so that the accessory does not remove an existing control. +18. As a rider, I want bindings to follow the current Board, so that the PoC needs no per-Board assignment workflow. +19. As a rider, I want calibration to explain when remounting requires adjustment, so that I do not assume one calibration fits every Board. +20. As a rider, I want a separate light to receive riding, braking, and hard-braking states, so that it can indicate slowdown. +21. As a rider, I want brake sensitivity to change slowdown thresholds in either travel direction, so that the light responds appropriately. +22. As a rider, I want an off/glow preference while not riding, so that the light can remain visible when parked. +23. As a rider, I want to preview the light states while parked, so that I can inspect behavior without taking a ride. +24. As a firmware builder, I want explicit telemetry-unavailable state and local command expiry, so that firmware decides how connection problems look. +25. As a rider, I want fresh-session recovery after a disconnect, so that old commands do not replay after power returns. +26. As a firmware builder, I want capability IDs, declared ranges/rates, explicit statuses, and acknowledged commands, so that integrations do not depend on sensor models or device names. +27. As a firmware builder, I want framing, compatibility, and retry rules, so that both implementations agree on behavior under partial delivery and reconnects. +28. As a firmware builder, I want future capability types to have separate handlers, so that extension does not require a general scripting engine in v1. + +## Implementation Decisions + +- The existing Board selector is the entry point for accessory management. Show separate Boards and Accessories sections, preserve the current Board selection/add flow, and add an Add accessory action. Accessory rows show connection status and open their configuration screen. Accessories are not nested under individual Boards and continue to target the currently connected Board. Replace the spike's Settings → Sensors entry with this flow. + +- Four modules form the implementation: accessory protocol/session handling; native accessory registry and lifecycle; ground-clearance binding; brake-light binding. Capability handlers expose a small typed interface for configuration, inputs, outputs, and expiry. UI uses native snapshots and sends intents. +- Use protocol v1's custom service, newline-delimited JSON, manifest handshake, applied-value acknowledgements, explicit set commands, per-session identity, and expiring runtime commands. Its numeric timing defaults are proposed and must be validated, not presented as established hardware performance. +- Support only ground-clearance and brake-light capability types. Keep unknown types isolated. No generic sensor editor, arbitrary action graph, or firmware-generated Board calibration. +- Persist enrollment and per-capability settings natively using the existing durable storage architecture, including migration and backup contracts when those stores are affected. Do not store high-rate samples durably for this feature. +- Bind to the current Board. Calibration is per accessory capability, not per Board. Save only complete valid calibration; preserve the last valid saved value while an editor contains an incomplete value. +- Reuse established native background lifecycle and Board state/trust checks on both platforms. Do not gate accessory work on React mounts or reuse recording Idle Pause as a substitute for current riding state without verifying semantics. +- Ground-clearance firmware keeps only VL53L0X support and exposes readings in centimetres. The driver remains independent of protocol and application calibration. +- Map near clearance to configured maximum input, far clearance to neutral, and interpolate linearly with clamping. Apply explicit direction. Sensor-driven commands never bypass firmware-dependent command trust. +- Sensor reads while riding or while its detail screen needs readings. Only riding permits tilt. Invalid/stale readings cancel through existing smooth native behavior; loss of the Board link cannot guarantee delivery of neutral. +- Keep the tilt pad visible as read-only commanded input while the configured accessory is connected. Board Move remains available while parked. Cancel pending sensor return ownership before handing the shared remote-input transport to Board Move. +- Derive brake states from decreasing speed magnitude. Smooth speed-derived deceleration and use hysteresis. Select and document initial thresholds within the braking slice, then validate on hardware. Constant-speed downhill riding is not braking under this agreed definition. +- Firmware receives semantic light states and owns animations. App owns sensitivity and parked off/glow preference. Parked preview uses the same expiring command path and restores actual state on exit. +- Final front/rear hardware is anticipated through capability identity, but arbitration is deferred. The PoC must not silently allow two competing sensor bindings to write tilt concurrently; show an unsupported combination instead. +- Reuse the spike as prior art, not as proof that the new protocol or locked-screen integration works. Its code is in an open PR, not the current checkout. + +## Testing Decisions + +Proposed for approval: test externally observable contracts in all four modules, with shared protocol fixtures across firmware/Kotlin/Swift where practical. Avoid tests for labels, trivial predicates, or duplicated implementation formulas. + +- Protocol/session tests: fragmented and concatenated JSON, limits, version handling, unknown types, duplicate requests, applied rates, expiry, and stale-session callbacks. +- Registry/lifecycle tests: save/reload settings, launch reconnect, actual measurement standby, preview demand, and current-Board changes. Extend shared persistence contracts if durable schema/operations change. +- Ground-clearance tests: calibration boundaries, direction, fresh riding/trust gates, smooth release, and Board Move handoff without a late sensor write. +- Brake tests: forward/reverse speed traces, noise/hysteresis, telemetry gaps, parked preference, preview exit, duplicate state renewal, and light fallback on lease expiry. +- Prior art includes existing Remote Tilt controller tests on both platforms, TS command/presentation ordering tests, native auto-connect gate tests, and shared persistence migration/reliability tests. +- Keep firmware output checks possible with a fake light driver until physical hardware exists. That proves state delivery, not real LED behavior. Final hardware validation is a separate human-assisted slice. + +## Out of Scope + +- Generic sensors, arbitrary mappings, scripting, joysticks, HUDs, powerbank monitoring, and direct accessory-to-accessory links. +- Per-Board assignment/configuration, competing front/rear tilt arbitration, cloud sync, and durable sensor history. +- Ultrasonic support and an app sensor-selection UI. +- Choosing an LED bus/model now. Physical light-driver integration waits for selected hardware. +- Production authentication guarantees. Explicit enrollment and IDs alone do not authenticate a device; the protocol draft names this remaining limitation. + +## Further Notes + +The design and protocol draft are the normative starting context and must be made accessible to implementation agents along with this PRD. Do not publish references that assume uncommitted local docs already exist on the default branch. Include the protocol draft in the published PRD body or a versioned published artifact until it is committed. + +The code slices below are AFK with deterministic validation. The last slice is HITL because the light hardware is not selected and real concurrent BLE behavior must be measured. All cross-platform native changes carry parity links and tests appropriate to the changed contract. + +## Proposed implementation slices + +All titles use `[Accessories] - `. Each issue will include the parent reference, acceptance checklist, blocker references, and all siblings after publication. Paths below are navigation hints; PR #441 paths are explicitly marked because they are absent from the current checkout. + +### 1 - Discover compatible accessories + +Type: AFK. Complexity: high. Blocked by: none. Stories: 1, 6, 26–28. + +Build an end-to-end protocol handshake from firmware advertisement through Android/iOS native parsing to an accessory discovery screen showing identity, capabilities, and compatibility. Adapt PR #441 as needed without merging it wholesale by assumption. Include bounded NDJSON framing and firmware manifest, but no automatic Board control. Ship protocol documentation and shared fixtures with the implementation. + +Acceptance: custom service discovery works independent of name; supported/unsupported versions and capability types are distinguished; fragmented manifests work; malformed/oversized input is bounded; Kotlin/Swift and firmware agree on fixtures; no control activates from discovery. + +Likely files: + +- `vescape-hardware: src/main.cpp` — existing BLE service and firmware entrypoint. +- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/HardwareLink.kt` — PR #441 transport starting point. +- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt` — Android bridge. +- `modules/vescape-core/ios/VescapeCoreModule.swift` — iOS bridge. +- `modules/vescape-core/src/index.ts` — native contract types. +- `src/app/settings/sensors.tsx` — PR #441 route; keep resulting route thin and domain UI in its module. +- `docs/accessory-protocol.md` — proposed contract to implement and validate. + +### 2 - Save and reconnect accessories + +Type: AFK. Complexity: high. Blocked by: 1. Stories: 2–5, 18, 25. + +Add explicit enrollment and native saved identity, then launch auto-connect and fresh-session restoration with visible connection state on the accessory screen. Include request IDs, applied acknowledgements, retry/lease mechanics and capability revalidation as observable session behavior. No dependency on JS liveness. Exercise a state-only accessory peer to verify app command expiry before light-specific rendering exists. + +Acceptance: unknown nearby devices are never auto-enrolled; reboot preserves saved identity; changed names do not duplicate hardware; new sessions discard old queues; disconnected/failed sessions are shown accurately; state-only peers demonstrate expiry; persistent changes have migration/restore coverage where applicable. + +Likely files: + +- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/AutoConnectProvider.kt` — process launch entry. +- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/CoreForegroundService.kt` — native lifetime. +- `modules/vescape-core/ios/connection/VescapeLaunchSubscriber.swift` — iOS launch/restoration. +- `modules/vescape-core/ios/connection/BoardSessionController.swift` — existing lifecycle integration. +- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt` — existing native storage facade; inspect before choosing durable representation. +- `modules/vescape-core/ios/telemetry/TelemetryDatabase.swift` — peer storage facade. +- `src/modules/hardware/store/hardwareStore.ts` — PR #441 presentation mirror starting point. +- `vescape-hardware: src/main.cpp` — session reset and volatile runtime ownership. + +### 3 - Calibrate live ground clearance + +Type: AFK. Complexity: high. Blocked by: 2. Stories: 7–10, 13–14, 19, 26. + +Deliver the ToF-only reading path with explicit status, accepted rates, live display, measurement standby, and automatically saved calibration. No tilt actuation in this slice. Firmware actually stops continuous measurement when demand expires; opening/closing the screen changes preview demand through native configuration. + +Acceptance: readings show centimetres; invalid data never becomes maximum distance; near/far/direction/strength save when complete and valid; settings survive restart; unconfigured state is explained; leaving the screen while not riding stops measurements but preserves BLE; ultrasonic code is removed; fresh-session sampling is covered. + +Likely files: + +- `vescape-hardware: src/main.cpp` — sensor driver, measurements, and command application. +- `vescape-hardware: platformio.ini` — existing firmware build/library configuration. +- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/SensorLog.kt` — PR #441 numeric buffering prior art. +- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/SensorReadings.kt` — PR #441 missing-value behavior to replace. +- `modules/vescape-core/ios/VescapeCoreModule.swift` — peer native bridge for new reading/settings contract. +- `src/modules/hardware/hooks/useSensors.ts` — PR #441 reading presentation starting point. +- `src/app/settings/sensors.tsx` — PR #441 screen starting point. +- `docs/accessories.md` — calibration/standby behavior. + +### 4 - Apply ground-clearance tilt + +Type: AFK. Complexity: high. Blocked by: 3. Stories: 11–12, 15–18. + +Connect valid calibrated readings to native Remote Tilt while riding with trusted Board state. Render commanded tilt read-only using existing UI, preserve Board Move while parked, and cancel smoothly on sensor failure. This slice includes native integration on both platforms and ownership regression coverage. + +Acceptance: linear/clamped direction-aware correction follows readings; missing/stale/error/out-of-range data releases input; untrusted/stale/not-riding Board state blocks sensor commands; manual pad is read-only; Board Move is not overwritten by pending sensor decay; changing Boards invalidates old writes; unsupported competing sensor inputs cannot race. + +Likely files: + +- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt` — trusted tilt methods and Board Move integration. +- `modules/vescape-core/ios/connection/BoardSessionController.swift` — peer command ownership. +- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/RemoteTiltController.kt` — existing hold/cancel/return. +- `modules/vescape-core/ios/RemoteTiltController.swift` — peer controller. +- `src/modules/board/components/RemoteTiltPad.tsx` — read-only presentation variant. +- `src/modules/board/hooks/useRemoteTiltControl.ts` — existing native state adapter. +- `src/screens/showcase/board/RemoteTiltPadShowcase.tsx` — required variant preview. +- `modules/vescape-core/android/src/test/java/expo/modules/vescapecore/RemoteTiltControllerTest.kt` — behavior test prior art; inspect Swift peer too. + +### 5 - Drive brake-light states + +Type: AFK. Complexity: high. Blocked by: 2. Stories: 20–24, 26. + +Deliver speed-derived native braking through acknowledged state messages into the separate firmware light capability, plus sensitivity, parked preference, and actual protocol preview in the app. Use a fake output driver in automated tests until LED hardware is chosen. Firmware rendering stays replaceable behind the state receiver. + +Acceptance: forward/reverse deceleration traces produce normal/braking/hard-braking states; constant speed does not indicate braking; gaps clear detector history and send unavailable; sensitivity and parked preference persist automatically; preview restores automatic state; renewals do not restart animation; expired app commands trigger firmware fallback. Document initial filtering and thresholds as PoC defaults. + +Likely files: + +- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/protocol/VescTelemetryModels.kt` — available speed and Board state. +- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt` — native telemetry consumer wiring. +- `modules/vescape-core/ios/connection/BoardSessionController.swift` — peer wiring. +- `modules/vescape-core/src/index.ts` — settings/status bridge contract. +- `src/app/settings/sensors.tsx` — PR #441 navigation starting point for accessory detail UI. +- `vescape-hardware: src/main.cpp` — current firmware entrypoint to split by capability. +- `docs/accessory-protocol.md` — semantic output and preview rules. + +### 6 - Validate connected riding + +Type: HITL. Complexity: high. Blocked by: 4, 5. Stories: 4, 11–17, 20–25. + +Choose and connect the physical light driver when hardware exists, then verify the complete sensor + Board + light flow in current Android/iOS development builds. Start with controlled stationary checks, then rider-assisted measurements. Tune documented PoC timings/thresholds from evidence. This is the hardware-completion boundary; fake output tests do not satisfy it. + +Acceptance: confirm current workspace firmware and app builds; observe real light states and preview; locked-screen concurrent BLE operation is measured; sensor and app power-loss cases produce expected fallback; Board Move handoff behaves correctly; record measured sampling/latency and remaining limits; no claims based on stale installed bundles. + +Likely files: + +- `vescape-hardware: src/main.cpp` — real light driver integration. +- `vescape-hardware: platformio.ini` — chosen driver's build configuration if needed. +- `vescape-hardware: docs/hardware.md` — actual wiring and measured configuration. +- `vescape-hardware: docs/flashing.md` — established upload procedure. +- `docs/accessory-protocol.md` — validated timings and failure evidence. +- `docs/accessories.md` — final PoC behavior and limitations. +- `docs/connectionState.md` — existing lifecycle expectations. diff --git a/docs/accessories.md b/docs/accessories.md new file mode 100644 index 00000000..b5926990 --- /dev/null +++ b/docs/accessories.md @@ -0,0 +1,78 @@ +# Accessories + +Design in progress. These are agreed requirements, not implemented behavior. + +## Initial scope + +- A board-mounted distance sensor controls Remote Tilt from ground clearance. +- A separate light accessory responds to Board braking telemetry. +- Enrolled accessories auto-connect when the app starts and operate through the native runtime while the screen is locked or the app is backgrounded. +- In v1, every Board-related binding targets the currently connected Board. There is no Board selector or per-Board binding configuration. +- Sensor calibration is saved once per binding. Moving the sensor to another Board or mounting position requires manual recalibration; explain this beside the calibration controls. + +## Discovery and enrollment + +- The existing Board selector is the accessory entry point, with separate Boards and Accessories sections and an Add accessory action alongside the existing Board management flow. +- Accessory rows show connection status and open the accessory's configuration screen. Accessories are not nested under individual Boards; bindings still use the currently connected Board. +- Evolve the spike's Settings → Sensors navigation into this flow. Compose Board and accessory domains at the screen level rather than adding hardware-domain dependencies inside Board components. +- Compatible accessories advertise a shared Vescape Accessory BLE service UUID, independent of their display names. The draft UUID is specified in [protocol v1](./accessory-protocol.md). +- After connecting, Vescape reads a manifest containing a stable accessory ID, display name, protocol version, firmware version, and capabilities. +- Each capability has a stable local ID and a recognized type. Ground-clearance inputs declare centimetres as their unit; brake-light outputs receive semantic states. +- Measurement capabilities declare supported measurement rates and numeric measurement ranges in the manifest. Vescape uses these hardware limits to validate requests; they are distinct from rider-selected near/far calibration distances. +- One accessory may expose multiple capabilities. Unknown types appear as unsupported without blocking recognized capabilities. +- Nearby accessories are added explicitly by the rider. Only saved accessories auto-connect; discovery alone never authorizes a tilt input. +- V1 supports exactly two capability types: `ground_clearance` and `brake_light`. Generic sensor support and arbitrary binding editors are out of scope. +- Recognized types provide predefined behavior and suggested settings that the rider can adjust. Keep capability-specific setup and runtime handling separate so future types can be added without redesigning discovery. +- Capability types and stable local IDs are separate: adding another type does not change existing capability identities. Protocol evolution must preserve recognized capabilities when an accessory also advertises unknown types. + +## Ground-clearance sensor + +- Use only the VL53L0X time-of-flight sensor for this PoC. Remove the spike's ultrasonic support when implementing the firmware changes; there is no sensor-selection UI. +- Firmware exposes one `ground_clearance` capability with readings in centimetres, independent of the underlying sensor driver. +- Calibration sets near and far distances in centimetres, correction direction, and maximum Remote Tilt input. +- Less ground clearance produces stronger correction. Mounting at the nose or tail determines the appropriate correction direction. +- Missing or stale readings and accessory connection loss use the existing smooth Remote Tilt cancellation behavior while the Board connection remains available. +- Disable sensor measurements and sensor-driven tilt while not riding, to save accessory power and prevent unwanted input. Keep BLE connected in standby so Vescape can resume measurements when riding starts. Reuse the existing native riding-state predicate after checking its implementation; standby commands are specified in protocol v1. +- Reuse existing app controls and native Remote Tilt behavior. +- The sensor accessory screen shows live distance in centimetres and lets the rider configure near/far distances, correction direction, and strength. Measurements run while this screen is open even when not riding; sensor-driven tilt remains disabled while not riding. +- The rider supplies Board-specific calibration during initial setup; firmware does not supply an assumed mounting calibration. Once configured, the binding operates automatically during riding with no separate arming step. +- Calibration edits save automatically when complete and valid, with near distance strictly below far distance. There is no Save or Apply step; a complete valid calibration activates the binding automatically for riding. +- The eventual hardware includes front and rear sensors. This PoC focuses on capability types and defers arbitration between competing tilt inputs; stable capability IDs leave room for both later. +- While a configured ground-clearance accessory is connected, the Remote Tilt pad remains visible as a read-only indicator of commanded tilt, with manual input disabled. It displays commanded input, not measured Board pitch. +- Board Move remains available while not riding, when sensor-driven tilt is inactive. Both use the existing native remote-input controller; transitions must prevent a pending sensor return from overriding Board Move. + +## Wire protocol + +- Use a small versioned JSON protocol for the PoC, covering the manifest, sensor readings, and app commands. [Protocol v1](./accessory-protocol.md) defines the implementation draft, including schemas and proposed defaults. +- Frame messages as newline-delimited JSON: one compact JSON object followed by `\n`. Both receivers buffer BLE chunks until a complete line arrives; BLE packet boundaries are not message boundaries. +- Enforce the protocol's fixed maximum message size and disconnect on malformed or oversized messages. +- Receivers ignore unknown optional fields within a supported protocol version. Changes to existing field meanings require a new protocol version. +- Unsupported protocol versions block operational commands and bindings. Vescape may show the discovered accessory with an incompatibility explanation, but does not activate its controls. +- Configuration requests carry a request ID. Firmware responds with a matching acknowledgement containing the settings actually applied, including the accepted measurement rate, or an explicit error. Vescape does not treat a successful BLE write as configuration acceptance. +- Sensor readings are unacknowledged streams; individual samples do not require a round trip. +- Commands set explicit desired values or states, such as measurement enabled/disabled or braking state. Do not use toggle/cycle commands: repeating a request must preserve the same result without restarting an unchanged light animation. +- Runtime commands expire unless renewed by Vescape. On expiry, sensors return to measurement standby and lights use their firmware-defined unavailable behavior. Protocol v1 proposes timeout values for PoC validation. +- Detect loss in both directions without requiring a final disconnect message: accessories detect expired app commands, and Vescape detects missing expected sensor readings. If accessory power dies, stale sensor input releases tilt through the existing smooth cancellation while the Board connection remains available. +- Keep unavailable Board telemetry distinct from an expired app command: Vescape can remain responsive while reporting that Board telemetry is unavailable. +- Every reconnect starts a fresh protocol session: read and validate the manifest again, then resend the current applicable configuration and state. Discard old queued commands and ignore acknowledgements or callbacks belonging to the previous session. +- Sensor reading messages identify their capability and carry an explicit status: `ok` with a numeric value, `out_of_range`, or `error`. Ground-clearance values are in centimetres. +- Out-of-range readings, sensor errors, and stale or missing readings release sensor-driven tilt through the existing smooth return behavior. A missing value never represents a valid maximum-distance sample. + +## Brake light + +- Vescape sends semantic states: riding, braking, hard braking, and not riding. Accessory firmware owns brightness, colors, and blink patterns. The initial light's intended behavior is dim red while riding, brighter red when braking, and blinking red under hard braking. +- Detect braking from decreasing Board speed magnitude over time, in either travel direction. Constant-speed riding does not activate braking, including downhill riding. +- One sensitivity control adjusts the deceleration thresholds for brighter red and hard-braking blinking. Smoothing and threshold values remain to be resolved. +- While not riding, the rider can choose between light off and a steady red glow, for example while leaving the Board outside a shop. +- Non-riding light behavior is independent of the sensor's measurement standby. +- Accessory firmware owns the visual behavior when disconnected or when Board telemetry is unavailable; Vescape does not prescribe a loading pattern or fallback color. +- Vescape owns braking detection and sensitivity; it does not stream individual LED frames. +- The light settings screen offers a parked preview of riding, braking, and hard-braking states on the actual accessory. Closing the preview restores automatic behavior. +- The accessory detects its own connection loss. While connected, Vescape explicitly reports Board telemetry unavailability. Missing app updates must also be detectable without receiving a final message. + +## Remaining work + +- Implement and validate the protocol draft across firmware and native app runtimes. +- Reuse native riding detection and Board telemetry freshness rules. +- Tune brake detection smoothing and sensitivity thresholds using ride data. +- Validate protocol timing defaults under concurrent Board and accessory traffic. diff --git a/docs/accessory-protocol.md b/docs/accessory-protocol.md new file mode 100644 index 00000000..3f5a614d --- /dev/null +++ b/docs/accessory-protocol.md @@ -0,0 +1,227 @@ +# Vescape Accessory Protocol v1 + +Status: implementation draft for the PoC. Product behavior is in [accessories.md](./accessories.md). This protocol is not implemented yet. Timing, rate, and size limits below are proposed PoC defaults, not measured reliability guarantees. + +## Ownership + +Firmware declares hardware capabilities, measures sensors, and renders outputs. Vescape owns saved calibration, current-Board selection, riding detection, tilt mapping, and speed-based braking detection. Native runs this work while the screen is locked. The wire protocol contains no Board IDs, tilt calibration, raw VESC commands, or LED frames. + +V1 recognizes `ground_clearance` and `brake_light`. Dispatch by capability type and address by capability ID; do not infer behavior from accessory names or sensor models. Future capability types get their own schemas and handlers. + +## BLE transport + +Assign this custom GATT service and characteristic set for the draft: + +| UUID | Purpose | Properties | +| -------------------------------------- | ------------------------- | ------------------- | +| `8d53dc10-1db7-4cd3-868b-8a527460aa84` | Vescape Accessory service | Advertised | +| `8d53dc11-1db7-4cd3-868b-8a527460aa84` | App to accessory | Write with response | +| `8d53dc12-1db7-4cd3-868b-8a527460aa84` | Accessory to app | Notify | + +These are project-assigned UUIDs, not Bluetooth SIG assigned services. Replace the spike's Nordic UART service on both sides together; that generic service also identifies other hardware and is not accessory identity. + +Both directions carry UTF-8 newline-delimited JSON. Each message is one compact object followed by LF. Examples below represent complete lines; send the trailing LF. Split outgoing bytes to fit the negotiated ATT payload. Reassemble bytes before decoding UTF-8 or JSON. Handle partial lines and multiple lines per received chunk. Serialize all chunks of one outgoing message before the next message. + +Maximum line length is 4096 bytes excluding LF. An oversized line, invalid UTF-8, or malformed JSON ends the protocol session and disconnects BLE. Clear receive and transmit buffers on disconnect. No plain-text logs or echo replies on these characteristics; use serial for diagnostics. + +BLE write completion confirms transport delivery, not command application. Application acknowledgements are defined below. + +## Identity, compatibility, and enrollment + +`accessoryId` is a factory-provisioned or once-generated persistent UUID that survives reboot and ordinary firmware updates. A display name or BLE address is not this identity. Capability IDs are unique within an accessory and stable across firmware updates. Saved settings key on accessory ID plus capability ID. + +An advertised service makes a device discoverable. The rider explicitly adds it before automatic operation. Only saved accessories auto-connect. Read the manifest on every connection and validate identity, version, and recognized capability schemas before using saved settings. If measurement limits change and saved calibration no longer fits, show setup required and keep that binding inactive. + +The bootstrap `hello` and `manifest` envelope remains readable across versions. Operational v1 schemas ignore unknown optional fields. Unknown capability types can be shown as unsupported while recognized types work. Unsupported protocol versions allow an incompatibility explanation but no operational commands. New optional fields must have omission semantics that preserve existing behavior; changing existing meanings requires a version bump. + +Stable IDs are identifiers, not authentication. This PoC enrollment does not claim protection from an accessory impersonating an enrolled ID. Authenticated enrollment is an explicit remaining protocol concern before use beyond controlled prototypes. + +## Session handshake + +1. Connect and subscribe to notifications. +2. App sends `hello` with a fresh random session UUID and supported versions. +3. Firmware resets volatile runtime state, selects v1 if supported, and returns its manifest. +4. App validates the manifest, then sends current configuration/state for enrolled capabilities. +5. Measurements begin only after an enabled configuration is applied. Light output begins when a state command is applied. + +```json +{ + "type": "hello", + "requestId": 1, + "sessionId": "b06b9d76-6c73-4d70-a763-d933b294c45b", + "supportedVersions": [1] +} +``` + +Example manifest for an illustrative sensor build. Ranges and rates must describe the actual firmware's supported operation; these example numbers are not VL53L0X guarantees. + +```json +{ + "type": "manifest", + "requestId": 1, + "sessionId": "b06b9d76-6c73-4d70-a763-d933b294c45b", + "protocolVersion": 1, + "accessoryId": "b36ed5bd-1d24-460c-8034-aaeaefc5d016", + "name": "Clearance sensor", + "firmwareVersion": "0.1.0", + "capabilities": [ + { + "id": "clearance", + "type": "ground_clearance", + "unit": "cm", + "range": { "min": 3, "max": 100 }, + "ratesHz": [10, 20, 30] + } + ] +} +``` + +A light advertises a capability object such as: + +```json +{ "id": "rear_light", "type": "brake_light" } +``` + +If there is no common version, return the same manifest envelope with `protocolVersion: null` and `supportedVersions`, then accept no operational commands. If a repeated hello carries the same session ID and request ID, resend the manifest without resetting runtime. A new session ID resets runtime and invalidates previous commands. + +Every post-hello message carries the agreed session ID. Ignore messages from other sessions; they never renew a timeout. A disconnect invalidates the session. An app re-handshake also clears its old queues and callback ownership before sending the new hello. + +## Requests and acknowledgements + +Request IDs are strictly increasing integers within a session. App sends at most one outstanding request per accessory, with request chunks serialized. Coalesce unsent state updates to the latest desired state. Fresh readings use a separate notification stream; they do not need acknowledgements. + +An acknowledgement means the command was validated and applied, not merely queued. It does not prove a physical LED illuminated. Unsupported values and invalid fields return errors without applying partial changes. + +Commands always set desired values. They never toggle or cycle. Retrying the same request ID must not restart an animation or reapply a transition. Firmware retains the latest request's response for duplicate replies, rejects older IDs as `stale_request`, and rejects reuse of an ID with a different body as `request_id_reused`. Duplicate retries do not extend command leases; a deliberate renewal uses a new request ID. + +Example error: + +```json +{ + "type": "error", + "sessionId": "b06b9d76-6c73-4d70-a763-d933b294c45b", + "requestId": 2, + "code": "invalid_argument", + "message": "rateHz must be positive" +} +``` + +V1 error codes: `invalid_argument`, `unknown_capability`, `unsupported_message`, `not_ready`, `hardware_error`, `stale_request`, `request_id_reused`. A well-formed unknown request returns `unsupported_message`; an unknown unsolicited message is ignored without renewing any timeout. + +## Ground-clearance configuration + +```json +{ + "type": "configure", + "sessionId": "b06b9d76-6c73-4d70-a763-d933b294c45b", + "requestId": 2, + "capabilityId": "clearance", + "enabled": true, + "rateHz": 20 +} +``` + +```json +{ + "type": "ack", + "sessionId": "b06b9d76-6c73-4d70-a763-d933b294c45b", + "requestId": 2, + "capabilityId": "clearance", + "applied": { "enabled": true, "rateHz": 20 }, + "leaseMs": 2000 +} +``` + +Select the nearest supported rate, choosing the lower rate on a tie, and acknowledge the actual rate. `enabled: false` stops actual measurement, including a sensor's continuous measurement mode, while preserving BLE connectivity. Require `rateHz` in both forms to keep commands complete and replayable. Capability configuration is volatile; reboot starts disabled. + +Vescape renews the complete configuration while it is needed. Measure while riding or while the sensor screen is open. Only fresh Board riding state, complete valid calibration, and fresh valid sensor samples permit sensor-driven tilt. Screen preview alone never permits tilt. Losing Board telemetry stops tilt and disables measurements unless the screen still needs them. + +Readings identify the capability and contain a per-capability sequence number and sample time in milliseconds since the current protocol session began: + +```json +{"type":"reading","sessionId":"b06b9d76-6c73-4d70-a763-d933b294c45b","capabilityId":"clearance","seq":1,"sampleTimeMs":125,"status":"ok","value":12.4} +{"type":"reading","sessionId":"b06b9d76-6c73-4d70-a763-d933b294c45b","capabilityId":"clearance","seq":2,"sampleTimeMs":175,"status":"out_of_range"} +{"type":"reading","sessionId":"b06b9d76-6c73-4d70-a763-d933b294c45b","capabilityId":"clearance","seq":3,"sampleTimeMs":225,"status":"error"} +``` + +`ok` requires a finite numeric value in the declared range. Other statuses omit `value`. A driver unable to distinguish missing hardware from no target must report `error`, rather than inventing a valid distance. Emit status samples at the configured cadence while enabled, including persistent error/out-of-range states. + +Sequence numbers start at 1 and increase across measurement pauses within a session. Drop duplicate or older samples. Sample timestamps use the accessory's monotonic clock; do not subtract them directly from phone timestamps. The app uses local monotonic receipt time for the missing-stream timeout and sequence/timestamp progress to reject regressions. This is a PoC freshness mechanism, not a claim of synchronized clocks or bounded end-to-end latency. + +Keep only the latest unsent reading per capability to avoid replaying a backlog. Once transmission of a line has started, finish that line before sending another. Out-of-range/error readings release tilt immediately through existing smooth cancellation; valid readings that stop arriving release it on the stale timeout. Acknowledgements do not refresh sensor freshness. + +## Brake-light state + +```json +{ + "type": "state", + "sessionId": "b06b9d76-6c73-4d70-a763-d933b294c45b", + "requestId": 3, + "capabilityId": "rear_light", + "telemetry": "available", + "mode": "braking", + "parked": "glow" +} +``` + +```json +{ + "type": "ack", + "sessionId": "b06b9d76-6c73-4d70-a763-d933b294c45b", + "requestId": 3, + "capabilityId": "rear_light", + "applied": { "telemetry": "available", "mode": "braking", "parked": "glow" }, + "leaseMs": 2000 +} +``` + +`mode` is `riding`, `braking`, `hard_braking`, or `not_riding`. `parked` is `off` or `glow`, and affects output only in `not_riding`. Firmware interprets these semantic states and owns brightness, color, and timing. Renewing an unchanged state does not restart blink phase. + +For absent/stale Board telemetry, use this complete replacement state; omit `mode`: + +```json +{ + "type": "state", + "sessionId": "b06b9d76-6c73-4d70-a763-d933b294c45b", + "requestId": 4, + "capabilityId": "rear_light", + "telemetry": "unavailable", + "parked": "glow" +} +``` + +Firmware chooses how telemetry-unavailable looks. Connection loss and expired app commands are detected locally and may have different firmware-defined behavior. There is no outgoing “disconnected” command. + +Parked preview sends the same state schema with `preview: true`. This optional field defaults to false and labels simulated state; it makes no claim that Board telemetry exists. Permit `telemetry: "unavailable"` plus a mode only when preview is true. Vescape sends the actual current state immediately on closing preview. Firmware's lease also ends preview if the app disappears. + +## PoC timing and failure defaults + +| Setting | Proposed default | +| -------------------------- | --------------------------------------------------------- | +| Preferred sensor rate | 20 Hz, resolved against manifest and confirmed by ack | +| Missing sensor stream | 300 ms, starting at enabled ack or latest accepted sample | +| Runtime command lease | 2000 ms per capability | +| App renewal interval | 500 ms, send state changes immediately | +| Request response timeout | 500 ms; retry once with the same ID | +| Handshake response timeout | 3000 ms; disconnect and use normal reconnect policy | + +For a selected rate below 10 Hz, use `max(300 ms, 3 * sample period)` for missing-stream detection. These values need validation under concurrent Board and accessory BLE traffic on Android and iOS. + +Any second request timeout marks the accessory unavailable, cancels its active sensor binding, and disconnects it for a fresh handshake. A malformed response follows the same failure path. Firmware never renews a lease on malformed, rejected, duplicate, or wrong-session commands. On lease expiry or disconnect it stops measurements and hands light output to its local unavailable behavior. + +If the Board link is itself gone, the app cannot promise to deliver a neutral command. Clear native input ownership and pending writes; receiver-side Board timeout remains the final fallback. If only the accessory dies and the Board link remains usable, use the existing smooth Remote Tilt cancellation. + +## Implementation checks + +- Split messages at every byte boundary, including UTF-8 characters; also accept concatenated lines. +- Reject oversized and malformed messages without an unbounded receive buffer. +- Exercise version rejection, unknown capabilities, and changed capability limits after reconnect. +- Drop an ack, retry, and confirm that a light animation does not restart or a lease extend twice. +- Stop app renewals with BLE connected; observe sensor standby and firmware-owned light fallback. +- Stop readings with configuration acks still arriving; observe tilt cancellation. +- Reconnect with queued old commands; confirm they cannot affect the new session. +- Validate measurement standby and locked-screen operation with current development builds on both platforms. + +## Remaining implementation work + +Inspect and reuse the existing native riding-state predicate and Board telemetry freshness rules. Define braking smoothing and sensitivity thresholds in the app, outside this wire protocol. Choose the real sensor manifest limits from driver configuration and measurements. Authenticated enrollment and simultaneous front/rear tilt arbitration remain outside this PoC draft. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index 63c19604..3130c168 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -72,6 +72,7 @@ Use one or more app-area labels for filtering: | `area:core` | `[Core]` | app shell, storage, lifecycle, infra | | `area:server` | `[Server]` | Vescape backend APIs, relay behavior, server policy, and deployment-facing contracts | | `area:board` | `[Board]` | board profiles, board table/settings | +| `area:accessories` | `[Accessories]` | accessory discovery, protocol, configuration, and Board bindings | | `area:telemetry` | `[Telemetry]` | live telemetry ingest/display | | `area:tunes` | `[Tunes]` | VESC tune read/write flows | | `area:alerts` | `[Alerts]` | alert rules, alert feedback, audio/TTS | diff --git a/docs/index.md b/docs/index.md index 235e57bd..6fbb54aa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,6 +25,9 @@ ### Features +- [accessories.md](./accessories.md) — in-progress accessory design: ground-clearance tilt sensor and brake light +- [accessory-protocol.md](./accessory-protocol.md) — JSON/BLE protocol v1 draft: discovery, capabilities, commands, readings, and failure handling +- [accessories-prd-draft.md](./accessories-prd-draft.md) — approved planning snapshot and links to published accessory PRD and implementation issues - [history.md](./history.md) — ride history persistence, grouping, markers, and map rendering - [tune.md](./tune.md) — Refloat tune screen behavior, basic slider formulas, field groups - [tune-preview-pl.md](./tune-preview-pl.md) — Tune vs Tune Preview, explained (Polish) From ef8e3af596727634422a6fa1fe715bed3e433b3e Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sun, 13 Sep 2026 06:25:18 +0200 Subject: [PATCH 02/26] Remove temporary PRD draft --- docs/accessories-prd-draft.md | 201 ---------------------------------- docs/index.md | 1 - 2 files changed, 202 deletions(-) delete mode 100644 docs/accessories-prd-draft.md diff --git a/docs/accessories-prd-draft.md b/docs/accessories-prd-draft.md deleted file mode 100644 index 2031ed0a..00000000 --- a/docs/accessories-prd-draft.md +++ /dev/null @@ -1,201 +0,0 @@ -# [PRD][Accessories] Accessory protocol PoC - -Approved and published as [PRD #475](https://github.com/vescape-app/vescape/issues/475), with implementation issues [#476](https://github.com/vescape-app/vescape/issues/476), [#477](https://github.com/vescape-app/vescape/issues/477), [#478](https://github.com/vescape-app/vescape/issues/478), [#479](https://github.com/vescape-app/vescape/issues/479), [#480](https://github.com/vescape-app/vescape/issues/480), and [#481](https://github.com/vescape-app/vescape/issues/481). Area: `[Accessories]`, `area:accessories`. GitHub is the implementation tracker; this file preserves the reviewed proposal. Relevant slices also require changes in `vescape-app/vescape-hardware`. - -## Problem Statement - -Riders need custom hardware to work with Vescape while riding with the phone locked. The current sensor spike shows readings, but has no reusable accessory discovery contract, durable enrollment, ground-clearance tilt binding, or brake-light output. Firmware builders need a small protocol that separates hardware implementation from app-owned behavior. - -## Solution - -Introduce saved Accessories with stable, typed capabilities and a versioned JSON/BLE protocol. The PoC supports a ground-clearance sensor and a separate brake-light accessory. The rider adds an accessory once, configures its behavior on its detail screen, and it operates automatically with the currently connected Board. Native owns connections, settings, and binding execution; firmware measures sensors and renders light states. - -## User Stories - -1. As a rider, I want to discover compatible nearby accessories, so that I can add my hardware without knowing BLE identifiers. -2. As a rider, I want to explicitly add an accessory, so that another rider's hardware does not activate my Board controls. -3. As a rider, I want saved accessories to reconnect when Vescape starts, so that setup is not repeated each ride. -4. As a rider, I want accessories to work while the phone is locked or backgrounded, so that I can ride with the phone in my pocket. -5. As a rider, I want saved identity and settings to survive accessory reboot and app restart, so that normal power cycles need no recalibration. -6. As a rider, I want incompatible firmware and unsupported capabilities explained, so that I understand why an accessory cannot operate. -7. As a rider, I want live clearance in centimetres on the accessory screen, so that I can calibrate its actual mounting. -8. As a rider, I want near/far distances, correction direction, and strength, so that correction matches my Board and mounting position. -9. As a rider, I want valid calibration edits saved automatically, so that there is no Save or Apply workflow. -10. As a rider, I want an initially unconfigured sensor to wait for calibration, so that mounting assumptions do not command tilt. -11. As a rider, I want less ground clearance to produce stronger correction, so that the nose or tail is lifted as configured. -12. As a rider, I want sensor-driven tilt only while riding, so that parking or carrying the Board does not generate input. -13. As a rider, I want sensor measurements to stop while parked but BLE to stay connected, so that the accessory saves power and can resume promptly. -14. As a rider, I want opening the sensor screen to resume readings while parked, so that calibration does not require riding. -15. As a rider, I want missing, erroneous, or stale readings to release tilt smoothly, so that the last correction is not held indefinitely. -16. As a rider, I want the existing tilt pad to display the commanded input without accepting competing gestures, so that I can see the sensor's action. -17. As a rider, I want Board Move to remain available while not riding, so that the accessory does not remove an existing control. -18. As a rider, I want bindings to follow the current Board, so that the PoC needs no per-Board assignment workflow. -19. As a rider, I want calibration to explain when remounting requires adjustment, so that I do not assume one calibration fits every Board. -20. As a rider, I want a separate light to receive riding, braking, and hard-braking states, so that it can indicate slowdown. -21. As a rider, I want brake sensitivity to change slowdown thresholds in either travel direction, so that the light responds appropriately. -22. As a rider, I want an off/glow preference while not riding, so that the light can remain visible when parked. -23. As a rider, I want to preview the light states while parked, so that I can inspect behavior without taking a ride. -24. As a firmware builder, I want explicit telemetry-unavailable state and local command expiry, so that firmware decides how connection problems look. -25. As a rider, I want fresh-session recovery after a disconnect, so that old commands do not replay after power returns. -26. As a firmware builder, I want capability IDs, declared ranges/rates, explicit statuses, and acknowledged commands, so that integrations do not depend on sensor models or device names. -27. As a firmware builder, I want framing, compatibility, and retry rules, so that both implementations agree on behavior under partial delivery and reconnects. -28. As a firmware builder, I want future capability types to have separate handlers, so that extension does not require a general scripting engine in v1. - -## Implementation Decisions - -- The existing Board selector is the entry point for accessory management. Show separate Boards and Accessories sections, preserve the current Board selection/add flow, and add an Add accessory action. Accessory rows show connection status and open their configuration screen. Accessories are not nested under individual Boards and continue to target the currently connected Board. Replace the spike's Settings → Sensors entry with this flow. - -- Four modules form the implementation: accessory protocol/session handling; native accessory registry and lifecycle; ground-clearance binding; brake-light binding. Capability handlers expose a small typed interface for configuration, inputs, outputs, and expiry. UI uses native snapshots and sends intents. -- Use protocol v1's custom service, newline-delimited JSON, manifest handshake, applied-value acknowledgements, explicit set commands, per-session identity, and expiring runtime commands. Its numeric timing defaults are proposed and must be validated, not presented as established hardware performance. -- Support only ground-clearance and brake-light capability types. Keep unknown types isolated. No generic sensor editor, arbitrary action graph, or firmware-generated Board calibration. -- Persist enrollment and per-capability settings natively using the existing durable storage architecture, including migration and backup contracts when those stores are affected. Do not store high-rate samples durably for this feature. -- Bind to the current Board. Calibration is per accessory capability, not per Board. Save only complete valid calibration; preserve the last valid saved value while an editor contains an incomplete value. -- Reuse established native background lifecycle and Board state/trust checks on both platforms. Do not gate accessory work on React mounts or reuse recording Idle Pause as a substitute for current riding state without verifying semantics. -- Ground-clearance firmware keeps only VL53L0X support and exposes readings in centimetres. The driver remains independent of protocol and application calibration. -- Map near clearance to configured maximum input, far clearance to neutral, and interpolate linearly with clamping. Apply explicit direction. Sensor-driven commands never bypass firmware-dependent command trust. -- Sensor reads while riding or while its detail screen needs readings. Only riding permits tilt. Invalid/stale readings cancel through existing smooth native behavior; loss of the Board link cannot guarantee delivery of neutral. -- Keep the tilt pad visible as read-only commanded input while the configured accessory is connected. Board Move remains available while parked. Cancel pending sensor return ownership before handing the shared remote-input transport to Board Move. -- Derive brake states from decreasing speed magnitude. Smooth speed-derived deceleration and use hysteresis. Select and document initial thresholds within the braking slice, then validate on hardware. Constant-speed downhill riding is not braking under this agreed definition. -- Firmware receives semantic light states and owns animations. App owns sensitivity and parked off/glow preference. Parked preview uses the same expiring command path and restores actual state on exit. -- Final front/rear hardware is anticipated through capability identity, but arbitration is deferred. The PoC must not silently allow two competing sensor bindings to write tilt concurrently; show an unsupported combination instead. -- Reuse the spike as prior art, not as proof that the new protocol or locked-screen integration works. Its code is in an open PR, not the current checkout. - -## Testing Decisions - -Proposed for approval: test externally observable contracts in all four modules, with shared protocol fixtures across firmware/Kotlin/Swift where practical. Avoid tests for labels, trivial predicates, or duplicated implementation formulas. - -- Protocol/session tests: fragmented and concatenated JSON, limits, version handling, unknown types, duplicate requests, applied rates, expiry, and stale-session callbacks. -- Registry/lifecycle tests: save/reload settings, launch reconnect, actual measurement standby, preview demand, and current-Board changes. Extend shared persistence contracts if durable schema/operations change. -- Ground-clearance tests: calibration boundaries, direction, fresh riding/trust gates, smooth release, and Board Move handoff without a late sensor write. -- Brake tests: forward/reverse speed traces, noise/hysteresis, telemetry gaps, parked preference, preview exit, duplicate state renewal, and light fallback on lease expiry. -- Prior art includes existing Remote Tilt controller tests on both platforms, TS command/presentation ordering tests, native auto-connect gate tests, and shared persistence migration/reliability tests. -- Keep firmware output checks possible with a fake light driver until physical hardware exists. That proves state delivery, not real LED behavior. Final hardware validation is a separate human-assisted slice. - -## Out of Scope - -- Generic sensors, arbitrary mappings, scripting, joysticks, HUDs, powerbank monitoring, and direct accessory-to-accessory links. -- Per-Board assignment/configuration, competing front/rear tilt arbitration, cloud sync, and durable sensor history. -- Ultrasonic support and an app sensor-selection UI. -- Choosing an LED bus/model now. Physical light-driver integration waits for selected hardware. -- Production authentication guarantees. Explicit enrollment and IDs alone do not authenticate a device; the protocol draft names this remaining limitation. - -## Further Notes - -The design and protocol draft are the normative starting context and must be made accessible to implementation agents along with this PRD. Do not publish references that assume uncommitted local docs already exist on the default branch. Include the protocol draft in the published PRD body or a versioned published artifact until it is committed. - -The code slices below are AFK with deterministic validation. The last slice is HITL because the light hardware is not selected and real concurrent BLE behavior must be measured. All cross-platform native changes carry parity links and tests appropriate to the changed contract. - -## Proposed implementation slices - -All titles use `[Accessories] - `. Each issue will include the parent reference, acceptance checklist, blocker references, and all siblings after publication. Paths below are navigation hints; PR #441 paths are explicitly marked because they are absent from the current checkout. - -### 1 - Discover compatible accessories - -Type: AFK. Complexity: high. Blocked by: none. Stories: 1, 6, 26–28. - -Build an end-to-end protocol handshake from firmware advertisement through Android/iOS native parsing to an accessory discovery screen showing identity, capabilities, and compatibility. Adapt PR #441 as needed without merging it wholesale by assumption. Include bounded NDJSON framing and firmware manifest, but no automatic Board control. Ship protocol documentation and shared fixtures with the implementation. - -Acceptance: custom service discovery works independent of name; supported/unsupported versions and capability types are distinguished; fragmented manifests work; malformed/oversized input is bounded; Kotlin/Swift and firmware agree on fixtures; no control activates from discovery. - -Likely files: - -- `vescape-hardware: src/main.cpp` — existing BLE service and firmware entrypoint. -- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/HardwareLink.kt` — PR #441 transport starting point. -- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt` — Android bridge. -- `modules/vescape-core/ios/VescapeCoreModule.swift` — iOS bridge. -- `modules/vescape-core/src/index.ts` — native contract types. -- `src/app/settings/sensors.tsx` — PR #441 route; keep resulting route thin and domain UI in its module. -- `docs/accessory-protocol.md` — proposed contract to implement and validate. - -### 2 - Save and reconnect accessories - -Type: AFK. Complexity: high. Blocked by: 1. Stories: 2–5, 18, 25. - -Add explicit enrollment and native saved identity, then launch auto-connect and fresh-session restoration with visible connection state on the accessory screen. Include request IDs, applied acknowledgements, retry/lease mechanics and capability revalidation as observable session behavior. No dependency on JS liveness. Exercise a state-only accessory peer to verify app command expiry before light-specific rendering exists. - -Acceptance: unknown nearby devices are never auto-enrolled; reboot preserves saved identity; changed names do not duplicate hardware; new sessions discard old queues; disconnected/failed sessions are shown accurately; state-only peers demonstrate expiry; persistent changes have migration/restore coverage where applicable. - -Likely files: - -- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/AutoConnectProvider.kt` — process launch entry. -- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/CoreForegroundService.kt` — native lifetime. -- `modules/vescape-core/ios/connection/VescapeLaunchSubscriber.swift` — iOS launch/restoration. -- `modules/vescape-core/ios/connection/BoardSessionController.swift` — existing lifecycle integration. -- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt` — existing native storage facade; inspect before choosing durable representation. -- `modules/vescape-core/ios/telemetry/TelemetryDatabase.swift` — peer storage facade. -- `src/modules/hardware/store/hardwareStore.ts` — PR #441 presentation mirror starting point. -- `vescape-hardware: src/main.cpp` — session reset and volatile runtime ownership. - -### 3 - Calibrate live ground clearance - -Type: AFK. Complexity: high. Blocked by: 2. Stories: 7–10, 13–14, 19, 26. - -Deliver the ToF-only reading path with explicit status, accepted rates, live display, measurement standby, and automatically saved calibration. No tilt actuation in this slice. Firmware actually stops continuous measurement when demand expires; opening/closing the screen changes preview demand through native configuration. - -Acceptance: readings show centimetres; invalid data never becomes maximum distance; near/far/direction/strength save when complete and valid; settings survive restart; unconfigured state is explained; leaving the screen while not riding stops measurements but preserves BLE; ultrasonic code is removed; fresh-session sampling is covered. - -Likely files: - -- `vescape-hardware: src/main.cpp` — sensor driver, measurements, and command application. -- `vescape-hardware: platformio.ini` — existing firmware build/library configuration. -- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/SensorLog.kt` — PR #441 numeric buffering prior art. -- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/SensorReadings.kt` — PR #441 missing-value behavior to replace. -- `modules/vescape-core/ios/VescapeCoreModule.swift` — peer native bridge for new reading/settings contract. -- `src/modules/hardware/hooks/useSensors.ts` — PR #441 reading presentation starting point. -- `src/app/settings/sensors.tsx` — PR #441 screen starting point. -- `docs/accessories.md` — calibration/standby behavior. - -### 4 - Apply ground-clearance tilt - -Type: AFK. Complexity: high. Blocked by: 3. Stories: 11–12, 15–18. - -Connect valid calibrated readings to native Remote Tilt while riding with trusted Board state. Render commanded tilt read-only using existing UI, preserve Board Move while parked, and cancel smoothly on sensor failure. This slice includes native integration on both platforms and ownership regression coverage. - -Acceptance: linear/clamped direction-aware correction follows readings; missing/stale/error/out-of-range data releases input; untrusted/stale/not-riding Board state blocks sensor commands; manual pad is read-only; Board Move is not overwritten by pending sensor decay; changing Boards invalidates old writes; unsupported competing sensor inputs cannot race. - -Likely files: - -- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt` — trusted tilt methods and Board Move integration. -- `modules/vescape-core/ios/connection/BoardSessionController.swift` — peer command ownership. -- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/RemoteTiltController.kt` — existing hold/cancel/return. -- `modules/vescape-core/ios/RemoteTiltController.swift` — peer controller. -- `src/modules/board/components/RemoteTiltPad.tsx` — read-only presentation variant. -- `src/modules/board/hooks/useRemoteTiltControl.ts` — existing native state adapter. -- `src/screens/showcase/board/RemoteTiltPadShowcase.tsx` — required variant preview. -- `modules/vescape-core/android/src/test/java/expo/modules/vescapecore/RemoteTiltControllerTest.kt` — behavior test prior art; inspect Swift peer too. - -### 5 - Drive brake-light states - -Type: AFK. Complexity: high. Blocked by: 2. Stories: 20–24, 26. - -Deliver speed-derived native braking through acknowledged state messages into the separate firmware light capability, plus sensitivity, parked preference, and actual protocol preview in the app. Use a fake output driver in automated tests until LED hardware is chosen. Firmware rendering stays replaceable behind the state receiver. - -Acceptance: forward/reverse deceleration traces produce normal/braking/hard-braking states; constant speed does not indicate braking; gaps clear detector history and send unavailable; sensitivity and parked preference persist automatically; preview restores automatic state; renewals do not restart animation; expired app commands trigger firmware fallback. Document initial filtering and thresholds as PoC defaults. - -Likely files: - -- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/protocol/VescTelemetryModels.kt` — available speed and Board state. -- `modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt` — native telemetry consumer wiring. -- `modules/vescape-core/ios/connection/BoardSessionController.swift` — peer wiring. -- `modules/vescape-core/src/index.ts` — settings/status bridge contract. -- `src/app/settings/sensors.tsx` — PR #441 navigation starting point for accessory detail UI. -- `vescape-hardware: src/main.cpp` — current firmware entrypoint to split by capability. -- `docs/accessory-protocol.md` — semantic output and preview rules. - -### 6 - Validate connected riding - -Type: HITL. Complexity: high. Blocked by: 4, 5. Stories: 4, 11–17, 20–25. - -Choose and connect the physical light driver when hardware exists, then verify the complete sensor + Board + light flow in current Android/iOS development builds. Start with controlled stationary checks, then rider-assisted measurements. Tune documented PoC timings/thresholds from evidence. This is the hardware-completion boundary; fake output tests do not satisfy it. - -Acceptance: confirm current workspace firmware and app builds; observe real light states and preview; locked-screen concurrent BLE operation is measured; sensor and app power-loss cases produce expected fallback; Board Move handoff behaves correctly; record measured sampling/latency and remaining limits; no claims based on stale installed bundles. - -Likely files: - -- `vescape-hardware: src/main.cpp` — real light driver integration. -- `vescape-hardware: platformio.ini` — chosen driver's build configuration if needed. -- `vescape-hardware: docs/hardware.md` — actual wiring and measured configuration. -- `vescape-hardware: docs/flashing.md` — established upload procedure. -- `docs/accessory-protocol.md` — validated timings and failure evidence. -- `docs/accessories.md` — final PoC behavior and limitations. -- `docs/connectionState.md` — existing lifecycle expectations. diff --git a/docs/index.md b/docs/index.md index 6fbb54aa..b64c238c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,7 +27,6 @@ - [accessories.md](./accessories.md) — in-progress accessory design: ground-clearance tilt sensor and brake light - [accessory-protocol.md](./accessory-protocol.md) — JSON/BLE protocol v1 draft: discovery, capabilities, commands, readings, and failure handling -- [accessories-prd-draft.md](./accessories-prd-draft.md) — approved planning snapshot and links to published accessory PRD and implementation issues - [history.md](./history.md) — ride history persistence, grouping, markers, and map rendering - [tune.md](./tune.md) — Refloat tune screen behavior, basic slider formulas, field groups - [tune-preview-pl.md](./tune-preview-pl.md) — Tune vs Tune Preview, explained (Polish) From a4d40d307333d1c05d818d351974d33aa95c77a0 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sun, 13 Sep 2026 07:06:50 +0200 Subject: [PATCH 03/26] Discover compatible accessories #476 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-hardware@4a29e4e --- CONTEXT.md | 12 + docs/accessories.md | 14 +- docs/accessory-protocol.md | 21 +- docs/index.md | 2 +- docs/native-api.md | 47 ++- modules/vescape-core/Package.swift | 3 +- .../modules/vescapecore/VescapeCoreModule.kt | 27 ++ .../accessory/AccessoryDiscovery.kt | 155 +++++++++ .../accessory/AccessoryGattHandshake.kt | 273 +++++++++++++++ .../accessory/AccessoryNdjsonFramer.kt | 110 ++++++ .../accessory/AccessoryProtocol.kt | 300 ++++++++++++++++ .../accessory/AccessoryFixtures.kt | 34 ++ .../accessory/AccessoryNdjsonFramerTest.kt | 87 +++++ .../accessory/AccessoryProtocolTest.kt | 145 ++++++++ .../vescape-core/ios/VescapeCoreModule.swift | 38 +- .../ios/accessory/AccessoryDiscovery.swift | 256 ++++++++++++++ .../ios/accessory/AccessoryFixtures.swift | 39 +++ .../accessory/AccessoryGattHandshake.swift | 186 ++++++++++ .../ios/accessory/AccessoryNdjsonFramer.swift | 88 +++++ .../AccessoryNdjsonFramerTests.swift | 64 ++++ .../ios/accessory/AccessoryProtocol.swift | 326 ++++++++++++++++++ .../accessory/AccessoryProtocolTests.swift | 120 +++++++ modules/vescape-core/src/index.ts | 165 +++++++++ .../fixtures/accessory-protocol/framing.json | 116 +++++++ .../accessory-protocol/handshake.json | 313 +++++++++++++++++ src/app/_layout.tsx | 2 + src/app/accessories/[accessoryId].tsx | 8 + src/app/accessories/scan.tsx | 14 + src/app/settings/components/accessories.tsx | 32 ++ src/app/settings/components/index.tsx | 8 + .../components/AccessoryCapabilityRow.tsx | 101 ++++++ .../AccessoryCompatibilityNotice.tsx | 71 ++++ .../accessories/components/AccessoryRow.tsx | 131 +++++++ .../components/AccessorySelectorSection.tsx | 108 ++++++ .../constants/accessoryCapabilities.ts | 52 +++ .../accessories/lib/accessoryStatus.ts | 89 +++++ .../screens/AccessoryDetailScreen.tsx | 139 ++++++++ .../screens/AccessoryScanScreen.tsx | 149 ++++++++ .../store/accessoryDiscoveryStore.test.ts | 117 +++++++ .../store/accessoryDiscoveryStore.ts | 174 ++++++++++ .../board/components/BoardSelectorSheet.tsx | 37 +- src/modules/moduleBoundaries.test.ts | 1 + src/navigation/routes.ts | 4 + .../overlays/BoardSelectorAccessories.tsx | 42 +++ src/screens/main/overlays/TopBar.tsx | 25 +- .../accessories/AccessoryManifestShowcase.tsx | 105 ++++++ .../AccessorySelectorSectionShowcase.tsx | 90 +++++ .../board/BoardSelectorSheetShowcase.tsx | 39 +++ 48 files changed, 4462 insertions(+), 17 deletions(-) create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryDiscovery.kt create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryGattHandshake.kt create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryNdjsonFramer.kt create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessoryFixtures.kt create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessoryNdjsonFramerTest.kt create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessoryProtocolTest.kt create mode 100644 modules/vescape-core/ios/accessory/AccessoryDiscovery.swift create mode 100644 modules/vescape-core/ios/accessory/AccessoryFixtures.swift create mode 100644 modules/vescape-core/ios/accessory/AccessoryGattHandshake.swift create mode 100644 modules/vescape-core/ios/accessory/AccessoryNdjsonFramer.swift create mode 100644 modules/vescape-core/ios/accessory/AccessoryNdjsonFramerTests.swift create mode 100644 modules/vescape-core/ios/accessory/AccessoryProtocol.swift create mode 100644 modules/vescape-core/ios/accessory/AccessoryProtocolTests.swift create mode 100644 shared/fixtures/accessory-protocol/framing.json create mode 100644 shared/fixtures/accessory-protocol/handshake.json create mode 100644 src/app/accessories/[accessoryId].tsx create mode 100644 src/app/accessories/scan.tsx create mode 100644 src/app/settings/components/accessories.tsx create mode 100644 src/modules/accessories/components/AccessoryCapabilityRow.tsx create mode 100644 src/modules/accessories/components/AccessoryCompatibilityNotice.tsx create mode 100644 src/modules/accessories/components/AccessoryRow.tsx create mode 100644 src/modules/accessories/components/AccessorySelectorSection.tsx create mode 100644 src/modules/accessories/constants/accessoryCapabilities.ts create mode 100644 src/modules/accessories/lib/accessoryStatus.ts create mode 100644 src/modules/accessories/screens/AccessoryDetailScreen.tsx create mode 100644 src/modules/accessories/screens/AccessoryScanScreen.tsx create mode 100644 src/modules/accessories/store/accessoryDiscoveryStore.test.ts create mode 100644 src/modules/accessories/store/accessoryDiscoveryStore.ts create mode 100644 src/screens/main/overlays/BoardSelectorAccessories.tsx create mode 100644 src/screens/showcase/accessories/AccessoryManifestShowcase.tsx create mode 100644 src/screens/showcase/accessories/AccessorySelectorSectionShowcase.tsx diff --git a/CONTEXT.md b/CONTEXT.md index 833c859b..e4c7e183 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -12,6 +12,18 @@ _Avoid_: Board, sensor module (when referring to accessories generally) A rider-configured relationship that maps an Accessory input or Board telemetry to an Accessory output or Board action. _Avoid_: Sync, hardware mapping +**Accessory Manifest**: +What an Accessory declares about itself on every connection: its persistent Accessory ID, display name, firmware version, agreed protocol version, and Accessory Capabilities. Read again on each reconnect before saved settings are trusted. +_Avoid_: Accessory info, device descriptor + +**Accessory Capability**: +One thing an Accessory declares it can do, identified by a stable local id and a capability type. Types the app recognizes have predefined behavior; unrecognized ones are shown as unsupported rather than hidden. +_Avoid_: Sensor, feature, channel + +**Accessory Compatibility**: +The app's verdict on a read Accessory Manifest: supported, no common protocol version, or no capability the app can drive. Distinct from reachability — an Accessory can answer perfectly and still be unusable. +_Avoid_: Accessory status, supported flag + **Sensor Tilt Calibration**: The near and far ground clearances in centimetres, correction direction, and maximum Remote Tilt input defining a board-mounted distance sensor's Accessory Binding, with less clearance producing stronger correction. _Avoid_: Sensor sensitivity (for the full calibration) diff --git a/docs/accessories.md b/docs/accessories.md index b5926990..fe516e7a 100644 --- a/docs/accessories.md +++ b/docs/accessories.md @@ -1,6 +1,18 @@ # Accessories -Design in progress. These are agreed requirements, not implemented behavior. +Design in progress. Most of this is agreed requirements, not implemented behavior. + +**Implemented so far**: discovery. The Board selector has separate Boards and Accessories sections +with an Add accessory action; scanning matches the Vescape Accessory service UUID rather than a +name; connecting reads the manifest and reports identity, firmware version, protocol compatibility +and capability types. Native owns the radio, the framing, the protocol session and the compatibility +verdict — `startAccessoryScan` / `inspectAccessory` on both platforms — and JS renders it. Discovery +disconnects as soon as the manifest is read, so nothing on an accessory can be activated by finding +it. + +**Not implemented**: enrollment and saved identities, auto-connect, calibration, measurements, tilt +bindings, and brake-light behavior. Accessories listed in the selector are what the current session +discovered, not saved units. ## Initial scope diff --git a/docs/accessory-protocol.md b/docs/accessory-protocol.md index 3f5a614d..23c2ebd5 100644 --- a/docs/accessory-protocol.md +++ b/docs/accessory-protocol.md @@ -1,6 +1,25 @@ # Vescape Accessory Protocol v1 -Status: implementation draft for the PoC. Product behavior is in [accessories.md](./accessories.md). This protocol is not implemented yet. Timing, rate, and size limits below are proposed PoC defaults, not measured reliability guarantees. +Status: implementation draft for the PoC. Product behavior is in [accessories.md](./accessories.md). Timing, rate, and size limits below are proposed PoC defaults, not measured reliability guarantees. + +**Implemented so far**: BLE transport, NDJSON framing with its bounds, the `hello`/`manifest` +handshake, version negotiation, and capability recognition. Everything operational — `configure`, +`state`, `reading`, leases, acknowledgements — is still a draft; the firmware answers those with +`unsupported_message` today. + +The implemented half has an executable form: `shared/fixtures/accessory-protocol/` holds the framing +and handshake corpus that Android Kotlin, iOS Swift and the ESP32 firmware all run +(`bun run test:android`, `bun run test:ios`, and `pio test -e native` in `vescape-hardware`). Change +the fixtures first; three implementations of one wire format drift silently otherwise. + +Two rules below are app-side decisions the fixtures pin down, rather than wire format: + +- A recognized capability type is not automatically a usable one. A `ground_clearance` must declare + centimetres, a range whose minimum is below its maximum, and at least one positive rate; anything + else is reported as an unsupported capability rather than guessed at. +- Compatibility is one of `supported`, `unsupported-version` (no common protocol version), or + `unsupported-capabilities` (version agreed, nothing recognized). A version mismatch marks every + capability unsupported, because none of them can be driven. ## Ownership diff --git a/docs/index.md b/docs/index.md index b64c238c..495f7642 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,7 +26,7 @@ ### Features - [accessories.md](./accessories.md) — in-progress accessory design: ground-clearance tilt sensor and brake light -- [accessory-protocol.md](./accessory-protocol.md) — JSON/BLE protocol v1 draft: discovery, capabilities, commands, readings, and failure handling +- [accessory-protocol.md](./accessory-protocol.md) — JSON/BLE protocol v1: discovery implemented against shared fixtures; commands, readings, and failure handling still draft - [history.md](./history.md) — ride history persistence, grouping, markers, and map rendering - [tune.md](./tune.md) — Refloat tune screen behavior, basic slider formulas, field groups - [tune-preview-pl.md](./tune-preview-pl.md) — Tune vs Tune Preview, explained (Polish) diff --git a/docs/native-api.md b/docs/native-api.md index 9586fa89..33cb7e03 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -26,6 +26,35 @@ Source of truth: `modules/vescape-core/src/index.ts` (types), `VescapeCoreModule | `scan()` | sync | void. Emits `onDevice` events per advertisement | | `stopScan()` | sync | void | +## Accessory discovery + +Read-only. Scanning matches the Vescape Accessory service UUID, never a name. One inspection runs at +a time; it writes one `hello`, reads the manifest, and disconnects, so nothing on an accessory is +activated by finding it. Contract: [accessory-protocol.md](./accessory-protocol.md). + +| fn | sync | returns | +| ----------------------------- | ----- | --------------------------------------------------------------------- | +| `startAccessoryScan()` | sync | void. Emits `onAccessoryDevice` per advertisement | +| `stopAccessoryScan()` | sync | void | +| `inspectAccessory(deviceId)` | async | `AccessoryInspection` — `{deviceId, advertisedName, manifest, error}` | +| `cancelAccessoryInspection()` | sync | void | + +### AccessoryManifest shape + +```ts +{ + accessoryId: string // persistent identity; saved settings key on it, never on the BLE handle + name: string + firmwareVersion: string + protocolVersion: number | null // null = no common version + supportedVersions: number[] // what the accessory offers instead, only when none was agreed + compatibility: 'supported' | 'unsupported-version' | 'unsupported-capabilities' + capabilities: { id, type, supported, unit, rangeMin, rangeMax, ratesHz }[] +} +``` + +`compatibility` and each capability's `supported` are native's verdict, not JS's to re-derive. + ## Location | fn | sync | returns | @@ -352,14 +381,16 @@ Rejection codes are rider-facing; `src/modules/settings/lib/companionErrors.ts` ## Events -| event | payload | when | -| ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `onDevice` | `{id, name, rssi, serviceUUIDs[]}` | BLE scan advertisement | -| `onError` | `{message}` | Native error | -| `onLiveState` | `LiveStateEvent` | Connection/GPS/scan/recording state change | -| `onTelemetry` | `TelemetryEvent` | Real-time board data. Includes `firedAlerts[]` | -| `onBms` | `BmsEvent` | Smart-BMS cell-group values, ~1/8 telemetry rate. See [vescProtocol.md](./vescProtocol.md#bms-cell-group-values) | -| `onLocation` | `LocationEvent` | GPS fix from `startLocationUpdates()` | +| event | payload | when | +| ---------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `onDevice` | `{id, name, rssi, serviceUUIDs[]}` | BLE scan advertisement | +| `onError` | `{message}` | Native error | +| `onLiveState` | `LiveStateEvent` | Connection/GPS/scan/recording state change | +| `onTelemetry` | `TelemetryEvent` | Real-time board data. Includes `firedAlerts[]` | +| `onBms` | `BmsEvent` | Smart-BMS cell-group values, ~1/8 telemetry rate. See [vescProtocol.md](./vescProtocol.md#bms-cell-group-values) | +| `onLocation` | `LocationEvent` | GPS fix from `startLocationUpdates()` | +| `onAccessoryDevice` | `{id, name, rssi}` | Vescape Accessory service advertisement | +| `onAccessoryScanError` | `{error}` | The accessory scan could not run (`bluetooth-unavailable`, `scan-failed`) | ### TelemetryEvent shape (live, not history) diff --git a/modules/vescape-core/Package.swift b/modules/vescape-core/Package.swift index 9fbae7c2..82f9a843 100644 --- a/modules/vescape-core/Package.swift +++ b/modules/vescape-core/Package.swift @@ -33,7 +33,8 @@ let expoOwnedSources: Set = [ /// Test-only helpers that are not themselves `XCTestCase` files, so the `*Tests.swift` rule misses /// them. They use `@testable import VescapeCore` and belong in the test target. let testSupportSources: Set = [ - "replay/ConfigReplayHarness.swift" + "replay/ConfigReplayHarness.swift", + "accessory/AccessoryFixtures.swift", ] /// Symlinks into `shared/`. The pod bundles all of them through `resource_bundles`; SPM only needs diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt index a9c14754..5c28a570 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt @@ -5,6 +5,7 @@ import expo.modules.kotlin.functions.Queues import expo.modules.vescapecore.diagnostics.UnexpectedNativeError import expo.modules.vescapecore.telemetry.FavoriteMediaCleanupException +import expo.modules.vescapecore.accessory.AccessoryDiscovery import expo.modules.vescapecore.alerts.AlertFeedback import expo.modules.vescapecore.alerts.normalizedAlertBeepCount import expo.modules.vescapecore.alerts.normalizedAlertRepeatSeconds @@ -193,8 +194,16 @@ class VescapeCoreModule : Module() { "onNavigation", "onRouteProgress", "onWeather", + "onAccessoryDevice", + "onAccessoryScanError", ) + // Accessory discovery pushes devices as the radio finds them; the module is only the pipe. + // @parity /modules/vescape-core/ios/VescapeCoreModule.swift `AccessoryDiscovery` + AccessoryDiscovery.emit = { name, body -> + mainHandler.post { if (shouldEmitToFrontend(name)) sendEvent(name, body) } + } + // Native owns App Status truth; JS mirrors it. Push every successful refresh (late subscribers // pull the current snapshot below and through `getAppStatus`). // @parity /modules/vescape-core/ios/VescapeCoreModule.swift `sendAppStatus` @@ -369,6 +378,10 @@ class VescapeCoreModule : Module() { sendEvent("onWeather", mapOf("weather" to WeatherCoordinator.get().current?.toMap())) } OnStopObserving("onWeather") { stopObserving("onWeather") } + OnStartObserving("onAccessoryDevice") { startObserving("onAccessoryDevice") } + OnStopObserving("onAccessoryDevice") { stopObserving("onAccessoryDevice") } + OnStartObserving("onAccessoryScanError") { startObserving("onAccessoryScanError") } + OnStopObserving("onAccessoryScanError") { stopObserving("onAccessoryScanError") } OnCreate { val storageOutageEvents = StorageOutageEventBridge( @@ -412,6 +425,9 @@ class VescapeCoreModule : Module() { previewAlertFeedback = null stopAlertTest() cancelActiveProbe(null, "module_destroyed") + AccessoryDiscovery.emit = null + AccessoryDiscovery.stopScan() + AccessoryDiscovery.cancelInspection() if (CoreForegroundService.emitEvent != null) { CoreForegroundService.emitEvent = null } @@ -419,6 +435,17 @@ class VescapeCoreModule : Module() { Function("scan") { startScan(resetRetries = true) } Function("stopScan") { stopScanInternal() } + + // Accessory discovery. Read-only: it scans for the Vescape Accessory service, reads one + // manifest, and disconnects. No Board or Accessory control can start from here. + // @parity /modules/vescape-core/ios/VescapeCoreModule.swift `startAccessoryScan` + // @parity /modules/vescape-core/src/index.ts `startAccessoryScan` + Function("startAccessoryScan") { AccessoryDiscovery.startScan(context.applicationContext) } + Function("stopAccessoryScan") { AccessoryDiscovery.stopScan() } + Function("cancelAccessoryInspection") { AccessoryDiscovery.cancelInspection() } + AsyncFunction("inspectAccessory") { deviceId: String, promise: Promise -> + AccessoryDiscovery.inspect(context.applicationContext, deviceId) { promise.resolve(it) } + } Function("exitApp") { CoreForegroundService.exitApp(context.applicationContext) } Function("startLocationUpdates") { startLocationUpdates() } Function("stopLocationUpdates") { stopLocationUpdates() } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryDiscovery.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryDiscovery.kt new file mode 100644 index 00000000..38740012 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryDiscovery.kt @@ -0,0 +1,155 @@ +package expo.modules.vescapecore.accessory + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.os.ParcelUuid +import android.util.Log +import java.util.UUID + +private const val TAG = "VescapeAccessory" + +/** + * Finding Accessories and asking each one what it is. Scanning matches the Vescape Accessory + * service UUID, never a name: a name is a label the rider can change and other hardware can copy, + * so it identifies nothing. The service is what makes a device an Accessory. + * + * Discovery is read-only by construction. It hands each device to a short-lived + * [AccessoryGattHandshake] that writes one `hello`, reads the manifest, and disconnects; nothing on + * this path can command an Accessory, and finding one never enrolls it. Enrollment is an explicit + * rider action in a later slice. + * + * One inspection runs at a time. Two concurrent GATT handshakes against the same radio mostly + * produce two timeouts, and the rider is looking at one row anyway. + * + * @parity /modules/vescape-core/ios/accessory/AccessoryDiscovery.swift + */ +@SuppressLint("MissingPermission") +object AccessoryDiscovery { + /** Set by the Expo module so discovery can push devices without holding a module reference. */ + var emit: ((String, Map) -> Unit)? = null + + private val handler = Handler(Looper.getMainLooper()) + private var scanCallback: ScanCallback? = null + private var scanContext: Context? = null + private var inFlight: AccessoryGattHandshake? = null + + fun startScan(context: Context) { + stopScan() + val app = context.applicationContext + val scanner = (app.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager) + ?.adapter + ?.bluetoothLeScanner + if (scanner == null) { + emit?.invoke("onAccessoryScanError", mapOf("error" to "bluetooth-unavailable")) + return + } + val callback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + emit?.invoke( + "onAccessoryDevice", + mapOf( + "id" to result.device.address, + // Nullable on purpose: a device that advertises no name is still a valid + // Accessory, and the manifest is where its real name comes from anyway. + "name" to (result.scanRecord?.deviceName ?: result.device.name), + "rssi" to result.rssi, + ), + ) + } + + override fun onBatchScanResults(results: MutableList) { + results.forEach { onScanResult(ScanSettings.CALLBACK_TYPE_ALL_MATCHES, it) } + } + + override fun onScanFailed(errorCode: Int) { + scanCallback = null + emit?.invoke("onAccessoryScanError", mapOf("error" to "scan-failed")) + } + } + scanCallback = callback + scanContext = app + scanner.startScan( + listOf( + ScanFilter.Builder() + .setServiceUuid(ParcelUuid(AccessoryProtocol.SERVICE_UUID)) + .build(), + ), + ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), + callback, + ) + } + + fun stopScan() { + val callback = scanCallback ?: return + val app = scanContext + scanCallback = null + scanContext = null + try { + (app?.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager) + ?.adapter + ?.bluetoothLeScanner + ?.stopScan(callback) + } catch (e: Exception) { + Log.w(TAG, "scan stop failed: ${e.message}") + } + } + + /** + * Connects to one discovered device and reads its manifest. [onResult] receives the bridge + * payload exactly once, whether the handshake succeeded, was rejected, or timed out. + */ + fun inspect(context: Context, deviceId: String, onResult: (Map) -> Unit) { + handler.post { + if (inFlight != null) { + onResult(payload(deviceId, null, null, "busy")) + return@post + } + // Scanning while a handshake runs slows the connection down for no benefit: the rider + // has already picked a row. + stopScan() + val sessionId = UUID.randomUUID().toString() + val handshake = AccessoryGattHandshake( + context.applicationContext, + handler, + deviceId, + sessionId, + ) { outcome -> + inFlight = null + onResult( + when (outcome) { + is AccessoryHandshakeOutcome.Ok -> + payload(deviceId, outcome.advertisedName, outcome.manifest, null) + is AccessoryHandshakeOutcome.Failed -> + payload(deviceId, outcome.advertisedName, null, outcome.error) + }, + ) + } + inFlight = handshake + handshake.start() + } + } + + /** Abandons an inspection the rider walked away from. */ + fun cancelInspection() { + handler.post { inFlight?.cancel() } + } + + private fun payload( + deviceId: String, + advertisedName: String?, + manifest: AccessoryManifest?, + error: String?, + ): Map = mapOf( + "deviceId" to deviceId, + "advertisedName" to advertisedName, + "manifest" to manifest?.toMap(), + "error" to error, + ) +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryGattHandshake.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryGattHandshake.kt new file mode 100644 index 00000000..9b0e8fec --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryGattHandshake.kt @@ -0,0 +1,273 @@ +package expo.modules.vescapecore.accessory + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.content.Context +import android.os.Build +import android.os.Handler +import android.util.Log +import java.util.UUID + +private const val TAG = "VescapeAccessory" +private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") +private const val REQUESTED_MTU = 517 + +/** ATT overhead on a write: the negotiated MTU minus the opcode and handle. */ +private const val ATT_WRITE_OVERHEAD = 3 + +/** Conservative default until the peer answers `onMtuChanged`. */ +private const val DEFAULT_MTU = 23 + +/** Connect, discover and subscribe must all land before the handshake is even sent. */ +private const val CONNECT_TIMEOUT_MS = 10_000L + +/** + * One Accessory discovery handshake: connect, subscribe, write `hello`, read the manifest back, + * disconnect. Nothing else is ever written on the link. + * + * That single-write shape is the guarantee behind "no control activates from discovery": the class + * has no path that can emit `configure` or `state`, so inspecting an Accessory cannot start a + * measurement or change a light. The operational session is a separate concern built on top of the + * same protocol in later slices. + * + * Short-lived by design — it is torn down the moment it has an answer, so discovery never holds a + * connection an Accessory's real session would have to fight for. + * + * @parity /modules/vescape-core/ios/accessory/AccessoryGattHandshake.swift + */ +@SuppressLint("MissingPermission") +internal class AccessoryGattHandshake( + private val context: Context, + private val handler: Handler, + private val deviceId: String, + private val sessionId: String, + private val onFinished: (AccessoryHandshakeOutcome) -> Unit, +) { + private var gatt: BluetoothGatt? = null + private var writeChar: BluetoothGattCharacteristic? = null + private val framer = AccessoryNdjsonFramer() + private var mtu = DEFAULT_MTU + private val pendingChunks = ArrayDeque() + private var writeInFlight = false + private var timeout: Runnable? = null + private var finished = false + private var advertisedName: String? = null + + fun start() { + val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter + if (adapter == null || !adapter.isEnabled) { + finish(AccessoryHandshakeOutcome.Failed("bluetooth-unavailable")) + return + } + val device = try { + adapter.getRemoteDevice(deviceId) + } catch (e: IllegalArgumentException) { + finish(AccessoryHandshakeOutcome.Failed("connect-failed")) + return + } + advertisedName = device.name + arm(CONNECT_TIMEOUT_MS, "timeout") + gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE) + } + + fun cancel() = finish(AccessoryHandshakeOutcome.Failed("cancelled")) + + private fun arm(delayMs: Long, error: String) { + timeout?.let { handler.removeCallbacks(it) } + val runnable = Runnable { finish(AccessoryHandshakeOutcome.Failed(error)) } + timeout = runnable + handler.postDelayed(runnable, delayMs) + } + + private fun finish(outcome: AccessoryHandshakeOutcome) { + if (finished) return + finished = true + timeout?.let { handler.removeCallbacks(it) } + timeout = null + framer.reset() + pendingChunks.clear() + writeChar = null + val target = gatt + gatt = null + try { + target?.disconnect() + target?.close() + } catch (e: Exception) { + Log.w(TAG, "gatt cleanup failed: ${e.message}") + } + onFinished( + when (outcome) { + is AccessoryHandshakeOutcome.Ok -> outcome.copy(advertisedName = advertisedName) + is AccessoryHandshakeOutcome.Failed -> outcome.copy(advertisedName = advertisedName) + }, + ) + } + + private val callback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { + if (g !== gatt) { + try { g.close() } catch (e: Exception) { Log.w(TAG, "stale close: ${e.message}") } + return + } + handler.post { + if (newState == BluetoothProfile.STATE_CONNECTED) { + g.requestMtu(REQUESTED_MTU) + } else { + finish(AccessoryHandshakeOutcome.Failed("connect-failed")) + } + } + } + + override fun onMtuChanged(g: BluetoothGatt, negotiated: Int, status: Int) { + handler.post { + if (g !== gatt) return@post + if (negotiated > 0) mtu = negotiated + g.discoverServices() + } + } + + override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { + handler.post { + if (g !== gatt) return@post + val service = g.getService(AccessoryProtocol.SERVICE_UUID) + ?: return@post finish(AccessoryHandshakeOutcome.Failed("service-missing")) + val notify = service.getCharacteristic(AccessoryProtocol.NOTIFY_UUID) + val write = service.getCharacteristic(AccessoryProtocol.WRITE_UUID) + if (notify == null || write == null) { + return@post finish(AccessoryHandshakeOutcome.Failed("service-missing")) + } + writeChar = write + g.setCharacteristicNotification(notify, true) + val cccd = notify.getDescriptor(CCCD_UUID) + ?: return@post finish(AccessoryHandshakeOutcome.Failed("service-missing")) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + g.writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + } else { + @Suppress("DEPRECATION") + run { + cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + g.writeDescriptor(cccd) + } + } + } + } + + override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { + handler.post { + if (g !== gatt) return@post + sendHello() + } + } + + override fun onCharacteristicWrite( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int, + ) { + handler.post { + if (g !== gatt) return@post + if (status != BluetoothGatt.GATT_SUCCESS) { + return@post finish(AccessoryHandshakeOutcome.Failed("write-failed")) + } + writeInFlight = false + drain() + } + } + + @Suppress("DEPRECATION") + override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { + deliver(g, characteristic.uuid, characteristic.value ?: return) + } + + override fun onCharacteristicChanged( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + ) { + deliver(g, characteristic.uuid, value) + } + } + + /** Subscribed and ready: write the one line discovery is allowed to send. */ + private fun sendHello() { + if (pendingChunks.isNotEmpty() || writeInFlight) return + val payload = (AccessoryProtocol.encodeHello(sessionId) + "\n").toByteArray(Charsets.UTF_8) + val limit = (mtu - ATT_WRITE_OVERHEAD).coerceAtLeast(20) + var offset = 0 + while (offset < payload.size) { + val end = minOf(offset + limit, payload.size) + pendingChunks.addLast(payload.copyOfRange(offset, end)) + offset = end + } + // The clock starts at the request, not at connect: a slow connect has its own budget. + arm(AccessoryProtocol.HANDSHAKE_TIMEOUT_MS, "timeout") + drain() + } + + /** One outstanding GATT write at a time; the chunks of one line stay in order. */ + private fun drain() { + if (writeInFlight) return + val target = gatt ?: return + val characteristic = writeChar ?: return + val chunk = pendingChunks.removeFirstOrNull() ?: return + writeInFlight = true + val queued = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + target.writeCharacteristic( + characteristic, + chunk, + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT, + ) == BluetoothGatt.GATT_SUCCESS + } else { + @Suppress("DEPRECATION") + run { + characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + characteristic.value = chunk + target.writeCharacteristic(characteristic) + } + } + if (!queued) finish(AccessoryHandshakeOutcome.Failed("write-failed")) + } + + private fun deliver(g: BluetoothGatt, uuid: UUID, value: ByteArray) { + if (g !== gatt || uuid != AccessoryProtocol.NOTIFY_UUID) return + handler.post { + if (finished) return@post + val result = framer.feed(value) + for (line in result.lines) { + when (val parsed = AccessoryProtocol.parseManifest(line, sessionId)) { + is ManifestResult.Ok -> + return@post finish(AccessoryHandshakeOutcome.Ok(parsed.manifest)) + is ManifestResult.Failed -> { + // A message from another session is noise on a shared characteristic, not a + // protocol violation: keep waiting for the manifest this hello asked for. + if (parsed.error != AccessoryHandshakeError.SESSION_MISMATCH) { + return@post finish(AccessoryHandshakeOutcome.Failed(parsed.error.wire)) + } + } + } + } + result.failure?.let { return@post finish(AccessoryHandshakeOutcome.Failed(it.wire)) } + } + } +} + +/** What one handshake produced, ready to cross the bridge. */ +internal sealed class AccessoryHandshakeOutcome { + abstract val advertisedName: String? + + data class Ok( + val manifest: AccessoryManifest, + override val advertisedName: String? = null, + ) : AccessoryHandshakeOutcome() + + data class Failed( + val error: String, + override val advertisedName: String? = null, + ) : AccessoryHandshakeOutcome() +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryNdjsonFramer.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryNdjsonFramer.kt new file mode 100644 index 00000000..814d504e --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryNdjsonFramer.kt @@ -0,0 +1,110 @@ +package expo.modules.vescapecore.accessory + +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction +import java.nio.charset.StandardCharsets + +/** + * Why framing ended the protocol session. Both are terminal: the transport disconnects and clears + * its buffers rather than trying to resynchronise mid-stream. + * + * @parity /modules/vescape-core/ios/accessory/AccessoryNdjsonFramer.swift `AccessoryFramingError` + * @parity /modules/vescape-core/src/index.ts `AccessoryInspectionError` + */ +enum class AccessoryFramingError(val wire: String) { + OVERSIZED("oversized"), + INVALID_UTF8("invalid-utf8"), +} + +/** Lines completed by one chunk, plus the failure that ended the stream if one did. */ +data class AccessoryFramingResult( + val lines: List, + val failure: AccessoryFramingError?, +) + +/** + * Newline-delimited JSON reassembly for the Accessory link. BLE packet boundaries are not message + * boundaries: one notification can carry half a line, several lines, or a byte that finishes a + * multi-byte character started in the previous one. + * + * Bounded by construction. The buffer can never hold more than [maxLineBytes]: the byte that would + * take it past the limit fails the stream instead of being appended, so a peer that never sends an + * LF costs a fixed 4 KB rather than growing until the process dies. A failure is terminal — the + * buffer is dropped and every later chunk is refused, because a stream that lost its framing has no + * trustworthy next boundary. + * + * UTF-8 is validated per complete line, after reassembly, never per chunk. + * + * @parity /modules/vescape-core/ios/accessory/AccessoryNdjsonFramer.swift + */ +class AccessoryNdjsonFramer( + private val maxLineBytes: Int = AccessoryProtocol.MAX_LINE_BYTES, +) { + private companion object { + const val LF = '\n'.code.toByte() + } + + private var buffer = ByteArray(minOf(INITIAL_CAPACITY, maxLineBytes)) + private var length = 0 + private var failure: AccessoryFramingError? = null + + /** Bytes currently held for the line being assembled. Never exceeds `maxLineBytes`. */ + val bufferedBytes: Int get() = length + + val failed: Boolean get() = failure != null + + fun feed(chunk: ByteArray): AccessoryFramingResult { + failure?.let { return AccessoryFramingResult(emptyList(), it) } + + val lines = mutableListOf() + for (byte in chunk) { + if (byte == LF) { + // An empty line is framing, not a message: the protocol sends one object per line, + // so a stray LF carries nothing to decode. + if (length > 0) { + val decoded = decode(buffer, length) + length = 0 + if (decoded == null) return fail(lines, AccessoryFramingError.INVALID_UTF8) + lines.add(decoded) + } + continue + } + if (length == maxLineBytes) return fail(lines, AccessoryFramingError.OVERSIZED) + if (length == buffer.size) buffer = buffer.copyOf(minOf(buffer.size * 2, maxLineBytes)) + buffer[length++] = byte + } + return AccessoryFramingResult(lines, null) + } + + /** Drops everything held. Called on disconnect so a new session starts with no old bytes. */ + fun reset() { + length = 0 + failure = null + buffer = ByteArray(minOf(INITIAL_CAPACITY, maxLineBytes)) + } + + private fun fail( + lines: List, + error: AccessoryFramingError, + ): AccessoryFramingResult { + failure = error + length = 0 + buffer = ByteArray(0) + return AccessoryFramingResult(lines, error) + } + + /** Strict UTF-8: a malformed sequence is an error, never a replacement character. */ + private fun decode(bytes: ByteArray, count: Int): String? { + val decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + return try { + decoder.decode(ByteBuffer.wrap(bytes, 0, count)).toString() + } catch (e: Exception) { + null + } + } +} + +/** A line's worth of buffer is rare; most messages are a few hundred bytes. */ +private const val INITIAL_CAPACITY = 256 diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt new file mode 100644 index 00000000..6b23fedc --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt @@ -0,0 +1,300 @@ +package expo.modules.vescapecore.accessory + +import org.json.JSONArray +import org.json.JSONObject +import org.json.JSONTokener +import java.util.UUID + +/** + * Vescape Accessory Protocol v1 — the discovery half: the custom GATT service that identifies an + * Accessory regardless of its advertised name, the `hello` the app writes once it has subscribed, + * and the manifest it reads back. + * + * Nothing here commands an Accessory. Discovery reads identity, protocol version and capability + * types; every operational message (`configure`, `state`, `reading`) belongs to the per-capability + * slices that follow, so an Accessory found here can never start measuring or lighting up. + * + * The wire contract is `docs/accessory-protocol.md`; the executable form of it is + * `shared/fixtures/accessory-protocol/`, which this file, its Swift peer and the ESP32 firmware all + * run. + * + * @parity /modules/vescape-core/ios/accessory/AccessoryProtocol.swift + * @parity /modules/vescape-core/src/index.ts `AccessoryManifest` + */ +object AccessoryProtocol { + /** Advertised service that makes a device a Vescape Accessory. Project-assigned, not SIG. */ + val SERVICE_UUID: UUID = UUID.fromString("8d53dc10-1db7-4cd3-868b-8a527460aa84") + + /** App to accessory, write with response. */ + val WRITE_UUID: UUID = UUID.fromString("8d53dc11-1db7-4cd3-868b-8a527460aa84") + + /** Accessory to app, notify. */ + val NOTIFY_UUID: UUID = UUID.fromString("8d53dc12-1db7-4cd3-868b-8a527460aa84") + + /** Maximum NDJSON line length excluding the LF. Anything longer ends the protocol session. */ + const val MAX_LINE_BYTES = 4096 + + /** Protocol versions this app can speak. */ + val SUPPORTED_VERSIONS: List = listOf(1) + + /** The handshake is the first request of a session, so its id is fixed. */ + const val HELLO_REQUEST_ID = 1 + + /** Manifest response timeout, `docs/accessory-protocol.md` PoC defaults. */ + const val HANDSHAKE_TIMEOUT_MS = 3_000L + + /** + * Capability types v1 recognizes. An accessory may advertise others; they are reported as + * unsupported rather than hiding the capabilities that do work. + * + * @parity /modules/vescape-core/src/index.ts `AccessoryCapabilityType` + */ + const val TYPE_GROUND_CLEARANCE = "ground_clearance" + const val TYPE_BRAKE_LIGHT = "brake_light" + + /** Ground clearance is measured in centimetres; any other unit is a capability we cannot use. */ + const val GROUND_CLEARANCE_UNIT = "cm" + + /** + * The one line discovery writes. Built by hand rather than through [JSONObject] because the + * shared fixture pins the exact bytes, and a map-backed encoder does not promise key order. + */ + fun encodeHello(sessionId: String): String = + "{\"type\":\"hello\",\"requestId\":$HELLO_REQUEST_ID,\"sessionId\":${quote(sessionId)}," + + "\"supportedVersions\":[${SUPPORTED_VERSIONS.joinToString(",")}]}" + + private fun quote(value: String): String = JSONObject.quote(value) + + /** + * Decodes one received line as the manifest answering [sessionId]/[requestId]. + * + * Rejection is deliberately coarse: a manifest that fails any envelope rule is not partially + * trusted, because saved settings key on the identity it carries. + */ + fun parseManifest( + line: String, + sessionId: String, + requestId: Int = HELLO_REQUEST_ID, + ): ManifestResult { + val root = try { + JSONTokener(line).nextValue() + } catch (e: Exception) { + return ManifestResult.Failed(AccessoryHandshakeError.MALFORMED) + } + if (root !is JSONObject) return ManifestResult.Failed(AccessoryHandshakeError.MALFORMED) + + // Session identity is checked before anything else is read: a message from a previous + // session must not renew or influence this one. + if (root.optString("sessionId") != sessionId || root.optInt("requestId", -1) != requestId) { + return ManifestResult.Failed(AccessoryHandshakeError.SESSION_MISMATCH) + } + if (root.optString("type") != "manifest") { + return ManifestResult.Failed(AccessoryHandshakeError.INVALID) + } + if (!root.has("protocolVersion")) { + return ManifestResult.Failed(AccessoryHandshakeError.INVALID) + } + + val accessoryId = requiredString(root, "accessoryId") + ?: return ManifestResult.Failed(AccessoryHandshakeError.INVALID) + val name = requiredString(root, "name") + ?: return ManifestResult.Failed(AccessoryHandshakeError.INVALID) + val firmwareVersion = requiredString(root, "firmwareVersion") + ?: return ManifestResult.Failed(AccessoryHandshakeError.INVALID) + + val protocolVersion = if (root.isNull("protocolVersion")) { + null + } else { + (root.opt("protocolVersion") as? Number)?.toInt() + ?: return ManifestResult.Failed(AccessoryHandshakeError.INVALID) + } + val versionAgreed = protocolVersion != null && SUPPORTED_VERSIONS.contains(protocolVersion) + + val supportedVersions = when (val offered = root.opt("supportedVersions")) { + null, JSONObject.NULL -> emptyList() + is JSONArray -> (0 until offered.length()).map { + (offered.opt(it) as? Number)?.toInt() + ?: return ManifestResult.Failed(AccessoryHandshakeError.INVALID) + } + else -> return ManifestResult.Failed(AccessoryHandshakeError.INVALID) + } + + val declared = when (val raw = root.opt("capabilities")) { + null, JSONObject.NULL -> JSONArray() + is JSONArray -> raw + else -> return ManifestResult.Failed(AccessoryHandshakeError.INVALID) + } + val capabilities = mutableListOf() + val seen = mutableSetOf() + for (i in 0 until declared.length()) { + val entry = declared.opt(i) as? JSONObject + ?: return ManifestResult.Failed(AccessoryHandshakeError.INVALID) + val capability = parseCapability(entry, versionAgreed) + ?: return ManifestResult.Failed(AccessoryHandshakeError.INVALID) + if (!seen.add(capability.id)) { + return ManifestResult.Failed(AccessoryHandshakeError.INVALID) + } + capabilities.add(capability) + } + + val compatibility = when { + !versionAgreed -> AccessoryCompatibility.UNSUPPORTED_VERSION + capabilities.none { it.supported } -> AccessoryCompatibility.UNSUPPORTED_CAPABILITIES + else -> AccessoryCompatibility.SUPPORTED + } + + return ManifestResult.Ok( + AccessoryManifest( + accessoryId = accessoryId, + name = name, + firmwareVersion = firmwareVersion, + protocolVersion = protocolVersion, + supportedVersions = supportedVersions, + compatibility = compatibility, + capabilities = capabilities, + ), + ) + } + + /** Null means the capability breaks an envelope rule and the whole manifest is rejected. */ + private fun parseCapability(entry: JSONObject, versionAgreed: Boolean): AccessoryCapability? { + val id = requiredString(entry, "id") ?: return null + val type = requiredString(entry, "type") ?: return null + val unit = (entry.opt("unit") as? String)?.takeIf { it.isNotEmpty() } + val range = entry.optJSONObject("range") + val rangeMin = (range?.opt("min") as? Number)?.toDouble() + val rangeMax = (range?.opt("max") as? Number)?.toDouble() + val ratesRaw = entry.optJSONArray("ratesHz") + val ratesHz = buildList { + if (ratesRaw != null) { + for (i in 0 until ratesRaw.length()) { + add((ratesRaw.opt(i) as? Number)?.toDouble() ?: return null) + } + } + } + return AccessoryCapability( + id = id, + type = type, + // A capability is only usable when the session speaks a version both sides agreed on, + // so a version mismatch grays out every capability rather than some of them. + supported = versionAgreed && typeUsable(type, unit, rangeMin, rangeMax, ratesHz), + unit = unit, + rangeMin = rangeMin, + rangeMax = rangeMax, + ratesHz = ratesHz, + ) + } + + /** + * Whether a recognized capability type also declares limits this app can work within. A + * `ground_clearance` in millimetres, with an empty range, or offering no rate is a capability + * we would have to guess about; a recognized type is not by itself a usable one. + */ + private fun typeUsable( + type: String, + unit: String?, + rangeMin: Double?, + rangeMax: Double?, + ratesHz: List, + ): Boolean = when (type) { + TYPE_BRAKE_LIGHT -> true + TYPE_GROUND_CLEARANCE -> unit == GROUND_CLEARANCE_UNIT && + rangeMin != null && rangeMax != null && + rangeMin.isFinite() && rangeMax.isFinite() && rangeMin < rangeMax && + ratesHz.isNotEmpty() && ratesHz.all { it.isFinite() && it > 0.0 } + else -> false + } + + private fun requiredString(json: JSONObject, key: String): String? { + if (json.isNull(key)) return null + val value = json.opt(key) as? String ?: return null + return value.takeIf { it.isNotBlank() } + } +} + +/** + * Why a handshake produced no usable Accessory. Mirrors the `errors` list in + * `shared/fixtures/accessory-protocol/handshake.json`. + * + * @parity /modules/vescape-core/ios/accessory/AccessoryProtocol.swift `AccessoryHandshakeError` + * @parity /modules/vescape-core/src/index.ts `AccessoryInspectionError` + */ +enum class AccessoryHandshakeError(val wire: String) { + MALFORMED("malformed"), + INVALID("invalid"), + SESSION_MISMATCH("session-mismatch"), +} + +/** + * How much of a discovered Accessory this app can actually use. + * + * @parity /modules/vescape-core/ios/accessory/AccessoryProtocol.swift `AccessoryCompatibility` + * @parity /modules/vescape-core/src/index.ts `AccessoryCompatibility` + */ +enum class AccessoryCompatibility(val wire: String) { + SUPPORTED("supported"), + UNSUPPORTED_VERSION("unsupported-version"), + UNSUPPORTED_CAPABILITIES("unsupported-capabilities"), +} + +/** + * One capability an Accessory declares. [type] keeps the raw wire value even when unrecognized, so + * an unknown capability can be named on screen instead of disappearing. + * + * @parity /modules/vescape-core/ios/accessory/AccessoryProtocol.swift `AccessoryCapability` + * @parity /modules/vescape-core/src/index.ts `AccessoryCapability` + */ +data class AccessoryCapability( + val id: String, + val type: String, + val supported: Boolean, + val unit: String?, + val rangeMin: Double?, + val rangeMax: Double?, + val ratesHz: List, +) { + fun toMap(): Map = mapOf( + "id" to id, + "type" to type, + "supported" to supported, + "unit" to unit, + "rangeMin" to rangeMin, + "rangeMax" to rangeMax, + "ratesHz" to ratesHz, + ) +} + +/** + * What an Accessory says about itself on every connection. Read again on each reconnect — saved + * settings are only trusted after the identity, version and capability limits here still match. + * + * @parity /modules/vescape-core/ios/accessory/AccessoryProtocol.swift `AccessoryManifest` + * @parity /modules/vescape-core/src/index.ts `AccessoryManifest` + */ +data class AccessoryManifest( + /** Factory-provisioned persistent UUID. Saved settings key on this, never on the BLE address. */ + val accessoryId: String, + val name: String, + val firmwareVersion: String, + /** Null when the accessory found no common version; it then accepts no operational commands. */ + val protocolVersion: Int?, + /** What the accessory offers instead, present only when no version was agreed. */ + val supportedVersions: List, + val compatibility: AccessoryCompatibility, + val capabilities: List, +) { + fun toMap(): Map = mapOf( + "accessoryId" to accessoryId, + "name" to name, + "firmwareVersion" to firmwareVersion, + "protocolVersion" to protocolVersion, + "supportedVersions" to supportedVersions, + "compatibility" to compatibility.wire, + "capabilities" to capabilities.map { it.toMap() }, + ) +} + +sealed class ManifestResult { + data class Ok(val manifest: AccessoryManifest) : ManifestResult() + data class Failed(val error: AccessoryHandshakeError) : ManifestResult() +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessoryFixtures.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessoryFixtures.kt new file mode 100644 index 00000000..489bf626 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessoryFixtures.kt @@ -0,0 +1,34 @@ +package expo.modules.vescapecore.accessory + +import java.io.File +import org.json.JSONObject + +/** + * The shared Accessory Protocol corpus, read straight off the repo tree the way the Refloat schema + * fixtures are. The same files drive the Swift peer and the ESP32 firmware's native tests, so a + * contract that drifts on one side fails on all three. + * + * @parity /modules/vescape-core/ios/accessory/AccessoryFixtures.swift + */ +internal object AccessoryFixtures { + private const val DIR = "shared/fixtures/accessory-protocol" + + fun load(name: String): JSONObject { + val file = File(repoRoot(), "$DIR/$name") + require(file.isFile) { "missing accessory fixture $name" } + return JSONObject(file.readText()) + } + + private fun repoRoot(): File { + var dir: File? = File(System.getProperty("user.dir")!!).absoluteFile + while (dir != null && !File(dir, DIR).isDirectory) dir = dir.parentFile + return requireNotNull(dir) { "$DIR not found above ${System.getProperty("user.dir")}" } + } + + fun hexToBytes(hex: String): ByteArray { + require(hex.length % 2 == 0) { "odd-length hex: $hex" } + return ByteArray(hex.length / 2) { + ((hex[it * 2].digitToInt(16) shl 4) or hex[it * 2 + 1].digitToInt(16)).toByte() + } + } +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessoryNdjsonFramerTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessoryNdjsonFramerTest.kt new file mode 100644 index 00000000..1ceb83a9 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessoryNdjsonFramerTest.kt @@ -0,0 +1,87 @@ +package expo.modules.vescapecore.accessory + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The NDJSON framing contract, driven by `shared/fixtures/accessory-protocol/framing.json`. Chunks + * arrive as bytes so the cases can split a line mid-UTF-8-character, which is exactly what a BLE + * notification boundary does. + * + * @parity /modules/vescape-core/ios/accessory/AccessoryNdjsonFramerTests.swift + */ +class AccessoryNdjsonFramerTest { + private val fixture = AccessoryFixtures.load("framing.json") + + @Test + fun everyFramingCaseMatchesTheSharedFixture() { + val maxLineBytes = fixture.getInt("maxLineBytes") + assertEquals( + "framer default must be the documented protocol limit", + maxLineBytes, + AccessoryProtocol.MAX_LINE_BYTES, + ) + + val cases = fixture.getJSONArray("cases") + for (i in 0 until cases.length()) { + val case = cases.getJSONObject(i) + val name = case.getString("name") + val framer = AccessoryNdjsonFramer(maxLineBytes) + val chunks = case.getJSONArray("chunksHex") + val lines = mutableListOf() + var failure: AccessoryFramingError? = null + for (c in 0 until chunks.length()) { + val result = framer.feed(AccessoryFixtures.hexToBytes(chunks.getString(c))) + lines.addAll(result.lines) + failure = failure ?: result.failure + assertTrue( + "$name: the buffer must never exceed the protocol line limit", + framer.bufferedBytes <= maxLineBytes, + ) + } + + val expectedLines = case.getJSONArray("lines") + assertEquals("$name: line count", expectedLines.length(), lines.size) + for (l in 0 until expectedLines.length()) { + assertEquals("$name: line $l", expectedLines.getString(l), lines[l]) + } + + if (case.isNull("failure")) { + assertNull("$name: expected no framing failure", failure) + } else { + assertEquals("$name: failure", case.getString("failure"), failure?.wire) + } + } + } + + @Test + fun aPeerThatNeverSendsALineFeedCostsAFixedBuffer() { + val framer = AccessoryNdjsonFramer() + // Ten times the limit, in chunks, with no LF anywhere: an unbounded accumulator would hold + // all of it. The framer must give up at the limit and stay terminal. + val chunk = ByteArray(1024) { 'x'.code.toByte() } + var failure: AccessoryFramingError? = null + repeat(40) { + failure = failure ?: framer.feed(chunk).failure + assertTrue(framer.bufferedBytes <= AccessoryProtocol.MAX_LINE_BYTES) + } + assertEquals(AccessoryFramingError.OVERSIZED, failure) + assertTrue(framer.failed) + assertEquals(0, framer.bufferedBytes) + } + + @Test + fun resetClearsAFailedStreamForTheNextSession() { + val framer = AccessoryNdjsonFramer(16) + assertEquals( + AccessoryFramingError.OVERSIZED, + framer.feed(ByteArray(32) { 'x'.code.toByte() }).failure, + ) + framer.reset() + val result = framer.feed("{\"a\":1}\n".toByteArray()) + assertEquals(listOf("{\"a\":1}"), result.lines) + assertNull(result.failure) + } +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessoryProtocolTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessoryProtocolTest.kt new file mode 100644 index 00000000..09ec3806 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessoryProtocolTest.kt @@ -0,0 +1,145 @@ +package expo.modules.vescapecore.accessory + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The discovery handshake contract, driven by + * `shared/fixtures/accessory-protocol/handshake.json`: the exact `hello` line discovery writes, and + * every manifest the parser must either accept with a compatibility verdict or refuse outright. + * + * @parity /modules/vescape-core/ios/accessory/AccessoryProtocolTests.swift + */ +class AccessoryProtocolTest { + private val fixture = AccessoryFixtures.load("handshake.json") + private val hello = fixture.getJSONObject("hello") + private val sessionId = hello.getString("sessionId") + + @Test + fun helloIsEncodedByteForByteAsTheFixturePinsIt() { + assertEquals(hello.getString("line"), AccessoryProtocol.encodeHello(sessionId)) + val offered = hello.getJSONArray("supportedVersions") + assertEquals(offered.length(), AccessoryProtocol.SUPPORTED_VERSIONS.size) + for (i in 0 until offered.length()) { + assertEquals(offered.getInt(i), AccessoryProtocol.SUPPORTED_VERSIONS[i]) + } + } + + @Test + fun recognizedCapabilityTypesMatchTheSharedFixture() { + val types = fixture.getJSONArray("recognizedCapabilityTypes") + val declared = (0 until types.length()).map { types.getString(it) }.toSet() + assertEquals( + setOf(AccessoryProtocol.TYPE_GROUND_CLEARANCE, AccessoryProtocol.TYPE_BRAKE_LIGHT), + declared, + ) + } + + @Test + fun everyManifestCaseMatchesTheSharedFixture() { + val cases = fixture.getJSONArray("cases") + assertTrue("fixture must carry cases", cases.length() > 0) + for (i in 0 until cases.length()) { + val case = cases.getJSONObject(i) + val name = case.getString("name") + val result = AccessoryProtocol.parseManifest(case.getString("line"), sessionId) + + if (!case.isNull("error")) { + val failed = result as? ManifestResult.Failed + ?: throw AssertionError("$name: expected rejection, got $result") + assertEquals("$name: error", case.getString("error"), failed.error.wire) + continue + } + + val ok = result as? ManifestResult.Ok + ?: throw AssertionError("$name: expected a manifest, got $result") + assertManifest(name, case.getJSONObject("expected"), ok.manifest) + } + } + + private fun assertManifest(name: String, expected: JSONObject, actual: AccessoryManifest) { + assertEquals("$name: accessoryId", expected.getString("accessoryId"), actual.accessoryId) + assertEquals("$name: name", expected.getString("name"), actual.name) + assertEquals( + "$name: firmwareVersion", + expected.getString("firmwareVersion"), + actual.firmwareVersion, + ) + if (expected.isNull("protocolVersion")) { + assertNull("$name: protocolVersion", actual.protocolVersion) + } else { + assertEquals( + "$name: protocolVersion", + expected.getInt("protocolVersion"), + actual.protocolVersion, + ) + } + val versions = expected.getJSONArray("supportedVersions") + assertEquals("$name: supportedVersions size", versions.length(), actual.supportedVersions.size) + for (i in 0 until versions.length()) { + assertEquals("$name: supportedVersions[$i]", versions.getInt(i), actual.supportedVersions[i]) + } + assertEquals( + "$name: compatibility", + expected.getString("compatibility"), + actual.compatibility.wire, + ) + + val caps = expected.getJSONArray("capabilities") + assertEquals("$name: capability count", caps.length(), actual.capabilities.size) + for (i in 0 until caps.length()) { + val want = caps.getJSONObject(i) + val got = actual.capabilities[i] + assertEquals("$name: capability $i id", want.getString("id"), got.id) + assertEquals("$name: capability $i type", want.getString("type"), got.type) + assertEquals( + "$name: capability $i supported", + want.getBoolean("supported"), + got.supported, + ) + assertEquals( + "$name: capability $i unit", + if (want.isNull("unit")) null else want.getString("unit"), + got.unit, + ) + assertEquals( + "$name: capability $i rangeMin", + if (want.isNull("rangeMin")) null else want.getDouble("rangeMin"), + got.rangeMin, + ) + assertEquals( + "$name: capability $i rangeMax", + if (want.isNull("rangeMax")) null else want.getDouble("rangeMax"), + got.rangeMax, + ) + val rates = want.getJSONArray("ratesHz") + assertEquals("$name: capability $i rate count", rates.length(), got.ratesHz.size) + for (r in 0 until rates.length()) { + assertEquals( + "$name: capability $i rate $r", + rates.getDouble(r), + got.ratesHz[r], + 0.0, + ) + } + } + } + + /** + * Discovery must not be able to speak past `hello`. There is one encoder on this path and it + * produces one message type; anything operational would have to be added here first. + */ + @Test + fun discoveryEncodesNothingButHello() { + val line = AccessoryProtocol.encodeHello(sessionId) + assertEquals("hello", JSONObject(line).getString("type")) + assertEquals(AccessoryProtocol.HELLO_REQUEST_ID, JSONObject(line).getInt("requestId")) + assertEquals( + setOf("type", "requestId", "sessionId", "supportedVersions"), + JSONObject(line).keys().asSequence().toSet(), + ) + } +} diff --git a/modules/vescape-core/ios/VescapeCoreModule.swift b/modules/vescape-core/ios/VescapeCoreModule.swift index 304ba852..b3de88f6 100644 --- a/modules/vescape-core/ios/VescapeCoreModule.swift +++ b/modules/vescape-core/ios/VescapeCoreModule.swift @@ -92,7 +92,7 @@ public class VescapeCoreModule: Module { // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `Events` // @parity /modules/vescape-core/src/index.ts `VescapeCoreEvents` - Events("onDevice", "onError", "onLiveState", "onLiveTick", "onLiveSeries", "onFocusedSeries", "onTelemetryHistory", "onBms", "onBmsSeries", "onLocation", "onReplayPhoneHeading", "onTelemetryRebuildProgress", "onBoardProbeProgress", "onAppDataChanged", "onGroupRideConnection", "onGroupRideSnapshot", "onGroupRideCreated", "onGroupRideUpdated", "onGroupRideEnded", "onGroupRideJoined", "onGroupRideRoster", "onGroupRideError", "onBoardWarnings", "onVescFaults", "onBoardConfigValues", "onMotorConfigValues", "onBoardConfigChangeNotice", "onBoardLights", "onAppStatus", "onNavigation", "onRouteProgress", "onWeather") + Events("onDevice", "onError", "onLiveState", "onLiveTick", "onLiveSeries", "onFocusedSeries", "onTelemetryHistory", "onBms", "onBmsSeries", "onLocation", "onReplayPhoneHeading", "onTelemetryRebuildProgress", "onBoardProbeProgress", "onAppDataChanged", "onGroupRideConnection", "onGroupRideSnapshot", "onGroupRideCreated", "onGroupRideUpdated", "onGroupRideEnded", "onGroupRideJoined", "onGroupRideRoster", "onGroupRideError", "onBoardWarnings", "onVescFaults", "onBoardConfigValues", "onMotorConfigValues", "onBoardConfigChangeNotice", "onBoardLights", "onAppStatus", "onNavigation", "onRouteProgress", "onWeather", "onAccessoryDevice", "onAccessoryScanError") // Track per-event JS listeners so native skips emitting into the void, and gate the whole // firehose on app foreground (see `frontendActive`). Mirrors Android's observing + lifecycle @@ -197,8 +197,19 @@ public class VescapeCoreModule: Module { self.sendEvent("onWeather", ["weather": WeatherCoordinator.shared.current?.map]) } OnStopObserving("onWeather") { self.observedEvents.remove("onWeather") } + OnStartObserving("onAccessoryDevice") { self.observedEvents.insert("onAccessoryDevice") } + OnStopObserving("onAccessoryDevice") { self.observedEvents.remove("onAccessoryDevice") } + OnStartObserving("onAccessoryScanError") { self.observedEvents.insert("onAccessoryScanError") } + OnStopObserving("onAccessoryScanError") { self.observedEvents.remove("onAccessoryScanError") } OnCreate { + // Accessory discovery pushes devices as the radio finds them; the module is only the pipe. + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `AccessoryDiscovery` + AccessoryDiscovery.shared.emit = { [weak self] name, body in + guard let self, self.shouldEmitToFrontend(name) else { return } + self.sendEvent(name, body) + } + RecordingStorageFailure.observeOutage { [weak self] in guard let self, self.shouldEmitToFrontend("onLiveState") else { return } self.sendEvent("onLiveState", self.liveState()) @@ -269,6 +280,9 @@ public class VescapeCoreModule: Module { self.observedEvents.removeAll() self.cancelActiveProbe(reason: "module_destroyed") self.stopAlertTest() + AccessoryDiscovery.shared.emit = nil + AccessoryDiscovery.shared.stopScan() + AccessoryDiscovery.shared.cancelInspection() } // MARK: Scan @@ -281,6 +295,28 @@ public class VescapeCoreModule: Module { self.coordinator.stopScan() } + // MARK: Accessory discovery + + // Read-only: it scans for the Vescape Accessory service, reads one manifest, and disconnects. + // No Board or Accessory control can start from here. + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `startAccessoryScan` + // @parity /modules/vescape-core/src/index.ts `startAccessoryScan` + Function("startAccessoryScan") { + AccessoryDiscovery.shared.startScan() + } + + Function("stopAccessoryScan") { + AccessoryDiscovery.shared.stopScan() + } + + Function("cancelAccessoryInspection") { + AccessoryDiscovery.shared.cancelInspection() + } + + AsyncFunction("inspectAccessory") { (deviceId: String, promise: Promise) in + AccessoryDiscovery.shared.inspect(deviceId: deviceId) { promise.resolve($0) } + } + // MARK: Location Function("startLocationUpdates") { diff --git a/modules/vescape-core/ios/accessory/AccessoryDiscovery.swift b/modules/vescape-core/ios/accessory/AccessoryDiscovery.swift new file mode 100644 index 00000000..0547200b --- /dev/null +++ b/modules/vescape-core/ios/accessory/AccessoryDiscovery.swift @@ -0,0 +1,256 @@ +import CoreBluetooth +import Foundation + +/// Finding Accessories and asking each one what it is. Scanning matches the Vescape Accessory +/// service UUID, never a name: a name is a label the rider can change and other hardware can copy, +/// so it identifies nothing. The service is what makes a device an Accessory. +/// +/// Discovery is read-only by construction. It hands each device to a short-lived +/// `AccessoryGattHandshake` that writes one `hello`, reads the manifest, and disconnects; nothing on +/// this path can command an Accessory, and finding one never enrolls it. Enrollment is an explicit +/// rider action in a later slice. +/// +/// One inspection runs at a time. Two concurrent handshakes against the same radio mostly produce +/// two timeouts, and the rider is looking at one row anyway. +/// +/// A central of its own, deliberately separate from the Board Session's: discovery must not disturb +/// a live Board link, and it never opts into CoreBluetooth state restoration — an accessory scan is +/// not worth resurrecting the app for. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryDiscovery.kt +final class AccessoryDiscovery: NSObject { + static let shared = AccessoryDiscovery() + + /// Set by the Expo module so discovery can push devices without holding a module reference. + var emit: ((String, [String: Any?]) -> Void)? + + private lazy var central = CBCentralManager(delegate: self, queue: nil) + private var scanRequested = false + private var handshake: AccessoryGattHandshake? + private var pendingInspection: (deviceId: String, onResult: ([String: Any?]) -> Void)? + /// Peripherals the scan saw, retained so a later `inspect` has something to connect to. + private var seen: [UUID: CBPeripheral] = [:] + + func startScan() { + scanRequested = true + guard central.state == .poweredOn else { + // The central reports `.poweredOn` asynchronously on first use; the scan starts there. + _ = central + return + } + beginScan() + } + + func stopScan() { + scanRequested = false + if central.state == .poweredOn { central.stopScan() } + } + + /// Connects to one discovered device and reads its manifest. `onResult` receives the bridge + /// payload exactly once, whether the handshake succeeded, was rejected, or timed out. + func inspect(deviceId: String, onResult: @escaping ([String: Any?]) -> Void) { + guard handshake == nil, pendingInspection == nil else { + return onResult(Self.payload(deviceId: deviceId, advertisedName: nil, manifest: nil, error: "busy")) + } + guard let uuid = UUID(uuidString: deviceId) else { + return onResult( + Self.payload(deviceId: deviceId, advertisedName: nil, manifest: nil, error: "connect-failed") + ) + } + // Scanning while a handshake runs slows the connection down for no benefit: the rider has + // already picked a row. + stopScan() + + guard central.state == .poweredOn else { + pendingInspection = (deviceId, onResult) + _ = central + return + } + guard let peripheral = resolve(uuid) else { + return onResult( + Self.payload(deviceId: deviceId, advertisedName: nil, manifest: nil, error: "connect-failed") + ) + } + begin(peripheral: peripheral, deviceId: deviceId, onResult: onResult) + } + + /// Abandons an inspection the rider walked away from. + func cancelInspection() { + pendingInspection = nil + handshake?.cancel() + } + + // MARK: - Internals + + private func beginScan() { + seen.removeAll() + central.scanForPeripherals( + withServices: [AccessoryProtocol.serviceUUID], + // Every advertisement, not one per peripheral: the row shows a live RSSI. + options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] + ) + } + + private func resolve(_ uuid: UUID) -> CBPeripheral? { + seen[uuid] ?? central.retrievePeripherals(withIdentifiers: [uuid]).first + } + + private func begin( + peripheral: CBPeripheral, + deviceId: String, + onResult: @escaping ([String: Any?]) -> Void + ) { + peripheral.delegate = self + let session = AccessoryGattHandshake( + peripheral: peripheral, + central: central, + sessionId: UUID().uuidString + ) { [weak self] outcome in + self?.handshake = nil + switch outcome { + case .ok(let manifest, let advertisedName): + onResult( + Self.payload( + deviceId: deviceId, advertisedName: advertisedName, manifest: manifest, error: nil) + ) + case .failed(let error, let advertisedName): + onResult( + Self.payload( + deviceId: deviceId, advertisedName: advertisedName, manifest: nil, error: error) + ) + } + } + handshake = session + session.start() + } + + private static func payload( + deviceId: String, + advertisedName: String?, + manifest: AccessoryManifest?, + error: String? + ) -> [String: Any?] { + [ + "deviceId": deviceId, + "advertisedName": advertisedName, + "manifest": manifest?.toMap(), + "error": error, + ] + } +} + +extension AccessoryDiscovery: CBCentralManagerDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + guard central.state == .poweredOn else { + if scanRequested || pendingInspection != nil { + emit?("onAccessoryScanError", ["error": "bluetooth-unavailable"]) + } + if let pending = pendingInspection { + pendingInspection = nil + pending.onResult( + Self.payload( + deviceId: pending.deviceId, advertisedName: nil, manifest: nil, + error: "bluetooth-unavailable") + ) + } + return + } + if let pending = pendingInspection { + pendingInspection = nil + guard let uuid = UUID(uuidString: pending.deviceId), let peripheral = resolve(uuid) else { + return pending.onResult( + Self.payload( + deviceId: pending.deviceId, advertisedName: nil, manifest: nil, error: "connect-failed") + ) + } + begin(peripheral: peripheral, deviceId: pending.deviceId, onResult: pending.onResult) + return + } + if scanRequested { beginScan() } + } + + func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + seen[peripheral.identifier] = peripheral + emit?( + "onAccessoryDevice", + [ + "id": peripheral.identifier.uuidString, + // Nullable on purpose: a device that advertises no name is still a valid Accessory, and + // the manifest is where its real name comes from anyway. + "name": advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? peripheral.name, + "rssi": RSSI.intValue, + ] + ) + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + guard handshake?.peripheralId == peripheral.identifier else { return } + handshake?.onConnected() + } + + func centralManager( + _ central: CBCentralManager, + didFailToConnect peripheral: CBPeripheral, + error: Error? + ) { + guard handshake?.peripheralId == peripheral.identifier else { return } + handshake?.onConnectFailed() + } + + func centralManager( + _ central: CBCentralManager, + didDisconnectPeripheral peripheral: CBPeripheral, + error: Error? + ) { + guard handshake?.peripheralId == peripheral.identifier else { return } + handshake?.onDisconnected() + } +} + +extension AccessoryDiscovery: CBPeripheralDelegate { + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard handshake?.peripheralId == peripheral.identifier else { return } + handshake?.onServicesDiscovered(error: error) + } + + func peripheral( + _ peripheral: CBPeripheral, + didDiscoverCharacteristicsFor service: CBService, + error: Error? + ) { + guard handshake?.peripheralId == peripheral.identifier else { return } + handshake?.onCharacteristicsDiscovered(for: service, error: error) + } + + func peripheral( + _ peripheral: CBPeripheral, + didUpdateNotificationStateFor characteristic: CBCharacteristic, + error: Error? + ) { + guard handshake?.peripheralId == peripheral.identifier else { return } + handshake?.onNotifyStateChanged(for: characteristic, error: error) + } + + func peripheral( + _ peripheral: CBPeripheral, + didWriteValueFor characteristic: CBCharacteristic, + error: Error? + ) { + guard handshake?.peripheralId == peripheral.identifier else { return } + handshake?.onWriteCompleted(error: error) + } + + func peripheral( + _ peripheral: CBPeripheral, + didUpdateValueFor characteristic: CBCharacteristic, + error: Error? + ) { + guard handshake?.peripheralId == peripheral.identifier else { return } + handshake?.onValueUpdated(for: characteristic, error: error) + } +} diff --git a/modules/vescape-core/ios/accessory/AccessoryFixtures.swift b/modules/vescape-core/ios/accessory/AccessoryFixtures.swift new file mode 100644 index 00000000..d9bd0b6a --- /dev/null +++ b/modules/vescape-core/ios/accessory/AccessoryFixtures.swift @@ -0,0 +1,39 @@ +import Foundation + +/// The shared Accessory Protocol corpus, located relative to this file the way the Refloat schema +/// fixtures are. The same files drive the Kotlin peer and the ESP32 firmware's native tests, so a +/// contract that drifts on one side fails on all three. +/// +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessoryFixtures.kt +enum AccessoryFixtures { + static func load(_ name: String) throws -> [String: Any] { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // accessory + .deletingLastPathComponent() // ios + .deletingLastPathComponent() // vescape-core + .deletingLastPathComponent() // modules + .deletingLastPathComponent() // repo root + let data = try Data( + contentsOf: root.appendingPathComponent("shared/fixtures/accessory-protocol/\(name)") + ) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw NSError( + domain: "AccessoryFixtures", code: 1, + userInfo: [NSLocalizedDescriptionKey: "\(name) is not a JSON object"] + ) + } + return object + } + + static func hexToBytes(_ hex: String) -> [UInt8] { + var bytes: [UInt8] = [] + bytes.reserveCapacity(hex.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + bytes.append(UInt8(hex[index.. Void + private let framer = AccessoryNdjsonFramer() + + private var writeCharacteristic: CBCharacteristic? + private var pendingChunks: [Data] = [] + private var writeInFlight = false + private var timeout: DispatchWorkItem? + private var finished = false + + init( + peripheral: CBPeripheral, + central: CBCentralManager, + sessionId: String, + onFinished: @escaping (AccessoryHandshakeOutcome) -> Void + ) { + self.peripheral = peripheral + self.central = central + self.sessionId = sessionId + self.onFinished = onFinished + } + + var peripheralId: UUID { peripheral.identifier } + + func start() { + arm(Self.connectTimeout, error: "timeout") + central.connect(peripheral, options: nil) + } + + func cancel() { finish(.failed("cancelled", advertisedName: peripheral.name)) } + + // MARK: - Central callbacks, forwarded by `AccessoryDiscovery` + + func onConnected() { + peripheral.discoverServices([AccessoryProtocol.serviceUUID]) + } + + func onDisconnected() { + finish(.failed("connect-failed", advertisedName: peripheral.name)) + } + + func onConnectFailed() { + finish(.failed("connect-failed", advertisedName: peripheral.name)) + } + + // MARK: - Peripheral callbacks + + func onServicesDiscovered(error: Error?) { + guard error == nil, + let service = peripheral.services?.first(where: { $0.uuid == AccessoryProtocol.serviceUUID }) + else { return finish(.failed("service-missing", advertisedName: peripheral.name)) } + peripheral.discoverCharacteristics( + [AccessoryProtocol.writeUUID, AccessoryProtocol.notifyUUID], + for: service + ) + } + + func onCharacteristicsDiscovered(for service: CBService, error: Error?) { + guard error == nil, service.uuid == AccessoryProtocol.serviceUUID else { + return finish(.failed("service-missing", advertisedName: peripheral.name)) + } + let characteristics = service.characteristics ?? [] + guard + let write = characteristics.first(where: { $0.uuid == AccessoryProtocol.writeUUID }), + let notify = characteristics.first(where: { $0.uuid == AccessoryProtocol.notifyUUID }) + else { return finish(.failed("service-missing", advertisedName: peripheral.name)) } + writeCharacteristic = write + peripheral.setNotifyValue(true, for: notify) + } + + func onNotifyStateChanged(for characteristic: CBCharacteristic, error: Error?) { + guard characteristic.uuid == AccessoryProtocol.notifyUUID else { return } + guard error == nil, characteristic.isNotifying else { + return finish(.failed("service-missing", advertisedName: peripheral.name)) + } + sendHello() + } + + func onWriteCompleted(error: Error?) { + guard error == nil else { return finish(.failed("write-failed", advertisedName: peripheral.name)) } + writeInFlight = false + drain() + } + + func onValueUpdated(for characteristic: CBCharacteristic, error: Error?) { + guard !finished, characteristic.uuid == AccessoryProtocol.notifyUUID, error == nil, + let value = characteristic.value + else { return } + + let result = framer.feed([UInt8](value)) + for line in result.lines { + switch AccessoryProtocol.parseManifest(line: line, sessionId: sessionId) { + case .ok(let manifest): + return finish(.ok(manifest, advertisedName: peripheral.name)) + case .failed(let reason): + // A message from another session is noise on a shared characteristic, not a protocol + // violation: keep waiting for the manifest this hello asked for. + if reason != .sessionMismatch { + return finish(.failed(reason.rawValue, advertisedName: peripheral.name)) + } + } + } + if let failure = result.failure { + finish(.failed(failure.rawValue, advertisedName: peripheral.name)) + } + } + + // MARK: - Internals + + /// Subscribed and ready: write the one line discovery is allowed to send. + private func sendHello() { + guard pendingChunks.isEmpty, !writeInFlight else { return } + let payload = Data((AccessoryProtocol.encodeHello(sessionId: sessionId) + "\n").utf8) + let limit = max(peripheral.maximumWriteValueLength(for: .withResponse), 20) + var offset = 0 + while offset < payload.count { + let end = min(offset + limit, payload.count) + pendingChunks.append(payload.subdata(in: offset.. AccessoryFramingResult { + if let failure { return AccessoryFramingResult(lines: [], failure: failure) } + + var lines: [String] = [] + for byte in chunk { + if byte == Self.lineFeed { + // An empty line is framing, not a message: the protocol sends one object per line, so a + // stray LF carries nothing to decode. + if !buffer.isEmpty { + let bytes = buffer + buffer.removeAll(keepingCapacity: true) + // Strict on purpose: `String(bytes:encoding:)` returns nil on a malformed sequence, + // where `String(decoding:as:)` would substitute replacement characters and hand the + // parser a line the accessory never sent. + guard let decoded = String(bytes: bytes, encoding: .utf8) else { + return fail(lines, .invalidUtf8) + } + lines.append(decoded) + } + continue + } + if buffer.count == maxLineBytes { return fail(lines, .oversized) } + buffer.append(byte) + } + return AccessoryFramingResult(lines: lines, failure: nil) + } + + /// Drops everything held. Called on disconnect so a new session starts with no old bytes. + func reset() { + buffer.removeAll(keepingCapacity: false) + buffer.reserveCapacity(min(256, maxLineBytes)) + failure = nil + } + + private func fail(_ lines: [String], _ error: AccessoryFramingError) -> AccessoryFramingResult { + failure = error + buffer.removeAll(keepingCapacity: false) + return AccessoryFramingResult(lines: lines, failure: error) + } +} diff --git a/modules/vescape-core/ios/accessory/AccessoryNdjsonFramerTests.swift b/modules/vescape-core/ios/accessory/AccessoryNdjsonFramerTests.swift new file mode 100644 index 00000000..81d74522 --- /dev/null +++ b/modules/vescape-core/ios/accessory/AccessoryNdjsonFramerTests.swift @@ -0,0 +1,64 @@ +import XCTest + +@testable import VescapeCore + +/// The NDJSON framing contract, driven by `shared/fixtures/accessory-protocol/framing.json`. Chunks +/// arrive as bytes so the cases can split a line mid-UTF-8-character, which is exactly what a BLE +/// notification boundary does. +/// +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessoryNdjsonFramerTest.kt +final class AccessoryNdjsonFramerTests: XCTestCase { + func testEveryFramingCaseMatchesTheSharedFixture() throws { + let fixture = try AccessoryFixtures.load("framing.json") + let maxLineBytes = try XCTUnwrap(fixture["maxLineBytes"] as? Int) + XCTAssertEqual( + maxLineBytes, AccessoryProtocol.maxLineBytes, + "framer default must be the documented protocol limit" + ) + + for entry in try XCTUnwrap(fixture["cases"] as? [[String: Any]]) { + let name = try XCTUnwrap(entry["name"] as? String) + let framer = AccessoryNdjsonFramer(maxLineBytes: maxLineBytes) + var lines: [String] = [] + var failure: AccessoryFramingError? + + for hex in try XCTUnwrap(entry["chunksHex"] as? [String]) { + let result = framer.feed(AccessoryFixtures.hexToBytes(hex)) + lines.append(contentsOf: result.lines) + failure = failure ?? result.failure + XCTAssertLessThanOrEqual( + framer.bufferedBytes, maxLineBytes, + "\(name): the buffer must never exceed the protocol line limit" + ) + } + + XCTAssertEqual(lines, try XCTUnwrap(entry["lines"] as? [String]), name) + XCTAssertEqual(failure?.rawValue, entry["failure"] as? String, "\(name): failure") + } + } + + func testAPeerThatNeverSendsALineFeedCostsAFixedBuffer() { + let framer = AccessoryNdjsonFramer() + // Ten times the limit, in chunks, with no LF anywhere: an unbounded accumulator would hold all + // of it. The framer must give up at the limit and stay terminal. + let chunk = [UInt8](repeating: UInt8(ascii: "x"), count: 1024) + var failure: AccessoryFramingError? + for _ in 0..<40 { + failure = failure ?? framer.feed(chunk).failure + XCTAssertLessThanOrEqual(framer.bufferedBytes, AccessoryProtocol.maxLineBytes) + } + XCTAssertEqual(failure, .oversized) + XCTAssertTrue(framer.failed) + XCTAssertEqual(framer.bufferedBytes, 0) + } + + func testResetClearsAFailedStreamForTheNextSession() { + let framer = AccessoryNdjsonFramer(maxLineBytes: 16) + XCTAssertEqual( + framer.feed([UInt8](repeating: UInt8(ascii: "x"), count: 32)).failure, .oversized) + framer.reset() + let result = framer.feed([UInt8]("{\"a\":1}\n".utf8)) + XCTAssertEqual(result.lines, ["{\"a\":1}"]) + XCTAssertNil(result.failure) + } +} diff --git a/modules/vescape-core/ios/accessory/AccessoryProtocol.swift b/modules/vescape-core/ios/accessory/AccessoryProtocol.swift new file mode 100644 index 00000000..7adce31b --- /dev/null +++ b/modules/vescape-core/ios/accessory/AccessoryProtocol.swift @@ -0,0 +1,326 @@ +import CoreBluetooth +import Foundation + +/// Why a handshake produced no usable Accessory. Mirrors the `errors` list in +/// `shared/fixtures/accessory-protocol/handshake.json`. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt `AccessoryHandshakeError` +/// @parity /modules/vescape-core/src/index.ts `AccessoryInspectionError` +enum AccessoryHandshakeError: String { + case malformed + case invalid + case sessionMismatch = "session-mismatch" +} + +/// How much of a discovered Accessory this app can actually use. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt `AccessoryCompatibility` +/// @parity /modules/vescape-core/src/index.ts `AccessoryCompatibility` +enum AccessoryCompatibility: String { + case supported + case unsupportedVersion = "unsupported-version" + case unsupportedCapabilities = "unsupported-capabilities" +} + +/// One capability an Accessory declares. `type` keeps the raw wire value even when unrecognized, so +/// an unknown capability can be named on screen instead of disappearing. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt `AccessoryCapability` +/// @parity /modules/vescape-core/src/index.ts `AccessoryCapability` +struct AccessoryCapability: Equatable { + let id: String + let type: String + let supported: Bool + let unit: String? + let rangeMin: Double? + let rangeMax: Double? + let ratesHz: [Double] + + func toMap() -> [String: Any?] { + [ + "id": id, + "type": type, + "supported": supported, + "unit": unit, + "rangeMin": rangeMin, + "rangeMax": rangeMax, + "ratesHz": ratesHz, + ] + } +} + +/// What an Accessory says about itself on every connection. Read again on each reconnect — saved +/// settings are only trusted after the identity, version and capability limits here still match. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt `AccessoryManifest` +/// @parity /modules/vescape-core/src/index.ts `AccessoryManifest` +struct AccessoryManifest: Equatable { + /// Factory-provisioned persistent UUID. Saved settings key on this, never on the BLE address. + let accessoryId: String + let name: String + let firmwareVersion: String + /// Nil when the accessory found no common version; it then accepts no operational commands. + let protocolVersion: Int? + /// What the accessory offers instead, present only when no version was agreed. + let supportedVersions: [Int] + let compatibility: AccessoryCompatibility + let capabilities: [AccessoryCapability] + + func toMap() -> [String: Any?] { + [ + "accessoryId": accessoryId, + "name": name, + "firmwareVersion": firmwareVersion, + "protocolVersion": protocolVersion, + "supportedVersions": supportedVersions, + "compatibility": compatibility.rawValue, + "capabilities": capabilities.map { $0.toMap() }, + ] + } +} + +enum ManifestResult: Equatable { + case ok(AccessoryManifest) + case failed(AccessoryHandshakeError) +} + +/// Vescape Accessory Protocol v1 — the discovery half: the custom GATT service that identifies an +/// Accessory regardless of its advertised name, the `hello` the app writes once it has subscribed, +/// and the manifest it reads back. +/// +/// Nothing here commands an Accessory. Discovery reads identity, protocol version and capability +/// types; every operational message (`configure`, `state`, `reading`) belongs to the per-capability +/// slices that follow, so an Accessory found here can never start measuring or lighting up. +/// +/// The wire contract is `docs/accessory-protocol.md`; the executable form of it is +/// `shared/fixtures/accessory-protocol/`, which this file, its Kotlin peer and the ESP32 firmware +/// all run. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt +/// @parity /modules/vescape-core/src/index.ts `AccessoryManifest` +enum AccessoryProtocol { + /// Advertised service that makes a device a Vescape Accessory. Project-assigned, not SIG. + static let serviceUUID = CBUUID(string: "8D53DC10-1DB7-4CD3-868B-8A527460AA84") + /// App to accessory, write with response. + static let writeUUID = CBUUID(string: "8D53DC11-1DB7-4CD3-868B-8A527460AA84") + /// Accessory to app, notify. + static let notifyUUID = CBUUID(string: "8D53DC12-1DB7-4CD3-868B-8A527460AA84") + + /// Maximum NDJSON line length excluding the LF. Anything longer ends the protocol session. + static let maxLineBytes = 4096 + + /// Protocol versions this app can speak. + static let supportedVersions = [1] + + /// The handshake is the first request of a session, so its id is fixed. + static let helloRequestId = 1 + + /// Manifest response timeout, `docs/accessory-protocol.md` PoC defaults. + static let handshakeTimeoutMs = 3_000 + + /// Capability types v1 recognizes. An accessory may advertise others; they are reported as + /// unsupported rather than hiding the capabilities that do work. + /// + /// @parity /modules/vescape-core/src/index.ts `AccessoryCapabilityType` + static let typeGroundClearance = "ground_clearance" + static let typeBrakeLight = "brake_light" + + /// Ground clearance is measured in centimetres; any other unit is a capability we cannot use. + static let groundClearanceUnit = "cm" + + /// The one line discovery writes. Built by hand rather than through `JSONSerialization` because + /// the shared fixture pins the exact bytes, and a dictionary encoder does not promise key order. + static func encodeHello(sessionId: String) -> String { + let versions = supportedVersions.map(String.init).joined(separator: ",") + return "{\"type\":\"hello\",\"requestId\":\(helloRequestId),\"sessionId\":\(quote(sessionId))," + + "\"supportedVersions\":[\(versions)]}" + } + + private static func quote(_ value: String) -> String { + var out = "\"" + for scalar in value.unicodeScalars { + switch scalar { + case "\"": out += "\\\"" + case "\\": out += "\\\\" + case "\n": out += "\\n" + case "\r": out += "\\r" + case "\t": out += "\\t" + default: + if scalar.value < 0x20 { + out += String(format: "\\u%04x", scalar.value) + } else { + out.unicodeScalars.append(scalar) + } + } + } + return out + "\"" + } + + /// Decodes one received line as the manifest answering `sessionId`/`requestId`. + /// + /// Rejection is deliberately coarse: a manifest that fails any envelope rule is not partially + /// trusted, because saved settings key on the identity it carries. + static func parseManifest( + line: String, + sessionId: String, + requestId: Int = helloRequestId + ) -> ManifestResult { + // intentional-suppression: malformed JSON is an expected input on this link, reported as + // `.malformed` so the caller ends the protocol session + guard let data = line.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data), + let object = root as? [String: Any] + else { return .failed(.malformed) } + + // Session identity is checked before anything else is read: a message from a previous session + // must not renew or influence this one. + guard object["sessionId"] as? String == sessionId, + integer(object["requestId"]) == requestId + else { return .failed(.sessionMismatch) } + guard object["type"] as? String == "manifest" else { return .failed(.invalid) } + guard object.keys.contains("protocolVersion") else { return .failed(.invalid) } + + guard let accessoryId = requiredString(object["accessoryId"]), + let name = requiredString(object["name"]), + let firmwareVersion = requiredString(object["firmwareVersion"]) + else { return .failed(.invalid) } + + var protocolVersion: Int? + if object["protocolVersion"] is NSNull { + protocolVersion = nil + } else if let value = integer(object["protocolVersion"]) { + protocolVersion = value + } else { + return .failed(.invalid) + } + let versionAgreed = protocolVersion.map { supportedVersions.contains($0) } ?? false + + var offeredVersions: [Int] = [] + if let raw = object["supportedVersions"], !(raw is NSNull) { + guard let array = raw as? [Any] else { return .failed(.invalid) } + for entry in array { + guard let value = integer(entry) else { return .failed(.invalid) } + offeredVersions.append(value) + } + } + + var declared: [Any] = [] + if let raw = object["capabilities"], !(raw is NSNull) { + guard let array = raw as? [Any] else { return .failed(.invalid) } + declared = array + } + + var capabilities: [AccessoryCapability] = [] + var seen = Set() + for entry in declared { + guard let map = entry as? [String: Any], + let capability = parseCapability(map, versionAgreed: versionAgreed), + seen.insert(capability.id).inserted + else { return .failed(.invalid) } + capabilities.append(capability) + } + + let compatibility: AccessoryCompatibility + if !versionAgreed { + compatibility = .unsupportedVersion + } else if !capabilities.contains(where: { $0.supported }) { + compatibility = .unsupportedCapabilities + } else { + compatibility = .supported + } + + return .ok( + AccessoryManifest( + accessoryId: accessoryId, + name: name, + firmwareVersion: firmwareVersion, + protocolVersion: protocolVersion, + supportedVersions: offeredVersions, + compatibility: compatibility, + capabilities: capabilities + ) + ) + } + + /// Nil means the capability breaks an envelope rule and the whole manifest is rejected. + private static func parseCapability( + _ entry: [String: Any], + versionAgreed: Bool + ) -> AccessoryCapability? { + guard let id = requiredString(entry["id"]), let type = requiredString(entry["type"]) else { + return nil + } + let unit = (entry["unit"] as? String).flatMap { $0.isEmpty ? nil : $0 } + let range = entry["range"] as? [String: Any] + let rangeMin = double(range?["min"]) + let rangeMax = double(range?["max"]) + + var ratesHz: [Double] = [] + if let raw = entry["ratesHz"], !(raw is NSNull) { + guard let array = raw as? [Any] else { return nil } + for value in array { + guard let rate = double(value) else { return nil } + ratesHz.append(rate) + } + } + + return AccessoryCapability( + id: id, + type: type, + // A capability is only usable when the session speaks a version both sides agreed on, so a + // version mismatch grays out every capability rather than some of them. + supported: versionAgreed + && typeUsable(type, unit: unit, rangeMin: rangeMin, rangeMax: rangeMax, ratesHz: ratesHz), + unit: unit, + rangeMin: rangeMin, + rangeMax: rangeMax, + ratesHz: ratesHz + ) + } + + /// Whether a recognized capability type also declares limits this app can work within. A + /// `ground_clearance` in millimetres, with an empty range, or offering no rate is a capability we + /// would have to guess about; a recognized type is not by itself a usable one. + private static func typeUsable( + _ type: String, + unit: String?, + rangeMin: Double?, + rangeMax: Double?, + ratesHz: [Double] + ) -> Bool { + switch type { + case typeBrakeLight: + return true + case typeGroundClearance: + guard unit == groundClearanceUnit, let min = rangeMin, let max = rangeMax else { return false } + return min.isFinite && max.isFinite && min < max + && !ratesHz.isEmpty && ratesHz.allSatisfy { $0.isFinite && $0 > 0 } + default: + return false + } + } + + private static func requiredString(_ value: Any?) -> String? { + guard let text = value as? String, !text.trimmingCharacters(in: .whitespaces).isEmpty else { + return nil + } + return text + } + + /// JSON numbers arrive as `NSNumber`; `true`/`false` arrive as one too. Identity is checked + /// against `CFBoolean` rather than `as? Bool`, which happily converts the number 1. + private static func isBoolean(_ value: Any?) -> Bool { + guard let number = value as? NSNumber else { return false } + return CFGetTypeID(number) == CFBooleanGetTypeID() + } + + private static func integer(_ value: Any?) -> Int? { + guard let number = value as? NSNumber, !isBoolean(value) else { return nil } + return number.intValue + } + + private static func double(_ value: Any?) -> Double? { + guard let number = value as? NSNumber, !isBoolean(value) else { return nil } + return number.doubleValue + } +} diff --git a/modules/vescape-core/ios/accessory/AccessoryProtocolTests.swift b/modules/vescape-core/ios/accessory/AccessoryProtocolTests.swift new file mode 100644 index 00000000..26820413 --- /dev/null +++ b/modules/vescape-core/ios/accessory/AccessoryProtocolTests.swift @@ -0,0 +1,120 @@ +import XCTest + +@testable import VescapeCore + +/// The discovery handshake contract, driven by +/// `shared/fixtures/accessory-protocol/handshake.json`: the exact `hello` line discovery writes, and +/// every manifest the parser must either accept with a compatibility verdict or refuse outright. +/// +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessoryProtocolTest.kt +final class AccessoryProtocolTests: XCTestCase { + private func fixture() throws -> [String: Any] { try AccessoryFixtures.load("handshake.json") } + + private func hello() throws -> [String: Any] { + try XCTUnwrap(try fixture()["hello"] as? [String: Any]) + } + + func testHelloIsEncodedByteForByteAsTheFixturePinsIt() throws { + let hello = try hello() + let sessionId = try XCTUnwrap(hello["sessionId"] as? String) + XCTAssertEqual( + AccessoryProtocol.encodeHello(sessionId: sessionId), + try XCTUnwrap(hello["line"] as? String) + ) + XCTAssertEqual( + try XCTUnwrap(hello["supportedVersions"] as? [Int]), + AccessoryProtocol.supportedVersions + ) + } + + func testRecognizedCapabilityTypesMatchTheSharedFixture() throws { + let types = try XCTUnwrap(try fixture()["recognizedCapabilityTypes"] as? [String]) + XCTAssertEqual( + Set(types), + [AccessoryProtocol.typeGroundClearance, AccessoryProtocol.typeBrakeLight] + ) + } + + func testEveryManifestCaseMatchesTheSharedFixture() throws { + let sessionId = try XCTUnwrap(try hello()["sessionId"] as? String) + let cases = try XCTUnwrap(try fixture()["cases"] as? [[String: Any]]) + XCTAssertFalse(cases.isEmpty, "fixture must carry cases") + + for entry in cases { + let name = try XCTUnwrap(entry["name"] as? String) + let result = AccessoryProtocol.parseManifest( + line: try XCTUnwrap(entry["line"] as? String), + sessionId: sessionId + ) + + if let expectedError = entry["error"] as? String { + guard case .failed(let error) = result else { + return XCTFail("\(name): expected rejection, got \(result)") + } + XCTAssertEqual(error.rawValue, expectedError, "\(name): error") + continue + } + + guard case .ok(let manifest) = result else { + return XCTFail("\(name): expected a manifest, got \(result)") + } + try assertManifest(name, try XCTUnwrap(entry["expected"] as? [String: Any]), manifest) + } + } + + private func assertManifest( + _ name: String, + _ expected: [String: Any], + _ actual: AccessoryManifest + ) throws { + XCTAssertEqual(expected["accessoryId"] as? String, actual.accessoryId, "\(name): accessoryId") + XCTAssertEqual(expected["name"] as? String, actual.name, "\(name): name") + XCTAssertEqual( + expected["firmwareVersion"] as? String, actual.firmwareVersion, "\(name): firmwareVersion") + XCTAssertEqual( + expected["protocolVersion"] as? Int, actual.protocolVersion, "\(name): protocolVersion") + XCTAssertEqual( + try XCTUnwrap(expected["supportedVersions"] as? [Int]), + actual.supportedVersions, + "\(name): supportedVersions" + ) + XCTAssertEqual( + expected["compatibility"] as? String, + actual.compatibility.rawValue, + "\(name): compatibility" + ) + + let caps = try XCTUnwrap(expected["capabilities"] as? [[String: Any]]) + XCTAssertEqual(caps.count, actual.capabilities.count, "\(name): capability count") + for (index, want) in caps.enumerated() where index < actual.capabilities.count { + let got = actual.capabilities[index] + XCTAssertEqual(want["id"] as? String, got.id, "\(name): capability \(index) id") + XCTAssertEqual(want["type"] as? String, got.type, "\(name): capability \(index) type") + XCTAssertEqual( + want["supported"] as? Bool, got.supported, "\(name): capability \(index) supported") + XCTAssertEqual(want["unit"] as? String, got.unit, "\(name): capability \(index) unit") + XCTAssertEqual( + want["rangeMin"] as? Double, got.rangeMin, "\(name): capability \(index) rangeMin") + XCTAssertEqual( + want["rangeMax"] as? Double, got.rangeMax, "\(name): capability \(index) rangeMax") + XCTAssertEqual( + try XCTUnwrap(want["ratesHz"] as? [Double]), + got.ratesHz, + "\(name): capability \(index) rates" + ) + } + } + + /// Discovery must not be able to speak past `hello`. There is one encoder on this path and it + /// produces one message type; anything operational would have to be added here first. + func testDiscoveryEncodesNothingButHello() throws { + let sessionId = try XCTUnwrap(try hello()["sessionId"] as? String) + let line = AccessoryProtocol.encodeHello(sessionId: sessionId) + let object = try XCTUnwrap( + try JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] + ) + XCTAssertEqual(object["type"] as? String, "hello") + XCTAssertEqual(object["requestId"] as? Int, AccessoryProtocol.helloRequestId) + XCTAssertEqual(Set(object.keys), ["type", "requestId", "sessionId", "supportedVersions"]) + } +} diff --git a/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index 9600e2dd..fdedeb48 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -33,6 +33,120 @@ export interface ErrorEvent { message: string } +/** + * An advertisement from something running the Vescape Accessory service. Discovery matches the + * service, never the name, so `name` is a label to show and nothing to trust: the Accessory's real + * identity only arrives with its manifest. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryDiscovery.kt + * @parity /modules/vescape-core/ios/accessory/AccessoryDiscovery.swift + */ +export interface AccessoryDeviceEvent { + /** BLE address on Android, peripheral UUID on iOS — the handle `inspectAccessory` takes. */ + id: string + name: string | null + rssi: number +} + +export interface AccessoryScanErrorEvent { + error: 'bluetooth-unavailable' | 'scan-failed' +} + +/** + * Capability types protocol v1 recognizes. An Accessory may advertise others; they arrive as raw + * strings with `supported: false` rather than being dropped. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt `TYPE_GROUND_CLEARANCE` + * @parity /modules/vescape-core/ios/accessory/AccessoryProtocol.swift `typeGroundClearance` + */ +export type AccessoryCapabilityType = 'ground_clearance' | 'brake_light' + +/** + * How much of a discovered Accessory this app can use. `unsupported-version` means the two sides + * found no common protocol version; `unsupported-capabilities` means the version is fine but + * nothing it offers is a capability type this app knows how to drive. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt `AccessoryCompatibility` + * @parity /modules/vescape-core/ios/accessory/AccessoryProtocol.swift `AccessoryCompatibility` + */ +export type AccessoryCompatibility = + | 'supported' + | 'unsupported-version' + | 'unsupported-capabilities' + +/** + * One capability an Accessory declares. `supported` is native's verdict, not a re-derivation + * target: it already accounts for the agreed protocol version and for limits this app can work + * within, so JS renders it rather than recomputing it. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt `AccessoryCapability` + * @parity /modules/vescape-core/ios/accessory/AccessoryProtocol.swift `AccessoryCapability` + */ +export interface AccessoryCapability { + /** Stable within the Accessory and across firmware updates. Saved settings key on it. */ + id: string + /** Raw wire type. Widen past `AccessoryCapabilityType` on purpose — unknown types are shown. */ + type: AccessoryCapabilityType | (string & {}) + supported: boolean + unit: string | null + rangeMin: number | null + rangeMax: number | null + ratesHz: number[] +} + +/** + * What an Accessory said about itself on this connection. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt `AccessoryManifest` + * @parity /modules/vescape-core/ios/accessory/AccessoryProtocol.swift `AccessoryManifest` + */ +export interface AccessoryManifest { + /** Persistent Accessory identity. Survives reboots and firmware updates; a BLE address does not. */ + accessoryId: string + name: string + firmwareVersion: string + /** Null when the Accessory found no common version — it then accepts no operational commands. */ + protocolVersion: number | null + /** What the Accessory offers instead, present only when no version was agreed. */ + supportedVersions: number[] + compatibility: AccessoryCompatibility + capabilities: AccessoryCapability[] +} + +/** + * Why a handshake produced no manifest. The first three are protocol rejections, the rest are the + * link failing around it. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt `AccessoryHandshakeError` + * @parity /modules/vescape-core/ios/accessory/AccessoryProtocol.swift `AccessoryHandshakeError` + */ +export type AccessoryInspectionError = + | 'malformed' + | 'invalid' + | 'session-mismatch' + | 'oversized' + | 'invalid-utf8' + | 'bluetooth-unavailable' + | 'connect-failed' + | 'service-missing' + | 'write-failed' + | 'timeout' + | 'cancelled' + | 'busy' + +/** + * One completed discovery handshake. Exactly one of `manifest` and `error` is set. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryDiscovery.kt `payload` + * @parity /modules/vescape-core/ios/accessory/AccessoryDiscovery.swift `payload` + */ +export interface AccessoryInspection { + deviceId: string + advertisedName: string | null + manifest: AccessoryManifest | null + error: AccessoryInspectionError | null +} + /** * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/protocol/VescTelemetryModels.kt `LocationSnapshot` * @parity /modules/vescape-core/ios/telemetry/TelemetryPipeline.swift `TelemetryLocationCapture` @@ -2129,6 +2243,10 @@ type VescapeCoreEvents = { onRouteProgress: (event: RouteProgressEvent) => void /** Native forecast, on every successful refresh and on subscribe. */ onWeather: (event: WeatherEvent) => void + /** One advertisement from a device running the Vescape Accessory service. */ + onAccessoryDevice: (event: AccessoryDeviceEvent) => void + /** The accessory scan could not run or stopped running. */ + onAccessoryScanError: (event: AccessoryScanErrorEvent) => void } interface NativeEventEmitter void>> { @@ -2146,6 +2264,10 @@ interface NativeEventEmitter & { scan(): void stopScan(): void + startAccessoryScan(): void + stopAccessoryScan(): void + cancelAccessoryInspection(): void + inspectAccessory(deviceId: string): Promise exitApp(): void startLocationUpdates(): void stopLocationUpdates(): void @@ -2370,6 +2492,37 @@ export function stopScan(): void { native.stopScan() } +/** + * Start scanning for Vescape Accessories — emits `onAccessoryDevice` per advertisement. + * + * Matching is on the Accessory service UUID, so a renamed accessory is still found and a device + * that merely copies the name is not. Scanning alone enrolls nothing. + */ +export function startAccessoryScan(): void { + native.startAccessoryScan() +} + +/** Stop the accessory scan. Also stopped natively for the duration of an inspection. */ +export function stopAccessoryScan(): void { + native.stopAccessoryScan() +} + +/** + * Connect to one discovered device, read its manifest, and disconnect. + * + * The whole exchange is one `hello` and one manifest: no configuration is sent, no measurement + * starts, and no light changes. Native owns the framing, the session and the compatibility verdict; + * this returns what it decided. + */ +export function inspectAccessory(deviceId: string): Promise { + return native.inspectAccessory(deviceId) +} + +/** Abandon an inspection whose screen the rider already left. */ +export function cancelAccessoryInspection(): void { + native.cancelAccessoryInspection() +} + /** Start app-level Android location updates independently of a board session. */ export function startLocationUpdates(): void { native.startLocationUpdates() @@ -3436,6 +3589,18 @@ export function addDeviceListener(cb: (event: DeviceFoundEvent) => void): EventS return emitter.addListener('onDevice', cb) } +export function addAccessoryDeviceListener( + cb: (event: AccessoryDeviceEvent) => void, +): EventSubscription { + return emitter.addListener('onAccessoryDevice', cb) +} + +export function addAccessoryScanErrorListener( + cb: (event: AccessoryScanErrorEvent) => void, +): EventSubscription { + return emitter.addListener('onAccessoryScanError', cb) +} + export function addErrorListener(cb: (event: ErrorEvent) => void): EventSubscription { return emitter.addListener('onError', cb) } diff --git a/shared/fixtures/accessory-protocol/framing.json b/shared/fixtures/accessory-protocol/framing.json new file mode 100644 index 00000000..24a81fd2 --- /dev/null +++ b/shared/fixtures/accessory-protocol/framing.json @@ -0,0 +1,116 @@ +{ + "$comment": "Vescape Accessory Protocol v1 NDJSON framing contract. Kotlin, Swift and the ESP32 firmware all run these cases. Chunks are hex-encoded bytes so a split can land mid-UTF-8-character; `lines` are the complete decoded messages the framer must deliver, in order; `failure` is the terminal framing error that ends the protocol session and disconnects BLE.", + "maxLineBytes": 4096, + "failures": ["oversized", "invalid-utf8"], + "cases": [ + { + "name": "one whole line in one chunk", + "chunksHex": [ + "7b2274797065223a2268656c6c6f222c22726571756573744964223a312c2273657373696f6e4964223a2262303662396437362d366337332d346437302d613736332d643933336232393463343562222c22737570706f7274656456657273696f6e73223a5b315d7d0a" + ], + "lines": [ + "{\"type\":\"hello\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"supportedVersions\":[1]}" + ], + "failure": null + }, + { + "name": "line split across chunks at an arbitrary byte boundary", + "chunksHex": [ + "7b2274797065223a2268656c6c6f222c22", + "726571756573744964223a312c2273657373696f6e4964223a2262303662396437362d366337332d346437302d613736332d643933336232393463343562222c22737570706f7274656456657273696f6e73223a5b315d7d0a" + ], + "lines": [ + "{\"type\":\"hello\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"supportedVersions\":[1]}" + ], + "failure": null + }, + { + "name": "two concatenated lines in one chunk", + "chunksHex": [ + "7b2274797065223a2268656c6c6f222c22726571756573744964223a312c2273657373696f6e4964223a2262303662396437362d366337332d346437302d613736332d643933336232393463343562222c22737570706f7274656456657273696f6e73223a5b315d7d0a7b2274797065223a2261636b222c22726571756573744964223a317d0a" + ], + "lines": [ + "{\"type\":\"hello\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"supportedVersions\":[1]}", + "{\"type\":\"ack\",\"requestId\":1}" + ], + "failure": null + }, + { + "name": "trailing partial line is held until its LF arrives", + "chunksHex": [ + "7b2274797065223a2268656c6c6f222c22726571756573744964223a312c2273657373696f6e4964223a2262303662396437362d366337332d346437302d613736332d643933336232393463343562222c22737570706f7274656456657273696f6e73223a5b315d7d0a7b2274797065223a22", + "61636b222c22726571756573744964223a317d0a" + ], + "lines": [ + "{\"type\":\"hello\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"supportedVersions\":[1]}", + "{\"type\":\"ack\",\"requestId\":1}" + ], + "failure": null + }, + { + "name": "multi-byte UTF-8 split across chunks reassembles", + "chunksHex": [ + "7b2274797065223a226d616e6966657374222c226e616d65223a2250727a65c5", + "9b77697420e29ca8227d0a" + ], + "lines": ["{\"type\":\"manifest\",\"name\":\"Prześwit ✨\"}"], + "failure": null + }, + { + "name": "empty lines carry no message", + "chunksHex": ["0a0a7b2274797065223a2261636b222c22726571756573744964223a317d0a0a"], + "lines": ["{\"type\":\"ack\",\"requestId\":1}"], + "failure": null + }, + { + "name": "a line of exactly the maximum length is accepted", + "chunksHex": [ + "7b22706164223a227878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878227d0a" + ], + "lines": [ + "{\"pad\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}" + ], + "failure": null + }, + { + "name": "an oversized line is rejected without buffering it", + "chunksHex": [ + "7b22706164223a22787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878227d0a" + ], + "lines": [], + "failure": "oversized" + }, + { + "name": "oversize is detected before any LF arrives", + "chunksHex": [ + "787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878", + "787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878" + ], + "lines": [], + "failure": "oversized" + }, + { + "name": "a complete line before the oversized one is still delivered", + "chunksHex": [ + "7b2274797065223a2261636b222c22726571756573744964223a317d0a7b22706164223a22787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878227d0a" + ], + "lines": ["{\"type\":\"ack\",\"requestId\":1}"], + "failure": "oversized" + }, + { + "name": "invalid UTF-8 in a complete line is rejected", + "chunksHex": ["7b2261223a22fffe227d0a"], + "lines": [], + "failure": "invalid-utf8" + }, + { + "name": "nothing is delivered after a failure", + "chunksHex": [ + "7b22706164223a22787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878227d0a", + "7b2274797065223a2261636b222c22726571756573744964223a317d0a" + ], + "lines": [], + "failure": "oversized" + } + ] +} diff --git a/shared/fixtures/accessory-protocol/handshake.json b/shared/fixtures/accessory-protocol/handshake.json new file mode 100644 index 00000000..8edab319 --- /dev/null +++ b/shared/fixtures/accessory-protocol/handshake.json @@ -0,0 +1,313 @@ +{ + "$comment": "Vescape Accessory Protocol v1 discovery handshake contract. Kotlin, Swift and the ESP32 firmware all run these cases. `hello.line` is the exact line the app writes after subscribing — the only write discovery is allowed to make. Each manifest case is one received line plus either the parsed accessory it must produce or the reason it must be rejected. `canonical: true` marks a line a conforming accessory emits byte for byte, so the firmware builder is asserted against it too; the rest exist only to exercise the app-side parser.", + "recognizedCapabilityTypes": ["ground_clearance", "brake_light"], + "compatibilities": ["supported", "unsupported-version", "unsupported-capabilities"], + "errors": ["malformed", "invalid", "session-mismatch"], + "hello": { + "sessionId": "b06b9d76-6c73-4d70-a763-d933b294c45b", + "requestId": 1, + "supportedVersions": [1], + "line": "{\"type\":\"hello\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"supportedVersions\":[1]}" + }, + "cases": [ + { + "name": "ground-clearance sensor is supported", + "canonical": true, + "line": "{\"type\":\"manifest\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"protocolVersion\":1,\"accessoryId\":\"b36ed5bd-1d24-460c-8034-aaeaefc5d016\",\"name\":\"Clearance sensor\",\"firmwareVersion\":\"0.1.0\",\"capabilities\":[{\"id\":\"clearance\",\"type\":\"ground_clearance\",\"unit\":\"cm\",\"range\":{\"min\":3,\"max\":100},\"ratesHz\":[10,20,30]}]}", + "error": null, + "expected": { + "accessoryId": "b36ed5bd-1d24-460c-8034-aaeaefc5d016", + "name": "Clearance sensor", + "firmwareVersion": "0.1.0", + "protocolVersion": 1, + "supportedVersions": [], + "compatibility": "supported", + "capabilities": [ + { + "id": "clearance", + "type": "ground_clearance", + "supported": true, + "unit": "cm", + "rangeMin": 3, + "rangeMax": 100, + "ratesHz": [10, 20, 30] + } + ] + } + }, + { + "name": "brake light is supported and declares no measurement limits", + "canonical": true, + "line": "{\"type\":\"manifest\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"protocolVersion\":1,\"accessoryId\":\"0f0be7c8-6d97-4a56-9b1b-2d5f3b6a0c11\",\"name\":\"Rear light\",\"firmwareVersion\":\"0.2.1\",\"capabilities\":[{\"id\":\"rear_light\",\"type\":\"brake_light\"}]}", + "error": null, + "expected": { + "accessoryId": "0f0be7c8-6d97-4a56-9b1b-2d5f3b6a0c11", + "name": "Rear light", + "firmwareVersion": "0.2.1", + "protocolVersion": 1, + "supportedVersions": [], + "compatibility": "supported", + "capabilities": [ + { + "id": "rear_light", + "type": "brake_light", + "supported": true, + "unit": null, + "rangeMin": null, + "rangeMax": null, + "ratesHz": [] + } + ] + } + }, + { + "name": "an unknown capability type is listed unsupported beside recognized ones", + "canonical": false, + "line": "{\"type\":\"manifest\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"protocolVersion\":1,\"accessoryId\":\"5c1a6f2e-9f43-4f6c-bd0a-7e1d8a4c9b20\",\"name\":\"Combo unit\",\"firmwareVersion\":\"0.3.0\",\"capabilities\":[{\"id\":\"clearance\",\"type\":\"ground_clearance\",\"unit\":\"cm\",\"range\":{\"min\":3,\"max\":100},\"ratesHz\":[10,20,30]},{\"id\":\"horn\",\"type\":\"air_horn\"},{\"id\":\"rear_light\",\"type\":\"brake_light\"}]}", + "error": null, + "expected": { + "accessoryId": "5c1a6f2e-9f43-4f6c-bd0a-7e1d8a4c9b20", + "name": "Combo unit", + "firmwareVersion": "0.3.0", + "protocolVersion": 1, + "supportedVersions": [], + "compatibility": "supported", + "capabilities": [ + { + "id": "clearance", + "type": "ground_clearance", + "supported": true, + "unit": "cm", + "rangeMin": 3, + "rangeMax": 100, + "ratesHz": [10, 20, 30] + }, + { + "id": "horn", + "type": "air_horn", + "supported": false, + "unit": null, + "rangeMin": null, + "rangeMax": null, + "ratesHz": [] + }, + { + "id": "rear_light", + "type": "brake_light", + "supported": true, + "unit": null, + "rangeMin": null, + "rangeMax": null, + "ratesHz": [] + } + ] + } + }, + { + "name": "an accessory with only unknown capability types is incompatible", + "canonical": false, + "line": "{\"type\":\"manifest\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"protocolVersion\":1,\"accessoryId\":\"a2d8e0f1-3b44-4d2f-8c6a-11d3b7e5f900\",\"name\":\"Air horn\",\"firmwareVersion\":\"1.0.0\",\"capabilities\":[{\"id\":\"horn\",\"type\":\"air_horn\"}]}", + "error": null, + "expected": { + "accessoryId": "a2d8e0f1-3b44-4d2f-8c6a-11d3b7e5f900", + "name": "Air horn", + "firmwareVersion": "1.0.0", + "protocolVersion": 1, + "supportedVersions": [], + "compatibility": "unsupported-capabilities", + "capabilities": [ + { + "id": "horn", + "type": "air_horn", + "supported": false, + "unit": null, + "rangeMin": null, + "rangeMax": null, + "ratesHz": [] + } + ] + } + }, + { + "name": "no common protocol version blocks every capability", + "canonical": true, + "line": "{\"type\":\"manifest\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"protocolVersion\":null,\"supportedVersions\":[2,3],\"accessoryId\":\"ee3c1a55-72b9-4b47-9f1e-6f0d2a3c4d55\",\"name\":\"Future sensor\",\"firmwareVersion\":\"2.0.0\",\"capabilities\":[{\"id\":\"clearance\",\"type\":\"ground_clearance\",\"unit\":\"cm\",\"range\":{\"min\":3,\"max\":100},\"ratesHz\":[10,20,30]}]}", + "error": null, + "expected": { + "accessoryId": "ee3c1a55-72b9-4b47-9f1e-6f0d2a3c4d55", + "name": "Future sensor", + "firmwareVersion": "2.0.0", + "protocolVersion": null, + "supportedVersions": [2, 3], + "compatibility": "unsupported-version", + "capabilities": [ + { + "id": "clearance", + "type": "ground_clearance", + "supported": false, + "unit": "cm", + "rangeMin": 3, + "rangeMax": 100, + "ratesHz": [10, 20, 30] + } + ] + } + }, + { + "name": "unknown optional fields are ignored within a supported version", + "canonical": false, + "line": "{\"type\":\"manifest\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"protocolVersion\":1,\"accessoryId\":\"77aa1122-3344-4556-8899-aabbccddeeff\",\"name\":\"Clearance sensor\",\"firmwareVersion\":\"0.1.0\",\"vendor\":\"Vescape\",\"batteryPercent\":82,\"capabilities\":[{\"id\":\"clearance\",\"type\":\"ground_clearance\",\"unit\":\"cm\",\"range\":{\"min\":3,\"max\":100},\"ratesHz\":[10,20,30],\"calibrationHint\":\"nose\"}]}", + "error": null, + "expected": { + "accessoryId": "77aa1122-3344-4556-8899-aabbccddeeff", + "name": "Clearance sensor", + "firmwareVersion": "0.1.0", + "protocolVersion": 1, + "supportedVersions": [], + "compatibility": "supported", + "capabilities": [ + { + "id": "clearance", + "type": "ground_clearance", + "supported": true, + "unit": "cm", + "rangeMin": 3, + "rangeMax": 100, + "ratesHz": [10, 20, 30] + } + ] + } + }, + { + "name": "a ground-clearance capability in the wrong unit is unsupported", + "canonical": false, + "line": "{\"type\":\"manifest\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"protocolVersion\":1,\"accessoryId\":\"9b8c7d6e-5f40-4312-a1b2-c3d4e5f60718\",\"name\":\"Millimetre sensor\",\"firmwareVersion\":\"0.1.0\",\"capabilities\":[{\"id\":\"clearance\",\"type\":\"ground_clearance\",\"unit\":\"mm\",\"range\":{\"min\":3,\"max\":100},\"ratesHz\":[10,20,30]}]}", + "error": null, + "expected": { + "accessoryId": "9b8c7d6e-5f40-4312-a1b2-c3d4e5f60718", + "name": "Millimetre sensor", + "firmwareVersion": "0.1.0", + "protocolVersion": 1, + "supportedVersions": [], + "compatibility": "unsupported-capabilities", + "capabilities": [ + { + "id": "clearance", + "type": "ground_clearance", + "supported": false, + "unit": "mm", + "rangeMin": 3, + "rangeMax": 100, + "ratesHz": [10, 20, 30] + } + ] + } + }, + { + "name": "a ground-clearance range that is not an interval is unsupported", + "canonical": false, + "line": "{\"type\":\"manifest\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"protocolVersion\":1,\"accessoryId\":\"1122aabb-ccdd-4eef-8899-001122334455\",\"name\":\"Broken range sensor\",\"firmwareVersion\":\"0.1.0\",\"capabilities\":[{\"id\":\"clearance\",\"type\":\"ground_clearance\",\"unit\":\"cm\",\"range\":{\"min\":100,\"max\":100},\"ratesHz\":[10,20,30]}]}", + "error": null, + "expected": { + "accessoryId": "1122aabb-ccdd-4eef-8899-001122334455", + "name": "Broken range sensor", + "firmwareVersion": "0.1.0", + "protocolVersion": 1, + "supportedVersions": [], + "compatibility": "unsupported-capabilities", + "capabilities": [ + { + "id": "clearance", + "type": "ground_clearance", + "supported": false, + "unit": "cm", + "rangeMin": 100, + "rangeMax": 100, + "ratesHz": [10, 20, 30] + } + ] + } + }, + { + "name": "a ground-clearance capability offering no usable rate is unsupported", + "canonical": false, + "line": "{\"type\":\"manifest\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"protocolVersion\":1,\"accessoryId\":\"33445566-7788-4999-aabb-ccddeeff0011\",\"name\":\"Rateless sensor\",\"firmwareVersion\":\"0.1.0\",\"capabilities\":[{\"id\":\"clearance\",\"type\":\"ground_clearance\",\"unit\":\"cm\",\"range\":{\"min\":3,\"max\":100},\"ratesHz\":[]}]}", + "error": null, + "expected": { + "accessoryId": "33445566-7788-4999-aabb-ccddeeff0011", + "name": "Rateless sensor", + "firmwareVersion": "0.1.0", + "protocolVersion": 1, + "supportedVersions": [], + "compatibility": "unsupported-capabilities", + "capabilities": [ + { + "id": "clearance", + "type": "ground_clearance", + "supported": false, + "unit": "cm", + "rangeMin": 3, + "rangeMax": 100, + "ratesHz": [] + } + ] + } + }, + { + "name": "a line that is not JSON is malformed", + "canonical": false, + "line": "{\"type\":\"manifest\"", + "error": "malformed", + "expected": null + }, + { + "name": "a JSON array is not a message", + "canonical": false, + "line": "[1,2,3]", + "error": "malformed", + "expected": null + }, + { + "name": "a manifest without an accessory id is invalid", + "canonical": false, + "line": "{\"type\":\"manifest\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"protocolVersion\":1,\"name\":\"Nameless\",\"firmwareVersion\":\"0.1.0\",\"capabilities\":[{\"id\":\"clearance\",\"type\":\"ground_clearance\",\"unit\":\"cm\",\"range\":{\"min\":3,\"max\":100},\"ratesHz\":[10,20,30]}]}", + "error": "invalid", + "expected": null + }, + { + "name": "a non-manifest reply to hello is invalid", + "canonical": false, + "line": "{\"type\":\"ack\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\"}", + "error": "invalid", + "expected": null + }, + { + "name": "a capability without an id is invalid", + "canonical": false, + "line": "{\"type\":\"manifest\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"protocolVersion\":1,\"accessoryId\":\"b36ed5bd-1d24-460c-8034-aaeaefc5d016\",\"name\":\"Clearance sensor\",\"firmwareVersion\":\"0.1.0\",\"capabilities\":[{\"type\":\"ground_clearance\",\"unit\":\"cm\"}]}", + "error": "invalid", + "expected": null + }, + { + "name": "duplicate capability ids are invalid", + "canonical": false, + "line": "{\"type\":\"manifest\",\"requestId\":1,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"protocolVersion\":1,\"accessoryId\":\"b36ed5bd-1d24-460c-8034-aaeaefc5d016\",\"name\":\"Clearance sensor\",\"firmwareVersion\":\"0.1.0\",\"capabilities\":[{\"id\":\"clearance\",\"type\":\"ground_clearance\",\"unit\":\"cm\",\"range\":{\"min\":3,\"max\":100},\"ratesHz\":[10,20,30]},{\"id\":\"clearance\",\"type\":\"brake_light\",\"unit\":\"cm\",\"range\":{\"min\":3,\"max\":100},\"ratesHz\":[10,20,30]}]}", + "error": "invalid", + "expected": null + }, + { + "name": "a manifest from another session is ignored", + "canonical": false, + "line": "{\"type\":\"manifest\",\"requestId\":1,\"sessionId\":\"00000000-0000-4000-8000-000000000000\",\"protocolVersion\":1,\"accessoryId\":\"b36ed5bd-1d24-460c-8034-aaeaefc5d016\",\"name\":\"Clearance sensor\",\"firmwareVersion\":\"0.1.0\",\"capabilities\":[{\"id\":\"clearance\",\"type\":\"ground_clearance\",\"unit\":\"cm\",\"range\":{\"min\":3,\"max\":100},\"ratesHz\":[10,20,30]}]}", + "error": "session-mismatch", + "expected": null + }, + { + "name": "a manifest answering another request is ignored", + "canonical": false, + "line": "{\"type\":\"manifest\",\"requestId\":7,\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"protocolVersion\":1,\"accessoryId\":\"b36ed5bd-1d24-460c-8034-aaeaefc5d016\",\"name\":\"Clearance sensor\",\"firmwareVersion\":\"0.1.0\",\"capabilities\":[{\"id\":\"clearance\",\"type\":\"ground_clearance\",\"unit\":\"cm\",\"range\":{\"min\":3,\"max\":100},\"ratesHz\":[10,20,30]}]}", + "error": "session-mismatch", + "expected": null + } + ] +} diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 59f11629..c9ce419f 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -228,6 +228,8 @@ function RootLayout() { + + {/* Above navigation so a Release surface covers every screen. Only ever one at a time. */} diff --git a/src/app/accessories/[accessoryId].tsx b/src/app/accessories/[accessoryId].tsx new file mode 100644 index 00000000..67383a03 --- /dev/null +++ b/src/app/accessories/[accessoryId].tsx @@ -0,0 +1,8 @@ +import { useLocalSearchParams } from 'expo-router' + +import { AccessoryDetailScreen } from '@/modules/accessories/screens/AccessoryDetailScreen' + +export default function AccessoryRoute() { + const { accessoryId } = useLocalSearchParams<{ accessoryId: string }>() + return +} diff --git a/src/app/accessories/scan.tsx b/src/app/accessories/scan.tsx new file mode 100644 index 00000000..93645261 --- /dev/null +++ b/src/app/accessories/scan.tsx @@ -0,0 +1,14 @@ +import { router } from 'expo-router' + +import { AccessoryScanScreen } from '@/modules/accessories/screens/AccessoryScanScreen' +import { routes } from '@/navigation/routes' + +export default function AccessoryScanRoute() { + return ( + + router.replace({ pathname: routes.accessory, params: { accessoryId } }) + } + /> + ) +} diff --git a/src/app/settings/components/accessories.tsx b/src/app/settings/components/accessories.tsx new file mode 100644 index 00000000..ba1c6805 --- /dev/null +++ b/src/app/settings/components/accessories.tsx @@ -0,0 +1,32 @@ +import { ScrollView, StyleSheet } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { PlugsConnectedIcon } from 'phosphor-react-native' + +import { IconHero } from '@/components/settings/IconHero' +import { AccessorySelectorSectionShowcase } from '@/screens/showcase/accessories/AccessorySelectorSectionShowcase' +import { + AccessoryCapabilityRowShowcase, + AccessoryCompatibilityNoticeShowcase, +} from '@/screens/showcase/accessories/AccessoryManifestShowcase' +import { theme } from '@/constants/theme' + +export default function AccessoryComponentsPage() { + return ( + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: theme.neutral.bg }, + content: { padding: 12, gap: 12, paddingBottom: 40 }, +}) diff --git a/src/app/settings/components/index.tsx b/src/app/settings/components/index.tsx index 86a03e56..ffd3c968 100644 --- a/src/app/settings/components/index.tsx +++ b/src/app/settings/components/index.tsx @@ -14,6 +14,7 @@ import { GearSixIcon, LightningIcon, MarkdownLogoIcon, + PlugsConnectedIcon, SquaresFourIcon, StackIcon, TextAaIcon, @@ -110,6 +111,13 @@ const groups = [ icon: LightningIcon, color: theme.palette.sky.color, }, + { + label: 'Accessories', + hint: 'Accessory rows, compatibility verdicts, and capability listings', + route: '/settings/components/accessories', + icon: PlugsConnectedIcon, + color: theme.palette.teal.color, + }, { label: 'Widgets', hint: 'Dashboard tiles for showing and editing live board data', diff --git a/src/modules/accessories/components/AccessoryCapabilityRow.tsx b/src/modules/accessories/components/AccessoryCapabilityRow.tsx new file mode 100644 index 00000000..8f08c719 --- /dev/null +++ b/src/modules/accessories/components/AccessoryCapabilityRow.tsx @@ -0,0 +1,101 @@ +import { StyleSheet, View } from 'react-native' +import type { AccessoryCapability } from 'vescape-core' + +import { Text } from '@/components/base/Text' +import { + capabilityLimits, + capabilityPresentation, +} from '@/modules/accessories/constants/accessoryCapabilities' +import { theme } from '@/constants/theme' + +/** + * One capability an Accessory declares, with what the app can do about it. + * + * An unsupported capability is shown rather than filtered out: a rider holding hardware Vescape + * half-understands should be told which half, not handed a shorter list. + */ +export function AccessoryCapabilityRow({ capability }: { capability: AccessoryCapability }) { + const { title, description, icon: CapabilityIcon } = capabilityPresentation(capability) + const limits = capabilityLimits(capability) + const tint = capability.supported ? theme.palette.sky.color : theme.neutral.textDim + + return ( + + + + + + + + {title} + + + + {capability.supported ? 'Supported' : 'Unsupported'} + + + + {description} + + {capability.id} + {limits ? ` · ${limits}` : ''} + + + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + gap: 12, + paddingVertical: 12, + paddingHorizontal: 14, + }, + icon: { + width: 34, + height: 34, + borderRadius: 9, + borderWidth: 1, + borderColor: theme.alpha(theme.neutral.border, 0.6), + alignItems: 'center', + justifyContent: 'center', + }, + body: { + flex: 1, + gap: 3, + }, + titleLine: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + title: { + flexShrink: 1, + color: theme.neutral.textPrimary, + fontSize: 14, + fontWeight: '700', + }, + badge: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 6, + paddingVertical: 1, + }, + badgeText: { + fontSize: 9, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.4, + }, + description: { + color: theme.neutral.textSecondary, + fontSize: 12, + lineHeight: 16, + }, + meta: { + fontFamily: theme.mono('600'), + color: theme.neutral.textDim, + fontSize: 11, + }, +}) diff --git a/src/modules/accessories/components/AccessoryCompatibilityNotice.tsx b/src/modules/accessories/components/AccessoryCompatibilityNotice.tsx new file mode 100644 index 00000000..63741e5d --- /dev/null +++ b/src/modules/accessories/components/AccessoryCompatibilityNotice.tsx @@ -0,0 +1,71 @@ +import { StyleSheet, View } from 'react-native' +import { CheckCircleIcon, WarningCircleIcon, XCircleIcon } from 'phosphor-react-native' +import type { AccessoryCompatibility } from 'vescape-core' + +import { Text } from '@/components/base/Text' +import { compatibilityCopy } from '@/modules/accessories/lib/accessoryStatus' +import { theme } from '@/constants/theme' + +const TONE = { + success: { color: theme.status.success.color, icon: CheckCircleIcon }, + caution: { color: theme.status.caution.color, icon: WarningCircleIcon }, + error: { color: theme.status.error.color, icon: XCircleIcon }, +} as const + +/** + * Native's compatibility verdict, stated plainly. It is the one place a rider learns that an + * accessory was found and understood but still cannot be used, and why — an unsupported protocol + * version and an unrecognized capability type fail in very different ways. + */ +export function AccessoryCompatibilityNotice({ + compatibility, + supportedVersions, +}: { + compatibility: AccessoryCompatibility + /** Versions the accessory offered instead, shown only when no version was agreed. */ + supportedVersions?: number[] +}) { + const copy = compatibilityCopy(compatibility) + const { color, icon: ToneIcon } = TONE[copy.tone] + const offered = + compatibility === 'unsupported-version' && supportedVersions && supportedVersions.length > 0 + ? `It speaks version ${supportedVersions.join(', ')}.` + : null + + return ( + + + + {copy.title} + + {copy.detail} + {offered ? ` ${offered}` : ''} + + + + ) +} + +const styles = StyleSheet.create({ + card: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 10, + borderWidth: 1, + borderRadius: 12, + padding: 12, + }, + body: { + flex: 1, + gap: 3, + }, + title: { + fontSize: 13, + fontWeight: '700', + }, + detail: { + color: theme.neutral.textSecondary, + fontSize: 12, + lineHeight: 17, + }, +}) diff --git a/src/modules/accessories/components/AccessoryRow.tsx b/src/modules/accessories/components/AccessoryRow.tsx new file mode 100644 index 00000000..765ca775 --- /dev/null +++ b/src/modules/accessories/components/AccessoryRow.tsx @@ -0,0 +1,131 @@ +import { Pressable, StyleSheet, View } from 'react-native' +import { PlugsConnectedIcon, WarningCircleIcon } from 'phosphor-react-native' + +import { Text } from '@/components/base/Text' +import { + accessoryStatusCopy, + type AccessoryLinkStatus, +} from '@/modules/accessories/lib/accessoryStatus' +import { interaction, theme } from '@/constants/theme' + +const TONE = { + success: theme.status.success.color, + neutral: theme.neutral.textDim, + caution: theme.status.caution.color, +} as const + +export interface AccessoryRowProps { + name: string + /** Firmware version, or whatever secondary fact best identifies this unit. */ + detail?: string | undefined + status: AccessoryLinkStatus + /** True when the manifest said this app cannot drive the accessory. */ + incompatible?: boolean + onPress: () => void +} + +/** + * One Accessory in the Board selector's Accessories section: what it is, whether the app is + * hearing it, and a way into its configuration. + * + * Deliberately dumb — it takes strings and a status, never a store or a manifest, so the same row + * serves the selector, the showcase, and whatever screen lists Accessories next. + */ +export function AccessoryRow({ name, detail, status, incompatible, onPress }: AccessoryRowProps) { + const copy = accessoryStatusCopy(status) + const tone = incompatible ? TONE.caution : TONE[copy.tone] + + return ( + [styles.row, pressed && styles.rowPressed]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={`${name}, ${incompatible ? 'not supported' : copy.label}`} + testID={`accessory-row-${name}`} + > + + {incompatible ? ( + + ) : ( + + )} + + + + {name} + + + + + {incompatible ? 'Not supported' : copy.label} + + {detail ? ( + <> + · + + {detail} + + + ) : null} + + + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingHorizontal: 10, + borderRadius: 10, + gap: 10, + }, + rowPressed: { + backgroundColor: interaction.pressedBg, + }, + // Matches the board rows' tile: same size and place, so the two sections read as one list. + icon: { + width: 32, + height: 32, + borderRadius: 8, + borderWidth: 1, + borderColor: theme.alpha(theme.neutral.border, 0.6), + alignItems: 'center', + justifyContent: 'center', + }, + info: { + flex: 1, + gap: 3, + }, + name: { + color: theme.neutral.textSecondary, + fontSize: 14, + fontWeight: '600', + }, + metaLine: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + }, + dot: { + width: 7, + height: 7, + borderRadius: 3.5, + borderWidth: 1.5, + }, + meta: { + color: theme.neutral.textDim, + fontSize: 11, + lineHeight: 14, + }, +}) diff --git a/src/modules/accessories/components/AccessorySelectorSection.tsx b/src/modules/accessories/components/AccessorySelectorSection.tsx new file mode 100644 index 00000000..18c0d2b2 --- /dev/null +++ b/src/modules/accessories/components/AccessorySelectorSection.tsx @@ -0,0 +1,108 @@ +import { Pressable, StyleSheet, View } from 'react-native' +import { PlusIcon } from 'phosphor-react-native' + +import { Text } from '@/components/base/Text' +import { AccessoryRow } from '@/modules/accessories/components/AccessoryRow' +import type { AccessoryLinkStatus } from '@/modules/accessories/lib/accessoryStatus' +import { interaction, theme } from '@/constants/theme' + +export interface AccessorySelectorItem { + accessoryId: string + name: string + detail?: string | undefined + status: AccessoryLinkStatus + incompatible?: boolean +} + +interface AccessorySelectorSectionProps { + accessories: AccessorySelectorItem[] + onSelectAccessory: (accessoryId: string) => void + onAddAccessory: () => void +} + +/** + * The Accessories half of the Board selector: its own section, not a branch of any Board. + * + * Accessories target whichever Board is connected, so nesting them under one would promise a + * per-Board binding that does not exist. The section is presentational — the screen composing the + * selector supplies the list and both actions. + */ +export function AccessorySelectorSection({ + accessories, + onSelectAccessory, + onAddAccessory, +}: AccessorySelectorSectionProps) { + return ( + + {accessories.length === 0 ? ( + + No accessories yet. Vescape finds them by the service they advertise, whatever they are + named. + + ) : ( + accessories.map((accessory) => ( + onSelectAccessory(accessory.accessoryId)} + /> + )) + )} + + [styles.addRow, pressed && styles.rowPressed]} + onPress={onAddAccessory} + testID="board-selector-add-accessory" + accessibilityRole="button" + accessibilityLabel="Add accessory" + > + + + + Add accessory + + + ) +} + +const styles = StyleSheet.create({ + frame: { + width: '100%', + }, + rowPressed: { + backgroundColor: interaction.pressedBg, + }, + empty: { + color: theme.neutral.textDim, + fontSize: 11, + lineHeight: 15, + paddingHorizontal: 10, + paddingTop: 2, + paddingBottom: 6, + }, + addRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 10, + borderRadius: 10, + gap: 10, + }, + addIcon: { + width: 32, + height: 32, + borderRadius: 8, + borderWidth: 1, + borderColor: theme.alpha(theme.neutral.border, 0.6), + alignItems: 'center', + justifyContent: 'center', + }, + addText: { + color: theme.palette.sky.color, + fontSize: 13, + fontWeight: '600', + }, +}) diff --git a/src/modules/accessories/constants/accessoryCapabilities.ts b/src/modules/accessories/constants/accessoryCapabilities.ts new file mode 100644 index 00000000..c27831ef --- /dev/null +++ b/src/modules/accessories/constants/accessoryCapabilities.ts @@ -0,0 +1,52 @@ +import { ArrowsVerticalIcon, LightbulbFilamentIcon, QuestionIcon } from 'phosphor-react-native' +import type { Icon } from 'phosphor-react-native' +import type { AccessoryCapability, AccessoryCapabilityType } from 'vescape-core' + +/** + * How each recognized capability type is presented. Purely rider-facing: titles, descriptions and + * icons native never defines. The type slugs themselves come from `vescape-core`, which mirrors the + * native enums. + */ +interface CapabilityPresentation { + title: string + description: string + icon: Icon +} + +const PRESENTATION: Record = { + ground_clearance: { + title: 'Ground clearance', + description: 'Measures how far the board sits above the ground and can drive Remote Tilt.', + icon: ArrowsVerticalIcon, + }, + brake_light: { + title: 'Brake light', + description: 'Shows riding, braking and parked states from the Board’s own telemetry.', + icon: LightbulbFilamentIcon, + }, +} + +export function capabilityPresentation(capability: AccessoryCapability): CapabilityPresentation { + const known = PRESENTATION[capability.type as AccessoryCapabilityType] + if (known) return known + return { + // An unrecognized type is named by its wire slug rather than hidden — an accessory advertising + // one is still usable for everything else it offers. + title: capability.type, + description: 'This app does not know this capability type yet.', + icon: QuestionIcon, + } +} + +/** Hardware limits the accessory declared, as one line. Empty when it declared none. */ +export function capabilityLimits(capability: AccessoryCapability): string | null { + const parts: string[] = [] + if (capability.rangeMin != null && capability.rangeMax != null) { + const unit = capability.unit ?? '' + parts.push(`${capability.rangeMin}–${capability.rangeMax}${unit ? ` ${unit}` : ''}`) + } + if (capability.ratesHz.length > 0) { + parts.push(`${capability.ratesHz.join(', ')} Hz`) + } + return parts.length > 0 ? parts.join(' · ') : null +} diff --git a/src/modules/accessories/lib/accessoryStatus.ts b/src/modules/accessories/lib/accessoryStatus.ts new file mode 100644 index 00000000..e454d4d7 --- /dev/null +++ b/src/modules/accessories/lib/accessoryStatus.ts @@ -0,0 +1,89 @@ +import type { AccessoryCompatibility, AccessoryInspectionError } from 'vescape-core' + +/** + * How an Accessory stands with the app right now. + * + * - `advertising` — a running scan is hearing it this moment. + * - `idle` — its manifest was read successfully, and nothing is scanning to say more than that. + * - `unreachable` — the last handshake with it failed. + * + * Discovery holds no connection: the handshake disconnects as soon as the manifest is read, so + * "connected" is deliberately absent until enrollment gives an Accessory a session to stay in. + */ +export type AccessoryLinkStatus = 'advertising' | 'idle' | 'unreachable' + +export interface AccessoryStatusCopy { + label: string + /** A `theme.status` / `theme.palette` key resolved by the caller, never a color literal. */ + tone: 'success' | 'neutral' | 'caution' +} + +export function accessoryStatusCopy(status: AccessoryLinkStatus): AccessoryStatusCopy { + switch (status) { + case 'advertising': + return { label: 'Nearby', tone: 'success' } + case 'unreachable': + return { label: 'Not reachable', tone: 'caution' } + case 'idle': + return { label: 'Paired', tone: 'neutral' } + } +} + +/** Rider-facing summary of native's compatibility verdict. Native decides; this only phrases it. */ +export function compatibilityCopy(compatibility: AccessoryCompatibility): { + title: string + detail: string + tone: 'success' | 'caution' | 'error' +} { + switch (compatibility) { + case 'supported': + return { + title: 'Compatible', + detail: 'Vescape speaks this accessory’s protocol version and recognises what it offers.', + tone: 'success', + } + case 'unsupported-version': + return { + title: 'Protocol not supported', + detail: + 'This accessory speaks a protocol version Vescape does not. Nothing on it can be configured until one of the two is updated.', + tone: 'error', + } + case 'unsupported-capabilities': + return { + title: 'Nothing Vescape can use', + detail: + 'Vescape reached this accessory, but none of the things it offers are types this app knows how to drive.', + tone: 'caution', + } + } +} + +/** Why a handshake produced no manifest, in rider language. */ +export function inspectionErrorCopy(error: AccessoryInspectionError): string { + switch (error) { + case 'malformed': + case 'invalid': + return 'The accessory answered with something this app could not read.' + case 'session-mismatch': + return 'The accessory answered a different request. Try again.' + case 'oversized': + return 'The accessory sent more than the protocol allows in one message.' + case 'invalid-utf8': + return 'The accessory sent bytes that are not valid text.' + case 'bluetooth-unavailable': + return 'Bluetooth is off or unavailable.' + case 'connect-failed': + return 'Could not connect. Move closer and try again.' + case 'service-missing': + return 'This device does not expose the Vescape Accessory service.' + case 'write-failed': + return 'The connection dropped before the handshake was sent.' + case 'timeout': + return 'The accessory did not answer in time.' + case 'cancelled': + return 'Cancelled.' + case 'busy': + return 'Another accessory is being checked right now.' + } +} diff --git a/src/modules/accessories/screens/AccessoryDetailScreen.tsx b/src/modules/accessories/screens/AccessoryDetailScreen.tsx new file mode 100644 index 00000000..db6d1423 --- /dev/null +++ b/src/modules/accessories/screens/AccessoryDetailScreen.tsx @@ -0,0 +1,139 @@ +import { ScrollView, StyleSheet, View } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { PlugsConnectedIcon } from 'phosphor-react-native' + +import { Text } from '@/components/base/Text' +import { IconHero } from '@/components/settings/IconHero' +import { SettingsSectionTitle } from '@/components/settings/SettingsSectionTitle' +import { AccessoryCapabilityRow } from '@/modules/accessories/components/AccessoryCapabilityRow' +import { AccessoryCompatibilityNotice } from '@/modules/accessories/components/AccessoryCompatibilityNotice' +import { accessoryStatusCopy } from '@/modules/accessories/lib/accessoryStatus' +import { + accessoryLinkStatus, + useAccessoryDiscoveryStore, +} from '@/modules/accessories/store/accessoryDiscoveryStore' +import { fmtTimeAgo } from '@/helpers/format' +import { theme } from '@/constants/theme' + +/** + * One Accessory's configuration screen: who it says it is, whether Vescape can drive it, and what + * it offers. + * + * Identity first, because everything saved about an Accessory keys on it. Per-capability setup — + * clearance calibration, brake-light behaviour — lives behind each capability in its own slice; + * this screen is the place they hang off, and the place that says plainly when they cannot. + */ +export function AccessoryDetailScreen({ accessoryId }: { accessoryId: string }) { + const accessory = useAccessoryDiscoveryStore((s) => + s.accessories.find((a) => a.accessoryId === accessoryId), + ) + const devices = useAccessoryDiscoveryStore((s) => s.devices) + + if (!accessory) { + return ( + + + + ) + } + + const { manifest } = accessory + const status = accessoryLinkStatus(accessory, devices) + + return ( + + + + + + + Identity + + + + + + + + Capabilities + + {manifest.capabilities.length === 0 ? ( + This accessory declared no capabilities. + ) : ( + manifest.capabilities.map((capability) => ( + + )) + )} + + + + Discovery reads this accessory and disconnects. Nothing on it runs until it is set up. + + + + ) +} + +function Fact({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( + + {label} + + {value} + + + ) +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: theme.neutral.bg }, + content: { padding: 12, gap: 8, paddingBottom: 40 }, + card: { + borderRadius: 12, + borderWidth: 1, + borderColor: theme.neutral.border, + backgroundColor: theme.neutral.surface, + overflow: 'hidden', + }, + fact: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + paddingHorizontal: 14, + paddingVertical: 11, + }, + factLabel: { color: theme.neutral.textMuted, fontSize: 12, fontWeight: '600' }, + factValue: { flexShrink: 1, color: theme.neutral.textSecondary, fontSize: 12 }, + factValueMono: { fontFamily: theme.mono('600'), fontSize: 11 }, + empty: { + color: theme.neutral.textDim, + fontSize: 12, + paddingHorizontal: 14, + paddingVertical: 12, + }, + footnote: { + color: theme.neutral.textDim, + fontSize: 11, + lineHeight: 16, + paddingHorizontal: 4, + paddingTop: 4, + }, +}) diff --git a/src/modules/accessories/screens/AccessoryScanScreen.tsx b/src/modules/accessories/screens/AccessoryScanScreen.tsx new file mode 100644 index 00000000..2b496dde --- /dev/null +++ b/src/modules/accessories/screens/AccessoryScanScreen.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useState } from 'react' +import { ActivityIndicator, FlatList, StyleSheet, View } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { PlugsConnectedIcon } from 'phosphor-react-native' +import { useShallow } from 'zustand/react/shallow' + +import { Text } from '@/components/base/Text' +import { DeviceRow } from '@/components/base/DeviceRow' +import { IconHero } from '@/components/settings/IconHero' +import { AccessoryCompatibilityNotice } from '@/modules/accessories/components/AccessoryCompatibilityNotice' +import { AccessoryCapabilityRow } from '@/modules/accessories/components/AccessoryCapabilityRow' +import { inspectionErrorCopy } from '@/modules/accessories/lib/accessoryStatus' +import { useAccessoryDiscoveryStore } from '@/modules/accessories/store/accessoryDiscoveryStore' +import { usePermissions } from '@/modules/settings/hooks/usePermissions' +import { theme } from '@/constants/theme' +import type { AccessoryInspection } from 'vescape-core' + +/** + * Finding an Accessory and reading what it is. + * + * Every row here is a device advertising the Vescape Accessory service — matched on the service, + * never the name, so a renamed accessory is still found and a namesake is not mistaken for one. + * Picking one performs the discovery handshake and shows its identity, protocol version and + * capabilities. Nothing is enrolled and nothing is commanded: the app disconnects as soon as the + * manifest is read. + */ +export function AccessoryScanScreen({ + onOpenAccessory, +}: { + /** Called with a persistent Accessory id once one has answered with a manifest. */ + onOpenAccessory: (accessoryId: string) => void +}) { + const { status, request } = usePermissions() + const { devices, scanning, scanError, inspecting, startScan, stopScan, inspect } = + useAccessoryDiscoveryStore( + useShallow((s) => ({ + devices: s.devices, + scanning: s.scanning, + scanError: s.scanError, + inspecting: s.inspecting, + startScan: s.startScan, + stopScan: s.stopScan, + inspect: s.inspect, + })), + ) + const [result, setResult] = useState(null) + + useEffect(() => { + void request() + }, [request]) + + useEffect(() => { + if (status === 'granted') startScan() + return () => stopScan() + }, [status, startScan, stopScan]) + + const onSelect = useCallback( + async (deviceId: string) => { + setResult(null) + const inspection = await inspect(deviceId) + setResult(inspection) + // Straight through on success: the rider picked a device to configure, not to read about. + if (inspection.manifest) onOpenAccessory(inspection.manifest.accessoryId) + }, + [inspect, onOpenAccessory], + ) + + const subtitle = + status === 'denied' + ? 'Bluetooth permission required' + : scanError === 'bluetooth-unavailable' + ? 'Bluetooth is off or unavailable' + : scanError === 'scan-failed' + ? 'The scan could not start' + : inspecting + ? 'Reading the accessory…' + : scanning + ? 'Scanning for accessories…' + : 'Scan stopped' + + return ( + + device.id} + contentContainerStyle={styles.list} + renderItem={({ item }) => ( + void onSelect(item.id)} + /> + )} + ListHeaderComponent={ + + + + {(scanning || inspecting) && ( + + )} + {subtitle} + + {result && !result.manifest && result.error ? ( + + {result.advertisedName ?? result.deviceId}: {inspectionErrorCopy(result.error)} + + ) : null} + {result?.manifest ? ( + + + {result.manifest.capabilities.map((capability) => ( + + ))} + + ) : null} + + } + ListEmptyComponent={ + scanning ? ( + Accessories will appear here as they are found + ) : null + } + /> + + ) +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: theme.neutral.bg }, + list: { padding: 16, flexGrow: 1 }, + header: { gap: 12, marginBottom: 12 }, + statusLine: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + subtitle: { color: theme.neutral.textSecondary, fontSize: 14 }, + error: { color: theme.status.error.text, fontSize: 13, lineHeight: 18 }, + preview: { gap: 8 }, + empty: { + color: theme.neutral.textDim, + textAlign: 'center', + marginTop: 40, + fontSize: 14, + }, +}) diff --git a/src/modules/accessories/store/accessoryDiscoveryStore.test.ts b/src/modules/accessories/store/accessoryDiscoveryStore.test.ts new file mode 100644 index 00000000..8fcfeeda --- /dev/null +++ b/src/modules/accessories/store/accessoryDiscoveryStore.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, expect, mock, test } from 'bun:test' +import type { AccessoryInspection, AccessoryManifest } from 'vescape-core' + +const actualVescapeCore = await import('@/../modules/vescape-core/src/index') + +let inspection: AccessoryInspection + +const inspectAccessory = mock(async () => inspection) +const startAccessoryScan = mock(() => {}) +const stopAccessoryScan = mock(() => {}) +const cancelAccessoryInspection = mock(() => {}) +const addAccessoryDeviceListener = mock(() => ({ remove: () => {} })) +const addAccessoryScanErrorListener = mock(() => ({ remove: () => {} })) + +mock.module('vescape-core', () => ({ + ...actualVescapeCore, + inspectAccessory, + startAccessoryScan, + stopAccessoryScan, + cancelAccessoryInspection, + addAccessoryDeviceListener, + addAccessoryScanErrorListener, +})) + +function manifest(overrides: Partial = {}): AccessoryManifest { + return { + accessoryId: 'acc-1', + name: 'Clearance sensor', + firmwareVersion: '0.1.0', + protocolVersion: 1, + supportedVersions: [], + compatibility: 'supported', + capabilities: [ + { + id: 'clearance', + type: 'ground_clearance', + supported: true, + unit: 'cm', + rangeMin: 3, + rangeMax: 100, + ratesHz: [10, 20], + }, + ], + ...overrides, + } +} + +beforeEach(async () => { + const { useAccessoryDiscoveryStore } = + await import('@/modules/accessories/store/accessoryDiscoveryStore') + useAccessoryDiscoveryStore.setState({ + scanning: false, + scanError: null, + devices: [], + accessories: [], + inspecting: null, + }) +}) + +test('an accessory is keyed on its manifest identity, not the BLE handle it answered on', async () => { + const { useAccessoryDiscoveryStore } = + await import('@/modules/accessories/store/accessoryDiscoveryStore') + + inspection = { + deviceId: 'AA:01', + advertisedName: 'Vescape-HW', + manifest: manifest(), + error: null, + } + await useAccessoryDiscoveryStore.getState().inspect('AA:01') + + // Same unit, new address — an Android MAC can rotate and an iOS peripheral id is per-device. + inspection = { + deviceId: 'BB:02', + advertisedName: 'Vescape-HW', + manifest: manifest({ firmwareVersion: '0.2.0' }), + error: null, + } + await useAccessoryDiscoveryStore.getState().inspect('BB:02') + + const { accessories } = useAccessoryDiscoveryStore.getState() + expect(accessories).toHaveLength(1) + expect(accessories[0]!.deviceId).toBe('BB:02') + expect(accessories[0]!.manifest.firmwareVersion).toBe('0.2.0') +}) + +test('a device that never answered with a manifest is not remembered as an accessory', async () => { + const { useAccessoryDiscoveryStore } = + await import('@/modules/accessories/store/accessoryDiscoveryStore') + + inspection = { deviceId: 'AA:01', advertisedName: null, manifest: null, error: 'timeout' } + await useAccessoryDiscoveryStore.getState().inspect('AA:01') + + expect(useAccessoryDiscoveryStore.getState().accessories).toEqual([]) + expect(useAccessoryDiscoveryStore.getState().inspecting).toBeNull() +}) + +test('a failed re-check marks a known accessory unreachable instead of dropping it', async () => { + const { accessoryLinkStatus, useAccessoryDiscoveryStore } = + await import('@/modules/accessories/store/accessoryDiscoveryStore') + + inspection = { deviceId: 'AA:01', advertisedName: null, manifest: manifest(), error: null } + await useAccessoryDiscoveryStore.getState().inspect('AA:01') + + inspection = { deviceId: 'AA:01', advertisedName: null, manifest: null, error: 'connect-failed' } + await useAccessoryDiscoveryStore.getState().inspect('AA:01') + + const accessory = useAccessoryDiscoveryStore.getState().accessories[0]! + expect(accessory.lastError).toBe('connect-failed') + expect(accessoryLinkStatus(accessory, [])).toBe('unreachable') + // Hearing it again outranks the stale failure: the scan is the fresher fact. + expect( + accessoryLinkStatus(accessory, [ + { id: 'AA:01', name: null, rssi: -50, lastSeenAt: Date.now() }, + ]), + ).toBe('advertising') +}) diff --git a/src/modules/accessories/store/accessoryDiscoveryStore.ts b/src/modules/accessories/store/accessoryDiscoveryStore.ts new file mode 100644 index 00000000..fd30ca15 --- /dev/null +++ b/src/modules/accessories/store/accessoryDiscoveryStore.ts @@ -0,0 +1,174 @@ +import { create } from 'zustand' +import { + addAccessoryDeviceListener, + addAccessoryScanErrorListener, + cancelAccessoryInspection as nativeCancelInspection, + inspectAccessory as nativeInspect, + startAccessoryScan as nativeStartScan, + stopAccessoryScan as nativeStopScan, + type AccessoryInspection, + type AccessoryInspectionError, + type AccessoryManifest, +} from 'vescape-core' + +import type { AccessoryLinkStatus } from '@/modules/accessories/lib/accessoryStatus' + +interface EventSubscription { + remove(): void +} + +/** One advertisement seen by the running scan. */ +export interface DiscoveredAccessoryDevice { + id: string + name: string | null + rssi: number + lastSeenAt: number +} + +/** + * An Accessory whose manifest this app has read. + * + * Session-scoped on purpose. Discovery answers "what is this thing", and nothing more: an + * Accessory is only remembered across launches once the rider enrolls it, and enrollment is native + * durable truth, not a JS store. This holds what the current session learned so the Board selector + * and the accessory screen have something to render. + */ +export interface KnownAccessory { + /** Persistent Accessory identity from the manifest, not the BLE handle. */ + accessoryId: string + /** The BLE handle it answered on this session — platform-scoped and not durable. */ + deviceId: string + manifest: AccessoryManifest + inspectedAt: number + /** Set when the last handshake with it failed, so the row can say it is not reachable. */ + lastError: AccessoryInspectionError | null +} + +interface AccessoryDiscoveryState { + scanning: boolean + scanError: 'bluetooth-unavailable' | 'scan-failed' | null + devices: DiscoveredAccessoryDevice[] + accessories: KnownAccessory[] + /** Device id currently being inspected, so its row can show the handshake running. */ + inspecting: string | null +} + +interface AccessoryDiscoveryActions { + startScan: () => void + stopScan: () => void + inspect: (deviceId: string) => Promise + cancelInspection: () => void +} + +let deviceSub: EventSubscription | null = null +let errorSub: EventSubscription | null = null + +/** + * Mirrors native accessory discovery. Native owns the radio, the NDJSON framing, the protocol + * session and the compatibility verdict; this store renders what it reports and sends intents back. + */ +export const useAccessoryDiscoveryStore = create< + AccessoryDiscoveryState & AccessoryDiscoveryActions +>((set, get) => ({ + scanning: false, + scanError: null, + devices: [], + accessories: [], + inspecting: null, + + startScan: () => { + if (get().scanning) return + deviceSub?.remove() + errorSub?.remove() + deviceSub = addAccessoryDeviceListener((event) => { + set((state) => { + const next: DiscoveredAccessoryDevice = { + id: event.id, + name: event.name, + rssi: event.rssi, + lastSeenAt: Date.now(), + } + const index = state.devices.findIndex((d) => d.id === event.id) + if (index === -1) return { devices: [...state.devices, next] } + const devices = [...state.devices] + devices[index] = next + return { devices } + }) + }) + errorSub = addAccessoryScanErrorListener(({ error }) => { + set({ scanError: error, scanning: false }) + }) + set({ scanning: true, scanError: null, devices: [] }) + nativeStartScan() + }, + + stopScan: () => { + deviceSub?.remove() + deviceSub = null + errorSub?.remove() + errorSub = null + if (get().scanning) nativeStopScan() + set({ scanning: false }) + }, + + inspect: async (deviceId) => { + set({ inspecting: deviceId }) + // Native stops the scan for the duration of a handshake; mirror that so the UI agrees. + if (get().scanning) set({ scanning: false }) + try { + const result = await nativeInspect(deviceId) + set((state) => ({ + inspecting: null, + accessories: mergeInspection(state.accessories, deviceId, result), + })) + return result + } catch (error) { + set({ inspecting: null }) + throw error + } + }, + + cancelInspection: () => { + nativeCancelInspection() + set({ inspecting: null }) + }, +})) + +/** + * Folds one handshake result into the known list. A successful read replaces the entry for that + * Accessory identity — the same physical unit can come back on a different BLE handle, and the + * manifest is what says which one it is. A failure only annotates an Accessory already known; + * a device that never answered is not an Accessory yet. + */ +function mergeInspection( + accessories: KnownAccessory[], + deviceId: string, + result: AccessoryInspection, +): KnownAccessory[] { + if (result.manifest) { + const entry: KnownAccessory = { + accessoryId: result.manifest.accessoryId, + deviceId, + manifest: result.manifest, + inspectedAt: Date.now(), + lastError: null, + } + const index = accessories.findIndex((a) => a.accessoryId === entry.accessoryId) + if (index === -1) return [...accessories, entry] + const next = [...accessories] + next[index] = entry + return next + } + if (!result.error) return accessories + const error = result.error + return accessories.map((a) => (a.deviceId === deviceId ? { ...a, lastError: error } : a)) +} + +/** What the Accessory's row should say about its link, given whatever the scan is hearing. */ +export function accessoryLinkStatus( + accessory: KnownAccessory, + devices: DiscoveredAccessoryDevice[], +): AccessoryLinkStatus { + if (devices.some((d) => d.id === accessory.deviceId)) return 'advertising' + return accessory.lastError ? 'unreachable' : 'idle' +} diff --git a/src/modules/board/components/BoardSelectorSheet.tsx b/src/modules/board/components/BoardSelectorSheet.tsx index 323a3839..8cfad5d6 100644 --- a/src/modules/board/components/BoardSelectorSheet.tsx +++ b/src/modules/board/components/BoardSelectorSheet.tsx @@ -27,6 +27,14 @@ export interface BoardSelectorLink { interface BoardSelectorContentProps { boards: Board[] + /** + * The Accessories half of the selector, supplied by the screen composing it. + * + * A plain node, not accessory data: the selector is the entry point for both domains but knows + * only one of them. Accessories target the connected Board rather than any Board in this list, so + * they sit beside the Boards section, never inside a Board's row. + */ + accessories?: React.ReactNode activeBoardId: string | null /** True while the active board has a live telemetry link, so its row shows the pull rate. */ activeBoardLive?: boolean @@ -165,6 +173,7 @@ function BoardIcon({ active }: { active: boolean }) { */ export function BoardSelectorContent({ boards, + accessories, activeBoardId, activeBoardLive = false, warnings, @@ -178,6 +187,8 @@ export function BoardSelectorContent({ return ( <> + {/* Headings appear only once there are two sections to tell apart. */} + {accessories ? Boards : null} {active && ( @@ -272,6 +283,14 @@ export function BoardSelectorContent({ Add new board + + {accessories ? ( + <> + + Accessories + {accessories} + + ) : null} ) } @@ -287,7 +306,7 @@ export function BoardSelectorSheet({ visible={visible} triggerRef={triggerRef} edge="top" - title="Boards" + title={content.accessories ? 'Boards & accessories' : 'Boards'} icon={LightningIcon} iconColor={theme.palette.sky.color} backdropTestID="board-selector-backdrop" @@ -302,6 +321,22 @@ const styles = StyleSheet.create({ frame: { width: '100%', }, + sectionLabel: { + color: theme.neutral.textMuted, + fontSize: 11, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.5, + paddingLeft: 10, + paddingBottom: 4, + }, + sectionDivider: { + height: StyleSheet.hairlineWidth * 2, + alignSelf: 'stretch', + backgroundColor: theme.alpha(theme.neutral.border, 0.6), + marginTop: 8, + marginBottom: 10, + }, // The active board is a card, not a list row — it takes the drawer's full width and the same // surface every other widget in there wears. activeBlock: { diff --git a/src/modules/moduleBoundaries.test.ts b/src/modules/moduleBoundaries.test.ts index 3a0004fa..27cdb632 100644 --- a/src/modules/moduleBoundaries.test.ts +++ b/src/modules/moduleBoundaries.test.ts @@ -18,6 +18,7 @@ const ALLOWED_EDGES = new Set([ 'alerts -> battery', 'board -> battery', // settings store is app-settings truth read by domain stores + 'accessories -> settings', 'alerts -> settings', 'board -> settings', 'history -> settings', diff --git a/src/navigation/routes.ts b/src/navigation/routes.ts index c6845e98..7aae5cac 100644 --- a/src/navigation/routes.ts +++ b/src/navigation/routes.ts @@ -19,6 +19,8 @@ export const routes = { editBoard: '/editBoard/[boardId]', editBoardLink: '/editBoard/link', editBoardConfig: '/editBoard/config', + accessoryScan: '/accessories/scan', + accessory: '/accessories/[accessoryId]', controlSpeed: '/control/speed', controlBattery: '/control/battery', controlBatteryRaw: '/control/battery/raw' as Href, @@ -68,6 +70,8 @@ export const stackScreens = { editBoard: 'editBoard/[boardId]', editBoardLink: 'editBoard/link', editBoardConfig: 'editBoard/config', + accessoryScan: 'accessories/scan', + accessory: 'accessories/[accessoryId]', controlBatteryRaw: 'control/battery/raw', settingsRawSettings: 'settings/raw-settings', settingsPrivacyZones: 'settings/privacy-zones', diff --git a/src/screens/main/overlays/BoardSelectorAccessories.tsx b/src/screens/main/overlays/BoardSelectorAccessories.tsx new file mode 100644 index 00000000..3efb3b0f --- /dev/null +++ b/src/screens/main/overlays/BoardSelectorAccessories.tsx @@ -0,0 +1,42 @@ +import { + AccessorySelectorSection, + type AccessorySelectorItem, +} from '@/modules/accessories/components/AccessorySelectorSection' +import { + accessoryLinkStatus, + useAccessoryDiscoveryStore, +} from '@/modules/accessories/store/accessoryDiscoveryStore' + +/** + * The Accessories section of the Board selector, composed here rather than inside the selector. + * + * The selector belongs to the Board domain and must not learn about hardware; this is the seam + * where the two meet. Accessories are listed flat, because an Accessory Binding targets whichever + * Board is connected, not the Board whose row it happens to sit under. + */ +export function BoardSelectorAccessories({ + onOpenAccessory, + onAddAccessory, +}: { + onOpenAccessory: (accessoryId: string) => void + onAddAccessory: () => void +}) { + const accessories = useAccessoryDiscoveryStore((s) => s.accessories) + const devices = useAccessoryDiscoveryStore((s) => s.devices) + + const items: AccessorySelectorItem[] = accessories.map((accessory) => ({ + accessoryId: accessory.accessoryId, + name: accessory.manifest.name, + detail: `v${accessory.manifest.firmwareVersion}`, + status: accessoryLinkStatus(accessory, devices), + incompatible: accessory.manifest.compatibility !== 'supported', + })) + + return ( + + ) +} diff --git a/src/screens/main/overlays/TopBar.tsx b/src/screens/main/overlays/TopBar.tsx index 4a8e4e47..98d906a3 100644 --- a/src/screens/main/overlays/TopBar.tsx +++ b/src/screens/main/overlays/TopBar.tsx @@ -17,6 +17,7 @@ import { SocialSheet } from '@/modules/group-ride/components/SocialSheet' import { SettingsSheet } from '@/screens/main/overlays/SettingsSheet' import { ConnectedBoardPill } from '@/modules/board/components/ConnectedBoardPill' import { BoardIssueDrawers } from '@/modules/board/components/BoardIssueDrawers' +import { BoardSelectorAccessories } from '@/screens/main/overlays/BoardSelectorAccessories' import { useBoardIssues } from '@/modules/board/hooks/useBoardIssues' import { useBleStore } from '@/modules/board/store/bleStore' import { isReplayBoardId } from 'vescape-core' @@ -81,9 +82,11 @@ export function TopBar({ const [faultsOpen, setFaultsOpen] = useState(false) // What the selector was asked for on its way out. Presenting a modal while another is still // dismissing is dropped, so anything opened from inside the selector waits for it to leave. - const pendingExit = useRef<{ kind: 'warnings' | 'faults' | 'edit'; boardId?: string } | null>( - null, - ) + const pendingExit = useRef<{ + kind: 'warnings' | 'faults' | 'edit' | 'accessory' | 'add-accessory' + boardId?: string + accessoryId?: string + } | null>(null) const [socialOpen, setSocialOpen] = useState(false) const settingsRef = useRef(null) const [settingsOpen, setSettingsOpen] = useState(false) @@ -241,6 +244,18 @@ export function TopBar({ visible={selectorOpen} triggerRef={pillRef} boards={boards} + accessories={ + { + pendingExit.current = { kind: 'accessory', accessoryId } + setSelectorOpen(false) + }} + onAddAccessory={() => { + pendingExit.current = { kind: 'add-accessory' } + setSelectorOpen(false) + }} + /> + } activeBoardId={activeBoardId} activeBoardLive={bleStatus === 'connected' || bleStatus === 'stale'} warnings={ @@ -275,6 +290,10 @@ export function TopBar({ if (exit?.kind === 'edit' && exit.boardId) { router.push({ pathname: routes.editBoard, params: { boardId: exit.boardId } }) } + if (exit?.kind === 'accessory' && exit.accessoryId) { + router.push({ pathname: routes.accessory, params: { accessoryId: exit.accessoryId } }) + } + if (exit?.kind === 'add-accessory') router.push(routes.accessoryScan) }} onSelectBoard={(id) => { onSelectBoard(id) diff --git a/src/screens/showcase/accessories/AccessoryManifestShowcase.tsx b/src/screens/showcase/accessories/AccessoryManifestShowcase.tsx new file mode 100644 index 00000000..5016056a --- /dev/null +++ b/src/screens/showcase/accessories/AccessoryManifestShowcase.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react' +import { StyleSheet, View } from 'react-native' +import type { AccessoryCapability, AccessoryCompatibility } from 'vescape-core' + +import { ShowcaseCard } from '@/components/dev/ShowcaseCard' +import { ChipRow } from '@/components/dev/ShowcaseControls' +import { AccessoryCapabilityRow } from '@/modules/accessories/components/AccessoryCapabilityRow' +import { AccessoryCompatibilityNotice } from '@/modules/accessories/components/AccessoryCompatibilityNotice' +import { theme } from '@/constants/theme' + +const COMPATIBILITIES: AccessoryCompatibility[] = [ + 'supported', + 'unsupported-version', + 'unsupported-capabilities', +] + +const CAPABILITIES: AccessoryCapability[] = [ + { + id: 'clearance', + type: 'ground_clearance', + supported: true, + unit: 'cm', + rangeMin: 3, + rangeMax: 100, + ratesHz: [10, 20, 30], + }, + { + id: 'rear_light', + type: 'brake_light', + supported: true, + unit: null, + rangeMin: null, + rangeMax: null, + ratesHz: [], + }, + // An accessory ahead of the app: named by its wire slug rather than hidden. + { + id: 'horn', + type: 'air_horn', + supported: false, + unit: null, + rangeMin: null, + rangeMax: null, + ratesHz: [], + }, + // A recognized type the app still cannot drive: the unit is not the one v1 defines. + { + id: 'clearance_mm', + type: 'ground_clearance', + supported: false, + unit: 'mm', + rangeMin: 30, + rangeMax: 1000, + ratesHz: [10], + }, +] + +export function AccessoryCompatibilityNoticeShowcase() { + const [compatibility, setCompatibility] = useState('supported') + + return ( + setCompatibility(next as AccessoryCompatibility)} + /> + } + > + + + + + ) +} + +export function AccessoryCapabilityRowShowcase() { + return ( + + + {CAPABILITIES.map((capability) => ( + + ))} + + + ) +} + +const styles = StyleSheet.create({ + stack: { alignSelf: 'stretch', gap: 8 }, + card: { + alignSelf: 'stretch', + borderRadius: 12, + borderWidth: 1, + borderColor: theme.neutral.border, + backgroundColor: theme.neutral.surface, + overflow: 'hidden', + }, +}) diff --git a/src/screens/showcase/accessories/AccessorySelectorSectionShowcase.tsx b/src/screens/showcase/accessories/AccessorySelectorSectionShowcase.tsx new file mode 100644 index 00000000..66228f8e --- /dev/null +++ b/src/screens/showcase/accessories/AccessorySelectorSectionShowcase.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react' +import { StyleSheet, View } from 'react-native' + +import { Text } from '@/components/base/Text' +import { ShowcaseCard } from '@/components/dev/ShowcaseCard' +import { ChipRow, ToggleRow } from '@/components/dev/ShowcaseControls' +import { + AccessorySelectorSection, + type AccessorySelectorItem, +} from '@/modules/accessories/components/AccessorySelectorSection' +import type { AccessoryLinkStatus } from '@/modules/accessories/lib/accessoryStatus' +import { theme } from '@/constants/theme' + +const STATUSES: AccessoryLinkStatus[] = ['advertising', 'idle', 'unreachable'] + +export function AccessorySelectorSectionShowcase() { + const [status, setStatus] = useState('advertising') + const [empty, setEmpty] = useState(false) + const [incompatible, setIncompatible] = useState(false) + const [lastAction, setLastAction] = useState('Tap a row to see its action here.') + + const accessories: AccessorySelectorItem[] = empty + ? [] + : [ + { + accessoryId: 'clearance-1', + name: 'Clearance sensor', + detail: 'v0.1.0', + status, + incompatible, + }, + { + accessoryId: 'light-1', + name: 'Rear light', + detail: 'v0.2.1', + status: 'idle', + }, + ] + + return ( + + setStatus(next as AccessoryLinkStatus)} + /> + + + + } + > + + setLastAction(`Open accessory ${id}`)} + onAddAccessory={() => setLastAction('Add accessory')} + /> + + {lastAction} + + ) +} + +const styles = StyleSheet.create({ + // The drawer the real section lives in, so the rows read at their true width. + sheet: { + alignSelf: 'stretch', + padding: 12, + borderRadius: 20, + borderWidth: 1, + borderColor: theme.neutral.border, + backgroundColor: theme.alpha(theme.neutral.bg, 0.85), + overflow: 'hidden', + }, + action: { + color: theme.neutral.textMuted, + fontSize: 11, + fontWeight: '600', + textAlign: 'center', + paddingTop: 8, + }, +}) diff --git a/src/screens/showcase/board/BoardSelectorSheetShowcase.tsx b/src/screens/showcase/board/BoardSelectorSheetShowcase.tsx index 0bc24200..855c9a7d 100644 --- a/src/screens/showcase/board/BoardSelectorSheetShowcase.tsx +++ b/src/screens/showcase/board/BoardSelectorSheetShowcase.tsx @@ -5,6 +5,10 @@ import { Text } from '@/components/base/Text' import { ShowcaseCard } from '@/components/dev/ShowcaseCard' import { ToggleRow } from '@/components/dev/ShowcaseControls' import { BoardSelectorContent } from '@/modules/board/components/BoardSelectorSheet' +import { + AccessorySelectorSection, + type AccessorySelectorItem, +} from '@/modules/accessories/components/AccessorySelectorSection' import type { Board } from '@/modules/board/store/boardStore' import { theme } from '@/constants/theme' @@ -41,6 +45,24 @@ const OTHERS = [ }), ] +/** Accessories are listed flat beside the Boards: they target whichever Board is connected. */ +const ACCESSORIES: AccessorySelectorItem[] = [ + { + accessoryId: 'clearance-1', + name: 'Clearance sensor', + detail: 'v0.1.0', + status: 'advertising', + }, + { accessoryId: 'light-1', name: 'Rear light', detail: 'v0.2.1', status: 'idle' }, + { + accessoryId: 'horn-1', + name: 'Air horn', + detail: 'v1.0.0', + status: 'unreachable', + incompatible: true, + }, +] + export function BoardSelectorSheetShowcase() { const [live, setLive] = useState(true) const [warningsOn, setWarningsOn] = useState(true) @@ -53,6 +75,8 @@ export function BoardSelectorSheetShowcase() { const [neverSeen, setNeverSeen] = useState(false) const [alone, setAlone] = useState(false) const [empty, setEmpty] = useState(false) + const [accessoriesOn, setAccessoriesOn] = useState(true) + const [noAccessories, setNoAccessories] = useState(false) const [lastAction, setLastAction] = useState('Tap a row or link to see its action here.') const active = unlinked ? UNLINKED_ACTIVE : neverSeen ? NEVER_SEEN : longName ? LONG_NAME : ACTIVE @@ -82,12 +106,27 @@ export function BoardSelectorSheetShowcase() { + + } > setLastAction(`Open accessory ${id}`)} + onAddAccessory={() => setLastAction('Add accessory')} + /> + ) : undefined + } activeBoardId={active.id} activeBoardLive={live} warnings={ From 711642c782739971683d4675929967acf29633f6 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sun, 13 Sep 2026 07:16:44 +0200 Subject: [PATCH 04/26] Harden accessory discovery against a cross-agent review #476 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- .../accessory/AccessoryDiscovery.kt | 19 ++- .../accessory/AccessoryGattHandshake.kt | 14 +- .../accessory/AccessoryProtocol.kt | 37 ++++- .../ios/accessory/AccessoryDiscovery.swift | 143 +++++++++++++----- .../ios/accessory/AccessoryProtocol.swift | 14 +- .../constants/accessoryCapabilities.ts | 37 +++-- .../screens/AccessoryScanScreen.tsx | 56 +++++-- .../store/accessoryDiscoveryStore.test.ts | 29 +++- .../store/accessoryDiscoveryStore.ts | 36 ++++- 9 files changed, 291 insertions(+), 94 deletions(-) diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryDiscovery.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryDiscovery.kt index 38740012..f1e2b399 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryDiscovery.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryDiscovery.kt @@ -40,8 +40,19 @@ object AccessoryDiscovery { private var scanContext: Context? = null private var inFlight: AccessoryGattHandshake? = null + /** + * Every entry point runs on the main looper. + * + * The module's `Function` bodies arrive on the JS thread while scan callbacks, the handshake and + * its timeouts all run here, and `scanCallback` / `inFlight` are shared between them. Posting is + * what stops a stop racing the start that was meant to precede it. + */ fun startScan(context: Context) { - stopScan() + handler.post { startScanNow(context) } + } + + private fun startScanNow(context: Context) { + stopScanNow() val app = context.applicationContext val scanner = (app.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager) ?.adapter @@ -87,6 +98,10 @@ object AccessoryDiscovery { } fun stopScan() { + handler.post { stopScanNow() } + } + + private fun stopScanNow() { val callback = scanCallback ?: return val app = scanContext scanCallback = null @@ -113,7 +128,7 @@ object AccessoryDiscovery { } // Scanning while a handshake runs slows the connection down for no benefit: the rider // has already picked a row. - stopScan() + stopScanNow() val sessionId = UUID.randomUUID().toString() val handshake = AccessoryGattHandshake( context.applicationContext, diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryGattHandshake.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryGattHandshake.kt index 9b0e8fec..c23a1909 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryGattHandshake.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryGattHandshake.kt @@ -111,11 +111,13 @@ internal class AccessoryGattHandshake( private val callback = object : BluetoothGattCallback() { override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { - if (g !== gatt) { - try { g.close() } catch (e: Exception) { Log.w(TAG, "stale close: ${e.message}") } - return - } + // Posted before anything is read: every field this class keeps lives on the main looper, + // and GATT callbacks arrive on a binder thread. handler.post { + if (g !== gatt) { + try { g.close() } catch (e: Exception) { Log.w(TAG, "stale close: ${e.message}") } + return@post + } if (newState == BluetoothProfile.STATE_CONNECTED) { g.requestMtu(REQUESTED_MTU) } else { @@ -235,9 +237,9 @@ internal class AccessoryGattHandshake( } private fun deliver(g: BluetoothGatt, uuid: UUID, value: ByteArray) { - if (g !== gatt || uuid != AccessoryProtocol.NOTIFY_UUID) return + if (uuid != AccessoryProtocol.NOTIFY_UUID) return handler.post { - if (finished) return@post + if (finished || g !== gatt) return@post val result = framer.feed(value) for (line in result.lines) { when (val parsed = AccessoryProtocol.parseManifest(line, sessionId)) { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt index 6b23fedc..d35f3be7 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt @@ -85,10 +85,10 @@ object AccessoryProtocol { // Session identity is checked before anything else is read: a message from a previous // session must not renew or influence this one. - if (root.optString("sessionId") != sessionId || root.optInt("requestId", -1) != requestId) { + if ((root.opt("sessionId") as? String) != sessionId || wholeNumber(root.opt("requestId")) != requestId) { return ManifestResult.Failed(AccessoryHandshakeError.SESSION_MISMATCH) } - if (root.optString("type") != "manifest") { + if ((root.opt("type") as? String) != "manifest") { return ManifestResult.Failed(AccessoryHandshakeError.INVALID) } if (!root.has("protocolVersion")) { @@ -105,7 +105,7 @@ object AccessoryProtocol { val protocolVersion = if (root.isNull("protocolVersion")) { null } else { - (root.opt("protocolVersion") as? Number)?.toInt() + wholeNumber(root.opt("protocolVersion")) ?: return ManifestResult.Failed(AccessoryHandshakeError.INVALID) } val versionAgreed = protocolVersion != null && SUPPORTED_VERSIONS.contains(protocolVersion) @@ -113,7 +113,7 @@ object AccessoryProtocol { val supportedVersions = when (val offered = root.opt("supportedVersions")) { null, JSONObject.NULL -> emptyList() is JSONArray -> (0 until offered.length()).map { - (offered.opt(it) as? Number)?.toInt() + wholeNumber(offered.opt(it)) ?: return ManifestResult.Failed(AccessoryHandshakeError.INVALID) } else -> return ManifestResult.Failed(AccessoryHandshakeError.INVALID) @@ -161,10 +161,20 @@ object AccessoryProtocol { val id = requiredString(entry, "id") ?: return null val type = requiredString(entry, "type") ?: return null val unit = (entry.opt("unit") as? String)?.takeIf { it.isNotEmpty() } - val range = entry.optJSONObject("range") + val range = when (val raw = entry.opt("range")) { + null, JSONObject.NULL -> null + is JSONObject -> raw + else -> return null + } val rangeMin = (range?.opt("min") as? Number)?.toDouble() val rangeMax = (range?.opt("max") as? Number)?.toDouble() - val ratesRaw = entry.optJSONArray("ratesHz") + // A present-but-wrong-typed `ratesHz` is a broken manifest, not an absent field. `optJSONArray` + // cannot tell those apart, and treating them alike let Android accept manifests iOS rejects. + val ratesRaw = when (val raw = entry.opt("ratesHz")) { + null, JSONObject.NULL -> null + is JSONArray -> raw + else -> return null + } val ratesHz = buildList { if (ratesRaw != null) { for (i in 0 until ratesRaw.length()) { @@ -205,6 +215,21 @@ object AccessoryProtocol { else -> false } + /** + * A JSON number that is genuinely a whole number. + * + * `Number.toInt()` truncates, which would let `protocolVersion: 1.9` pass as the v1 this app + * speaks, and `optInt` additionally coerces numeric strings — so Android accepted envelopes iOS + * refused. A version or request id is an integer or it is nothing. + */ + private fun wholeNumber(value: Any?): Int? { + val number = value as? Number ?: return null + val asDouble = number.toDouble() + if (!asDouble.isFinite() || asDouble != Math.floor(asDouble)) return null + if (asDouble < Int.MIN_VALUE.toDouble() || asDouble > Int.MAX_VALUE.toDouble()) return null + return asDouble.toInt() + } + private fun requiredString(json: JSONObject, key: String): String? { if (json.isNull(key)) return null val value = json.opt(key) as? String ?: return null diff --git a/modules/vescape-core/ios/accessory/AccessoryDiscovery.swift b/modules/vescape-core/ios/accessory/AccessoryDiscovery.swift index 0547200b..bd8b461e 100644 --- a/modules/vescape-core/ios/accessory/AccessoryDiscovery.swift +++ b/modules/vescape-core/ios/accessory/AccessoryDiscovery.swift @@ -28,60 +28,122 @@ final class AccessoryDiscovery: NSObject { private var scanRequested = false private var handshake: AccessoryGattHandshake? private var pendingInspection: (deviceId: String, onResult: ([String: Any?]) -> Void)? + /// Bounds the wait for a central that is still starting up, so a state that never arrives cannot + /// leave an `inspectAccessory` promise hanging forever. + private var pendingTimeout: DispatchWorkItem? /// Peripherals the scan saw, retained so a later `inspect` has something to connect to. private var seen: [UUID: CBPeripheral] = [:] + /// How long a deferred inspection waits for the central to report a usable state. + private static let centralStartupTimeout: TimeInterval = 5 + + /// Everything below runs on the main queue. + /// + /// The central is created with `queue: nil`, so CoreBluetooth delivers on main, but the module's + /// entry points do not all arrive there: `inspectAccessory` is an `AsyncFunction` on Expo's own + /// queue while the scan intents come off the JS thread. Hopping here is what stops a scan callback + /// mutating `seen` underneath a lookup, or a cancel racing a handshake's completion. + private func onMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) + } + } + func startScan() { - scanRequested = true - guard central.state == .poweredOn else { - // The central reports `.poweredOn` asynchronously on first use; the scan starts there. - _ = central - return + onMain { + self.scanRequested = true + guard self.central.state == .poweredOn else { + // The central reports `.poweredOn` asynchronously on first use; the scan starts there. + _ = self.central + return + } + self.beginScan() } - beginScan() } func stopScan() { - scanRequested = false - if central.state == .poweredOn { central.stopScan() } + onMain { + self.scanRequested = false + if self.central.state == .poweredOn { self.central.stopScan() } + } } /// Connects to one discovered device and reads its manifest. `onResult` receives the bridge /// payload exactly once, whether the handshake succeeded, was rejected, or timed out. func inspect(deviceId: String, onResult: @escaping ([String: Any?]) -> Void) { - guard handshake == nil, pendingInspection == nil else { - return onResult(Self.payload(deviceId: deviceId, advertisedName: nil, manifest: nil, error: "busy")) - } - guard let uuid = UUID(uuidString: deviceId) else { - return onResult( - Self.payload(deviceId: deviceId, advertisedName: nil, manifest: nil, error: "connect-failed") - ) - } - // Scanning while a handshake runs slows the connection down for no benefit: the rider has - // already picked a row. - stopScan() + onMain { + guard self.handshake == nil, self.pendingInspection == nil else { + return onResult( + Self.payload(deviceId: deviceId, advertisedName: nil, manifest: nil, error: "busy")) + } + guard let uuid = UUID(uuidString: deviceId) else { + return onResult( + Self.payload( + deviceId: deviceId, advertisedName: nil, manifest: nil, error: "connect-failed") + ) + } + // Scanning while a handshake runs slows the connection down for no benefit: the rider has + // already picked a row. + self.scanRequested = false + if self.central.state == .poweredOn { self.central.stopScan() } - guard central.state == .poweredOn else { - pendingInspection = (deviceId, onResult) - _ = central - return - } - guard let peripheral = resolve(uuid) else { - return onResult( - Self.payload(deviceId: deviceId, advertisedName: nil, manifest: nil, error: "connect-failed") - ) + switch self.central.state { + case .poweredOn: + break + case .unknown, .resetting: + // Genuinely transient: the central publishes its first state asynchronously. Wait, but not + // indefinitely — a state that never arrives would strand the promise. + self.pendingInspection = (deviceId, onResult) + _ = self.central + let timeout = DispatchWorkItem { [weak self] in + self?.resolvePending(error: "timeout") + } + self.pendingTimeout = timeout + DispatchQueue.main.asyncAfter( + deadline: .now() + Self.centralStartupTimeout, execute: timeout) + return + default: + // Off, unauthorized or unsupported: no later state change is coming to rescue this, so + // answer now rather than waiting for one. + return onResult( + Self.payload( + deviceId: deviceId, advertisedName: nil, manifest: nil, error: "bluetooth-unavailable") + ) + } + + guard let peripheral = self.resolve(uuid) else { + return onResult( + Self.payload( + deviceId: deviceId, advertisedName: nil, manifest: nil, error: "connect-failed") + ) + } + self.begin(peripheral: peripheral, deviceId: deviceId, onResult: onResult) } - begin(peripheral: peripheral, deviceId: deviceId, onResult: onResult) } - /// Abandons an inspection the rider walked away from. + /// Abandons an inspection the rider walked away from. The caller still gets its one answer. func cancelInspection() { - pendingInspection = nil - handshake?.cancel() + onMain { + self.resolvePending(error: "cancelled") + self.handshake?.cancel() + } } // MARK: - Internals + /// Answers a deferred inspection and clears it. No-op when nothing is deferred. + private func resolvePending(error: String) { + pendingTimeout?.cancel() + pendingTimeout = nil + guard let pending = pendingInspection else { return } + pendingInspection = nil + pending.onResult( + Self.payload(deviceId: pending.deviceId, advertisedName: nil, manifest: nil, error: error) + ) + } + private func beginScan() { seen.removeAll() central.scanForPeripherals( @@ -142,21 +204,22 @@ final class AccessoryDiscovery: NSObject { extension AccessoryDiscovery: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { guard central.state == .poweredOn else { + // `.unknown` and `.resetting` are the central still settling; anything else is a real refusal + // and the deferred inspection has nothing left to wait for. + guard central.state != .unknown, central.state != .resetting else { return } if scanRequested || pendingInspection != nil { emit?("onAccessoryScanError", ["error": "bluetooth-unavailable"]) } - if let pending = pendingInspection { - pendingInspection = nil - pending.onResult( - Self.payload( - deviceId: pending.deviceId, advertisedName: nil, manifest: nil, - error: "bluetooth-unavailable") - ) - } + // A scan cannot survive the radio going away, and leaving the intent armed would restart one + // later with nothing listening to it. + scanRequested = false + resolvePending(error: "bluetooth-unavailable") return } if let pending = pendingInspection { pendingInspection = nil + pendingTimeout?.cancel() + pendingTimeout = nil guard let uuid = UUID(uuidString: pending.deviceId), let peripheral = resolve(uuid) else { return pending.onResult( Self.payload( diff --git a/modules/vescape-core/ios/accessory/AccessoryProtocol.swift b/modules/vescape-core/ios/accessory/AccessoryProtocol.swift index 7adce31b..40ba2296 100644 --- a/modules/vescape-core/ios/accessory/AccessoryProtocol.swift +++ b/modules/vescape-core/ios/accessory/AccessoryProtocol.swift @@ -251,7 +251,11 @@ enum AccessoryProtocol { return nil } let unit = (entry["unit"] as? String).flatMap { $0.isEmpty ? nil : $0 } - let range = entry["range"] as? [String: Any] + var range: [String: Any]? + if let raw = entry["range"], !(raw is NSNull) { + guard let object = raw as? [String: Any] else { return nil } + range = object + } let rangeMin = double(range?["min"]) let rangeMax = double(range?["max"]) @@ -314,8 +318,16 @@ enum AccessoryProtocol { return CFGetTypeID(number) == CFBooleanGetTypeID() } + /// A JSON number that is genuinely a whole number. + /// + /// `intValue` truncates, which would let `protocolVersion: 1.9` pass as the v1 this app speaks. + /// A version or request id is an integer or it is nothing. private static func integer(_ value: Any?) -> Int? { guard let number = value as? NSNumber, !isBoolean(value) else { return nil } + let asDouble = number.doubleValue + guard asDouble.isFinite, asDouble == asDouble.rounded(.down), + asDouble >= Double(Int32.min), asDouble <= Double(Int32.max) + else { return nil } return number.intValue } diff --git a/src/modules/accessories/constants/accessoryCapabilities.ts b/src/modules/accessories/constants/accessoryCapabilities.ts index c27831ef..b7edf563 100644 --- a/src/modules/accessories/constants/accessoryCapabilities.ts +++ b/src/modules/accessories/constants/accessoryCapabilities.ts @@ -13,21 +13,32 @@ interface CapabilityPresentation { icon: Icon } -const PRESENTATION: Record = { - ground_clearance: { - title: 'Ground clearance', - description: 'Measures how far the board sits above the ground and can drive Remote Tilt.', - icon: ArrowsVerticalIcon, - }, - brake_light: { - title: 'Brake light', - description: 'Shows riding, braking and parked states from the Board’s own telemetry.', - icon: LightbulbFilamentIcon, - }, -} +/** + * A `Map`, not an object literal: the key is a wire string from an accessory, and an object lookup + * would happily answer `constructor` or `toString` with something inherited from `Object.prototype` + * — truthy, and missing every field this returns. + */ +const PRESENTATION = new Map([ + [ + 'ground_clearance', + { + title: 'Ground clearance', + description: 'Measures how far the board sits above the ground and can drive Remote Tilt.', + icon: ArrowsVerticalIcon, + }, + ], + [ + 'brake_light', + { + title: 'Brake light', + description: 'Shows riding, braking and parked states from the Board’s own telemetry.', + icon: LightbulbFilamentIcon, + }, + ], +]) export function capabilityPresentation(capability: AccessoryCapability): CapabilityPresentation { - const known = PRESENTATION[capability.type as AccessoryCapabilityType] + const known = PRESENTATION.get(capability.type as AccessoryCapabilityType) if (known) return known return { // An unrecognized type is named by its wire slug rather than hidden — an accessory advertising diff --git a/src/modules/accessories/screens/AccessoryScanScreen.tsx b/src/modules/accessories/screens/AccessoryScanScreen.tsx index 2b496dde..3a68b47d 100644 --- a/src/modules/accessories/screens/AccessoryScanScreen.tsx +++ b/src/modules/accessories/screens/AccessoryScanScreen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { ActivityIndicator, FlatList, StyleSheet, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { PlugsConnectedIcon } from 'phosphor-react-native' @@ -31,33 +31,53 @@ export function AccessoryScanScreen({ onOpenAccessory: (accessoryId: string) => void }) { const { status, request } = usePermissions() - const { devices, scanning, scanError, inspecting, startScan, stopScan, inspect } = - useAccessoryDiscoveryStore( - useShallow((s) => ({ - devices: s.devices, - scanning: s.scanning, - scanError: s.scanError, - inspecting: s.inspecting, - startScan: s.startScan, - stopScan: s.stopScan, - inspect: s.inspect, - })), - ) + const { + devices, + scanning, + scanError, + inspecting, + startScan, + stopScan, + inspect, + cancelInspection, + } = useAccessoryDiscoveryStore( + useShallow((s) => ({ + devices: s.devices, + scanning: s.scanning, + scanError: s.scanError, + inspecting: s.inspecting, + startScan: s.startScan, + stopScan: s.stopScan, + inspect: s.inspect, + cancelInspection: s.cancelInspection, + })), + ) const [result, setResult] = useState(null) + /** + * False once the rider has left. A handshake outlives this screen by up to its timeout, and a + * result landing after that must not drag them back out of wherever they went. + */ + const live = useRef(true) useEffect(() => { void request() }, [request]) useEffect(() => { + live.current = true if (status === 'granted') startScan() - return () => stopScan() - }, [status, startScan, stopScan]) + return () => { + live.current = false + stopScan() + cancelInspection() + } + }, [status, startScan, stopScan, cancelInspection]) const onSelect = useCallback( async (deviceId: string) => { setResult(null) const inspection = await inspect(deviceId) + if (!live.current) return setResult(inspection) // Straight through on success: the rider picked a device to configure, not to read about. if (inspection.manifest) onOpenAccessory(inspection.manifest.accessoryId) @@ -89,7 +109,11 @@ export function AccessoryScanScreen({ id={item.id} name={item.name ?? 'Unnamed accessory'} rssi={item.rssi} - onPress={() => void onSelect(item.id)} + // One handshake at a time: a second tap would be refused natively anyway, and the row + // going dead is a clearer answer than a tap that quietly does nothing. + onPress={() => { + if (!inspecting) void onSelect(item.id) + }} /> )} ListHeaderComponent={ diff --git a/src/modules/accessories/store/accessoryDiscoveryStore.test.ts b/src/modules/accessories/store/accessoryDiscoveryStore.test.ts index 8fcfeeda..32bc1a47 100644 --- a/src/modules/accessories/store/accessoryDiscoveryStore.test.ts +++ b/src/modules/accessories/store/accessoryDiscoveryStore.test.ts @@ -108,10 +108,35 @@ test('a failed re-check marks a known accessory unreachable instead of dropping const accessory = useAccessoryDiscoveryStore.getState().accessories[0]! expect(accessory.lastError).toBe('connect-failed') expect(accessoryLinkStatus(accessory, [])).toBe('unreachable') - // Hearing it again outranks the stale failure: the scan is the fresher fact. + // Still advertising and still refusing to answer is exactly the case the rider needs told: the + // failed handshake is the useful fact, not the radio carrier. expect( accessoryLinkStatus(accessory, [ { id: 'AA:01', name: null, rssi: -50, lastSeenAt: Date.now() }, ]), - ).toBe('advertising') + ).toBe('unreachable') +}) + +test('a second selection is refused while a handshake is running', async () => { + const { useAccessoryDiscoveryStore } = + await import('@/modules/accessories/store/accessoryDiscoveryStore') + + let release: (() => void) | null = null + inspectAccessory.mockImplementationOnce(async () => { + await new Promise((resolve) => { + release = resolve + }) + return { deviceId: 'AA:01', advertisedName: null, manifest: manifest(), error: null } + }) + + const first = useAccessoryDiscoveryStore.getState().inspect('AA:01') + const second = await useAccessoryDiscoveryStore.getState().inspect('BB:02') + + expect(second.error).toBe('busy') + // The refused tap must not take `inspecting` away from the handshake that is still running. + expect(useAccessoryDiscoveryStore.getState().inspecting).toBe('AA:01') + + release!() + await first + expect(useAccessoryDiscoveryStore.getState().inspecting).toBeNull() }) diff --git a/src/modules/accessories/store/accessoryDiscoveryStore.ts b/src/modules/accessories/store/accessoryDiscoveryStore.ts index fd30ca15..5823642e 100644 --- a/src/modules/accessories/store/accessoryDiscoveryStore.ts +++ b/src/modules/accessories/store/accessoryDiscoveryStore.ts @@ -107,30 +107,44 @@ export const useAccessoryDiscoveryStore = create< deviceSub = null errorSub?.remove() errorSub = null - if (get().scanning) nativeStopScan() - set({ scanning: false }) + // Unconditional: a scan that failed natively already cleared this store's `scanning` flag, and + // gating the stop on it left the native scan intent armed — on iOS that resumed a listener-less + // scan as soon as Bluetooth came back. + nativeStopScan() + // Sightings belong to the scan that gathered them. Keeping them would leave every accessory + // reading "Nearby" indefinitely, which is the one thing the row's status must not lie about. + set({ scanning: false, devices: [] }) }, inspect: async (deviceId) => { + // One handshake at a time, decided here as well as natively: a second tap would otherwise take + // back `inspecting` from the running one, hiding its spinner and letting its result arrive + // after the rider already moved on. + if (get().inspecting) { + return { deviceId, advertisedName: null, manifest: null, error: 'busy' } + } set({ inspecting: deviceId }) // Native stops the scan for the duration of a handshake; mirror that so the UI agrees. if (get().scanning) set({ scanning: false }) try { const result = await nativeInspect(deviceId) set((state) => ({ - inspecting: null, + inspecting: state.inspecting === deviceId ? null : state.inspecting, accessories: mergeInspection(state.accessories, deviceId, result), })) return result } catch (error) { - set({ inspecting: null }) + set((state) => ({ + inspecting: state.inspecting === deviceId ? null : state.inspecting, + })) throw error } }, cancelInspection: () => { nativeCancelInspection() - set({ inspecting: null }) + // `inspecting` is left to the in-flight `inspect` call to clear when native answers with + // `cancelled`, so the two writers cannot disagree about which handshake is running. }, })) @@ -164,11 +178,17 @@ function mergeInspection( return accessories.map((a) => (a.deviceId === deviceId ? { ...a, lastError: error } : a)) } -/** What the Accessory's row should say about its link, given whatever the scan is hearing. */ +/** + * What the Accessory's row should say about its link, given whatever the scan is hearing. + * + * A failed handshake outranks a sighting. An accessory can advertise perfectly and still refuse to + * answer — saying "Nearby" then would describe the radio rather than the thing the rider cares + * about, which is whether Vescape can talk to it. + */ export function accessoryLinkStatus( accessory: KnownAccessory, devices: DiscoveredAccessoryDevice[], ): AccessoryLinkStatus { - if (devices.some((d) => d.id === accessory.deviceId)) return 'advertising' - return accessory.lastError ? 'unreachable' : 'idle' + if (accessory.lastError) return 'unreachable' + return devices.some((d) => d.id === accessory.deviceId) ? 'advertising' : 'idle' } From b426b997457316d670e29fe9b970a573c437196a Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sun, 13 Sep 2026 09:18:16 +0200 Subject: [PATCH 05/26] Save and reconnect accessories #477 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/accessories.md | 30 +- docs/accessory-protocol.md | 15 +- docs/connectionState.md | 21 + docs/native-api.md | 38 ++ docs/persistence-operation-inventory.md | 1 + .../modules/vescapecore/VescapeCoreModule.kt | 26 + .../vescapecore/accessory/AccessoryLink.kt | 548 ++++++++++++++++++ .../vescapecore/accessory/AccessorySession.kt | 219 +++++++ .../accessory/AccessorySessionManager.kt | 376 ++++++++++++ .../connection/BoardSessionController.kt | 10 +- .../service/AutoConnectProvider.kt | 7 +- .../service/CoreForegroundService.kt | 37 ++ .../service/CoreForegroundServiceLauncher.kt | 31 + .../service/ForegroundServiceTypes.kt | 5 +- .../telemetry/AccessoryPersistence.kt | 35 ++ .../vescapecore/telemetry/TelemetryDao.kt | 25 + .../telemetry/TelemetryEntities.kt | 34 ++ .../telemetry/TelemetryMigrations.kt | 22 + .../telemetry/TelemetryRoomDatabase.kt | 4 +- .../accessory/AccessorySessionTest.kt | 148 +++++ .../telemetry/AccessoryMigrationTest.kt | 60 ++ .../telemetry/RideTrackMigrationTest.kt | 5 +- .../vescape-core/ios/VescapeCoreModule.swift | 31 +- .../ios/accessory/AccessoryLink.swift | 428 ++++++++++++++ .../ios/accessory/AccessoryProtocol.swift | 4 +- .../ios/accessory/AccessorySession.swift | 181 ++++++ .../AccessorySessionController.swift | 419 +++++++++++++ .../ios/accessory/AccessorySessionTests.swift | 135 +++++ .../connection/VescapeLaunchSubscriber.swift | 5 + .../ios/telemetry/AccessoryPersistence.swift | 141 +++++ .../ios/telemetry/DatabaseBackupManager.swift | 2 +- .../ios/telemetry/PersistenceSchema.swift | 7 + .../ios/telemetry/TelemetryDatabase.swift | 5 + .../persistence-jvm/build.gradle.kts | 1 + .../telemetry/AccessoryPersistenceHostTest.kt | 114 ++++ .../telemetry/DatabaseRestoreHostTest.kt | 9 +- .../TelemetryMigrationMatrixHostTest.kt | 4 +- .../AccessoryPersistence.swift | 1 + .../vescape-core/persistence-macos/main.swift | 105 ++++ .../accessory-persistence-contract.json | 28 + .../shared/migration-fixture-manifest.json | 9 +- modules/vescape-core/src/index.ts | 104 ++++ .../fixtures/accessory-protocol/session.json | 254 ++++++++ src/app/_layout.tsx | 3 + src/app/accessories/[accessoryId].tsx | 11 +- .../accessories/components/AccessoryRow.tsx | 33 +- .../components/AccessorySelectorSection.tsx | 14 +- .../accessories/lib/accessoryStatus.ts | 85 ++- .../screens/AccessoryDetailScreen.tsx | 114 +++- .../screens/AccessoryScanScreen.tsx | 62 +- .../store/accessoryDiscoveryStore.test.ts | 51 +- .../store/accessoryDiscoveryStore.ts | 84 +-- .../accessories/store/accessoryStore.ts | 90 +++ .../overlays/BoardSelectorAccessories.tsx | 29 +- .../AccessorySelectorSectionShowcase.tsx | 35 +- .../board/BoardSelectorSheetShowcase.tsx | 8 +- 56 files changed, 4028 insertions(+), 275 deletions(-) create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryLink.kt create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySession.kt create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AccessoryPersistence.kt create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessorySessionTest.kt create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/AccessoryMigrationTest.kt create mode 100644 modules/vescape-core/ios/accessory/AccessoryLink.swift create mode 100644 modules/vescape-core/ios/accessory/AccessorySession.swift create mode 100644 modules/vescape-core/ios/accessory/AccessorySessionController.swift create mode 100644 modules/vescape-core/ios/accessory/AccessorySessionTests.swift create mode 100644 modules/vescape-core/ios/telemetry/AccessoryPersistence.swift create mode 100644 modules/vescape-core/persistence-jvm/src/test/kotlin/expo/modules/vescapecore/telemetry/AccessoryPersistenceHostTest.kt create mode 120000 modules/vescape-core/persistence-macos/AccessoryPersistence.swift create mode 100644 modules/vescape-core/shared/accessory-persistence-contract.json create mode 100644 shared/fixtures/accessory-protocol/session.json create mode 100644 src/modules/accessories/store/accessoryStore.ts diff --git a/docs/accessories.md b/docs/accessories.md index fe516e7a..2a8cb121 100644 --- a/docs/accessories.md +++ b/docs/accessories.md @@ -2,17 +2,25 @@ Design in progress. Most of this is agreed requirements, not implemented behavior. -**Implemented so far**: discovery. The Board selector has separate Boards and Accessories sections -with an Add accessory action; scanning matches the Vescape Accessory service UUID rather than a -name; connecting reads the manifest and reports identity, firmware version, protocol compatibility -and capability types. Native owns the radio, the framing, the protocol session and the compatibility -verdict — `startAccessoryScan` / `inspectAccessory` on both platforms — and JS renders it. Discovery -disconnects as soon as the manifest is read, so nothing on an accessory can be activated by finding -it. - -**Not implemented**: enrollment and saved identities, auto-connect, calibration, measurements, tilt -bindings, and brake-light behavior. Accessories listed in the selector are what the current session -discovered, not saved units. +**Implemented so far**: discovery, enrollment, and the reconnecting session. + +Scanning matches the Vescape Accessory service UUID rather than a name; connecting reads the +manifest and reports identity, firmware version, protocol compatibility and capability types. +Adding an accessory saves that identity durably, and from then on native connects to it on its own +at process launch — with the app backgrounded, the screen locked, or the JS runtime never started. +The Board selector's Accessories section lists saved accessories with the link phase native is +actually in, and each row opens that accessory's configuration. + +Every reconnect is a fresh protocol session: a new session ID, request IDs from the start, and the +current desired state re-sent from scratch. Commands are acknowledged and leased — the app renews +while it is alive and willing, and the accessory falls back to its own behavior when the renewals +stop. Identity is always the manifest's accessory ID, so a renamed or re-flashed unit on a new BLE +handle stays one accessory, and a different unit answering on a remembered handle is refused. + +**Not implemented**: calibration, measurements, tilt bindings, and brake-light behavior. An enrolled +accessory's session holds each capability at the protocol's neutral state — a clearance sensor in +measurement standby, a light told Board telemetry is unavailable — which is what the slices below +replace with the rider's actual demand. ## Initial scope diff --git a/docs/accessory-protocol.md b/docs/accessory-protocol.md index 23c2ebd5..ea5cae4a 100644 --- a/docs/accessory-protocol.md +++ b/docs/accessory-protocol.md @@ -3,15 +3,20 @@ Status: implementation draft for the PoC. Product behavior is in [accessories.md](./accessories.md). Timing, rate, and size limits below are proposed PoC defaults, not measured reliability guarantees. **Implemented so far**: BLE transport, NDJSON framing with its bounds, the `hello`/`manifest` -handshake, version negotiation, and capability recognition. Everything operational — `configure`, -`state`, `reading`, leases, acknowledgements — is still a draft; the firmware answers those with -`unsupported_message` today. +handshake, version negotiation, capability recognition, and the operational command channel — +`configure`, `state`, acknowledgements, request-id discipline and leases. `reading` is still a +draft; nothing streams samples yet. -The implemented half has an executable form: `shared/fixtures/accessory-protocol/` holds the framing -and handshake corpus that Android Kotlin, iOS Swift and the ESP32 firmware all run +The implemented half has an executable form: `shared/fixtures/accessory-protocol/` holds the +framing, handshake and session corpus that Android Kotlin, iOS Swift and the ESP32 firmware all run (`bun run test:android`, `bun run test:ios`, and `pio test -e native` in `vescape-hardware`). Change the fixtures first; three implementations of one wire format drift silently otherwise. +`session.json` pins three things the prose below only describes: the exact bytes of every command +the app writes, what each accessory line must mean to a live session, and — through its `peer` +sequences — the replies an accessory must produce for a stale id, a reused id, and the app's one +permitted retry. + Two rules below are app-side decisions the fixtures pin down, rather than wire format: - A recognized capability type is not automatically a usable one. A `ground_clearance` must declare diff --git a/docs/connectionState.md b/docs/connectionState.md index 1a44f6f0..df34ed3f 100644 --- a/docs/connectionState.md +++ b/docs/connectionState.md @@ -142,6 +142,27 @@ on its own, with or without a JS runtime. Native owns the connection throughout. On Android the foreground service keeps BLE work alive while JS is backgrounded or frozen. +### Accessories + +Enrolled Accessories ride the same two launch triggers and are otherwise independent of the Board: +they come up with no Board selected, with the `autoConnect` setting off, and after a manual Board +stop, because the rider enrolled the Accessory rather than the Board it happens to ride with. + +- Android: `AutoConnectProvider` → `CoreForegroundService.autoConnectAccessories` → + `AccessorySessionManager`. The service is started only when something is actually enrolled, and + once started, live Accessory sessions keep it alive the way a Board Session or GPS does — a rider + with a light and no Board still has a link that must stay up. +- iOS: `VescapeLaunchSubscriber` → `AccessorySessionController.prepareForLaunch`, after the Board's + prepare. Its central carries **its own restore identifier**, so CoreBluetooth can relaunch the app + for an Accessory link; like the Board's, it only works when the central is re-created inside + `didFinishLaunchingWithOptions`. + +An Accessory link never optimistically reports connected. Each reconnect reads the manifest again +and checks it against the enrolled identity before any saved setting is used; a different unit +answering on a remembered handle is refused rather than driven. A drop reports `connecting`, not an +error — Android's `autoConnect` GATT and CoreBluetooth's open-ended `connect` both keep trying — and +`AccessorySessionManager` / `AccessorySessionController` push every change as `onAccessoryState`. + ### Fast Connect Stability The fastest stable path is not to wait longer; it is to avoid competing native diff --git a/docs/native-api.md b/docs/native-api.md index 33cb7e03..6fe1e21b 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -55,6 +55,43 @@ activated by finding it. Contract: [accessory-protocol.md](./accessory-protocol. `compatibility` and each capability's `supported` are native's verdict, not JS's to re-derive. +## Enrolled Accessories + +Durable. Only an Accessory the rider added gets a session, and native keeps that session running +with the JS runtime dead — Android from `CoreForegroundService`, iOS from a restore-identified +central created in `didFinishLaunchingWithOptions`. JS sends intents and renders `onAccessoryState`. + +`enrollAccessory` takes a **device handle**, never an identity: native performs its own handshake +and saves what the hardware actually said, so an enrollment cannot record a manifest JS invented. + +| fn | sync | returns | +| ------------------------------ | ----- | ---------------------------------------------------------------- | +| `enrollAccessory(deviceId)` | async | `AccessoryEnrollment` — `{accessoryId, error}` | +| `forgetAccessory(accessoryId)` | async | `boolean` — whether a saved Accessory was removed | +| `getAccessories()` | sync | `SavedAccessory[]` — the same snapshot `onAccessoryState` pushes | + +### SavedAccessory shape + +```ts +{ + accessoryId: string // manifest identity; the row's primary key + name: string // live manifest name while connected, else the saved one + firmwareVersion: string + protocolVersion: number | null + deviceId: string | null // where it answered last; a reconnect hint, never identity + enrolledAt: number + lastConnectedAt: number | null + phase: 'idle' | 'connecting' | 'handshaking' | 'connected' | 'unavailable' | 'incompatible' + error: string | null // native's wire string for the last failure + compatibility: AccessoryCompatibility | null // null until a session reads a manifest + capabilities: AccessoryCapability[] + capabilitiesChanged: boolean // declared limits moved since enrollment; saved settings suspect + leaseHeldMs: number | null // since the accessory last acknowledged a command +} +``` + +A drop is `connecting`, not an error: both platforms keep the reconnect alive on their own. + ## Location | fn | sync | returns | @@ -391,6 +428,7 @@ Rejection codes are rider-facing; `src/modules/settings/lib/companionErrors.ts` | `onLocation` | `LocationEvent` | GPS fix from `startLocationUpdates()` | | `onAccessoryDevice` | `{id, name, rssi}` | Vescape Accessory service advertisement | | `onAccessoryScanError` | `{error}` | The accessory scan could not run (`bluetooth-unavailable`, `scan-failed`) | +| `onAccessoryState` | `{accessories}` | Every enrolled Accessory and its native link phase, on every change and on subscribe | ### TelemetryEvent shape (live, not history) diff --git a/docs/persistence-operation-inventory.md b/docs/persistence-operation-inventory.md index f6abcfd3..9c0c011b 100644 --- a/docs/persistence-operation-inventory.md +++ b/docs/persistence-operation-inventory.md @@ -27,6 +27,7 @@ private leaf statements because the transaction, ordering, and rollback are the | Board Warnings | one/Board/all reads; typed upsert; delete one/all | `remaining-stores-close-reopen-rollback` covers typed upsert/read/reopen/query failure; delete one/all is exercised through the production registry suites and Board tombstone contract | | VESC Fault Occurrences | Board/all/open/one reads; insert-or-advance; dismiss | `remaining-stores-close-reopen-rollback` covers progression, dismissal preservation, open/all reads, reopen, and query failure; coordinator suites cover lifecycle decisions | | VESC Fault Captures | typed capture upsert/read; append/read ordered samples | `remaining-stores-close-reopen-rollback` covers metadata, ordered append/read, reopen, and late append rollback | +| Enrolled Accessories | list/get; enroll and re-validate upsert; touch last connection; forget | `accessory-enrollment-close-reopen` covers close/reopen, a rename plus firmware change plus new BLE handle landing on one row with its original enrollment time, touch on a missing row, and forget leaving other enrollments alone | | `map_points`, `map_point_reactions` | none | Legacy migration tables only. Map Points are server-owned and production native code performs no SQLite operation. Kept until #468 tests supported migration/backup restoration. | | Device credentials | Keychain/EncryptedSharedPreferences read/write/delete | Outside SQLite host contract: platform security-store tests own it; no table exists in the native database. | | Session resume and navigation runtime snapshot | UserDefaults/shared-preference read/write/delete | Outside SQLite host contract: platform unit tests own these OS preference adapters; no table exists in the native database. | diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt index 5c28a570..b768f698 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt @@ -6,6 +6,7 @@ import expo.modules.vescapecore.diagnostics.UnexpectedNativeError import expo.modules.vescapecore.telemetry.FavoriteMediaCleanupException import expo.modules.vescapecore.accessory.AccessoryDiscovery +import expo.modules.vescapecore.accessory.AccessorySessionManager import expo.modules.vescapecore.alerts.AlertFeedback import expo.modules.vescapecore.alerts.normalizedAlertBeepCount import expo.modules.vescapecore.alerts.normalizedAlertRepeatSeconds @@ -196,6 +197,7 @@ class VescapeCoreModule : Module() { "onWeather", "onAccessoryDevice", "onAccessoryScanError", + "onAccessoryState", ) // Accessory discovery pushes devices as the radio finds them; the module is only the pipe. @@ -204,6 +206,13 @@ class VescapeCoreModule : Module() { mainHandler.post { if (shouldEmitToFrontend(name)) sendEvent(name, body) } } + // Enrolled Accessory sessions are native-owned and outlive this module; the bridge only mirrors + // their state while a JS runtime happens to exist. + // @parity /modules/vescape-core/ios/VescapeCoreModule.swift `AccessorySessionController` + AccessorySessionManager.emit = { name, body -> + mainHandler.post { if (shouldEmitToFrontend(name)) sendEvent(name, body) } + } + // Native owns App Status truth; JS mirrors it. Push every successful refresh (late subscribers // pull the current snapshot below and through `getAppStatus`). // @parity /modules/vescape-core/ios/VescapeCoreModule.swift `sendAppStatus` @@ -382,6 +391,8 @@ class VescapeCoreModule : Module() { OnStopObserving("onAccessoryDevice") { stopObserving("onAccessoryDevice") } OnStartObserving("onAccessoryScanError") { startObserving("onAccessoryScanError") } OnStopObserving("onAccessoryScanError") { stopObserving("onAccessoryScanError") } + OnStartObserving("onAccessoryState") { startObserving("onAccessoryState") } + OnStopObserving("onAccessoryState") { stopObserving("onAccessoryState") } OnCreate { val storageOutageEvents = StorageOutageEventBridge( @@ -428,6 +439,9 @@ class VescapeCoreModule : Module() { AccessoryDiscovery.emit = null AccessoryDiscovery.stopScan() AccessoryDiscovery.cancelInspection() + // Only the mirror is dropped. The sessions belong to the foreground service, and JS going + // away is not a reason for an enrolled Accessory to stop working. + AccessorySessionManager.emit = null if (CoreForegroundService.emitEvent != null) { CoreForegroundService.emitEvent = null } @@ -446,6 +460,18 @@ class VescapeCoreModule : Module() { AsyncFunction("inspectAccessory") { deviceId: String, promise: Promise -> AccessoryDiscovery.inspect(context.applicationContext, deviceId) { promise.resolve(it) } } + + // Enrollment and the saved sessions. JS sends the intent and renders the snapshot; identity, + // the manifest and the session all stay native. + // @parity /modules/vescape-core/ios/VescapeCoreModule.swift `enrollAccessory` + // @parity /modules/vescape-core/src/index.ts `enrollAccessory` + AsyncFunction("enrollAccessory") { deviceId: String, promise: Promise -> + AccessorySessionManager.enroll(context.applicationContext, deviceId) { promise.resolve(it) } + } + AsyncFunction("forgetAccessory") { accessoryId: String, promise: Promise -> + AccessorySessionManager.forget(context.applicationContext, accessoryId) { promise.resolve(it) } + } + Function("getAccessories") { AccessorySessionManager.snapshot() } Function("exitApp") { CoreForegroundService.exitApp(context.applicationContext) } Function("startLocationUpdates") { startLocationUpdates() } Function("stopLocationUpdates") { stopLocationUpdates() } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryLink.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryLink.kt new file mode 100644 index 00000000..3a527cb8 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryLink.kt @@ -0,0 +1,548 @@ +package expo.modules.vescapecore.accessory + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.SystemClock +import android.util.Log +import java.util.UUID + +private const val TAG = "VescapeAccessory" +private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") +private const val REQUESTED_MTU = 517 +private const val ATT_WRITE_OVERHEAD = 3 +private const val DEFAULT_MTU = 23 + +/** + * How long to wait for GATT to come up before giving the OS-managed reconnect another go. Generous + * on purpose: an Accessory that is simply out of range is the normal case, not a failure. + */ +private const val CONNECT_TIMEOUT_MS = 20_000L + +/** Backoff between deliberate reconnect attempts after the link failed rather than merely dropped. */ +private const val RETRY_DELAY_MS = 5_000L + +/** + * Where one enrolled Accessory's link stands. Native decides this; JS renders it and never derives + * one from a boolean, exactly as it does for a Board. + * + * @parity /modules/vescape-core/ios/accessory/AccessoryLink.swift `AccessoryLinkPhase` + * @parity /modules/vescape-core/src/index.ts `AccessoryLinkPhase` + */ +enum class AccessoryLinkPhase(val wire: String) { + /** No link is held and none is being attempted. */ + IDLE("idle"), + + /** The radio is trying, including while the OS holds a background reconnect open. */ + CONNECTING("connecting"), + + /** GATT is up; the manifest has not been validated yet. */ + HANDSHAKING("handshaking"), + + /** Manifest validated and the session's commands are being acknowledged. */ + CONNECTED("connected"), + + /** It answered, but the session could not be kept: refused or unacknowledged commands. */ + UNAVAILABLE("unavailable"), + + /** Its manifest says this app cannot drive it. Nothing is commanded; the row explains why. */ + INCOMPATIBLE("incompatible"), +} + +/** + * A live protocol session with one enrolled Accessory. + * + * Long-lived, unlike [AccessoryGattHandshake]: this is the link an Accessory keeps while the rider + * is riding, the screen is off and the JS runtime is gone. Android's own `autoConnect` reconnect is + * what carries it across a walk out of range, so being dropped is not an error and does not reset + * anything durable. + * + * Every connection is a **fresh protocol session**. A new session id goes out with the hello, the + * request counter restarts, and the desired commands are re-sent from scratch — so a command queued + * against the previous session can never reach this one, and an ack belonging to it is ignored + * rather than matched against the wrong request. + * + * The clock is [SystemClock.elapsedRealtime]: leases and request timeouts are durations, and wall + * clock moves under them (NTP, time zones, the rider changing the date). A lease measured on the + * wrong clock is a light that goes dark at midnight. + * + * @parity /modules/vescape-core/ios/accessory/AccessoryLink.swift + */ +@SuppressLint("MissingPermission") +internal class AccessoryLink( + private val context: Context, + private val handler: Handler, + /** Manifest identity this link is for. A manifest naming anything else is refused. */ + private val accessoryId: String, + private val onChanged: () -> Unit, + private val onManifest: (AccessoryManifest, deviceId: String) -> Unit, +) { + var phase: AccessoryLinkPhase = AccessoryLinkPhase.IDLE + private set + + /** Wire string for the last failure, or null while nothing is wrong. */ + var lastError: String? = null + private set + + /** Manifest read on the current connection. Null whenever no session is established. */ + var manifest: AccessoryManifest? = null + private set + + /** Monotonic timestamp of the last ack, for the lease the accessory is holding. */ + var lastAckAtMs: Long? = null + private set + + private var deviceId: String? = null + private var gatt: BluetoothGatt? = null + private var writeChar: BluetoothGattCharacteristic? = null + private val framer = AccessoryNdjsonFramer() + private var mtu = DEFAULT_MTU + private val pendingChunks = ArrayDeque() + private var writeInFlight = false + private var started = false + + private var sessionId: String? = null + private var nextRequestId = AccessorySession.FIRST_COMMAND_REQUEST_ID + + /** Desired state per capability. Coalesced: only the latest matters, because commands are absolute. */ + private val desired = LinkedHashMap() + + /** The one request allowed to be outstanding, with the retry budget it has left. */ + private var outstanding: Outstanding? = null + + private var connectTimeout: Runnable? = null + private var requestTimeout: Runnable? = null + private var renewTick: Runnable? = null + private var retry: Runnable? = null + + private data class Outstanding( + val requestId: Int, + val command: AccessoryCommand, + val line: String, + /** False until the one permitted retry has gone out with the same id. */ + val retried: Boolean, + ) + + /** Starts, or re-points at a newly discovered handle. Idempotent. */ + fun start(deviceId: String?) { + if (deviceId != null && deviceId != this.deviceId) { + this.deviceId = deviceId + if (started) { + // A different handle is a different peripheral object; the old connection cannot be + // re-pointed at it. + teardown() + } + } + if (started) return + started = true + connect() + } + + fun stop() { + started = false + teardown() + setPhase(AccessoryLinkPhase.IDLE, error = null) + } + + /** + * Sets the desired state for one capability. + * + * Absolute, never incremental: the accessory is told what to be, so the same call repeated is + * the renewal and a dropped one costs nothing but latency. An unchanged command is not re-queued + * — the renewal tick already re-sends it, and re-queueing would burn a request id per call. + */ + fun setDesired(command: AccessoryCommand) { + if (desired[command.capabilityId] == command) return + desired[command.capabilityId] = command + // A changed state goes out immediately rather than waiting for the next renewal tick. + if (phase == AccessoryLinkPhase.CONNECTED) pump() + } + + fun clearDesired(capabilityId: String) { + desired.remove(capabilityId) + } + + // MARK: - Connection + + private fun connect() { + val address = deviceId + if (address == null) { + setPhase(AccessoryLinkPhase.IDLE, error = "unknown-device") + return + } + val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter + if (adapter == null || !adapter.isEnabled) { + setPhase(AccessoryLinkPhase.CONNECTING, error = "bluetooth-unavailable") + scheduleRetry() + return + } + val device = try { + adapter.getRemoteDevice(address) + } catch (e: IllegalArgumentException) { + setPhase(AccessoryLinkPhase.IDLE, error = "unknown-device") + return + } + setPhase(AccessoryLinkPhase.CONNECTING, error = null) + armConnectTimeout() + // `autoConnect = true`: the OS keeps the attempt alive across the Accessory going out of + // range and back, without the app holding a scan or a wakelock. This is the whole reason a + // session survives a dead JS runtime. + gatt = device.connectGatt(context, true, callback, BluetoothDevice.TRANSPORT_LE) + } + + private fun teardown() { + cancel(connectTimeout); connectTimeout = null + cancel(requestTimeout); requestTimeout = null + cancel(renewTick); renewTick = null + cancel(retry); retry = null + framer.reset() + pendingChunks.clear() + writeInFlight = false + writeChar = null + sessionId = null + outstanding = null + manifest = null + lastAckAtMs = null + val target = gatt + gatt = null + try { + target?.disconnect() + target?.close() + } catch (e: Exception) { + Log.w(TAG, "link cleanup failed: ${e.message}") + } + } + + /** A failed link is rebuilt from scratch rather than resumed: a broken session has no state worth keeping. */ + private fun fail(error: String, phase: AccessoryLinkPhase = AccessoryLinkPhase.UNAVAILABLE) { + teardown() + setPhase(phase, error) + if (started) scheduleRetry() + } + + private fun scheduleRetry() { + if (!started) return + cancel(retry) + val runnable = Runnable { if (started && gatt == null) connect() } + retry = runnable + handler.postDelayed(runnable, RETRY_DELAY_MS) + } + + private fun armConnectTimeout() { + cancel(connectTimeout) + val runnable = Runnable { fail("timeout", AccessoryLinkPhase.CONNECTING) } + connectTimeout = runnable + handler.postDelayed(runnable, CONNECT_TIMEOUT_MS) + } + + private fun cancel(runnable: Runnable?) { + runnable?.let { handler.removeCallbacks(it) } + } + + private fun setPhase(next: AccessoryLinkPhase, error: String?) { + if (phase == next && lastError == error) return + phase = next + lastError = error + onChanged() + } + + private val callback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { + handler.post { + if (g !== gatt) { + try { g.close() } catch (e: Exception) { Log.w(TAG, "stale close: ${e.message}") } + return@post + } + if (newState == BluetoothProfile.STATE_CONNECTED) { + cancel(connectTimeout); connectTimeout = null + setPhase(AccessoryLinkPhase.HANDSHAKING, error = null) + g.requestMtu(REQUESTED_MTU) + return@post + } + // A drop is not a failure: `autoConnect` keeps trying on its own, so the session is + // discarded but the link stays armed and the row says "connecting". + framer.reset() + pendingChunks.clear() + writeInFlight = false + sessionId = null + outstanding = null + manifest = null + lastAckAtMs = null + cancel(requestTimeout); requestTimeout = null + cancel(renewTick); renewTick = null + if (started) { + armConnectTimeout() + setPhase(AccessoryLinkPhase.CONNECTING, error = null) + } else { + setPhase(AccessoryLinkPhase.IDLE, error = null) + } + } + } + + override fun onMtuChanged(g: BluetoothGatt, negotiated: Int, status: Int) { + handler.post { + if (g !== gatt) return@post + if (negotiated > 0) mtu = negotiated + g.discoverServices() + } + } + + override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { + handler.post { + if (g !== gatt) return@post + val service = g.getService(AccessoryProtocol.SERVICE_UUID) + ?: return@post fail("service-missing") + val notify = service.getCharacteristic(AccessoryProtocol.NOTIFY_UUID) + val write = service.getCharacteristic(AccessoryProtocol.WRITE_UUID) + if (notify == null || write == null) return@post fail("service-missing") + writeChar = write + g.setCharacteristicNotification(notify, true) + val cccd = notify.getDescriptor(CCCD_UUID) ?: return@post fail("service-missing") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + g.writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + } else { + @Suppress("DEPRECATION") + run { + cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + g.writeDescriptor(cccd) + } + } + } + } + + override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { + handler.post { + if (g !== gatt) return@post + sendHello() + } + } + + override fun onCharacteristicWrite( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int, + ) { + handler.post { + if (g !== gatt) return@post + if (status != BluetoothGatt.GATT_SUCCESS) return@post fail("write-failed") + writeInFlight = false + drain() + } + } + + @Suppress("DEPRECATION") + override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { + deliver(g, characteristic.uuid, characteristic.value ?: return) + } + + override fun onCharacteristicChanged( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + ) { + deliver(g, characteristic.uuid, value) + } + } + + // MARK: - Protocol session + + private fun sendHello() { + val fresh = UUID.randomUUID().toString() + sessionId = fresh + // A new session starts its request numbering over, which is exactly what makes an old + // queue harmless: nothing from the previous session shares a (session, request) pair. + nextRequestId = AccessorySession.FIRST_COMMAND_REQUEST_ID + outstanding = null + pendingChunks.clear() + write(AccessoryProtocol.encodeHello(fresh)) + cancel(requestTimeout) + val runnable = Runnable { fail("timeout") } + requestTimeout = runnable + handler.postDelayed(runnable, AccessoryProtocol.HANDSHAKE_TIMEOUT_MS) + } + + private fun deliver(g: BluetoothGatt, uuid: UUID, value: ByteArray) { + if (uuid != AccessoryProtocol.NOTIFY_UUID) return + handler.post { + if (g !== gatt) return@post + val session = sessionId ?: return@post + val result = framer.feed(value) + for (line in result.lines) { + if (manifest == null) { + handleHandshakeLine(line, session) + } else { + handleSessionLine(line, session) + } + if (gatt !== g) return@post + } + result.failure?.let { fail(it.wire) } + } + } + + private fun handleHandshakeLine(line: String, session: String) { + when (val parsed = AccessoryProtocol.parseManifest(line, session)) { + is ManifestResult.Ok -> onManifestRead(parsed.manifest) + is ManifestResult.Failed -> + // Another session's message is noise on a shared characteristic, not a violation. + if (parsed.error != AccessoryHandshakeError.SESSION_MISMATCH) { + fail(parsed.error.wire) + } + } + } + + private fun onManifestRead(read: AccessoryManifest) { + cancel(requestTimeout); requestTimeout = null + // Identity is checked before anything saved is trusted. A different accessory answering on + // a remembered handle is a stale handle, never a reason to drive someone else's hardware. + if (read.accessoryId != accessoryId) { + fail("identity-mismatch", AccessoryLinkPhase.UNAVAILABLE) + return + } + manifest = read + val device = deviceId + if (device != null) onManifest(read, device) + if (read.compatibility != AccessoryCompatibility.SUPPORTED) { + // Read, recognised, and deliberately left alone: an accessory this app cannot drive + // stays connected only long enough to say so. + setPhase(AccessoryLinkPhase.INCOMPATIBLE, error = read.compatibility.wire) + return + } + setPhase(AccessoryLinkPhase.CONNECTED, error = null) + armRenewal() + pump() + } + + private fun handleSessionLine(line: String, session: String) { + when (val response = AccessoryResponse.parse(line, session)) { + is AccessoryResponse.Ack -> { + val pending = outstanding ?: return + if (response.requestId != pending.requestId) return + cancel(requestTimeout); requestTimeout = null + outstanding = null + lastAckAtMs = SystemClock.elapsedRealtime() + setPhase(AccessoryLinkPhase.CONNECTED, error = null) + pump() + } + + is AccessoryResponse.Failed -> { + val pending = outstanding + if (pending != null && response.requestId != null && response.requestId != pending.requestId) return + cancel(requestTimeout); requestTimeout = null + outstanding = null + // The refusal is the accessory's answer, not a broken link: stay connected and say + // what it refused, rather than dropping a session that is otherwise healthy. + setPhase(AccessoryLinkPhase.UNAVAILABLE, error = response.code) + } + + AccessoryResponse.Malformed -> fail("malformed") + AccessoryResponse.Ignored -> Unit + } + } + + // MARK: - Request pump + + /** Sends the next desired command that is not already the one outstanding. */ + private fun pump() { + if (outstanding != null) return + val session = sessionId ?: return + val supported = manifest?.capabilities?.filter { it.supported }?.map { it.id }?.toSet() ?: return + val next = desired.entries.firstOrNull { it.key in supported }?.value ?: return + // Round-robin: the capability just sent goes to the back, so one capability cannot starve + // another's renewal. + desired.remove(next.capabilityId) + desired[next.capabilityId] = next + val requestId = nextRequestId++ + val line = next.encode(session, requestId) + outstanding = Outstanding(requestId, next, line, retried = false) + write(line) + armRequestTimeout() + } + + private fun armRequestTimeout() { + cancel(requestTimeout) + val runnable = Runnable { onRequestTimedOut() } + requestTimeout = runnable + handler.postDelayed(runnable, AccessorySession.REQUEST_TIMEOUT_MS) + } + + /** + * One retry with the *same* request id, then the accessory is unavailable. + * + * Reusing the id is the point: the accessory recognises a duplicate and replays its previous + * answer instead of applying the command twice, so a retry cannot restart an animation or + * extend a lease twice. + */ + private fun onRequestTimedOut() { + val pending = outstanding ?: return + if (!pending.retried) { + outstanding = pending.copy(retried = true) + write(pending.line) + armRequestTimeout() + return + } + fail("timeout") + } + + /** + * Re-sends the current desired state often enough that the accessory's lease never lapses while + * the app is alive and willing. Nothing here is incremental: a renewal is the same absolute + * command, so a missed tick costs latency and not correctness. + */ + private fun armRenewal() { + cancel(renewTick) + val runnable = object : Runnable { + override fun run() { + if (phase == AccessoryLinkPhase.CONNECTED) pump() + handler.postDelayed(this, AccessorySession.RENEW_INTERVAL_MS) + } + } + renewTick = runnable + handler.postDelayed(runnable, AccessorySession.RENEW_INTERVAL_MS) + } + + // MARK: - Writing + + private fun write(line: String) { + val payload = (line + "\n").toByteArray(Charsets.UTF_8) + val limit = (mtu - ATT_WRITE_OVERHEAD).coerceAtLeast(20) + var offset = 0 + while (offset < payload.size) { + val end = minOf(offset + limit, payload.size) + pendingChunks.addLast(payload.copyOfRange(offset, end)) + offset = end + } + drain() + } + + /** One outstanding GATT write at a time; the chunks of one line stay in order. */ + private fun drain() { + if (writeInFlight) return + val target = gatt ?: return + val characteristic = writeChar ?: return + val chunk = pendingChunks.removeFirstOrNull() ?: return + writeInFlight = true + val queued = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + target.writeCharacteristic( + characteristic, + chunk, + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT, + ) == BluetoothGatt.GATT_SUCCESS + } else { + @Suppress("DEPRECATION") + run { + characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + characteristic.value = chunk + target.writeCharacteristic(characteristic) + } + } + if (!queued) fail("write-failed") + } +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySession.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySession.kt new file mode 100644 index 00000000..cce111e2 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySession.kt @@ -0,0 +1,219 @@ +package expo.modules.vescapecore.accessory + +import org.json.JSONObject +import org.json.JSONTokener + +/** + * Vescape Accessory Protocol v1 — the operational half: the commands an enrolled Accessory's + * session sends, and the acknowledgements it accepts back. + * + * Pure and transport-free on purpose. [AccessoryLink] owns the radio and the clock; everything + * here is bytes in, bytes out, so the request-id discipline and the encodings can be asserted + * against `shared/fixtures/accessory-protocol/session.json` without a peripheral in the room. + * + * Two rules this file exists to keep: + * + * - **Commands set desired values.** Nothing toggles or cycles, so resending the same command is + * always safe and a dropped ack costs a retry rather than a restarted animation. + * - **Request ids are strictly increasing within a session, and never reused with a different + * body.** A new protocol session restarts them, which is what makes an old queue harmless. + * + * @parity /modules/vescape-core/ios/accessory/AccessorySession.swift + * @parity /modules/vescape-core/src/index.ts `AccessoryCapabilitySettings` + */ +object AccessorySession { + /** How long an accessory holds a command before falling back to its local behavior. */ + const val LEASE_MS = 2_000L + + /** How often the app re-sends the current desired command to hold the lease open. */ + const val RENEW_INTERVAL_MS = 500L + + /** + * How long one request waits for its ack. The first timeout retries with the *same* id — a + * retry must not look like a new command — and the second gives up on the accessory. + */ + const val REQUEST_TIMEOUT_MS = 500L + + /** The handshake owns request id 1, so operational requests start after it. */ + const val FIRST_COMMAND_REQUEST_ID = AccessoryProtocol.HELLO_REQUEST_ID + 1 + + /** + * Nearest supported rate, lower on a tie. + * + * The accessory resolves this too and answers with what it actually applied; the app resolves + * it first only so the request it sends is one the hardware can accept. An empty rate list + * means the capability declared none, and a capability with no rate is not configurable. + */ + fun resolveRateHz(requested: Double, ratesHz: List): Double? { + val usable = ratesHz.filter { it.isFinite() && it > 0.0 } + if (usable.isEmpty()) return null + // `<` and not `<=`: equal distance keeps the earlier-sorted, i.e. lower, rate. + return usable.sorted().reduce { best, candidate -> + if (Math.abs(candidate - requested) < Math.abs(best - requested)) candidate else best + } + } +} + +/** + * One desired capability state. Complete by construction: every field the accessory needs is + * carried on every send, so a renewal is a replay and never a partial update. + * + * @parity /modules/vescape-core/ios/accessory/AccessorySession.swift `AccessoryCommand` + * @parity /modules/vescape-core/src/index.ts `AccessoryCommandSnapshot` + */ +sealed class AccessoryCommand { + abstract val capabilityId: String + + /** Measurement demand for a `ground_clearance` capability. */ + data class Configure( + override val capabilityId: String, + val enabled: Boolean, + val rateHz: Double, + ) : AccessoryCommand() + + /** Semantic output state for a `brake_light` capability. */ + data class State( + override val capabilityId: String, + /** `available` or `unavailable` — whether Board telemetry is reaching the app at all. */ + val telemetry: String, + /** Null when telemetry is unavailable outside preview; the accessory then owns the look. */ + val mode: String?, + val parked: String, + val preview: Boolean = false, + ) : AccessoryCommand() + + /** + * The exact line to write, at [requestId], inside [sessionId]. + * + * Built by hand rather than through [JSONObject] for the same reason `encodeHello` is: the + * shared fixture compares bytes, and a map-backed encoder does not promise key order. + */ + fun encode(sessionId: String, requestId: Int): String = when (this) { + is Configure -> + "{\"type\":\"configure\",\"sessionId\":${quote(sessionId)},\"requestId\":$requestId," + + "\"capabilityId\":${quote(capabilityId)},\"enabled\":$enabled," + + "\"rateHz\":${number(rateHz)}}" + + is State -> buildString { + append("{\"type\":\"state\",\"sessionId\":").append(quote(sessionId)) + append(",\"requestId\":").append(requestId) + append(",\"capabilityId\":").append(quote(capabilityId)) + append(",\"telemetry\":").append(quote(telemetry)) + if (mode != null) append(",\"mode\":").append(quote(mode)) + append(",\"parked\":").append(quote(parked)) + // Omitted when false: the protocol's default, and an omitted field keeps older + // accessories reading exactly the state they read before preview existed. + if (preview) append(",\"preview\":true") + append("}") + } + } +} + +private fun quote(value: String): String = JSONObject.quote(value) + +/** Whole rates print without a decimal point, matching every other encoder on this link. */ +private fun number(value: Double): String = + if (value.isFinite() && value == Math.floor(value) && Math.abs(value) < 1e15) { + value.toLong().toString() + } else { + value.toString() + } + +/** + * What one received line means to a live session. + * + * [Ignored] is deliberately distinct from [Malformed]: a line for another session, or of a type + * this slice does not handle, is ordinary traffic on a shared characteristic. Only something the + * framer or the JSON parser could not make sense of ends the session. + * + * @parity /modules/vescape-core/ios/accessory/AccessorySession.swift `AccessoryResponse` + */ +sealed class AccessoryResponse { + /** + * A command was validated and applied, and the accessory will hold it for [leaseMs]. + * + * [applied] is flattened to strings: the app compares what was applied against what it asked + * for, and a textual comparison is the same on both platforms where `1` and `true` are not. + */ + data class Ack( + val requestId: Int, + val capabilityId: String, + val leaseMs: Long, + val applied: Map, + ) : AccessoryResponse() + + /** The accessory refused a request. Nothing partial was applied. */ + data class Failed(val requestId: Int?, val code: String) : AccessoryResponse() + + object Ignored : AccessoryResponse() + + object Malformed : AccessoryResponse() + + companion object { + /** + * Decodes one received line against [sessionId]. + * + * Session identity is checked first and an ack missing its lease is refused: without a + * lease the app has no idea how long the accessory will hold what it just applied, and + * guessing one is how a light ends up dark with the app believing otherwise. + */ + fun parse(line: String, sessionId: String): AccessoryResponse { + val root = try { + JSONTokener(line).nextValue() + } catch (e: Exception) { + return Malformed + } + if (root !is JSONObject) return Malformed + if ((root.opt("sessionId") as? String) != sessionId) return Ignored + + return when (root.opt("type") as? String) { + "ack" -> { + val requestId = wholeNumber(root.opt("requestId")) ?: return Ignored + val capabilityId = (root.opt("capabilityId") as? String) + ?.takeIf { it.isNotBlank() } ?: return Ignored + val leaseMs = wholeNumber(root.opt("leaseMs"))?.toLong() ?: return Ignored + if (leaseMs <= 0L) return Ignored + val applied = (root.opt("applied") as? JSONObject)?.let { json -> + buildMap { + for (key in json.keys()) { + if (json.isNull(key)) continue + put(key, describe(json.opt(key))) + } + } + } ?: emptyMap() + Ack(requestId, capabilityId, leaseMs, applied) + } + + "error" -> { + val code = (root.opt("code") as? String)?.takeIf { it.isNotBlank() } + ?: return Ignored + Failed(wholeNumber(root.opt("requestId")), code) + } + + else -> Ignored + } + } + + /** One applied value as text, printing whole numbers without a decimal point. */ + private fun describe(value: Any?): String = when (value) { + is Boolean -> if (value) "true" else "false" + is Number -> { + val asDouble = value.toDouble() + if (asDouble.isFinite() && asDouble == Math.floor(asDouble) && Math.abs(asDouble) < 1e15) { + asDouble.toLong().toString() + } else { + asDouble.toString() + } + } + else -> value.toString() + } + + private fun wholeNumber(value: Any?): Int? { + val number = value as? Number ?: return null + val asDouble = number.toDouble() + if (!asDouble.isFinite() || asDouble != Math.floor(asDouble)) return null + if (asDouble < Int.MIN_VALUE.toDouble() || asDouble > Int.MAX_VALUE.toDouble()) return null + return asDouble.toInt() + } + } +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt new file mode 100644 index 00000000..6d084f91 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt @@ -0,0 +1,376 @@ +package expo.modules.vescapecore.accessory + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import expo.modules.vescapecore.recording.RecordingStorageFailure +import expo.modules.vescapecore.telemetry.AccessoryPersistence +import expo.modules.vescapecore.telemetry.SavedAccessoryEntity +import expo.modules.vescapecore.telemetry.TelemetryDatabase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.json.JSONObject +import org.json.JSONTokener + +/** + * Enrolled Accessories: what is saved, what is connected, and the sessions in between. + * + * The durable half lives in the database and the live half in [AccessoryLink]; this object is the + * only place the two meet. Two rules shape it: + * + * - **Only enrolled Accessories auto-connect.** Discovery finds hardware; the rider adds it. A + * device that merely advertises nearby is never given a session, so nothing on it can be started + * by walking past it. + * - **Identity is the manifest's accessory id.** Enrollment reads a manifest natively rather than + * trusting one handed over the bridge, and every reconnect re-reads it. A renamed unit updates + * its row; a different unit on a remembered handle is refused. + * + * JS never drives any of this. The launch path starts sessions with or without a JS runtime, and + * the bridge only sends intents (enroll, forget) and renders the snapshot. + * + * @parity /modules/vescape-core/ios/accessory/AccessorySessionController.swift + */ +object AccessorySessionManager { + /** Set by the Expo module so state can be pushed without holding a module reference. */ + var emit: ((String, Map) -> Unit)? = null + + private val handler = Handler(Looper.getMainLooper()) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val links = LinkedHashMap() + private val saved = LinkedHashMap() + + /** Capabilities whose declared limits moved since enrollment, per accessory. */ + private val capabilitiesChanged = mutableSetOf() + + private var appContext: Context? = null + + /** + * Brings up every enrolled Accessory's session. + * + * Called from process launch, not from JS coming up. Safe to call repeatedly: a link already + * started is left alone. + */ + fun start(context: Context) { + val app = context.applicationContext + appContext = app + scope.launch { + val rows = try { + persistence(app).getAccessories() + } catch (error: Throwable) { + // Nothing starts, and the outage is reported rather than looking like "no + // Accessories" — a rider whose database is unreadable has not lost their hardware. + RecordingStorageFailure.reportRead("accessory_list", error) + return@launch + } + handler.post { + saved.clear() + rows.forEach { saved[it.accessoryId] = it } + rows.forEach { link(it).start(it.deviceId) } + publish() + } + } + } + + /** True when at least one Accessory is enrolled, so a host lifetime is worth holding open. */ + fun hasSessions(): Boolean = links.isNotEmpty() + + fun stopAll() { + handler.post { + links.values.forEach { it.stop() } + links.clear() + publish() + } + } + + /** + * Adds one Accessory the rider picked, by reading its manifest natively first. + * + * The manifest is never taken from the bridge. JS supplies a device handle it saw in a scan; + * identity, protocol version and capability limits are all decided here, so an enrollment can + * only ever record what the hardware actually said. + */ + fun enroll(context: Context, deviceId: String, onResult: (Map) -> Unit) { + val app = context.applicationContext + appContext = app + AccessoryDiscovery.inspect(app, deviceId) { inspection -> + @Suppress("UNCHECKED_CAST") + val manifestMap = inspection["manifest"] as? Map + if (manifestMap == null) { + onResult(mapOf("accessoryId" to null, "error" to (inspection["error"] ?: "connect-failed"))) + return@inspect + } + val accessoryId = manifestMap["accessoryId"] as? String + if (accessoryId.isNullOrBlank()) { + onResult(mapOf("accessoryId" to null, "error" to "invalid")) + return@inspect + } + val row = SavedAccessoryEntity( + accessoryId = accessoryId, + name = manifestMap["name"] as? String ?: accessoryId, + firmwareVersion = manifestMap["firmwareVersion"] as? String ?: "", + protocolVersion = (manifestMap["protocolVersion"] as? Number)?.toInt(), + deviceId = deviceId, + capabilitiesJson = encodeCapabilities(manifestMap["capabilities"]), + enrolledAt = System.currentTimeMillis(), + lastConnectedAt = null, + ) + scope.launch { + val stored = try { + persistence(app).upsert(row) + } catch (error: Throwable) { + RecordingStorageFailure.report("accessory_enroll", "write_failed", error) + onResult(mapOf("accessoryId" to null, "error" to "storage-unavailable")) + return@launch + } + handler.post { + saved[accessoryId] = stored + capabilitiesChanged.remove(accessoryId) + link(stored).start(deviceId) + publish() + onResult(mapOf("accessoryId" to accessoryId, "error" to null)) + } + } + } + } + + /** Drops the saved identity and the session with it. Forgetting is the only way one goes away. */ + fun forget(context: Context, accessoryId: String, onResult: (Boolean) -> Unit) { + val app = context.applicationContext + appContext = app + scope.launch { + val removed = try { + persistence(app).forget(accessoryId) + } catch (error: Throwable) { + // The saved identity is still there, so the Accessory is still enrolled. Tearing + // down the live session anyway would make it come back on the next launch with no + // explanation. + RecordingStorageFailure.report("accessory_forget", "write_failed", error) + onResult(false) + return@launch + } + handler.post { + links.remove(accessoryId)?.stop() + saved.remove(accessoryId) + capabilitiesChanged.remove(accessoryId) + publish() + onResult(removed) + } + } + } + + /** + * Current snapshot, for a late subscriber or a JS foreground restore. + * + * The bridge's synchronous getter reads this off the JS thread while the maps are only written + * from the main looper. That is a read of a consistent-enough render state, not a claim of + * atomicity: the next `onAccessoryState` corrects anything caught mid-change. + */ + fun snapshot(): List> = buildSnapshot() + + private fun buildSnapshot(): List> = saved.values.map { row -> + val link = links[row.accessoryId] + val live = link?.manifest + mapOf( + "accessoryId" to row.accessoryId, + // The live manifest wins while one is held: an Accessory renamed since enrollment reads + // as its current name straight away, and the saved row catches up on the same handshake. + "name" to (live?.name ?: row.name), + "firmwareVersion" to (live?.firmwareVersion ?: row.firmwareVersion), + "protocolVersion" to (live?.protocolVersion ?: row.protocolVersion), + "deviceId" to row.deviceId, + "enrolledAt" to row.enrolledAt, + "lastConnectedAt" to row.lastConnectedAt, + "phase" to (link?.phase ?: AccessoryLinkPhase.IDLE).wire, + "error" to link?.lastError, + "compatibility" to live?.compatibility?.wire, + "capabilities" to (live?.capabilities?.map { it.toMap() } ?: decodeCapabilities(row.capabilitiesJson)), + // The declared limits moved since enrollment, so anything calibrated against the old + // ones needs the rider to look at it again. + "capabilitiesChanged" to capabilitiesChanged.contains(row.accessoryId), + "leaseHeldMs" to link?.lastAckAtMs?.let { SystemClock.elapsedRealtime() - it }, + ) + } + + private fun publish() { + emit?.invoke("onAccessoryState", mapOf("accessories" to buildSnapshot())) + } + + // MARK: - Internals + + private fun persistence(context: Context) = + AccessoryPersistence(TelemetryDatabase.get(context).telemetryDao()) + + private fun link(row: SavedAccessoryEntity): AccessoryLink = + links.getOrPut(row.accessoryId) { + AccessoryLink( + context = requireNotNull(appContext) { "AccessorySessionManager used before start" }, + handler = handler, + accessoryId = row.accessoryId, + onChanged = { publish() }, + onManifest = { manifest, deviceId -> onManifestValidated(manifest, deviceId) }, + ).also { it.setDesiredAll(row) } + } + + /** + * A handshake that produced a manifest for an Accessory we have saved. + * + * The row is refreshed from what the hardware just said — name, firmware, protocol version, the + * handle it answered on — and the capability set is compared against the one enrollment + * validated. A capability whose limits moved is flagged rather than silently accepted: saved + * calibration was made against the old numbers. + */ + private fun onManifestValidated(manifest: AccessoryManifest, deviceId: String) { + val previous = saved[manifest.accessoryId] ?: return + val capabilitiesJson = encodeCapabilityList(manifest.capabilities) + if (capabilitiesJson != previous.capabilitiesJson) { + capabilitiesChanged.add(manifest.accessoryId) + } + val row = previous.copy( + name = manifest.name, + firmwareVersion = manifest.firmwareVersion, + protocolVersion = manifest.protocolVersion, + deviceId = deviceId, + capabilitiesJson = capabilitiesJson, + lastConnectedAt = System.currentTimeMillis(), + ) + saved[manifest.accessoryId] = row + links[manifest.accessoryId]?.setDesiredAll(row, manifest) + val app = appContext ?: return + scope.launch { + try { + persistence(app).upsert(row) + } catch (error: Throwable) { + // The session is live and correct; only the saved copy of what the manifest just + // said is stale, which the next successful handshake fixes. + RecordingStorageFailure.report("accessory_revalidate", "write_failed", error) + } + } + } + + /** + * The baseline every session establishes for each capability it can drive. + * + * Both are the protocol's own neutral state, not a feature: a clearance sensor is held in + * measurement standby, and a light is told plainly that Board telemetry is unavailable. They + * exist so the session has a real acknowledged command to hold — which is what makes the lease, + * the retry and the expiry observable before any capability's own behaviour is built. The + * slices that own those capabilities replace these with the rider's actual demand. + */ + private fun AccessoryLink.setDesiredAll( + row: SavedAccessoryEntity, + manifest: AccessoryManifest? = null, + ) { + val capabilities = manifest?.capabilities + ?: decodeCapabilities(row.capabilitiesJson).mapNotNull(::capabilityFromMap) + for (capability in capabilities) { + if (!capability.supported) continue + when (capability.type) { + AccessoryProtocol.TYPE_GROUND_CLEARANCE -> { + val rate = AccessorySession.resolveRateHz(PREFERRED_RATE_HZ, capability.ratesHz) + ?: continue + setDesired(AccessoryCommand.Configure(capability.id, enabled = false, rateHz = rate)) + } + + AccessoryProtocol.TYPE_BRAKE_LIGHT -> setDesired( + AccessoryCommand.State( + capabilityId = capability.id, + telemetry = "unavailable", + mode = null, + parked = "off", + ), + ) + + else -> Unit + } + } + } + + /** `docs/accessory-protocol.md` PoC default, resolved against whatever the manifest offers. */ + private const val PREFERRED_RATE_HZ = 20.0 + + /** + * The capability set as one canonical line. + * + * Built by hand with a fixed key order rather than through [JSONObject], which does not promise + * one: this text is compared against the stored text to decide whether an Accessory's declared + * limits moved, and it travels between platforms inside a database backup. Two encodings of the + * same capabilities must be the same bytes on both, or restoring a backup would claim every + * Accessory changed. + * + * @parity /modules/vescape-core/ios/accessory/AccessorySessionController.swift `encodeCapabilities` + */ + private fun encodeCapabilities(raw: Any?): String { + @Suppress("UNCHECKED_CAST") + val list = raw as? List> ?: return "[]" + return list.joinToString(",", prefix = "[", postfix = "]", transform = ::capabilityJson) + } + + private fun encodeCapabilityList(capabilities: List): String = + encodeCapabilities(capabilities.map { it.toMap() }) + + private fun capabilityJson(entry: Map): String = buildString { + append("{\"id\":").append(JSONObject.quote(entry["id"] as? String ?: "")) + append(",\"type\":").append(JSONObject.quote(entry["type"] as? String ?: "")) + append(",\"supported\":").append(entry["supported"] == true) + append(",\"unit\":").append((entry["unit"] as? String)?.let(JSONObject::quote) ?: "null") + append(",\"rangeMin\":").append(numberOrNull(entry["rangeMin"])) + append(",\"rangeMax\":").append(numberOrNull(entry["rangeMax"])) + append(",\"ratesHz\":[") + val rates = (entry["ratesHz"] as? List<*>).orEmpty().mapNotNull { it as? Number } + append(rates.joinToString(",") { number(it.toDouble()) }) + append("]}") + } + + private fun numberOrNull(value: Any?): String = + (value as? Number)?.let { number(it.toDouble()) } ?: "null" + + private fun number(value: Double): String = + if (value.isFinite() && value == Math.floor(value) && Math.abs(value) < 1e15) { + value.toLong().toString() + } else { + value.toString() + } + + private fun decodeCapabilities(json: String): List> { + val root = try { + JSONTokener(json).nextValue() + } catch (e: Exception) { + return emptyList() + } + if (root !is JSONArray) return emptyList() + return (0 until root.length()).mapNotNull { index -> + val entry = root.optJSONObject(index) ?: return@mapNotNull null + mapOf( + "id" to entry.optString("id"), + "type" to entry.optString("type"), + "supported" to entry.optBoolean("supported"), + "unit" to if (entry.isNull("unit")) null else entry.optString("unit"), + "rangeMin" to if (entry.isNull("rangeMin")) null else entry.optDouble("rangeMin"), + "rangeMax" to if (entry.isNull("rangeMax")) null else entry.optDouble("rangeMax"), + "ratesHz" to entry.optJSONArray("ratesHz")?.let { rates -> + (0 until rates.length()).map { rates.optDouble(it) } + }.orEmpty(), + ) + } + } + + private fun capabilityFromMap(entry: Map): AccessoryCapability? { + val id = entry["id"] as? String ?: return null + val type = entry["type"] as? String ?: return null + @Suppress("UNCHECKED_CAST") + return AccessoryCapability( + id = id, + type = type, + supported = entry["supported"] == true, + unit = entry["unit"] as? String, + rangeMin = (entry["rangeMin"] as? Number)?.toDouble(), + rangeMax = (entry["rangeMax"] as? Number)?.toDouble(), + ratesHz = (entry["ratesHz"] as? List).orEmpty(), + ) + } +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt index 134be24d..bdc27cf4 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt @@ -1,5 +1,6 @@ package expo.modules.vescapecore.connection +import expo.modules.vescapecore.accessory.AccessorySessionManager import expo.modules.vescapecore.service.foregroundServiceType import expo.modules.vescapecore.service.ACTION_CONNECT_FROM_NOTIFICATION import expo.modules.vescapecore.service.ACTION_DISCONNECT_FROM_NOTIFICATION @@ -963,7 +964,11 @@ private var wearAutoLaunchOnConnect = true val isStopping: Boolean get() = isStoppingService fun stopIfIdle() { - if (boardConfig == null && !gpsMonitor.active && !groupRideObserver.active) { + // Accessory sessions keep the host alive on their own. They are not a Board's property: a + // rider with no Board selected and a light enrolled still has a link that must stay up. + if (boardConfig == null && !gpsMonitor.active && !groupRideObserver.active && + !AccessorySessionManager.hasSessions() + ) { isStoppingService = true notificationController.cancel() service.stopSelf() @@ -987,7 +992,7 @@ private var wearAutoLaunchOnConnect = true return } stop.onSuccess() - if (!gpsMonitor.active && !groupRideObserver.active) { + if (!gpsMonitor.active && !groupRideObserver.active && !AccessorySessionManager.hasSessions()) { isStoppingService = true service.stopSelf() } @@ -1186,6 +1191,7 @@ private var wearAutoLaunchOnConnect = true return foregroundServiceType( boardActive = boardConfig != null, gpsActive = gpsMonitor.active, + accessoryActive = AccessorySessionManager.hasSessions(), ) } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/AutoConnectProvider.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/AutoConnectProvider.kt index 1c5fda87..0f961fce 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/AutoConnectProvider.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/AutoConnectProvider.kt @@ -13,7 +13,12 @@ import android.net.Uri */ class AutoConnectProvider : ContentProvider() { override fun onCreate(): Boolean { - context?.applicationContext?.let(CoreForegroundService::autoConnectSelectedBoard) + context?.applicationContext?.let { app -> + CoreForegroundService.autoConnectSelectedBoard(app) + // Enrolled Accessories come up on the same trigger but through their own path: they are + // not gated on a selected Board, the Board auto-connect setting, or a manual Board stop. + CoreForegroundService.autoConnectAccessories(app) + } return true } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/CoreForegroundService.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/CoreForegroundService.kt index dd33967f..8f3ea0ae 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/CoreForegroundService.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/CoreForegroundService.kt @@ -1,5 +1,6 @@ package expo.modules.vescapecore.service +import expo.modules.vescapecore.accessory.AccessorySessionManager import expo.modules.vescapecore.alerts.AlertFeedback import expo.modules.vescapecore.connection.BoardSessionController import expo.modules.vescapecore.connection.BoardTransport @@ -20,10 +21,12 @@ import expo.modules.vescapecore.recording.RecordingStorageFailureKind import expo.modules.vescapecore.recording.recordingFailureState import expo.modules.vescapecore.liveStateWithStorageFailure import expo.modules.vescapecore.protocol.LocationSnapshot +import expo.modules.vescapecore.telemetry.AccessoryPersistence import expo.modules.vescapecore.telemetry.AppDataRepository import expo.modules.vescapecore.telemetry.DEFAULT_LIVE_HISTORY_LIMIT_MINUTES import expo.modules.vescapecore.telemetry.MAX_LIVE_HISTORY_LIMIT_MINUTES import expo.modules.vescapecore.telemetry.MIN_LIVE_HISTORY_LIMIT_MINUTES +import expo.modules.vescapecore.telemetry.TelemetryDatabase import expo.modules.vescapecore.telemetry.TelemetryRepository import expo.modules.vescapecore.watch.WatchLightsSwitch import expo.modules.vescapecore.watch.WatchMirrorWakeLevel @@ -48,6 +51,7 @@ private const val ACTION_STOP_GPS_MONITORING = "expo.modules.vescapecore.ACTION_ internal const val ACTION_START_GROUP_RIDE_OBSERVE = "expo.modules.vescapecore.ACTION_START_GROUP_RIDE_OBSERVE" private const val ACTION_STOP_GROUP_RIDE_OBSERVE = "expo.modules.vescapecore.ACTION_STOP_GROUP_RIDE_OBSERVE" internal const val ACTION_AUTO_CONNECT_SELECTED_BOARD = "expo.modules.vescapecore.ACTION_AUTO_CONNECT_SELECTED_BOARD" +internal const val ACTION_AUTO_CONNECT_ACCESSORIES = "expo.modules.vescapecore.ACTION_AUTO_CONNECT_ACCESSORIES" internal const val ACTION_COMPANION_DEVICE_APPEARED = "expo.modules.vescapecore.ACTION_COMPANION_DEVICE_APPEARED" internal const val EXTRA_COMPANION_ADDRESS = "expo.modules.vescapecore.EXTRA_COMPANION_ADDRESS" internal const val TELEMETRY_STALE_MS = 4_000L @@ -208,6 +212,31 @@ class CoreForegroundService : Service() { } } + /** + * Brings up every enrolled Accessory's session at process start. + * + * Deliberately separate from the Board's auto-connect: an Accessory is enrolled in its own + * right, so it comes up with no Board selected, with Board auto-connect off, and after a + * manual Board disconnect. The service is only started when something is actually enrolled + * — a rider with no Accessories pays nothing for this path. + * + * @parity /modules/vescape-core/ios/connection/VescapeLaunchSubscriber.swift + */ + fun autoConnectAccessories(context: Context) { + val app = context.applicationContext + appDataScope.launch { + val enrolled = try { + AccessoryPersistence(TelemetryDatabase.get(app).telemetryDao()).getAccessories() + } catch (e: Exception) { + android.util.Log.w(VESC_SESSION_TAG, "Accessory auto-connect read failed: ${e.message}") + return@launch + } + if (enrolled.isEmpty()) return@launch + CoreForegroundServiceLauncher.autoConnectAccessories(app) + .logIfSkipped("Accessory session service start skipped") + } + } + fun getRefloatConfigSnapshot( onSuccess: (Map) -> Unit, onError: (String, String) -> Unit, @@ -542,6 +571,10 @@ class CoreForegroundService : Service() { controller.promoteConnectedDeviceForeground() controller.autoConnectSelectedBoard() } + ACTION_AUTO_CONNECT_ACCESSORIES -> { + controller.promoteConnectedDeviceForeground() + AccessorySessionManager.start(applicationContext) + } ACTION_COMPANION_DEVICE_APPEARED -> { controller.promoteConnectedDeviceForeground() intent.getStringExtra(EXTRA_COMPANION_ADDRESS)?.let(controller::connectCompanionDevice) @@ -558,6 +591,10 @@ class CoreForegroundService : Service() { } override fun onDestroy() { + // The Accessory sessions' host is going away, so the links go with it. Keeping GATT open + // past the service is how a background BLE link becomes a leak Android eventually kills + // anyway, without the rider ever being told it stopped. + AccessorySessionManager.stopAll() controller.onServiceDestroy() instance = null super.onDestroy() diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/CoreForegroundServiceLauncher.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/CoreForegroundServiceLauncher.kt index b9efd340..99b2bdc8 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/CoreForegroundServiceLauncher.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/CoreForegroundServiceLauncher.kt @@ -11,6 +11,7 @@ internal enum class ForegroundServiceStartAction { BoardSession, CompanionDevice, AutoConnectSelectedBoard, + AccessorySessions, GpsMonitoring, GroupRideObserve, } @@ -81,6 +82,13 @@ internal fun foregroundServiceLaunchSkipReason( else -> null } } + ForegroundServiceStartAction.AccessorySessions -> { + if (!preflight.bluetoothConnectGranted) { + ForegroundServiceLaunchSkipReason.BluetoothPermissionMissing + } else { + null + } + } ForegroundServiceStartAction.GpsMonitoring -> { if (!preflight.locationGranted) { ForegroundServiceLaunchSkipReason.LocationPermissionMissing @@ -134,6 +142,29 @@ internal object CoreForegroundServiceLauncher { ) } + /** + * Brings the host up for enrolled Accessories, independently of any Board. + * + * An Accessory session is not a Board session: it must come up with no Board selected and with + * Board auto-connect switched off, because the rider enrolled the Accessory rather than the + * Board it happens to ride with. + */ + fun autoConnectAccessories(context: Context): ForegroundServiceLaunchResult { + val skipReason = foregroundServiceLaunchSkipReason( + ForegroundServiceLaunchPreflight( + action = ForegroundServiceStartAction.AccessorySessions, + bluetoothConnectGranted = hasBluetoothConnectPermission(context), + ), + ) + if (skipReason != null) return ForegroundServiceLaunchResult(started = false, skipReason = skipReason) + return startForegroundService( + context = context, + intentAction = ACTION_AUTO_CONNECT_ACCESSORIES, + failurePrefix = "Accessory session service start", + beforeStart = {}, + ) + } + fun startGpsMonitoring(context: Context, beforeStart: () -> Unit): ForegroundServiceLaunchResult { val skipReason = foregroundServiceLaunchSkipReason( ForegroundServiceLaunchPreflight( diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/ForegroundServiceTypes.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/ForegroundServiceTypes.kt index 65dceec3..c056e29f 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/ForegroundServiceTypes.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/service/ForegroundServiceTypes.kt @@ -5,9 +5,12 @@ import android.content.pm.ServiceInfo internal fun foregroundServiceType( boardActive: Boolean, gpsActive: Boolean, + accessoryActive: Boolean = false, ): Int { var type = 0 - if (boardActive) type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + // An Accessory session is a connected device just as a Board is: the service exists to hold a + // BLE link open while the screen is off, and which link it is does not change the type. + if (boardActive || accessoryActive) type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE if (gpsActive) type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION return type } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AccessoryPersistence.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AccessoryPersistence.kt new file mode 100644 index 00000000..ae2f5db6 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AccessoryPersistence.kt @@ -0,0 +1,35 @@ +package expo.modules.vescapecore.telemetry + +/** + * Durable Accessory enrollment. Production Room operations shared by the Android adapter and the + * host persistence contract. + * + * Everything here keys on the manifest's persistent accessory id. That is the whole point of the + * store: an Accessory is remembered because the rider enrolled *it*, not because it happened to + * answer on a BLE handle, so a new name, a firmware bump or a rotated MAC all land on the same row. + * + * @parity /modules/vescape-core/ios/telemetry/AccessoryPersistence.swift + */ +internal class AccessoryPersistence(private val dao: TelemetryDao) { + suspend fun getAccessories(): List = dao.getAccessories() + + suspend fun getAccessory(accessoryId: String): SavedAccessoryEntity? = dao.getAccessory(accessoryId) + + /** + * Enrollment, and the re-validation every later handshake performs. + * + * [enrolledAt] is preserved across re-validation: it says when the rider added this Accessory, + * and reading a manifest again is not adding it again. + */ + suspend fun upsert(accessory: SavedAccessoryEntity): SavedAccessoryEntity { + val existing = dao.getAccessory(accessory.accessoryId) + val row = if (existing == null) accessory else accessory.copy(enrolledAt = existing.enrolledAt) + dao.upsertAccessory(row) + return row + } + + suspend fun forget(accessoryId: String): Boolean = dao.deleteAccessory(accessoryId) > 0 + + suspend fun touch(accessoryId: String, deviceId: String?, connectedAt: Long): Boolean = + dao.touchAccessory(accessoryId, deviceId, connectedAt) > 0 +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index dc765f90..75a8fa20 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -847,6 +847,31 @@ interface TelemetryDao { @Query("DELETE FROM board_warnings WHERE board_id = :boardId") suspend fun deleteBoardWarnings(boardId: String): Int + // Enrolled Accessories. Deliberately unrelated to `boards`: an Accessory Binding targets whichever + // Board is connected, so deleting a Board must not forget the rider's hardware. + // @parity /modules/vescape-core/ios/telemetry/AccessoryPersistence.swift + + @Query("SELECT * FROM accessories ORDER BY enrolled_at ASC") + suspend fun getAccessories(): List + + @Query("SELECT * FROM accessories WHERE accessory_id = :accessoryId LIMIT 1") + suspend fun getAccessory(accessoryId: String): SavedAccessoryEntity? + + /** + * Enroll or re-validate. `REPLACE` on the manifest identity is the whole duplicate defence: the + * same hardware under a new name, a new firmware version or a new BLE handle updates its row + * instead of adding one. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAccessory(accessory: SavedAccessoryEntity) + + @Query("DELETE FROM accessories WHERE accessory_id = :accessoryId") + suspend fun deleteAccessory(accessoryId: String): Int + + /** Records a successful session without rewriting the manifest facts the handshake validated. */ + @Query("UPDATE accessories SET device_id = :deviceId, last_connected_at = :connectedAt WHERE accessory_id = :accessoryId") + suspend fun touchAccessory(accessoryId: String, deviceId: String?, connectedAt: Long): Int + // VESC Fault Occurrences — see VescFaultCoordinator for lifecycle rules. Deliberately absent from // `deleteBoardWithSettings`: fault evidence outlives the Board record. // @parity /modules/vescape-core/ios/faults/VescFaultStore.swift diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt index b06751e1..f0d94c64 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt @@ -896,3 +896,37 @@ data class VescFaultCaptureSampleEntity( val adc2: Double?, val state: Int?, ) + +/** + * One enrolled Accessory: the durable half of an Accessory, and the only reason one auto-connects. + * + * Identity is [accessoryId] — the persistent UUID the manifest carries — never the BLE handle and + * never the name. Both of those move: Android reports a rotating MAC, iOS a per-install peripheral + * id, and the rider can rename the unit from its own firmware. Keying the row on the manifest id is + * what makes a renamed Accessory the same Accessory instead of a second one. + * + * [deviceId] is a reconnect hint and nothing more. It is where the Accessory answered last time, so + * the session has somewhere to look before falling back to a scan; a stale one costs a scan, never + * a duplicate row. + * + * [capabilitiesJson] is the capability set validated at the last successful handshake. Every + * reconnect reads the manifest again and compares: a capability whose declared limits moved is a + * capability whose saved per-capability settings may no longer fit, and the binding says setup is + * required rather than driving hardware to numbers it no longer accepts. + * + * @parity /modules/vescape-core/ios/telemetry/AccessoryPersistence.swift `SavedAccessory` + */ +@Entity(tableName = "accessories") +data class SavedAccessoryEntity( + @PrimaryKey @ColumnInfo(name = "accessory_id") val accessoryId: String, + /** Manifest name at the last handshake. A label to show, refreshed on every reconnect. */ + val name: String, + @ColumnInfo(name = "firmware_version") val firmwareVersion: String, + /** Last agreed protocol version, or null when the two sides found none. */ + @ColumnInfo(name = "protocol_version") val protocolVersion: Int?, + /** Where it answered last. A hint for the next connect, not identity. */ + @ColumnInfo(name = "device_id") val deviceId: String?, + @ColumnInfo(name = "capabilities_json") val capabilitiesJson: String, + @ColumnInfo(name = "enrolled_at") val enrolledAt: Long, + @ColumnInfo(name = "last_connected_at") val lastConnectedAt: Long?, +) diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryMigrations.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryMigrations.kt index a566dcaf..7817a14b 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryMigrations.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryMigrations.kt @@ -1500,6 +1500,27 @@ internal object TelemetryMigrations { } + /** + * Enrolled Accessories. Keyed on the manifest's persistent accessory id, so the same hardware + * renamed, re-flashed or seen on a different BLE handle stays one row. + */ + internal val MIGRATION_43_44 = migration(43, 44) { db -> + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS accessories ( + accessory_id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + firmware_version TEXT NOT NULL, + protocol_version INTEGER, + device_id TEXT, + capabilities_json TEXT NOT NULL, + enrolled_at INTEGER NOT NULL, + last_connected_at INTEGER + ) + """.trimIndent(), + ) + } + /** Every migration registered with Room, in the graph's production order. */ val all = listOf( MIGRATION_3_4, @@ -1539,6 +1560,7 @@ internal object TelemetryMigrations { MIGRATION_40_41, MIGRATION_41_42, MIGRATION_42_43, + MIGRATION_43_44, ) } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRoomDatabase.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRoomDatabase.kt index 989e9b5d..0cba1384 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRoomDatabase.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRoomDatabase.kt @@ -4,7 +4,7 @@ import androidx.room.Database import androidx.room.RoomDatabase // @parity /modules/vescape-core/ios/telemetry/DatabaseBackupManager.swift `TELEMETRY_SCHEMA_VERSION` -internal const val TELEMETRY_DATABASE_VERSION = 43 +internal const val TELEMETRY_DATABASE_VERSION = 44 /** Production Room schema/DAO, portable to JVM hosts. Android open/migration lifecycle stays in [TelemetryDatabase]. */ @Database( @@ -16,7 +16,7 @@ internal const val TELEMETRY_DATABASE_VERSION = 43 PrivacyZoneEntity::class, BoardWarningEntity::class, VescFaultOccurrenceEntity::class, VescFaultCaptureEntity::class, VescFaultCaptureSampleEntity::class, FavoriteEntity::class, FavoriteMediaEntity::class, BoardConfigValuesEntity::class, MotorConfigValuesEntity::class, - BoardConfigChangeNoticeEntity::class, + BoardConfigChangeNoticeEntity::class, SavedAccessoryEntity::class, ], version = TELEMETRY_DATABASE_VERSION, exportSchema = false, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessorySessionTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessorySessionTest.kt new file mode 100644 index 00000000..db320655 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessorySessionTest.kt @@ -0,0 +1,148 @@ +package expo.modules.vescapecore.accessory + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The operational session contract, driven by `shared/fixtures/accessory-protocol/session.json`: + * the exact bytes of every command this app writes, and what each accessory line must mean to a + * live session. + * + * @parity /modules/vescape-core/ios/accessory/AccessorySessionTests.swift + */ +class AccessorySessionTest { + private val fixture = AccessoryFixtures.load("session.json") + private val sessionId = fixture.getString("sessionId") + + @Test + fun timingDefaultsMatchTheSharedFixture() { + val timing = fixture.getJSONObject("timing") + assertEquals(timing.getLong("leaseMs"), AccessorySession.LEASE_MS) + assertEquals(timing.getLong("renewIntervalMs"), AccessorySession.RENEW_INTERVAL_MS) + assertEquals(timing.getLong("requestTimeoutMs"), AccessorySession.REQUEST_TIMEOUT_MS) + assertEquals(timing.getLong("handshakeTimeoutMs"), AccessoryProtocol.HANDSHAKE_TIMEOUT_MS) + } + + @Test + fun theFirstCommandComesAfterTheHandshakeRequestId() { + // The hello owns request id 1; an operational request that reused it would look to the + // accessory like a duplicate handshake rather than a new command. + assertEquals( + fixture.getInt("helloRequestId") + 1, + AccessorySession.FIRST_COMMAND_REQUEST_ID, + ) + } + + @Test + fun everyCommandIsEncodedByteForByteAsTheFixturePinsIt() { + val cases = fixture.getJSONArray("encode") + assertTrue("fixture must carry encode cases", cases.length() > 0) + for (i in 0 until cases.length()) { + val case = cases.getJSONObject(i) + val name = case.getString("name") + val spec = case.getJSONObject("command") + val capabilityId = spec.getString("capabilityId") + val command = when (val kind = spec.getString("kind")) { + "configure" -> AccessoryCommand.Configure( + capabilityId = capabilityId, + enabled = spec.getBoolean("enabled"), + rateHz = spec.getDouble("rateHz"), + ) + + "state" -> AccessoryCommand.State( + capabilityId = capabilityId, + telemetry = spec.getString("telemetry"), + mode = if (spec.isNull("mode")) null else spec.getString("mode"), + parked = spec.getString("parked"), + preview = spec.getBoolean("preview"), + ) + + else -> error("unknown command kind $kind in $name") + } + assertEquals( + name, + case.getString("line"), + command.encode(sessionId, case.getInt("requestId")), + ) + } + } + + @Test + fun everyResponseCaseMatchesTheSharedFixture() { + val cases = fixture.getJSONArray("decode") + assertTrue("fixture must carry decode cases", cases.length() > 0) + for (i in 0 until cases.length()) { + val case = cases.getJSONObject(i) + val name = case.getString("name") + val parsed = AccessoryResponse.parse(case.getString("line"), sessionId) + + when { + case.optBoolean("malformed") -> + assertEquals(name, AccessoryResponse.Malformed, parsed) + + case.optBoolean("ignored") -> + assertEquals(name, AccessoryResponse.Ignored, parsed) + + case.has("error") -> { + val expected = case.getJSONObject("error") + assertEquals( + name, + AccessoryResponse.Failed(expected.getInt("requestId"), expected.getString("code")), + parsed, + ) + } + + else -> { + val expected = case.getJSONObject("ack") + val ack = parsed as? AccessoryResponse.Ack ?: error("$name: expected an ack, got $parsed") + assertEquals(name, expected.getInt("requestId"), ack.requestId) + assertEquals(name, expected.getString("capabilityId"), ack.capabilityId) + assertEquals(name, expected.getLong("leaseMs"), ack.leaseMs) + // The applied values are compared as text so `20` and `20.0` cannot disagree + // across the two platforms that have to read the same line. + if (expected.has("appliedRateHz")) { + assertEquals(name, expected.getInt("appliedRateHz").toString(), ack.applied["rateHz"]) + } + if (expected.has("appliedEnabled")) { + assertEquals( + name, + expected.getBoolean("appliedEnabled").toString(), + ack.applied["enabled"], + ) + } + if (expected.has("appliedTelemetry")) { + assertEquals(name, expected.getString("appliedTelemetry"), ack.applied["telemetry"]) + } + if (expected.has("appliedParked")) { + assertEquals(name, expected.getString("appliedParked"), ack.applied["parked"]) + } + } + } + } + } + + @Test + fun measurementRatesResolveAgainstWhatTheHardwareDeclared() { + val cases = fixture.getJSONArray("rateResolution") + for (i in 0 until cases.length()) { + val case = cases.getJSONObject(i) + val rates = case.getJSONArray("ratesHz") + val declared = (0 until rates.length()).map { rates.getDouble(it) } + assertEquals( + case.getString("name"), + case.getDouble("resolved"), + AccessorySession.resolveRateHz(case.getDouble("requested"), declared)!!, + 0.0, + ) + } + } + + @Test + fun aCapabilityDeclaringNoRateIsNotConfigurable() { + // Not a clamp to some default: a rate the hardware never offered is one this app invented, + // and a sensor asked to run at it would be right to refuse. + assertEquals(null, AccessorySession.resolveRateHz(20.0, emptyList())) + assertEquals(null, AccessorySession.resolveRateHz(20.0, listOf(0.0, -5.0, Double.NaN))) + } +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/AccessoryMigrationTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/AccessoryMigrationTest.kt new file mode 100644 index 00000000..b8e32081 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/AccessoryMigrationTest.kt @@ -0,0 +1,60 @@ +package expo.modules.vescapecore.telemetry + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The schema edge that made Accessories durable. + * + * Pinned to the columns rather than to the SQL text: what matters is that an enrolled Accessory + * survives a reboot keyed on its manifest identity, and that a restored database from an older + * app reaches this shape without losing the Accessories it never had. + * + * @parity /modules/vescape-core/ios/telemetry/PersistenceSchema.swift `createAccessories` + */ +class AccessoryMigrationTest { + private fun migrationSql(): List { + val sql = mutableListOf() + val db = object : TelemetryMigrationDatabase { + override fun execSQL(statement: String) { sql += statement } + override fun hasColumn(tableName: String, columnName: String) = false + } + TelemetryMigrations.MIGRATION_43_44.migrate(db) + return sql + } + + @Test + fun accessoriesAreTheCurrentTailOfTheMigrationGraph() { + assertEquals(44, TELEMETRY_DATABASE_VERSION) + assertEquals(43, TelemetryMigrations.MIGRATION_43_44.startVersion) + assertEquals(TELEMETRY_DATABASE_VERSION, TelemetryMigrations.MIGRATION_43_44.endVersion) + assertEquals(TelemetryMigrations.all.last(), TelemetryMigrations.MIGRATION_43_44) + } + + @Test + fun anEnrolledAccessoryIsKeyedOnItsManifestIdentity() { + val create = migrationSql().single() + // The primary key is the whole duplicate defence: the same hardware renamed, re-flashed, or + // seen on a different BLE handle updates one row rather than adding a second. + assertTrue(create, create.contains("accessory_id TEXT NOT NULL PRIMARY KEY")) + for (column in listOf( + "name TEXT NOT NULL", + "firmware_version TEXT NOT NULL", + "protocol_version INTEGER", + "device_id TEXT", + "capabilities_json TEXT NOT NULL", + "enrolled_at INTEGER NOT NULL", + "last_connected_at INTEGER", + )) { + assertTrue("missing `$column`", create.contains(column)) + } + } + + @Test + fun theTableIsCreatedIfAbsentSoARestoredIosDatabaseIsAccepted() { + // GRDB creates the same table under its own migration id. A backup restored from iOS arrives + // already holding it, and the Room path must reconcile rather than fail. + assertTrue(migrationSql().single().contains("CREATE TABLE IF NOT EXISTS accessories")) + } +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/RideTrackMigrationTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/RideTrackMigrationTest.kt index fd8c4ca1..6b8b2c7d 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/RideTrackMigrationTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/RideTrackMigrationTest.kt @@ -32,8 +32,9 @@ class RideTrackMigrationTest { ?: throw AssertionError("no migration statement contains `$match`") @Test - fun migrationTargetsTheCurrentSchemaVersion() { - assertEquals(43, TELEMETRY_DATABASE_VERSION) + fun migrationCoversItsOwnEdgeOfTheGraph() { + // Deliberately not pinned to `TELEMETRY_DATABASE_VERSION`: this migration owns one edge, and + // later schema work adds edges after it without changing what this one did. assertEquals(42, TelemetryMigrations.MIGRATION_42_43.startVersion) assertEquals(43, TelemetryMigrations.MIGRATION_42_43.endVersion) } diff --git a/modules/vescape-core/ios/VescapeCoreModule.swift b/modules/vescape-core/ios/VescapeCoreModule.swift index b3de88f6..c63f0266 100644 --- a/modules/vescape-core/ios/VescapeCoreModule.swift +++ b/modules/vescape-core/ios/VescapeCoreModule.swift @@ -92,7 +92,7 @@ public class VescapeCoreModule: Module { // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `Events` // @parity /modules/vescape-core/src/index.ts `VescapeCoreEvents` - Events("onDevice", "onError", "onLiveState", "onLiveTick", "onLiveSeries", "onFocusedSeries", "onTelemetryHistory", "onBms", "onBmsSeries", "onLocation", "onReplayPhoneHeading", "onTelemetryRebuildProgress", "onBoardProbeProgress", "onAppDataChanged", "onGroupRideConnection", "onGroupRideSnapshot", "onGroupRideCreated", "onGroupRideUpdated", "onGroupRideEnded", "onGroupRideJoined", "onGroupRideRoster", "onGroupRideError", "onBoardWarnings", "onVescFaults", "onBoardConfigValues", "onMotorConfigValues", "onBoardConfigChangeNotice", "onBoardLights", "onAppStatus", "onNavigation", "onRouteProgress", "onWeather", "onAccessoryDevice", "onAccessoryScanError") + Events("onDevice", "onError", "onLiveState", "onLiveTick", "onLiveSeries", "onFocusedSeries", "onTelemetryHistory", "onBms", "onBmsSeries", "onLocation", "onReplayPhoneHeading", "onTelemetryRebuildProgress", "onBoardProbeProgress", "onAppDataChanged", "onGroupRideConnection", "onGroupRideSnapshot", "onGroupRideCreated", "onGroupRideUpdated", "onGroupRideEnded", "onGroupRideJoined", "onGroupRideRoster", "onGroupRideError", "onBoardWarnings", "onVescFaults", "onBoardConfigValues", "onMotorConfigValues", "onBoardConfigChangeNotice", "onBoardLights", "onAppStatus", "onNavigation", "onRouteProgress", "onWeather", "onAccessoryDevice", "onAccessoryScanError", "onAccessoryState") // Track per-event JS listeners so native skips emitting into the void, and gate the whole // firehose on app foreground (see `frontendActive`). Mirrors Android's observing + lifecycle @@ -201,6 +201,8 @@ public class VescapeCoreModule: Module { OnStopObserving("onAccessoryDevice") { self.observedEvents.remove("onAccessoryDevice") } OnStartObserving("onAccessoryScanError") { self.observedEvents.insert("onAccessoryScanError") } OnStopObserving("onAccessoryScanError") { self.observedEvents.remove("onAccessoryScanError") } + OnStartObserving("onAccessoryState") { self.observedEvents.insert("onAccessoryState") } + OnStopObserving("onAccessoryState") { self.observedEvents.remove("onAccessoryState") } OnCreate { // Accessory discovery pushes devices as the radio finds them; the module is only the pipe. @@ -210,6 +212,14 @@ public class VescapeCoreModule: Module { self.sendEvent(name, body) } + // Enrolled Accessory sessions are native-owned and outlive this module; the bridge only + // mirrors their state while a JS runtime happens to exist. + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `AccessorySessionManager` + AccessorySessionController.shared.emit = { [weak self] name, body in + guard let self, self.shouldEmitToFrontend(name) else { return } + self.sendEvent(name, body) + } + RecordingStorageFailure.observeOutage { [weak self] in guard let self, self.shouldEmitToFrontend("onLiveState") else { return } self.sendEvent("onLiveState", self.liveState()) @@ -283,6 +293,9 @@ public class VescapeCoreModule: Module { AccessoryDiscovery.shared.emit = nil AccessoryDiscovery.shared.stopScan() AccessoryDiscovery.shared.cancelInspection() + // Only the mirror is dropped. The sessions belong to the launch-created central, and JS going + // away is not a reason for an enrolled Accessory to stop working. + AccessorySessionController.shared.emit = nil } // MARK: Scan @@ -317,6 +330,22 @@ public class VescapeCoreModule: Module { AccessoryDiscovery.shared.inspect(deviceId: deviceId) { promise.resolve($0) } } + // Enrollment and the saved sessions. JS sends the intent and renders the snapshot; identity, + // the manifest and the session all stay native. + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `enrollAccessory` + // @parity /modules/vescape-core/src/index.ts `enrollAccessory` + AsyncFunction("enrollAccessory") { (deviceId: String, promise: Promise) in + AccessorySessionController.shared.enroll(deviceId: deviceId) { promise.resolve($0) } + } + + AsyncFunction("forgetAccessory") { (accessoryId: String, promise: Promise) in + AccessorySessionController.shared.forget(accessoryId: accessoryId) { promise.resolve($0) } + } + + Function("getAccessories") { + AccessorySessionController.shared.snapshot() + } + // MARK: Location Function("startLocationUpdates") { diff --git a/modules/vescape-core/ios/accessory/AccessoryLink.swift b/modules/vescape-core/ios/accessory/AccessoryLink.swift new file mode 100644 index 00000000..3620e6ed --- /dev/null +++ b/modules/vescape-core/ios/accessory/AccessoryLink.swift @@ -0,0 +1,428 @@ +import CoreBluetooth +import Foundation + +/// Where one enrolled Accessory's link stands. Native decides this; JS renders it and never derives +/// one from a boolean, exactly as it does for a Board. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryLink.kt `AccessoryLinkPhase` +/// @parity /modules/vescape-core/src/index.ts `AccessoryLinkPhase` +enum AccessoryLinkPhase: String { + /// No link is held and none is being attempted. + case idle + /// The radio is trying, including while CoreBluetooth holds an open-ended connect. + case connecting + /// GATT is up; the manifest has not been validated yet. + case handshaking + /// Manifest validated and the session's commands are being acknowledged. + case connected + /// It answered, but the session could not be kept: refused or unacknowledged commands. + case unavailable + /// Its manifest says this app cannot drive it. Nothing is commanded; the row explains why. + case incompatible +} + +/// A live protocol session with one enrolled Accessory. +/// +/// Long-lived, unlike `AccessoryGattHandshake`: this is the link an Accessory keeps while the rider +/// is riding, the screen is off and the JS runtime is gone. CoreBluetooth's open-ended +/// `connect(_:)` is what carries it across a walk out of range, so being dropped is not an error +/// and does not reset anything durable. +/// +/// Every connection is a **fresh protocol session**. A new session id goes out with the hello, the +/// request counter restarts, and the desired commands are re-sent from scratch — so a command +/// queued against the previous session can never reach this one, and an ack belonging to it is +/// ignored rather than matched against the wrong request. +/// +/// Timers are `DispatchQueue.main.asyncAfter` deadlines, which run on the monotonic uptime clock. +/// Leases and request timeouts are durations, and wall clock moves under them (NTP, time zones, the +/// rider changing the date); a lease measured on the wrong clock is a light that goes dark at +/// midnight. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryLink.kt +final class AccessoryLink { + /// How long to wait for a connection before reporting the accessory unreachable. CoreBluetooth + /// itself never gives up, so this only exists to keep the row honest. + private static let connectTimeout: TimeInterval = 20 + /// Backoff between deliberate reconnect attempts after the link failed rather than merely dropped. + private static let retryDelay: TimeInterval = 5 + + /// Manifest identity this link is for. A manifest naming anything else is refused. + let accessoryId: String + + private(set) var phase: AccessoryLinkPhase = .idle + /// Wire string for the last failure, or nil while nothing is wrong. + private(set) var lastError: String? + /// Manifest read on the current connection. Nil whenever no session is established. + private(set) var manifest: AccessoryManifest? + /// Monotonic timestamp of the last ack, for the lease the accessory is holding. + private(set) var lastAckAt: TimeInterval? + + private(set) var peripheral: CBPeripheral? + private let central: CBCentralManager + private let onChanged: () -> Void + private let onManifest: (AccessoryManifest, String) -> Void + + private var writeCharacteristic: CBCharacteristic? + private let framer = AccessoryNdjsonFramer() + private var pendingChunks: [Data] = [] + private var writeInFlight = false + private var started = false + + private var sessionId: String? + private var nextRequestId = AccessorySession.firstCommandRequestId + + /// Desired state per capability, in insertion order. Coalesced: only the latest matters, because + /// commands are absolute. + private var desiredOrder: [String] = [] + private var desired: [String: AccessoryCommand] = [:] + + private struct Outstanding { + let requestId: Int + let line: String + /// False until the one permitted retry has gone out with the same id. + var retried: Bool + } + private var outstanding: Outstanding? + + private var connectTimeoutWork: DispatchWorkItem? + private var requestTimeoutWork: DispatchWorkItem? + private var renewWork: DispatchWorkItem? + private var retryWork: DispatchWorkItem? + + init( + accessoryId: String, + central: CBCentralManager, + onChanged: @escaping () -> Void, + onManifest: @escaping (AccessoryManifest, String) -> Void + ) { + self.accessoryId = accessoryId + self.central = central + self.onChanged = onChanged + self.onManifest = onManifest + } + + var deviceId: String? { peripheral?.identifier.uuidString } + + /// Starts, or re-points at a newly discovered peripheral. Idempotent. + func start(peripheral: CBPeripheral?) { + if let peripheral, peripheral.identifier != self.peripheral?.identifier { + if started { teardown() } + self.peripheral = peripheral + } + guard !started else { return } + started = true + connect() + } + + func stop() { + started = false + teardown() + setPhase(.idle, error: nil) + } + + /// Sets the desired state for one capability. + /// + /// Absolute, never incremental: the accessory is told what to be, so the same call repeated is + /// the renewal and a dropped one costs nothing but latency. An unchanged command is not re-queued + /// — the renewal tick already re-sends it, and re-queueing would burn a request id per call. + func setDesired(_ command: AccessoryCommand) { + if desired[command.capabilityId] == command { return } + if desired[command.capabilityId] == nil { desiredOrder.append(command.capabilityId) } + desired[command.capabilityId] = command + // A changed state goes out immediately rather than waiting for the next renewal tick. + if phase == .connected { pump() } + } + + // MARK: - Connection + + private func connect() { + guard let peripheral else { return setPhase(.idle, error: "unknown-device") } + guard central.state == .poweredOn else { + setPhase(.connecting, error: "bluetooth-unavailable") + return + } + setPhase(.connecting, error: nil) + armConnectTimeout() + // No timeout option: CoreBluetooth keeps the attempt alive across the Accessory going out of + // range and back, without the app holding a scan. This is the whole reason a session survives a + // dead JS runtime, and with state restoration it survives the process too. + central.connect(peripheral, options: nil) + } + + private func teardown() { + connectTimeoutWork?.cancel(); connectTimeoutWork = nil + requestTimeoutWork?.cancel(); requestTimeoutWork = nil + renewWork?.cancel(); renewWork = nil + retryWork?.cancel(); retryWork = nil + framer.reset() + pendingChunks.removeAll() + writeInFlight = false + writeCharacteristic = nil + sessionId = nil + outstanding = nil + manifest = nil + lastAckAt = nil + if let peripheral { + peripheral.delegate = nil + central.cancelPeripheralConnection(peripheral) + } + } + + /// A failed link is rebuilt from scratch rather than resumed: a broken session has no state worth + /// keeping. + private func fail(_ error: String, phase: AccessoryLinkPhase = .unavailable) { + let peripheral = self.peripheral + teardown() + self.peripheral = peripheral + setPhase(phase, error: error) + if started { scheduleRetry() } + } + + private func scheduleRetry() { + retryWork?.cancel() + let work = DispatchWorkItem { [weak self] in + guard let self, self.started else { return } + self.connect() + } + retryWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + Self.retryDelay, execute: work) + } + + private func armConnectTimeout() { + connectTimeoutWork?.cancel() + let work = DispatchWorkItem { [weak self] in self?.fail("timeout", phase: .connecting) } + connectTimeoutWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + Self.connectTimeout, execute: work) + } + + private func setPhase(_ next: AccessoryLinkPhase, error: String?) { + guard phase != next || lastError != error else { return } + phase = next + lastError = error + onChanged() + } + + // MARK: - Central callbacks, forwarded by `AccessorySessionController` + + func onConnected() { + connectTimeoutWork?.cancel(); connectTimeoutWork = nil + setPhase(.handshaking, error: nil) + // The delegate is the controller's, assigned when it handed this peripheral over; it routes + // every peripheral callback back here by identifier. + peripheral?.discoverServices([AccessoryProtocol.serviceUUID]) + } + + func onConnectFailed() { fail("connect-failed", phase: .connecting) } + + func onDisconnected() { + // A drop is not a failure: CoreBluetooth keeps trying on its own, so the session is discarded + // but the link stays armed and the row says "connecting". + framer.reset() + pendingChunks.removeAll() + writeInFlight = false + writeCharacteristic = nil + sessionId = nil + outstanding = nil + manifest = nil + lastAckAt = nil + requestTimeoutWork?.cancel(); requestTimeoutWork = nil + renewWork?.cancel(); renewWork = nil + guard started, let peripheral else { return setPhase(.idle, error: nil) } + armConnectTimeout() + setPhase(.connecting, error: nil) + central.connect(peripheral, options: nil) + } + + // MARK: - Peripheral callbacks + + func onServicesDiscovered(error: Error?) { + guard error == nil, let peripheral, + let service = peripheral.services?.first(where: { $0.uuid == AccessoryProtocol.serviceUUID }) + else { return fail("service-missing") } + peripheral.discoverCharacteristics( + [AccessoryProtocol.writeUUID, AccessoryProtocol.notifyUUID], for: service) + } + + func onCharacteristicsDiscovered(for service: CBService, error: Error?) { + guard error == nil, service.uuid == AccessoryProtocol.serviceUUID, + let peripheral, + let characteristics = service.characteristics, + let write = characteristics.first(where: { $0.uuid == AccessoryProtocol.writeUUID }), + let notify = characteristics.first(where: { $0.uuid == AccessoryProtocol.notifyUUID }) + else { return fail("service-missing") } + writeCharacteristic = write + peripheral.setNotifyValue(true, for: notify) + } + + func onNotifyStateChanged(for characteristic: CBCharacteristic, error: Error?) { + guard characteristic.uuid == AccessoryProtocol.notifyUUID else { return } + guard error == nil, characteristic.isNotifying else { return fail("service-missing") } + sendHello() + } + + func onWriteCompleted(error: Error?) { + guard error == nil else { return fail("write-failed") } + writeInFlight = false + drain() + } + + func onValueUpdated(for characteristic: CBCharacteristic, error: Error?) { + guard characteristic.uuid == AccessoryProtocol.notifyUUID, error == nil, + let value = characteristic.value, let session = sessionId + else { return } + let result = framer.feed([UInt8](value)) + for line in result.lines { + if manifest == nil { + handleHandshakeLine(line, session: session) + } else { + handleSessionLine(line, session: session) + } + guard sessionId == session else { return } + } + if let failure = result.failure { fail(failure.rawValue) } + } + + // MARK: - Protocol session + + private func sendHello() { + let fresh = UUID().uuidString + sessionId = fresh + // A new session starts its request numbering over, which is exactly what makes an old queue + // harmless: nothing from the previous session shares a (session, request) pair. + nextRequestId = AccessorySession.firstCommandRequestId + outstanding = nil + pendingChunks.removeAll() + write(AccessoryProtocol.encodeHello(sessionId: fresh)) + armTimeout(ms: AccessoryProtocol.handshakeTimeoutMs) { [weak self] in self?.fail("timeout") } + } + + private func handleHandshakeLine(_ line: String, session: String) { + switch AccessoryProtocol.parseManifest(line: line, sessionId: session) { + case .ok(let read): onManifestRead(read) + case .failed(let reason): + // Another session's message is noise on a shared characteristic, not a violation. + if reason != .sessionMismatch { fail(reason.rawValue) } + } + } + + private func onManifestRead(_ read: AccessoryManifest) { + requestTimeoutWork?.cancel(); requestTimeoutWork = nil + // Identity is checked before anything saved is trusted. A different accessory answering on a + // remembered handle is a stale handle, never a reason to drive someone else's hardware. + guard read.accessoryId == accessoryId else { return fail("identity-mismatch") } + manifest = read + if let deviceId { onManifest(read, deviceId) } + guard read.compatibility == .supported else { + // Read, recognised, and deliberately left alone: an accessory this app cannot drive stays + // connected only long enough to say so. + return setPhase(.incompatible, error: read.compatibility.rawValue) + } + setPhase(.connected, error: nil) + armRenewal() + pump() + } + + private func handleSessionLine(_ line: String, session: String) { + switch AccessoryResponse.parse(line: line, sessionId: session) { + case .ack(let requestId, _, _, _): + guard let pending = outstanding, pending.requestId == requestId else { return } + requestTimeoutWork?.cancel(); requestTimeoutWork = nil + outstanding = nil + lastAckAt = ProcessInfo.processInfo.systemUptime + setPhase(.connected, error: nil) + pump() + + case .failed(let requestId, let code): + if let pending = outstanding, let requestId, requestId != pending.requestId { return } + requestTimeoutWork?.cancel(); requestTimeoutWork = nil + outstanding = nil + // The refusal is the accessory's answer, not a broken link: stay connected and say what it + // refused, rather than dropping a session that is otherwise healthy. + setPhase(.unavailable, error: code) + + case .malformed: fail("malformed") + case .ignored: break + } + } + + // MARK: - Request pump + + /// Sends the next desired command that is not already the one outstanding. + private func pump() { + guard outstanding == nil, let session = sessionId, let manifest else { return } + let supported = Set(manifest.capabilities.filter(\.supported).map(\.id)) + guard let capabilityId = desiredOrder.first(where: { supported.contains($0) }), + let command = desired[capabilityId] + else { return } + // Round-robin: the capability just sent goes to the back, so one capability cannot starve + // another's renewal. + desiredOrder.removeAll { $0 == capabilityId } + desiredOrder.append(capabilityId) + let requestId = nextRequestId + nextRequestId += 1 + let line = command.encode(sessionId: session, requestId: requestId) + outstanding = Outstanding(requestId: requestId, line: line, retried: false) + write(line) + armTimeout(ms: AccessorySession.requestTimeoutMs) { [weak self] in self?.onRequestTimedOut() } + } + + /// One retry with the *same* request id, then the accessory is unavailable. + /// + /// Reusing the id is the point: the accessory recognises a duplicate and replays its previous + /// answer instead of applying the command twice, so a retry cannot restart an animation or extend + /// a lease twice. + private func onRequestTimedOut() { + guard var pending = outstanding else { return } + guard !pending.retried else { return fail("timeout") } + pending.retried = true + outstanding = pending + write(pending.line) + armTimeout(ms: AccessorySession.requestTimeoutMs) { [weak self] in self?.onRequestTimedOut() } + } + + /// Re-sends the current desired state often enough that the accessory's lease never lapses while + /// the app is alive and willing. Nothing here is incremental: a renewal is the same absolute + /// command, so a missed tick costs latency and not correctness. + private func armRenewal() { + renewWork?.cancel() + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + if self.phase == .connected { self.pump() } + self.armRenewal() + } + renewWork = work + DispatchQueue.main.asyncAfter( + deadline: .now() + .milliseconds(AccessorySession.renewIntervalMs), execute: work) + } + + private func armTimeout(ms: Int, _ body: @escaping () -> Void) { + requestTimeoutWork?.cancel() + let work = DispatchWorkItem(block: body) + requestTimeoutWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(ms), execute: work) + } + + // MARK: - Writing + + private func write(_ line: String) { + guard let peripheral else { return } + let payload = Data((line + "\n").utf8) + let limit = max(peripheral.maximumWriteValueLength(for: .withResponse), 20) + var offset = 0 + while offset < payload.count { + let end = min(offset + limit, payload.count) + pendingChunks.append(payload.subdata(in: offset.. String { + /// Shared with `AccessoryCommand.encode`: every line this app writes is quoted the same way, and + /// the shared fixture compares the bytes. + static func quote(_ value: String) -> String { var out = "\"" for scalar in value.unicodeScalars { switch scalar { diff --git a/modules/vescape-core/ios/accessory/AccessorySession.swift b/modules/vescape-core/ios/accessory/AccessorySession.swift new file mode 100644 index 00000000..804855df --- /dev/null +++ b/modules/vescape-core/ios/accessory/AccessorySession.swift @@ -0,0 +1,181 @@ +import Foundation + +/// Vescape Accessory Protocol v1 — the operational half: the commands an enrolled Accessory's +/// session sends, and the acknowledgements it accepts back. +/// +/// Pure and transport-free on purpose. `AccessoryLink` owns the radio and the clock; everything +/// here is bytes in, bytes out, so the request-id discipline and the encodings can be asserted +/// against `shared/fixtures/accessory-protocol/session.json` without a peripheral in the room. +/// +/// Two rules this file exists to keep: +/// +/// - **Commands set desired values.** Nothing toggles or cycles, so resending the same command is +/// always safe and a dropped ack costs a retry rather than a restarted animation. +/// - **Request ids are strictly increasing within a session, and never reused with a different +/// body.** A new protocol session restarts them, which is what makes an old queue harmless. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySession.kt +/// @parity /modules/vescape-core/src/index.ts `AccessoryCapabilitySettings` +enum AccessorySession { + /// How long an accessory holds a command before falling back to its local behavior. + static let leaseMs: Int = 2_000 + + /// How often the app re-sends the current desired command to hold the lease open. + static let renewIntervalMs: Int = 500 + + /// How long one request waits for its ack. The first timeout retries with the *same* id — a + /// retry must not look like a new command — and the second gives up on the accessory. + static let requestTimeoutMs: Int = 500 + + /// The handshake owns request id 1, so operational requests start after it. + static let firstCommandRequestId = AccessoryProtocol.helloRequestId + 1 + + /// Nearest supported rate, lower on a tie. + /// + /// The accessory resolves this too and answers with what it actually applied; the app resolves + /// it first only so the request it sends is one the hardware can accept. An empty rate list + /// means the capability declared none, and a capability with no rate is not configurable. + static func resolveRateHz(requested: Double, ratesHz: [Double]) -> Double? { + let usable = ratesHz.filter { $0.isFinite && $0 > 0 }.sorted() + guard let first = usable.first else { return nil } + // `<` and not `<=`: equal distance keeps the earlier-sorted, i.e. lower, rate. + return usable.dropFirst().reduce(first) { best, candidate in + abs(candidate - requested) < abs(best - requested) ? candidate : best + } + } +} + +/// One desired capability state. Complete by construction: every field the accessory needs is +/// carried on every send, so a renewal is a replay and never a partial update. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySession.kt `AccessoryCommand` +/// @parity /modules/vescape-core/src/index.ts `AccessoryCommandSnapshot` +enum AccessoryCommand: Equatable { + /// Measurement demand for a `ground_clearance` capability. + case configure(capabilityId: String, enabled: Bool, rateHz: Double) + /// Semantic output state for a `brake_light` capability. `telemetry` says whether Board data is + /// reaching the app at all; `mode` is nil when it is not, outside preview. + case state( + capabilityId: String, telemetry: String, mode: String?, parked: String, preview: Bool) + + var capabilityId: String { + switch self { + case .configure(let id, _, _): return id + case .state(let id, _, _, _, _): return id + } + } + + /// The exact line to write, at `requestId`, inside `sessionId`. + /// + /// Built by hand rather than through `JSONSerialization` for the same reason `encodeHello` is: + /// the shared fixture compares bytes, and a dictionary encoder does not promise key order. + func encode(sessionId: String, requestId: Int) -> String { + switch self { + case .configure(let capabilityId, let enabled, let rateHz): + return "{\"type\":\"configure\",\"sessionId\":\(quote(sessionId)),\"requestId\":\(requestId)," + + "\"capabilityId\":\(quote(capabilityId)),\"enabled\":\(enabled)," + + "\"rateHz\":\(number(rateHz))}" + + case .state(let capabilityId, let telemetry, let mode, let parked, let preview): + var out = "{\"type\":\"state\",\"sessionId\":\(quote(sessionId))" + out += ",\"requestId\":\(requestId)" + out += ",\"capabilityId\":\(quote(capabilityId))" + out += ",\"telemetry\":\(quote(telemetry))" + if let mode { out += ",\"mode\":\(quote(mode))" } + out += ",\"parked\":\(quote(parked))" + // Omitted when false: the protocol's default, and an omitted field keeps older accessories + // reading exactly the state they read before preview existed. + if preview { out += ",\"preview\":true" } + return out + "}" + } + } + + private func quote(_ value: String) -> String { AccessoryProtocol.quote(value) } + + /// Whole rates print without a decimal point, matching every other encoder on this link. + private func number(_ value: Double) -> String { + if value.isFinite, value == value.rounded(.down), abs(value) < 1e15 { + return String(Int64(value)) + } + return String(value) + } +} + +/// What one received line means to a live session. +/// +/// `ignored` is deliberately distinct from `malformed`: a line for another session, or of a type +/// this slice does not handle, is ordinary traffic on a shared characteristic. Only something the +/// framer or the JSON parser could not make sense of ends the session. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySession.kt `AccessoryResponse` +enum AccessoryResponse: Equatable { + /// A command was validated and applied, and the accessory will hold it for `leaseMs`. + case ack(requestId: Int, capabilityId: String, leaseMs: Int, applied: [String: String]) + /// The accessory refused a request. Nothing partial was applied. + case failed(requestId: Int?, code: String) + case ignored + case malformed + + /// Decodes one received line against `sessionId`. + /// + /// Session identity is checked first and an ack missing its lease is refused: without a lease + /// the app has no idea how long the accessory will hold what it just applied, and guessing one + /// is how a light ends up dark with the app believing otherwise. + static func parse(line: String, sessionId: String) -> AccessoryResponse { + // intentional-suppression: a line that will not decode *is* `.malformed` — the failure is the + // return value, and the caller ends the protocol session on it. + guard let data = line.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return .malformed } + guard root["sessionId"] as? String == sessionId else { return .ignored } + + switch root["type"] as? String { + case "ack": + guard let requestId = wholeNumber(root["requestId"]), + let capabilityId = (root["capabilityId"] as? String), !capabilityId.isEmpty, + let leaseMs = wholeNumber(root["leaseMs"]), leaseMs > 0 + else { return .ignored } + // Flattened to strings: the app compares what was applied against what it asked for, and a + // textual comparison is the same on both platforms where `1` and `true` are not. + var applied: [String: String] = [:] + for (key, value) in (root["applied"] as? [String: Any]) ?? [:] { + applied[key] = describe(value) + } + return .ack( + requestId: requestId, capabilityId: capabilityId, leaseMs: leaseMs, applied: applied) + + case "error": + guard let code = root["code"] as? String, !code.isEmpty else { return .ignored } + return .failed(requestId: wholeNumber(root["requestId"]), code: code) + + default: + return .ignored + } + } + + private static func describe(_ value: Any) -> String { + if let text = value as? String { return text } + if let number = value as? NSNumber { + // `as? Bool` is not the test: `NSNumber(1)` bridges to `true`, so a `rateHz` of 1 would be + // described as a boolean. Only a genuine `CFBoolean` is one. + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue ? "true" : "false" + } + let asDouble = number.doubleValue + if asDouble.isFinite, asDouble == asDouble.rounded(.down), abs(asDouble) < 1e15 { + return String(Int64(asDouble)) + } + return String(asDouble) + } + return "\(value)" + } + + private static func wholeNumber(_ value: Any?) -> Int? { + guard let number = value as? NSNumber, !(number is NSNull) else { return nil } + let asDouble = number.doubleValue + guard asDouble.isFinite, asDouble == asDouble.rounded(.down), + asDouble >= Double(Int32.min), asDouble <= Double(Int32.max) + else { return nil } + return Int(asDouble) + } +} diff --git a/modules/vescape-core/ios/accessory/AccessorySessionController.swift b/modules/vescape-core/ios/accessory/AccessorySessionController.swift new file mode 100644 index 00000000..ef90490f --- /dev/null +++ b/modules/vescape-core/ios/accessory/AccessorySessionController.swift @@ -0,0 +1,419 @@ +import CoreBluetooth +import Foundation + +/// Enrolled Accessories: what is saved, what is connected, and the sessions in between. +/// +/// The durable half lives in the database and the live half in `AccessoryLink`; this controller is +/// the only place the two meet. Two rules shape it: +/// +/// - **Only enrolled Accessories auto-connect.** Discovery finds hardware; the rider adds it. A +/// device that merely advertises nearby is never given a session, so nothing on it can be started +/// by walking past it. +/// - **Identity is the manifest's accessory id.** Enrollment reads a manifest natively rather than +/// trusting one handed over the bridge, and every reconnect re-reads it. A renamed unit updates +/// its row; a different unit on a remembered handle is refused. +/// +/// A central of its own with a restore identifier, deliberately separate from both the Board +/// Session's central and `AccessoryDiscovery`'s. Restoration is what lets iOS relaunch the app for +/// an Accessory link the way `CoreForegroundService` keeps Android's process alive — and it only +/// works when the central is re-created inside `didFinishLaunchingWithOptions`, which is why +/// `prepareForLaunch()` exists and why JS never creates this. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt +public final class AccessorySessionController: NSObject { + public static let shared = AccessorySessionController() + + /// `docs/accessory-protocol.md` PoC default, resolved against whatever the manifest offers. + private static let preferredRateHz: Double = 20 + + private static let restoreIdentifier = "com.vescape.accessory.sessions" + + /// Set by the Expo module so state can be pushed without holding a module reference. + var emit: ((String, [String: Any?]) -> Void)? + + private var central: CBCentralManager? + private var links: [String: AccessoryLink] = [:] + private var saved: [String: SavedAccessory] = [:] + private var order: [String] = [] + /// Capabilities whose declared limits moved since enrollment, per accessory. + private var capabilitiesChanged: Set = [] + private var store: AccessoryStore { AccessoryStore.shared } + + /// Peripherals handed back by state restoration before the saved rows have been read. + private var restored: [UUID: CBPeripheral] = [:] + + /// Brings up every enrolled Accessory's session. + /// + /// Called from the app-delegate launch hook, not from JS coming up. Safe to call repeatedly: a + /// link already started is left alone. + public func prepareForLaunch() { + onMain { + if self.central == nil { + self.central = CBCentralManager( + delegate: self, queue: nil, + options: [CBCentralManagerOptionRestoreIdentifierKey: Self.restoreIdentifier]) + } + self.loadSaved() + } + } + + /// True when at least one Accessory is enrolled and holding a link. + var hasSessions: Bool { !links.isEmpty } + + func stopAll() { + onMain { + self.links.values.forEach { $0.stop() } + self.links.removeAll() + self.publish() + } + } + + /// Adds one Accessory the rider picked, by reading its manifest natively first. + /// + /// The manifest is never taken from the bridge. JS supplies a device handle it saw in a scan; + /// identity, protocol version and capability limits are all decided here, so an enrollment can + /// only ever record what the hardware actually said. + func enroll(deviceId: String, onResult: @escaping ([String: Any?]) -> Void) { + AccessoryDiscovery.shared.inspect(deviceId: deviceId) { [weak self] inspection in + guard let self else { return } + guard let manifest = inspection["manifest"] as? [String: Any?], + let accessoryId = manifest["accessoryId"] as? String, !accessoryId.isEmpty + else { + return onResult([ + "accessoryId": nil, "error": (inspection["error"] as? String) ?? "connect-failed", + ]) + } + let row = SavedAccessory( + accessoryId: accessoryId, + name: (manifest["name"] as? String) ?? accessoryId, + firmwareVersion: (manifest["firmwareVersion"] as? String) ?? "", + protocolVersion: manifest["protocolVersion"] as? Int, + deviceId: deviceId, + capabilitiesJson: Self.encodeCapabilities(manifest["capabilities"]), + enrolledAt: Int64(Date().timeIntervalSince1970 * 1000), + lastConnectedAt: nil) + self.onMain { + do { + let stored = try self.store.upsert(row) + self.remember(stored) + self.capabilitiesChanged.remove(accessoryId) + self.start(stored) + self.publish() + onResult(["accessoryId": accessoryId, "error": nil]) + } catch { + RecordingStorageFailure.report( + operation: "accessory_enroll", category: "write_failed", error: error) + onResult(["accessoryId": nil, "error": "storage-unavailable"]) + } + } + } + } + + /// Drops the saved identity and the session with it. Forgetting is the only way one goes away. + func forget(accessoryId: String, onResult: @escaping (Bool) -> Void) { + onMain { + let removed: Bool + do { + removed = try self.store.forget(accessoryId) + } catch { + // The saved identity is still there, so the Accessory is still enrolled. Tearing down the + // live session anyway would make it come back on the next launch with no explanation. + RecordingStorageFailure.report( + operation: "accessory_forget", category: "write_failed", error: error) + return onResult(false) + } + self.links.removeValue(forKey: accessoryId)?.stop() + self.saved.removeValue(forKey: accessoryId) + self.order.removeAll { $0 == accessoryId } + self.capabilitiesChanged.remove(accessoryId) + self.publish() + onResult(removed) + } + } + + /// Current snapshot, for a late subscriber or a JS foreground restore. + func snapshot() -> [[String: Any?]] { + order.compactMap { accessoryId in + guard let row = saved[accessoryId] else { return nil } + let link = links[accessoryId] + let live = link?.manifest + return [ + "accessoryId": row.accessoryId, + // The live manifest wins while one is held: an Accessory renamed since enrollment reads as + // its current name straight away, and the saved row catches up on the same handshake. + "name": live?.name ?? row.name, + "firmwareVersion": live?.firmwareVersion ?? row.firmwareVersion, + "protocolVersion": live?.protocolVersion ?? row.protocolVersion, + "deviceId": row.deviceId, + "enrolledAt": row.enrolledAt, + "lastConnectedAt": row.lastConnectedAt, + "phase": (link?.phase ?? .idle).rawValue, + "error": link?.lastError, + "compatibility": live?.compatibility.rawValue, + "capabilities": live?.capabilities.map { $0.toMap() } + ?? Self.decodeCapabilities(row.capabilitiesJson), + // The declared limits moved since enrollment, so anything calibrated against the old ones + // needs the rider to look at it again. + "capabilitiesChanged": capabilitiesChanged.contains(row.accessoryId), + "leaseHeldMs": link?.lastAckAt.map { + Int((ProcessInfo.processInfo.systemUptime - $0) * 1000) + }, + ] + } + } + + // MARK: - Internals + + private func onMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) } + } + + private func publish() { + emit?("onAccessoryState", ["accessories": snapshot()]) + } + + private func loadSaved() { + let rows: [SavedAccessory] + do { + rows = try store.accessories() + } catch { + // Nothing starts, and the outage is reported rather than looking like "no Accessories". + RecordingStorageFailure.reportRead(operation: "accessory_list", error: error) + return + } + saved.removeAll() + order.removeAll() + rows.forEach { remember($0) } + rows.forEach { start($0) } + publish() + } + + private func remember(_ row: SavedAccessory) { + if saved[row.accessoryId] == nil { order.append(row.accessoryId) } + saved[row.accessoryId] = row + } + + private func start(_ row: SavedAccessory) { + // Every link shares the one restore-identified central. A second central here would get its own + // restoration identity and iOS would relaunch the app into a controller holding neither. + guard let central else { return } + let link: AccessoryLink + if let existing = links[row.accessoryId] { + link = existing + } else { + link = AccessoryLink( + accessoryId: row.accessoryId, + central: central, + onChanged: { [weak self] in self?.publish() }, + onManifest: { [weak self] manifest, deviceId in + self?.onManifestValidated(manifest, deviceId: deviceId) + }) + links[row.accessoryId] = link + } + applyBaseline(to: link, row: row, manifest: nil) + link.start(peripheral: peripheral(for: row)) + } + + /// The peripheral to connect to: one restoration handed back, else one resolved from the saved + /// handle. A stale handle costs a failed connect and a retry, never a wrong Accessory — the + /// manifest check is what decides identity. + private func peripheral(for row: SavedAccessory) -> CBPeripheral? { + guard let deviceId = row.deviceId, let uuid = UUID(uuidString: deviceId) else { return nil } + if let restoredPeripheral = restored[uuid] { + restoredPeripheral.delegate = self + return restoredPeripheral + } + guard let central else { return nil } + let found = central.retrievePeripherals(withIdentifiers: [uuid]).first + found?.delegate = self + return found + } + + /// A handshake that produced a manifest for an Accessory we have saved. + /// + /// The row is refreshed from what the hardware just said — name, firmware, protocol version, the + /// handle it answered on — and the capability set is compared against the one enrollment + /// validated. A capability whose limits moved is flagged rather than silently accepted: saved + /// calibration was made against the old numbers. + private func onManifestValidated(_ manifest: AccessoryManifest, deviceId: String) { + guard let previous = saved[manifest.accessoryId] else { return } + let capabilitiesJson = Self.encodeCapabilities(manifest.capabilities.map { $0.toMap() }) + if capabilitiesJson != previous.capabilitiesJson { + capabilitiesChanged.insert(manifest.accessoryId) + } + let row = SavedAccessory( + accessoryId: previous.accessoryId, + name: manifest.name, + firmwareVersion: manifest.firmwareVersion, + protocolVersion: manifest.protocolVersion, + deviceId: deviceId, + capabilitiesJson: capabilitiesJson, + enrolledAt: previous.enrolledAt, + lastConnectedAt: Int64(Date().timeIntervalSince1970 * 1000)) + remember(row) + if let link = links[manifest.accessoryId] { + applyBaseline(to: link, row: row, manifest: manifest) + } + do { + try store.upsert(row) + } catch { + // The session is live and correct; only the saved copy of what the manifest just said is + // stale, which the next successful handshake fixes. + RecordingStorageFailure.report( + operation: "accessory_revalidate", category: "write_failed", error: error) + } + } + + /// The baseline every session establishes for each capability it can drive. + /// + /// Both are the protocol's own neutral state, not a feature: a clearance sensor is held in + /// measurement standby, and a light is told plainly that Board telemetry is unavailable. They + /// exist so the session has a real acknowledged command to hold — which is what makes the lease, + /// the retry and the expiry observable before any capability's own behaviour is built. The slices + /// that own those capabilities replace these with the rider's actual demand. + private func applyBaseline( + to link: AccessoryLink, row: SavedAccessory, manifest: AccessoryManifest? + ) { + let capabilities = + manifest?.capabilities ?? Self.capabilitiesFrom(json: row.capabilitiesJson) + for capability in capabilities where capability.supported { + switch capability.type { + case AccessoryProtocol.typeGroundClearance: + guard + let rate = AccessorySession.resolveRateHz( + requested: Self.preferredRateHz, ratesHz: capability.ratesHz) + else { continue } + link.setDesired(.configure(capabilityId: capability.id, enabled: false, rateHz: rate)) + case AccessoryProtocol.typeBrakeLight: + link.setDesired( + .state( + capabilityId: capability.id, telemetry: "unavailable", mode: nil, parked: "off", + preview: false)) + default: + continue + } + } + } + + // MARK: - Capability encoding + + /// Key order is fixed so two encodings of the same capability set compare equal as text. + private static func encodeCapabilities(_ raw: Any?) -> String { + guard let list = raw as? [[String: Any?]] else { return "[]" } + let parts = list.map { entry -> String in + var out = "{\"id\":\(AccessoryProtocol.quote((entry["id"] as? String) ?? ""))" + out += ",\"type\":\(AccessoryProtocol.quote((entry["type"] as? String) ?? ""))" + out += ",\"supported\":\((entry["supported"] as? Bool) == true)" + out += ",\"unit\":\((entry["unit"] as? String).map(AccessoryProtocol.quote) ?? "null")" + out += ",\"rangeMin\":\(numberOrNull(entry["rangeMin"] ?? nil))" + out += ",\"rangeMax\":\(numberOrNull(entry["rangeMax"] ?? nil))" + let rates = ((entry["ratesHz"] as? [Double]) ?? []).map { number($0) }.joined(separator: ",") + return out + ",\"ratesHz\":[\(rates)]}" + } + return "[" + parts.joined(separator: ",") + "]" + } + + private static func numberOrNull(_ value: Any?) -> String { + guard let value = value as? Double else { return "null" } + return number(value) + } + + private static func number(_ value: Double) -> String { + if value.isFinite, value == value.rounded(.down), abs(value) < 1e15 { return String(Int64(value)) } + return String(value) + } + + private static func decodeCapabilities(_ json: String) -> [[String: Any?]] { + capabilitiesFrom(json: json).map { $0.toMap() } + } + + private static func capabilitiesFrom(json: String) -> [AccessoryCapability] { + // intentional-suppression: a capability blob that will not decode is a capability set this app + // cannot trust; an empty list is the outcome, and the next handshake rewrites the row. + guard let data = json.data(using: .utf8), + let list = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { return [] } + return list.compactMap { entry in + guard let id = entry["id"] as? String, let type = entry["type"] as? String else { return nil } + return AccessoryCapability( + id: id, type: type, supported: (entry["supported"] as? Bool) == true, + unit: entry["unit"] as? String, + rangeMin: (entry["rangeMin"] as? NSNumber)?.doubleValue, + rangeMax: (entry["rangeMax"] as? NSNumber)?.doubleValue, + ratesHz: (entry["ratesHz"] as? [NSNumber])?.map(\.doubleValue) ?? []) + } + } + + private func link(for peripheral: CBPeripheral) -> AccessoryLink? { + links.values.first { $0.peripheral?.identifier == peripheral.identifier } + } +} + +extension AccessorySessionController: CBCentralManagerDelegate { + public func centralManagerDidUpdateState(_ central: CBCentralManager) { + guard central.state == .poweredOn else { return } + // The links were created before the radio was usable; their first connect was refused and this + // is where it becomes possible. Re-starting is idempotent. + order.compactMap { saved[$0] }.forEach { start($0) } + } + + /// iOS relaunched the app for a link this controller owned. The peripherals come back before the + /// database has been read, so they are held until `loadSaved()` matches them to saved rows. + public func centralManager( + _ central: CBCentralManager, willRestoreState state: [String: Any] + ) { + let peripherals = (state[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? [] + for peripheral in peripherals { + peripheral.delegate = self + restored[peripheral.identifier] = peripheral + } + } + + public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + peripheral.delegate = self + link(for: peripheral)?.onConnected() + } + + public func centralManager( + _ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error? + ) { + link(for: peripheral)?.onConnectFailed() + } + + public func centralManager( + _ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error? + ) { + link(for: peripheral)?.onDisconnected() + } +} + +extension AccessorySessionController: CBPeripheralDelegate { + public func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + link(for: peripheral)?.onServicesDiscovered(error: error) + } + + public func peripheral( + _ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error? + ) { + link(for: peripheral)?.onCharacteristicsDiscovered(for: service, error: error) + } + + public func peripheral( + _ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, + error: Error? + ) { + link(for: peripheral)?.onNotifyStateChanged(for: characteristic, error: error) + } + + public func peripheral( + _ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error? + ) { + link(for: peripheral)?.onWriteCompleted(error: error) + } + + public func peripheral( + _ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error? + ) { + link(for: peripheral)?.onValueUpdated(for: characteristic, error: error) + } +} diff --git a/modules/vescape-core/ios/accessory/AccessorySessionTests.swift b/modules/vescape-core/ios/accessory/AccessorySessionTests.swift new file mode 100644 index 00000000..4c2c2e65 --- /dev/null +++ b/modules/vescape-core/ios/accessory/AccessorySessionTests.swift @@ -0,0 +1,135 @@ +import XCTest + +@testable import VescapeCore + +/// The operational session contract, driven by `shared/fixtures/accessory-protocol/session.json`: +/// the exact bytes of every command this app writes, and what each accessory line must mean to a +/// live session. +/// +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/AccessorySessionTest.kt +final class AccessorySessionTests: XCTestCase { + private func fixture() throws -> [String: Any] { try AccessoryFixtures.load("session.json") } + + func testTimingDefaultsMatchTheSharedFixture() throws { + let timing = try XCTUnwrap(fixture()["timing"] as? [String: Any]) + XCTAssertEqual(timing["leaseMs"] as? Int, AccessorySession.leaseMs) + XCTAssertEqual(timing["renewIntervalMs"] as? Int, AccessorySession.renewIntervalMs) + XCTAssertEqual(timing["requestTimeoutMs"] as? Int, AccessorySession.requestTimeoutMs) + XCTAssertEqual(timing["handshakeTimeoutMs"] as? Int, AccessoryProtocol.handshakeTimeoutMs) + } + + func testTheFirstCommandComesAfterTheHandshakeRequestId() throws { + // The hello owns request id 1; an operational request that reused it would look to the + // accessory like a duplicate handshake rather than a new command. + let helloRequestId = try XCTUnwrap(fixture()["helloRequestId"] as? Int) + XCTAssertEqual(helloRequestId + 1, AccessorySession.firstCommandRequestId) + } + + func testEveryCommandIsEncodedByteForByteAsTheFixturePinsIt() throws { + let json = try fixture() + let sessionId = try XCTUnwrap(json["sessionId"] as? String) + let cases = try XCTUnwrap(json["encode"] as? [[String: Any]]) + XCTAssertFalse(cases.isEmpty, "fixture must carry encode cases") + + for entry in cases { + let name = (entry["name"] as? String) ?? "?" + let spec = try XCTUnwrap(entry["command"] as? [String: Any], name) + let capabilityId = try XCTUnwrap(spec["capabilityId"] as? String, name) + let command: AccessoryCommand + switch spec["kind"] as? String { + case "configure": + command = .configure( + capabilityId: capabilityId, + enabled: try XCTUnwrap(spec["enabled"] as? Bool, name), + rateHz: try XCTUnwrap((spec["rateHz"] as? NSNumber)?.doubleValue, name)) + case "state": + command = .state( + capabilityId: capabilityId, + telemetry: try XCTUnwrap(spec["telemetry"] as? String, name), + mode: spec["mode"] as? String, + parked: try XCTUnwrap(spec["parked"] as? String, name), + preview: (spec["preview"] as? Bool) == true) + default: + return XCTFail("unknown command kind in \(name)") + } + XCTAssertEqual( + command.encode(sessionId: sessionId, requestId: try XCTUnwrap(entry["requestId"] as? Int)), + entry["line"] as? String, + name) + } + } + + func testEveryResponseCaseMatchesTheSharedFixture() throws { + let json = try fixture() + let sessionId = try XCTUnwrap(json["sessionId"] as? String) + let cases = try XCTUnwrap(json["decode"] as? [[String: Any]]) + XCTAssertFalse(cases.isEmpty, "fixture must carry decode cases") + + for entry in cases { + let name = (entry["name"] as? String) ?? "?" + let parsed = AccessoryResponse.parse( + line: try XCTUnwrap(entry["line"] as? String, name), sessionId: sessionId) + + if (entry["malformed"] as? Bool) == true { + XCTAssertEqual(parsed, .malformed, name) + continue + } + if (entry["ignored"] as? Bool) == true { + XCTAssertEqual(parsed, .ignored, name) + continue + } + if let expected = entry["error"] as? [String: Any] { + XCTAssertEqual( + parsed, + .failed( + requestId: expected["requestId"] as? Int, + code: try XCTUnwrap(expected["code"] as? String, name)), + name) + continue + } + + let expected = try XCTUnwrap(entry["ack"] as? [String: Any], name) + guard case .ack(let requestId, let capabilityId, let leaseMs, let applied) = parsed else { + XCTFail("\(name): expected an ack, got \(parsed)") + continue + } + XCTAssertEqual(requestId, expected["requestId"] as? Int, name) + XCTAssertEqual(capabilityId, expected["capabilityId"] as? String, name) + XCTAssertEqual(leaseMs, expected["leaseMs"] as? Int, name) + // The applied values are compared as text so `20` and `20.0` cannot disagree across the two + // platforms that have to read the same line. + if let rate = expected["appliedRateHz"] as? Int { + XCTAssertEqual(applied["rateHz"], String(rate), name) + } + if let enabled = expected["appliedEnabled"] as? Bool { + XCTAssertEqual(applied["enabled"], enabled ? "true" : "false", name) + } + if let telemetry = expected["appliedTelemetry"] as? String { + XCTAssertEqual(applied["telemetry"], telemetry, name) + } + if let parked = expected["appliedParked"] as? String { + XCTAssertEqual(applied["parked"], parked, name) + } + } + } + + func testMeasurementRatesResolveAgainstWhatTheHardwareDeclared() throws { + let cases = try XCTUnwrap(fixture()["rateResolution"] as? [[String: Any]]) + for entry in cases { + let name = (entry["name"] as? String) ?? "?" + let rates = ((entry["ratesHz"] as? [NSNumber]) ?? []).map(\.doubleValue) + let requested = try XCTUnwrap((entry["requested"] as? NSNumber)?.doubleValue, name) + XCTAssertEqual( + AccessorySession.resolveRateHz(requested: requested, ratesHz: rates), + (entry["resolved"] as? NSNumber)?.doubleValue, + name) + } + } + + func testACapabilityDeclaringNoRateIsNotConfigurable() { + // Not a clamp to some default: a rate the hardware never offered is one this app invented, and + // a sensor asked to run at it would be right to refuse. + XCTAssertNil(AccessorySession.resolveRateHz(requested: 20, ratesHz: [])) + XCTAssertNil(AccessorySession.resolveRateHz(requested: 20, ratesHz: [0, -5, .nan])) + } +} diff --git a/modules/vescape-core/ios/connection/VescapeLaunchSubscriber.swift b/modules/vescape-core/ios/connection/VescapeLaunchSubscriber.swift index 54844f99..98369ffa 100644 --- a/modules/vescape-core/ios/connection/VescapeLaunchSubscriber.swift +++ b/modules/vescape-core/ios/connection/VescapeLaunchSubscriber.swift @@ -29,6 +29,11 @@ public final class VescapeLaunchSubscriber: ExpoAppDelegateSubscriber { NSLog("[VescAutoConnect] didFinishLaunchingWithOptions") BoardSessionController.shared.prepareForLaunch() BoardSessionController.shared.autoConnectSelectedBoard() + // Enrolled Accessories come up on the same launch hook and for the same reason: their central + // carries its own restore identifier, and CoreBluetooth only replays a preserved central to one + // re-created during the launch sequence. They are not gated on a selected Board, the Board + // auto-connect setting, or a manual Board stop — an Accessory is enrolled in its own right. + AccessorySessionController.shared.prepareForLaunch() return false } } diff --git a/modules/vescape-core/ios/telemetry/AccessoryPersistence.swift b/modules/vescape-core/ios/telemetry/AccessoryPersistence.swift new file mode 100644 index 00000000..1ab82c0d --- /dev/null +++ b/modules/vescape-core/ios/telemetry/AccessoryPersistence.swift @@ -0,0 +1,141 @@ +import Foundation +import GRDB + +/// One enrolled Accessory: the durable half of an Accessory, and the only reason one auto-connects. +/// +/// Identity is `accessoryId` — the persistent UUID the manifest carries — never the peripheral id +/// and never the name. Both of those move: iOS mints a per-install peripheral id, Android sees a +/// rotating MAC, and the rider can rename the unit from its own firmware. Keying on the manifest id +/// is what makes a renamed Accessory the same Accessory instead of a second one. +/// +/// `deviceId` is a reconnect hint and nothing more. A stale one costs a scan, never a duplicate row. +/// +/// `capabilitiesJson` is the capability set validated at the last successful handshake. Every +/// reconnect reads the manifest again and compares: a capability whose declared limits moved is a +/// capability whose saved per-capability settings may no longer fit. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `SavedAccessoryEntity` +/// @parity /modules/vescape-core/src/index.ts `SavedAccessory` +struct SavedAccessory: Equatable { + let accessoryId: String + let name: String + let firmwareVersion: String + /// Last agreed protocol version, or nil when the two sides found none. + let protocolVersion: Int? + /// Where it answered last. A hint for the next connect, not identity. + let deviceId: String? + let capabilitiesJson: String + let enrolledAt: Int64 + let lastConnectedAt: Int64? +} + +/// Durable Accessory enrollment. Production GRDB operations shared by the app and the macOS host +/// persistence contract. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AccessoryPersistence.kt +struct AccessoryStore { + private struct WriterUnavailable: Error {} + private let resolveWriter: () -> DatabaseWriter? + + static let shared = AccessoryStore { TelemetryDatabase.pool } + + init(_ resolveWriter: @escaping () -> DatabaseWriter?) { self.resolveWriter = resolveWriter } + init(dbWriter: DatabaseWriter) { self.resolveWriter = { dbWriter } } + + private struct Record: Codable, FetchableRecord, PersistableRecord { + static let databaseTableName = "accessories" + let accessoryId: String + let name: String + let firmwareVersion: String + let protocolVersion: Int? + let deviceId: String? + let capabilitiesJson: String + let enrolledAt: Int64 + let lastConnectedAt: Int64? + + enum CodingKeys: String, CodingKey { + case accessoryId = "accessory_id" + case name + case firmwareVersion = "firmware_version" + case protocolVersion = "protocol_version" + case deviceId = "device_id" + case capabilitiesJson = "capabilities_json" + case enrolledAt = "enrolled_at" + case lastConnectedAt = "last_connected_at" + } + + init(_ accessory: SavedAccessory) { + accessoryId = accessory.accessoryId + name = accessory.name + firmwareVersion = accessory.firmwareVersion + protocolVersion = accessory.protocolVersion + deviceId = accessory.deviceId + capabilitiesJson = accessory.capabilitiesJson + enrolledAt = accessory.enrolledAt + lastConnectedAt = accessory.lastConnectedAt + } + + var accessory: SavedAccessory { + .init( + accessoryId: accessoryId, name: name, firmwareVersion: firmwareVersion, + protocolVersion: protocolVersion, deviceId: deviceId, + capabilitiesJson: capabilitiesJson, enrolledAt: enrolledAt, + lastConnectedAt: lastConnectedAt) + } + } + + static func createTables(_ db: Database) throws { try PersistenceSchema.createAccessories(db) } + + private func writer() throws -> DatabaseWriter { + guard let writer = resolveWriter() else { throw WriterUnavailable() } + return writer + } + + func accessories() throws -> [SavedAccessory] { + try writer().read { db in + try Record.order(Column("enrolled_at")).fetchAll(db).map(\.accessory) + } + } + + func accessory(_ accessoryId: String) throws -> SavedAccessory? { + try writer().read { db in + try Record.fetchOne(db, key: ["accessory_id": accessoryId])?.accessory + } + } + + /// Enrollment, and the re-validation every later handshake performs. + /// + /// `enrolledAt` is preserved across re-validation: it says when the rider added this Accessory, + /// and reading a manifest again is not adding it again. + @discardableResult + func upsert(_ accessory: SavedAccessory) throws -> SavedAccessory { + try writer().write { db in + let existing = try Record.fetchOne(db, key: ["accessory_id": accessory.accessoryId]) + let row = SavedAccessory( + accessoryId: accessory.accessoryId, name: accessory.name, + firmwareVersion: accessory.firmwareVersion, protocolVersion: accessory.protocolVersion, + deviceId: accessory.deviceId, capabilitiesJson: accessory.capabilitiesJson, + enrolledAt: existing?.enrolledAt ?? accessory.enrolledAt, + lastConnectedAt: accessory.lastConnectedAt) + try Record(row).save(db) + return row + } + } + + @discardableResult + func forget(_ accessoryId: String) throws -> Bool { + try writer().write { db in try Record.deleteOne(db, key: ["accessory_id": accessoryId]) } + } + + /// Records a successful session without rewriting the manifest facts the handshake validated. + @discardableResult + func touch(_ accessoryId: String, deviceId: String?, connectedAt: Int64) throws -> Bool { + try writer().write { db in + try db.execute( + sql: + "UPDATE accessories SET device_id = ?, last_connected_at = ? WHERE accessory_id = ?", + arguments: [deviceId, connectedAt, accessoryId]) + return db.changesCount > 0 + } + } +} diff --git a/modules/vescape-core/ios/telemetry/DatabaseBackupManager.swift b/modules/vescape-core/ios/telemetry/DatabaseBackupManager.swift index 50059d04..0c0954da 100644 --- a/modules/vescape-core/ios/telemetry/DatabaseBackupManager.swift +++ b/modules/vescape-core/ios/telemetry/DatabaseBackupManager.swift @@ -7,7 +7,7 @@ import GRDB /// `TelemetryDatabase.migrator`. /// /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `TELEMETRY_DATABASE_VERSION` -internal let TELEMETRY_SCHEMA_VERSION = 43 +internal let TELEMETRY_SCHEMA_VERSION = 44 /// Released schema generations that have a complete production path to the current schema. /// 37–39 never shipped as standalone migrations: Android deliberately jumps 36→40. diff --git a/modules/vescape-core/ios/telemetry/PersistenceSchema.swift b/modules/vescape-core/ios/telemetry/PersistenceSchema.swift index 402e5080..7b20cfa5 100644 --- a/modules/vescape-core/ios/telemetry/PersistenceSchema.swift +++ b/modules/vescape-core/ios/telemetry/PersistenceSchema.swift @@ -49,4 +49,11 @@ enum PersistenceSchema { try db.execute(sql: "CREATE TABLE IF NOT EXISTS vesc_fault_capture_samples (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, occurrence_id TEXT NOT NULL, captured_at INTEGER NOT NULL, speed REAL, duty_cycle REAL, erpm REAL, battery_voltage REAL, battery_current REAL, motor_current REAL, temp_mosfet REAL, temp_motor REAL, pitch REAL, roll REAL, balance_pitch REAL, adc1 REAL, adc2 REAL, state INTEGER)") try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_vesc_fault_capture_samples_occurrence_id_captured_at ON vesc_fault_capture_samples(occurrence_id, captured_at)") } + + /// Enrolled Accessories. Keyed on the manifest's persistent accessory id, so the same hardware + /// renamed, re-flashed or seen on a different peripheral id stays one row. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryMigrations.kt `MIGRATION_43_44` + static func createAccessories(_ db: Database) throws { + try db.execute(sql: "CREATE TABLE IF NOT EXISTS accessories (accessory_id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL, firmware_version TEXT NOT NULL, protocol_version INTEGER, device_id TEXT, capabilities_json TEXT NOT NULL, enrolled_at INTEGER NOT NULL, last_connected_at INTEGER)") + } } diff --git a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift index 9a31f1fb..635816c3 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift @@ -825,6 +825,11 @@ enum TelemetryDatabase { try rebuildBucketsOnRecordingId(db) } + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryMigrations.kt `MIGRATION_43_44` + migrator.registerMigration("v44_accessories") { db in + try PersistenceSchema.createAccessories(db) + } + return migrator } } diff --git a/modules/vescape-core/persistence-jvm/build.gradle.kts b/modules/vescape-core/persistence-jvm/build.gradle.kts index 9bba0dfd..6435526c 100644 --- a/modules/vescape-core/persistence-jvm/build.gradle.kts +++ b/modules/vescape-core/persistence-jvm/build.gradle.kts @@ -24,6 +24,7 @@ val extractProductionPersistence by tasks.registering { "expo/modules/vescapecore/telemetry/ConfigPersistence.kt", "expo/modules/vescapecore/telemetry/TuneAlertPersistence.kt", "expo/modules/vescapecore/telemetry/BoardSettingsPersistence.kt", + "expo/modules/vescapecore/telemetry/AccessoryPersistence.kt", "expo/modules/vescapecore/telemetry/RecordingPersistence.kt", "expo/modules/vescapecore/telemetry/TelemetryRoomDatabase.kt", "expo/modules/vescapecore/telemetry/DatabaseUpgradeContract.kt", diff --git a/modules/vescape-core/persistence-jvm/src/test/kotlin/expo/modules/vescapecore/telemetry/AccessoryPersistenceHostTest.kt b/modules/vescape-core/persistence-jvm/src/test/kotlin/expo/modules/vescapecore/telemetry/AccessoryPersistenceHostTest.kt new file mode 100644 index 00000000..433725dd --- /dev/null +++ b/modules/vescape-core/persistence-jvm/src/test/kotlin/expo/modules/vescapecore/telemetry/AccessoryPersistenceHostTest.kt @@ -0,0 +1,114 @@ +package expo.modules.vescapecore.telemetry + +import androidx.room.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import java.nio.file.Files +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Enrolled Accessories through the production Room path, driven by the shared contract fixture the + * GRDB host runs too. + * + * The scenario is the one that actually matters for this table: an Accessory the rider renamed and + * re-flashed, met again on a different BLE handle, must stay one Accessory. If identity ever slipped + * to the name or the handle, this is where two rows would appear. + * + * @parity /modules/vescape-core/persistence-macos/main.swift `accessory-enrollment-close-reopen` + */ +class AccessoryPersistenceHostTest { + private fun fixture(): JSONObject = + JSONObject(Files.readString(java.nio.file.Path.of("../shared/accessory-persistence-contract.json"))) + + @Test fun anAccessorySurvivesCloseReopenAndARenameNeverDuplicatesIt(): Unit = runBlocking { + val contract = fixture() + assertEquals("accessory-enrollment-close-reopen", contract.getString("scenario")) + val spec = contract.getJSONObject("accessory") + val other = contract.getJSONObject("other") + + val path = Files.createTempFile("vescape-accessories", ".db") + Files.deleteIfExists(path) + fun open() = Room.databaseBuilder(path.toString()) + .setDriver(BundledSQLiteDriver()) + .build() + + var db = open() + var store = AccessoryPersistence(db.telemetryDao()) + + val enrolled = SavedAccessoryEntity( + accessoryId = spec.getString("accessoryId"), + name = spec.getString("name"), + firmwareVersion = spec.getString("firmwareVersion"), + protocolVersion = spec.getInt("protocolVersion"), + deviceId = spec.getString("deviceId"), + capabilitiesJson = spec.getString("capabilitiesJson"), + enrolledAt = spec.getLong("enrolledAt"), + lastConnectedAt = null, + ) + store.upsert(enrolled) + store.upsert( + SavedAccessoryEntity( + accessoryId = other.getString("accessoryId"), + name = other.getString("name"), + firmwareVersion = other.getString("firmwareVersion"), + protocolVersion = other.getInt("protocolVersion"), + deviceId = other.getString("deviceId"), + capabilitiesJson = other.getString("capabilitiesJson"), + enrolledAt = other.getLong("enrolledAt"), + lastConnectedAt = null, + ), + ) + db.close() + + db = open() + store = AccessoryPersistence(db.telemetryDao()) + val reopened = store.getAccessories() + assertEquals(listOf(spec.getString("accessoryId"), other.getString("accessoryId")), reopened.map { it.accessoryId }) + assertEquals(spec.getString("capabilitiesJson"), reopened.first().capabilitiesJson) + assertNull(reopened.first().lastConnectedAt) + + // The same unit after a rename, a firmware update and a new BLE handle. Anything keyed on a + // name or an address would add a second row here. + val revalidated = store.upsert( + enrolled.copy( + name = spec.getString("renamedTo"), + firmwareVersion = spec.getString("updatedFirmwareVersion"), + deviceId = spec.getString("movedDeviceId"), + capabilitiesJson = spec.getString("changedCapabilitiesJson"), + enrolledAt = spec.getLong("reEnrolledAt"), + ), + ) + assertEquals(2, store.getAccessories().size) + assertEquals(spec.getString("renamedTo"), revalidated.name) + // Reading a manifest again is not adding the Accessory again. + assertEquals(spec.getLong("enrolledAt"), revalidated.enrolledAt) + + assertTrue(store.touch(spec.getString("accessoryId"), spec.getString("movedDeviceId"), spec.getLong("connectedAt"))) + assertFalse(store.touch("not-enrolled", null, spec.getLong("connectedAt"))) + db.close() + + db = open() + store = AccessoryPersistence(db.telemetryDao()) + val persisted = store.getAccessory(spec.getString("accessoryId"))!! + assertEquals(spec.getString("updatedFirmwareVersion"), persisted.firmwareVersion) + assertEquals(spec.getString("movedDeviceId"), persisted.deviceId) + assertEquals(spec.getString("changedCapabilitiesJson"), persisted.capabilitiesJson) + assertEquals(spec.getLong("connectedAt"), persisted.lastConnectedAt) + + // Forgetting takes the Accessory and nothing else: the other enrollment is untouched. + assertTrue(store.forget(spec.getString("accessoryId"))) + assertFalse(store.forget(spec.getString("accessoryId"))) + db.close() + + db = open() + store = AccessoryPersistence(db.telemetryDao()) + assertEquals(listOf(other.getString("accessoryId")), store.getAccessories().map { it.accessoryId }) + db.close() + Files.deleteIfExists(path) + } +} diff --git a/modules/vescape-core/persistence-jvm/src/test/kotlin/expo/modules/vescapecore/telemetry/DatabaseRestoreHostTest.kt b/modules/vescape-core/persistence-jvm/src/test/kotlin/expo/modules/vescapecore/telemetry/DatabaseRestoreHostTest.kt index 888131ff..a510d14f 100644 --- a/modules/vescape-core/persistence-jvm/src/test/kotlin/expo/modules/vescapecore/telemetry/DatabaseRestoreHostTest.kt +++ b/modules/vescape-core/persistence-jvm/src/test/kotlin/expo/modules/vescapecore/telemetry/DatabaseRestoreHostTest.kt @@ -85,8 +85,13 @@ class DatabaseRestoreHostTest { @Test fun productionMigrationGraphRejectsVersionsWithoutAPath() { assertTrue((3..36).all { it in SUPPORTED_ANDROID_DATABASE_VERSIONS }) - assertTrue((40..43).all { it in SUPPORTED_ANDROID_DATABASE_VERSIONS }) - assertTrue(listOf(1, 2, 37, 38, 39, 44).all { it !in SUPPORTED_ANDROID_DATABASE_VERSIONS }) + assertTrue((40..TELEMETRY_DATABASE_VERSION).all { it in SUPPORTED_ANDROID_DATABASE_VERSIONS }) + // The gap and the generation past the current one: 37-39 never shipped, and a database from a + // newer app than this one is not something an older migration graph may guess at. + assertTrue( + (listOf(1, 2, 37, 38, 39) + (TELEMETRY_DATABASE_VERSION + 1)) + .all { it !in SUPPORTED_ANDROID_DATABASE_VERSIONS }, + ) assertTrue((14..36).all { it in EXPORTED_ANDROID_DATABASE_VERSIONS }) assertTrue((3..13).all { it !in EXPORTED_ANDROID_DATABASE_VERSIONS }) assertEquals(1, roomVersionForBackup("ios", 1)) diff --git a/modules/vescape-core/persistence-jvm/src/test/kotlin/expo/modules/vescapecore/telemetry/TelemetryMigrationMatrixHostTest.kt b/modules/vescape-core/persistence-jvm/src/test/kotlin/expo/modules/vescapecore/telemetry/TelemetryMigrationMatrixHostTest.kt index 0126ac1d..ccf71adb 100644 --- a/modules/vescape-core/persistence-jvm/src/test/kotlin/expo/modules/vescapecore/telemetry/TelemetryMigrationMatrixHostTest.kt +++ b/modules/vescape-core/persistence-jvm/src/test/kotlin/expo/modules/vescapecore/telemetry/TelemetryMigrationMatrixHostTest.kt @@ -15,7 +15,7 @@ import org.junit.Test /** Executes every supported start with the production migration algorithms on real SQLite. */ class TelemetryMigrationMatrixHostTest { - private val supportedStarts = (3..36).toList() + listOf(40, 41, 42, 43) + private val supportedStarts = (3..36).toList() + listOf(40, 41, 42, 43, 44) private fun manifest() = JSONObject( Files.readString(java.nio.file.Path.of("../shared/migration-fixture-manifest.json")), @@ -186,7 +186,7 @@ class TelemetryMigrationMatrixHostTest { } @Test fun missingAndUnsupportedStartsFailWithoutCreatingTargetData() { - for (start in listOf(1, 2, 37, 38, 39, 44)) { + for (start in listOf(1, 2, 37, 38, 39, TELEMETRY_DATABASE_VERSION + 1)) { val path = Files.createTempFile("vescape-unsupported-v$start-", ".db") BundledSQLiteDriver().open(path.toString()).use { db -> db.exec("CREATE TABLE sentinel(value TEXT NOT NULL)") diff --git a/modules/vescape-core/persistence-macos/AccessoryPersistence.swift b/modules/vescape-core/persistence-macos/AccessoryPersistence.swift new file mode 120000 index 00000000..87e11af2 --- /dev/null +++ b/modules/vescape-core/persistence-macos/AccessoryPersistence.swift @@ -0,0 +1 @@ +../ios/telemetry/AccessoryPersistence.swift \ No newline at end of file diff --git a/modules/vescape-core/persistence-macos/main.swift b/modules/vescape-core/persistence-macos/main.swift index e42aad2e..db65d561 100644 --- a/modules/vescape-core/persistence-macos/main.swift +++ b/modules/vescape-core/persistence-macos/main.swift @@ -979,6 +979,111 @@ if let exchangePath = ProcessInfo.processInfo.environment["VESCAPE_BACKUP_EXCHAN try DatabaseBackupArchive.archive(database: iosData, manifest: iosManifest) .write(to: exchange.appendingPathComponent("ios.zip"), options: .atomic) } +// MARK: - Enrolled Accessories +// +// The scenario that actually matters for this table: an Accessory the rider renamed and re-flashed, +// met again on a different peripheral id, must stay one Accessory. If identity ever slipped to the +// name or the handle, this is where a second row would appear. +// +// @parity /modules/vescape-core/persistence-jvm/src/test/kotlin/expo/modules/vescapecore/telemetry/AccessoryPersistenceHostTest.kt +let accessoryFixture = try JSONSerialization.jsonObject( + with: Data(contentsOf: root.appendingPathComponent("shared/accessory-persistence-contract.json")) +) as! [String: Any] +try require( + accessoryFixture["scenario"] as? String == "accessory-enrollment-close-reopen", + "unknown accessory scenario") +let accessorySpec = accessoryFixture["accessory"] as! [String: Any] +let otherAccessorySpec = accessoryFixture["other"] as! [String: Any] +let accessoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("vescape-accessories-\(UUID().uuidString).db") +var accessoryQueue: DatabaseQueue? = try DatabaseQueue(path: accessoryURL.path) +try TelemetryDatabase.migrator.migrate(accessoryQueue!) +var accessoryStore = AccessoryStore(dbWriter: accessoryQueue!) + +func savedAccessory(_ spec: [String: Any], overrides: [String: Any] = [:]) -> SavedAccessory { + func value(_ key: String) -> Any? { overrides[key] ?? spec[key] } + return SavedAccessory( + accessoryId: value("accessoryId") as! String, + name: value("name") as! String, + firmwareVersion: value("firmwareVersion") as! String, + protocolVersion: (value("protocolVersion") as? NSNumber)?.intValue, + deviceId: value("deviceId") as? String, + capabilitiesJson: value("capabilitiesJson") as! String, + enrolledAt: Int64(int(value("enrolledAt"))), + lastConnectedAt: (value("lastConnectedAt") as? NSNumber)?.int64Value) +} + +try accessoryStore.upsert(savedAccessory(accessorySpec)) +try accessoryStore.upsert(savedAccessory(otherAccessorySpec)) +try accessoryQueue!.close() + +accessoryQueue = try DatabaseQueue(path: accessoryURL.path) +accessoryStore = AccessoryStore(dbWriter: accessoryQueue!) +let reopenedAccessories = try accessoryStore.accessories() +try require( + reopenedAccessories.map(\.accessoryId) + == [accessorySpec["accessoryId"] as! String, otherAccessorySpec["accessoryId"] as! String], + "Accessory reopen order") +try require( + reopenedAccessories.first?.capabilitiesJson == accessorySpec["capabilitiesJson"] as? String, + "Accessory capabilities reopen") +try require(reopenedAccessories.first?.lastConnectedAt == nil, "Accessory connected before it was") + +let revalidated = try accessoryStore.upsert( + savedAccessory( + accessorySpec, + overrides: [ + "name": accessorySpec["renamedTo"]!, + "firmwareVersion": accessorySpec["updatedFirmwareVersion"]!, + "deviceId": accessorySpec["movedDeviceId"]!, + "capabilitiesJson": accessorySpec["changedCapabilitiesJson"]!, + "enrolledAt": accessorySpec["reEnrolledAt"]!, + ])) +let afterRevalidation = try accessoryStore.accessories() +try require(afterRevalidation.count == 2, "rename duplicated an Accessory") +try require(revalidated.name == accessorySpec["renamedTo"] as? String, "Accessory rename") +// Reading a manifest again is not adding the Accessory again. +try require( + revalidated.enrolledAt == Int64(int(accessorySpec["enrolledAt"])), "Accessory enrolledAt moved") + +let touched = try accessoryStore.touch( + accessorySpec["accessoryId"] as! String, + deviceId: accessorySpec["movedDeviceId"] as? String, + connectedAt: Int64(int(accessorySpec["connectedAt"]))) +try require(touched, "Accessory touch") +let touchedUnknown = try accessoryStore.touch("not-enrolled", deviceId: nil, connectedAt: 1) +try require(!touchedUnknown, "touch invented an Accessory") +try accessoryQueue!.close() + +accessoryQueue = try DatabaseQueue(path: accessoryURL.path) +accessoryStore = AccessoryStore(dbWriter: accessoryQueue!) +let persistedAccessory = try accessoryStore.accessory(accessorySpec["accessoryId"] as! String) +try require( + persistedAccessory?.firmwareVersion == accessorySpec["updatedFirmwareVersion"] as? String, + "Accessory firmware reopen") +try require( + persistedAccessory?.capabilitiesJson == accessorySpec["changedCapabilitiesJson"] as? String, + "Accessory capabilities revalidation") +try require( + persistedAccessory?.lastConnectedAt == Int64(int(accessorySpec["connectedAt"])), + "Accessory last connected reopen") + +// Forgetting takes the Accessory and nothing else. +let forgotten = try accessoryStore.forget(accessorySpec["accessoryId"] as! String) +try require(forgotten, "Accessory forget") +let forgottenAgain = try accessoryStore.forget(accessorySpec["accessoryId"] as! String) +try require(!forgottenAgain, "forgetting twice reported a second removal") +try accessoryQueue!.close() + +accessoryQueue = try DatabaseQueue(path: accessoryURL.path) +accessoryStore = AccessoryStore(dbWriter: accessoryQueue!) +let remainingAccessories = try accessoryStore.accessories() +try require( + remainingAccessories.map(\.accessoryId) == [otherAccessorySpec["accessoryId"] as! String], + "forget removed the wrong Accessory") +try accessoryQueue!.close() +try? FileManager.default.removeItem(at: accessoryURL) + print("recording-contract macOS runtimeMs=\(Int(Date().timeIntervalSince(started) * 1000)) scenario=\(fixture["scenario"]!)") private extension String { diff --git a/modules/vescape-core/shared/accessory-persistence-contract.json b/modules/vescape-core/shared/accessory-persistence-contract.json new file mode 100644 index 00000000..2443174e --- /dev/null +++ b/modules/vescape-core/shared/accessory-persistence-contract.json @@ -0,0 +1,28 @@ +{ + "scenario": "accessory-enrollment-close-reopen", + "$comment": "Enrolled Accessories, the same case on Room and GRDB. The point of the scenario is that identity is the manifest's accessory id: the second enrollment is the same physical unit after a rename and a firmware update, seen on a different BLE handle, and it must land on the same row with its original `enrolledAt` intact. `other` exists so the reopen proves rows are kept apart rather than merged.", + "accessory": { + "accessoryId": "b36ed5bd-1d24-460c-8034-aaeaefc5d016", + "name": "Clearance sensor", + "renamedTo": "Nose sensor", + "firmwareVersion": "0.1.0", + "updatedFirmwareVersion": "0.3.0", + "protocolVersion": 1, + "deviceId": "AA:BB:CC:DD:EE:01", + "movedDeviceId": "AA:BB:CC:DD:EE:99", + "capabilitiesJson": "[{\"id\":\"clearance\",\"type\":\"ground_clearance\",\"supported\":true,\"unit\":\"cm\",\"rangeMin\":3,\"rangeMax\":100,\"ratesHz\":[10,20,30]}]", + "changedCapabilitiesJson": "[{\"id\":\"clearance\",\"type\":\"ground_clearance\",\"supported\":true,\"unit\":\"cm\",\"rangeMin\":5,\"rangeMax\":80,\"ratesHz\":[10,20]}]", + "enrolledAt": 1700000000000, + "reEnrolledAt": 1799999999000, + "connectedAt": 1700000060000 + }, + "other": { + "accessoryId": "0f0be7c8-6d97-4a56-9b1b-2d5f3b6a0c11", + "name": "Rear light", + "firmwareVersion": "0.2.1", + "protocolVersion": 1, + "deviceId": "AA:BB:CC:DD:EE:02", + "capabilitiesJson": "[{\"id\":\"rear_light\",\"type\":\"brake_light\",\"supported\":true,\"unit\":null,\"rangeMin\":null,\"rangeMax\":null,\"ratesHz\":[]}]", + "enrolledAt": 1700000001000 + } +} diff --git a/modules/vescape-core/shared/migration-fixture-manifest.json b/modules/vescape-core/shared/migration-fixture-manifest.json index 7a80118f..ad6c47b7 100644 --- a/modules/vescape-core/shared/migration-fixture-manifest.json +++ b/modules/vescape-core/shared/migration-fixture-manifest.json @@ -3,13 +3,13 @@ "baseline": { "version": 3, "source": "f51663a8^" }, "supportedStarts": [ 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 35, 36, 40, 41, 42, 43 + 28, 29, 30, 31, 32, 33, 34, 35, 36, 40, 41, 42, 43, 44 ], "archiveSupportedStarts": [ 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, - 40, 41, 42, 43 + 40, 41, 42, 43, 44 ], - "unsupportedStarts": [1, 2, 37, 38, 39, 44], + "unsupportedStarts": [1, 2, 37, 38, 39, 45], "historicalVariants": [ { "version": 22, "source": "10deb46c^", "shape": "tune_profiles_without_icon_or_color" } ], @@ -41,7 +41,8 @@ "v40_vesc_faults", "v41_board_deleted_at", "v42_telemetry_board_id", - "v43_ride_track" + "v43_ride_track", + "v44_accessories" ], "historicalVariants": [ { "migration": "v1", "source": "db6e9b9", "shape": "global_alerts_and_legacy_fault_columns" } diff --git a/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index fdedeb48..a4214203 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -147,6 +147,78 @@ export interface AccessoryInspection { error: AccessoryInspectionError | null } +/** + * Where one enrolled Accessory's link stands, decided natively. + * + * JS never derives one of these from a boolean, exactly as it never derives a Board phase. A drop + * reads as `connecting`, not as an error: the OS keeps the reconnect alive on both platforms, and a + * rider who walked out of range has not lost their Accessory. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryLink.kt `AccessoryLinkPhase` + * @parity /modules/vescape-core/ios/accessory/AccessoryLink.swift `AccessoryLinkPhase` + */ +export type AccessoryLinkPhase = + | 'idle' + | 'connecting' + | 'handshaking' + | 'connected' + | 'unavailable' + | 'incompatible' + +/** + * One enrolled Accessory, as native currently sees it: the durable row plus whatever the live + * session knows. + * + * Identity is `accessoryId`, the manifest's persistent UUID. `deviceId` is where it answered last + * and is a reconnect hint, never identity — a renamed unit on a new BLE handle is the same + * Accessory, which is why nothing here is keyed on either. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt `buildSnapshot` + * @parity /modules/vescape-core/ios/accessory/AccessorySessionController.swift `snapshot` + */ +export interface SavedAccessory { + accessoryId: string + /** Manifest name, refreshed on every handshake. */ + name: string + firmwareVersion: string + /** Last agreed protocol version, or null when the two sides found none. */ + protocolVersion: number | null + /** Where it answered last. A hint for the next connect, not identity. */ + deviceId: string | null + enrolledAt: number + lastConnectedAt: number | null + phase: AccessoryLinkPhase + /** Wire string for the last failure, or null while nothing is wrong. */ + error: string | null + /** Native's verdict from the live manifest; null while no session is established. */ + compatibility: AccessoryCompatibility | null + /** Live capabilities while connected, else the set validated at the last handshake. */ + capabilities: AccessoryCapability[] + /** + * The declared capability limits moved since enrollment. Anything calibrated against the old ones + * needs the rider to look at it again before it drives hardware. + */ + capabilitiesChanged: boolean + /** How long ago the accessory last acknowledged a command, or null if it never has. */ + leaseHeldMs: number | null +} + +/** + * The saved Accessories and their live sessions, pushed on every change. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt `publish` + * @parity /modules/vescape-core/ios/accessory/AccessorySessionController.swift `publish` + */ +export interface AccessoryStateEvent { + accessories: SavedAccessory[] +} + +/** What `enrollAccessory` decided. `accessoryId` is set only when a manifest was read and saved. */ +export interface AccessoryEnrollment { + accessoryId: string | null + error: AccessoryInspectionError | 'storage-unavailable' | null +} + /** * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/protocol/VescTelemetryModels.kt `LocationSnapshot` * @parity /modules/vescape-core/ios/telemetry/TelemetryPipeline.swift `TelemetryLocationCapture` @@ -2247,6 +2319,8 @@ type VescapeCoreEvents = { onAccessoryDevice: (event: AccessoryDeviceEvent) => void /** The accessory scan could not run or stopped running. */ onAccessoryScanError: (event: AccessoryScanErrorEvent) => void + /** Every enrolled Accessory and its native link state, on every change and on subscribe. */ + onAccessoryState: (event: AccessoryStateEvent) => void } interface NativeEventEmitter void>> { @@ -2268,6 +2342,9 @@ type VescapeCoreNativeModule = NativeEventEmitter & { stopAccessoryScan(): void cancelAccessoryInspection(): void inspectAccessory(deviceId: string): Promise + enrollAccessory(deviceId: string): Promise + forgetAccessory(accessoryId: string): Promise + getAccessories(): SavedAccessory[] exitApp(): void startLocationUpdates(): void stopLocationUpdates(): void @@ -2523,6 +2600,27 @@ export function cancelAccessoryInspection(): void { native.cancelAccessoryInspection() } +/** + * Add one discovered Accessory, so it is remembered and auto-connects from now on. + * + * The manifest is read natively before anything is saved — this takes a device handle, never an + * identity. Enrollment is the rider's explicit act and the only thing that gives an Accessory a + * session; a device that merely advertises nearby is never added on its own. + */ +export function enrollAccessory(deviceId: string): Promise { + return native.enrollAccessory(deviceId) +} + +/** Forget an Accessory: the saved identity goes, and its session with it. */ +export function forgetAccessory(accessoryId: string): Promise { + return native.forgetAccessory(accessoryId) +} + +/** Current saved Accessories and their link state, for a late subscriber or a foreground restore. */ +export function getAccessories(): SavedAccessory[] { + return native.getAccessories() +} + /** Start app-level Android location updates independently of a board session. */ export function startLocationUpdates(): void { native.startLocationUpdates() @@ -3601,6 +3699,12 @@ export function addAccessoryScanErrorListener( return emitter.addListener('onAccessoryScanError', cb) } +export function addAccessoryStateListener( + cb: (event: AccessoryStateEvent) => void, +): EventSubscription { + return emitter.addListener('onAccessoryState', cb) +} + export function addErrorListener(cb: (event: ErrorEvent) => void): EventSubscription { return emitter.addListener('onError', cb) } diff --git a/shared/fixtures/accessory-protocol/session.json b/shared/fixtures/accessory-protocol/session.json new file mode 100644 index 00000000..d6ae857c --- /dev/null +++ b/shared/fixtures/accessory-protocol/session.json @@ -0,0 +1,254 @@ +{ + "$comment": "Vescape Accessory Protocol v1 operational session contract: the commands an enrolled Accessory's session sends, the acknowledgements it accepts back, and the request-id rules the accessory enforces. Kotlin, Swift and the ESP32 firmware all run these cases. `encode` pins the exact bytes the app writes for one desired command at one request id — a map-backed encoder does not promise key order, and three implementations comparing bytes is what keeps the wire from drifting. `decode` is the app-side parser: what an accessory line must produce, or that it must be ignored without disturbing the session. `peer` drives the firmware: a sequence of received lines and the exact lines it must answer with, which is where stale/reused request ids, duplicate-retry replay and rate resolution are pinned.", + "sessionId": "b06b9d76-6c73-4d70-a763-d933b294c45b", + "otherSessionId": "1a2b3c4d-0000-4000-8000-000000000000", + "helloRequestId": 1, + "timing": { + "leaseMs": 2000, + "renewIntervalMs": 500, + "requestTimeoutMs": 500, + "handshakeTimeoutMs": 3000 + }, + "errorCodes": [ + "invalid_argument", + "unknown_capability", + "unsupported_message", + "not_ready", + "hardware_error", + "stale_request", + "request_id_reused" + ], + "encode": [ + { + "name": "configure puts a clearance sensor into measurement standby", + "requestId": 2, + "command": { + "kind": "configure", + "capabilityId": "clearance", + "enabled": false, + "rateHz": 20 + }, + "line": "{\"type\":\"configure\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"clearance\",\"enabled\":false,\"rateHz\":20}" + }, + { + "name": "configure enables measurement at a resolved rate", + "requestId": 3, + "command": { + "kind": "configure", + "capabilityId": "clearance", + "enabled": true, + "rateHz": 30 + }, + "line": "{\"type\":\"configure\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":3,\"capabilityId\":\"clearance\",\"enabled\":true,\"rateHz\":30}" + }, + { + "name": "state reports Board telemetry unavailable and omits mode", + "requestId": 4, + "command": { + "kind": "state", + "capabilityId": "rear_light", + "telemetry": "unavailable", + "mode": null, + "parked": "off", + "preview": false + }, + "line": "{\"type\":\"state\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":4,\"capabilityId\":\"rear_light\",\"telemetry\":\"unavailable\",\"parked\":\"off\"}" + }, + { + "name": "state carries a riding mode when Board telemetry is available", + "requestId": 5, + "command": { + "kind": "state", + "capabilityId": "rear_light", + "telemetry": "available", + "mode": "braking", + "parked": "glow", + "preview": false + }, + "line": "{\"type\":\"state\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":5,\"capabilityId\":\"rear_light\",\"telemetry\":\"available\",\"mode\":\"braking\",\"parked\":\"glow\"}" + }, + { + "name": "preview labels simulated state and may pair unavailable telemetry with a mode", + "requestId": 6, + "command": { + "kind": "state", + "capabilityId": "rear_light", + "telemetry": "unavailable", + "mode": "hard_braking", + "parked": "glow", + "preview": true + }, + "line": "{\"type\":\"state\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":6,\"capabilityId\":\"rear_light\",\"telemetry\":\"unavailable\",\"mode\":\"hard_braking\",\"parked\":\"glow\",\"preview\":true}" + } + ], + "decode": [ + { + "name": "ack carries the values actually applied and the lease they hold", + "line": "{\"type\":\"ack\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"clearance\",\"applied\":{\"enabled\":false,\"rateHz\":20},\"leaseMs\":2000}", + "ack": { + "requestId": 2, + "capabilityId": "clearance", + "leaseMs": 2000, + "appliedEnabled": false, + "appliedRateHz": 20 + } + }, + { + "name": "ack for a state command reports the state the accessory is now in", + "line": "{\"type\":\"ack\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":4,\"capabilityId\":\"rear_light\",\"applied\":{\"telemetry\":\"unavailable\",\"parked\":\"off\"},\"leaseMs\":2000}", + "ack": { + "requestId": 4, + "capabilityId": "rear_light", + "leaseMs": 2000, + "appliedTelemetry": "unavailable", + "appliedParked": "off" + } + }, + { + "name": "an error names the request it refused", + "line": "{\"type\":\"error\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":3,\"code\":\"invalid_argument\",\"message\":\"rateHz must be positive\"}", + "error": { "requestId": 3, "code": "invalid_argument" } + }, + { + "name": "an ack from another session is ignored and renews nothing", + "line": "{\"type\":\"ack\",\"sessionId\":\"1a2b3c4d-0000-4000-8000-000000000000\",\"requestId\":2,\"capabilityId\":\"clearance\",\"applied\":{\"enabled\":false,\"rateHz\":20},\"leaseMs\":2000}", + "ignored": true + }, + { + "name": "an ack without a lease is not an applied command", + "line": "{\"type\":\"ack\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"clearance\",\"applied\":{\"enabled\":false,\"rateHz\":20}}", + "ignored": true + }, + { + "name": "a message type this slice does not handle is ignored, not a protocol failure", + "line": "{\"type\":\"reading\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\",\"seq\":1,\"sampleTimeMs\":125,\"status\":\"ok\",\"value\":12.4}", + "ignored": true + }, + { + "name": "a line that is not JSON ends the protocol session", + "line": "not json at all", + "malformed": true + } + ], + "peer": [ + { + "name": "configure is acknowledged with the values actually applied", + "requests": [ + "{\"type\":\"configure\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"rear_light\",\"enabled\":false,\"rateHz\":20}" + ], + "replies": [ + "{\"type\":\"error\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"code\":\"invalid_argument\",\"message\":\"capability does not take a configuration\"}" + ] + }, + { + "name": "a state command is acknowledged and leased", + "requests": [ + "{\"type\":\"state\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"rear_light\",\"telemetry\":\"unavailable\",\"parked\":\"off\"}" + ], + "replies": [ + "{\"type\":\"ack\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"rear_light\",\"applied\":{\"telemetry\":\"unavailable\",\"parked\":\"off\"},\"leaseMs\":2000}" + ] + }, + { + "name": "retrying the same request id replays the same reply", + "requests": [ + "{\"type\":\"state\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"rear_light\",\"telemetry\":\"unavailable\",\"parked\":\"off\"}", + "{\"type\":\"state\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"rear_light\",\"telemetry\":\"unavailable\",\"parked\":\"off\"}" + ], + "replies": [ + "{\"type\":\"ack\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"rear_light\",\"applied\":{\"telemetry\":\"unavailable\",\"parked\":\"off\"},\"leaseMs\":2000}", + "{\"type\":\"ack\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"rear_light\",\"applied\":{\"telemetry\":\"unavailable\",\"parked\":\"off\"},\"leaseMs\":2000}" + ], + "leaseRenewals": 1 + }, + { + "name": "an older request id is refused as stale", + "requests": [ + "{\"type\":\"state\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":3,\"capabilityId\":\"rear_light\",\"telemetry\":\"unavailable\",\"parked\":\"off\"}", + "{\"type\":\"state\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"rear_light\",\"telemetry\":\"available\",\"mode\":\"riding\",\"parked\":\"off\"}" + ], + "replies": [ + "{\"type\":\"ack\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":3,\"capabilityId\":\"rear_light\",\"applied\":{\"telemetry\":\"unavailable\",\"parked\":\"off\"},\"leaseMs\":2000}", + "{\"type\":\"error\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"code\":\"stale_request\",\"message\":\"request id is older than the last one applied\"}" + ], + "leaseRenewals": 1 + }, + { + "name": "reusing a request id with a different body is refused", + "requests": [ + "{\"type\":\"state\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"rear_light\",\"telemetry\":\"unavailable\",\"parked\":\"off\"}", + "{\"type\":\"state\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"rear_light\",\"telemetry\":\"available\",\"mode\":\"riding\",\"parked\":\"off\"}" + ], + "replies": [ + "{\"type\":\"ack\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"rear_light\",\"applied\":{\"telemetry\":\"unavailable\",\"parked\":\"off\"},\"leaseMs\":2000}", + "{\"type\":\"error\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"code\":\"request_id_reused\",\"message\":\"request id was already used with a different body\"}" + ], + "leaseRenewals": 1 + }, + { + "name": "an unknown capability id is refused without touching anything", + "requests": [ + "{\"type\":\"state\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"front_light\",\"telemetry\":\"unavailable\",\"parked\":\"off\"}" + ], + "replies": [ + "{\"type\":\"error\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"code\":\"unknown_capability\",\"message\":\"no capability with that id\"}" + ], + "leaseRenewals": 0 + }, + { + "name": "a mode without available telemetry is only legal under preview", + "requests": [ + "{\"type\":\"state\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"rear_light\",\"telemetry\":\"unavailable\",\"mode\":\"braking\",\"parked\":\"off\"}" + ], + "replies": [ + "{\"type\":\"error\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"code\":\"invalid_argument\",\"message\":\"mode requires available telemetry unless preview\"}" + ], + "leaseRenewals": 0 + }, + { + "name": "a command from another session never renews a lease", + "requests": [ + "{\"type\":\"state\",\"sessionId\":\"1a2b3c4d-0000-4000-8000-000000000000\",\"requestId\":2,\"capabilityId\":\"rear_light\",\"telemetry\":\"unavailable\",\"parked\":\"off\"}" + ], + "replies": [], + "leaseRenewals": 0 + }, + { + "name": "a well-formed message type v1 does not define is refused, not disconnected", + "requests": [ + "{\"type\":\"calibrate\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"capabilityId\":\"rear_light\"}" + ], + "replies": [ + "{\"type\":\"error\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"requestId\":2,\"code\":\"unsupported_message\",\"message\":\"v1 defines no such message\"}" + ], + "leaseRenewals": 0 + } + ], + "rateResolution": [ + { "name": "exact rate is kept", "ratesHz": [10, 20, 30], "requested": 20, "resolved": 20 }, + { + "name": "nearest supported rate wins", + "ratesHz": [10, 20, 30], + "requested": 28, + "resolved": 30 + }, + { + "name": "a tie resolves to the lower rate", + "ratesHz": [10, 20, 30], + "requested": 25, + "resolved": 20 + }, + { + "name": "below the slowest rate clamps to it", + "ratesHz": [10, 20, 30], + "requested": 1, + "resolved": 10 + }, + { + "name": "above the fastest rate clamps to it", + "ratesHz": [10, 20, 30], + "requested": 500, + "resolved": 30 + } + ] +} diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index c9ce419f..26ebb5ea 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -19,6 +19,7 @@ import { DiagnosticErrorBoundary } from '@/modules/diagnostics/DiagnosticErrorBo import { HeaderBackButton } from '@/components/base/HeaderBackButton' import { initSentry } from '@/config/sentry' import { stackScreens } from '@/navigation/routes' +import { startAccessoryStateMirror } from '@/modules/accessories/store/accessoryStore' import { startAlertPresetConfigSync } from '@/modules/alerts/lib/alertPresetConfigSync' import { startAlertsBoardSync } from '@/bootstrap/alertsBoardSync' import { startAppDataSync } from '@/bootstrap/appDataSync' @@ -106,6 +107,7 @@ function RootLayout() { const stopAppStatusSync = startAppStatusSync() const stopNavigationSync = startNavigationSync() const stopWeatherSync = startWeatherSync() + const stopAccessoryStateMirror = startAccessoryStateMirror() return () => { useGroupRideStore.getState().stopObserving() stopAppDataSync() @@ -120,6 +122,7 @@ function RootLayout() { stopAppStatusSync() stopNavigationSync() stopWeatherSync() + stopAccessoryStateMirror() } }, [fixturesReady]) diff --git a/src/app/accessories/[accessoryId].tsx b/src/app/accessories/[accessoryId].tsx index 67383a03..7c49fa19 100644 --- a/src/app/accessories/[accessoryId].tsx +++ b/src/app/accessories/[accessoryId].tsx @@ -1,8 +1,15 @@ -import { useLocalSearchParams } from 'expo-router' +import { router, useLocalSearchParams } from 'expo-router' import { AccessoryDetailScreen } from '@/modules/accessories/screens/AccessoryDetailScreen' export default function AccessoryRoute() { const { accessoryId } = useLocalSearchParams<{ accessoryId: string }>() - return + return ( + { + if (router.canGoBack()) router.back() + }} + /> + ) } diff --git a/src/modules/accessories/components/AccessoryRow.tsx b/src/modules/accessories/components/AccessoryRow.tsx index 765ca775..5d8baf06 100644 --- a/src/modules/accessories/components/AccessoryRow.tsx +++ b/src/modules/accessories/components/AccessoryRow.tsx @@ -2,10 +2,8 @@ import { Pressable, StyleSheet, View } from 'react-native' import { PlugsConnectedIcon, WarningCircleIcon } from 'phosphor-react-native' import { Text } from '@/components/base/Text' -import { - accessoryStatusCopy, - type AccessoryLinkStatus, -} from '@/modules/accessories/lib/accessoryStatus' +import { accessoryStatusCopy } from '@/modules/accessories/lib/accessoryStatus' +import type { AccessoryLinkPhase } from 'vescape-core' import { interaction, theme } from '@/constants/theme' const TONE = { @@ -18,9 +16,10 @@ export interface AccessoryRowProps { name: string /** Firmware version, or whatever secondary fact best identifies this unit. */ detail?: string | undefined - status: AccessoryLinkStatus - /** True when the manifest said this app cannot drive the accessory. */ - incompatible?: boolean + /** Native's link phase. Never derived here — this row phrases it and nothing else. */ + phase: AccessoryLinkPhase + /** True when saved settings can no longer be trusted: changed limits, or nothing usable left. */ + needsSetup?: boolean onPress: () => void } @@ -31,20 +30,22 @@ export interface AccessoryRowProps { * Deliberately dumb — it takes strings and a status, never a store or a manifest, so the same row * serves the selector, the showcase, and whatever screen lists Accessories next. */ -export function AccessoryRow({ name, detail, status, incompatible, onPress }: AccessoryRowProps) { - const copy = accessoryStatusCopy(status) - const tone = incompatible ? TONE.caution : TONE[copy.tone] +export function AccessoryRow({ name, detail, phase, needsSetup, onPress }: AccessoryRowProps) { + const copy = accessoryStatusCopy(phase) + const label = needsSetup ? 'Setup required' : copy.label + const tone = needsSetup ? TONE.caution : TONE[copy.tone] + const warn = needsSetup || phase === 'incompatible' return ( [styles.row, pressed && styles.rowPressed]} onPress={onPress} accessibilityRole="button" - accessibilityLabel={`${name}, ${incompatible ? 'not supported' : copy.label}`} + accessibilityLabel={`${name}, ${label}`} testID={`accessory-row-${name}`} > - {incompatible ? ( + {warn ? ( ) : ( @@ -60,13 +61,13 @@ export function AccessoryRow({ name, detail, status, incompatible, onPress }: Ac styles.dot, { borderColor: tone, - backgroundColor: status === 'advertising' && !incompatible ? tone : 'transparent', + // Filled only while the link is actually up: a hollow dot is the honest shape for + // "trying", and a filled one must never promise a connection there isn't. + backgroundColor: phase === 'connected' && !warn ? tone : 'transparent', }, ]} /> - - {incompatible ? 'Not supported' : copy.label} - + {label} {detail ? ( <> · diff --git a/src/modules/accessories/components/AccessorySelectorSection.tsx b/src/modules/accessories/components/AccessorySelectorSection.tsx index 18c0d2b2..4bb2f0f3 100644 --- a/src/modules/accessories/components/AccessorySelectorSection.tsx +++ b/src/modules/accessories/components/AccessorySelectorSection.tsx @@ -3,15 +3,15 @@ import { PlusIcon } from 'phosphor-react-native' import { Text } from '@/components/base/Text' import { AccessoryRow } from '@/modules/accessories/components/AccessoryRow' -import type { AccessoryLinkStatus } from '@/modules/accessories/lib/accessoryStatus' +import type { AccessoryLinkPhase } from 'vescape-core' import { interaction, theme } from '@/constants/theme' export interface AccessorySelectorItem { accessoryId: string name: string detail?: string | undefined - status: AccessoryLinkStatus - incompatible?: boolean + phase: AccessoryLinkPhase + needsSetup?: boolean } interface AccessorySelectorSectionProps { @@ -36,8 +36,8 @@ export function AccessorySelectorSection({ {accessories.length === 0 ? ( - No accessories yet. Vescape finds them by the service they advertise, whatever they are - named. + No accessories yet. Add one and Vescape connects to it on its own from then on, whatever + it is named. ) : ( accessories.map((accessory) => ( @@ -45,8 +45,8 @@ export function AccessorySelectorSection({ key={accessory.accessoryId} name={accessory.name} detail={accessory.detail} - status={accessory.status} - incompatible={accessory.incompatible} + phase={accessory.phase} + needsSetup={accessory.needsSetup} onPress={() => onSelectAccessory(accessory.accessoryId)} /> )) diff --git a/src/modules/accessories/lib/accessoryStatus.ts b/src/modules/accessories/lib/accessoryStatus.ts index e454d4d7..d33d0dc1 100644 --- a/src/modules/accessories/lib/accessoryStatus.ts +++ b/src/modules/accessories/lib/accessoryStatus.ts @@ -1,16 +1,8 @@ -import type { AccessoryCompatibility, AccessoryInspectionError } from 'vescape-core' - -/** - * How an Accessory stands with the app right now. - * - * - `advertising` — a running scan is hearing it this moment. - * - `idle` — its manifest was read successfully, and nothing is scanning to say more than that. - * - `unreachable` — the last handshake with it failed. - * - * Discovery holds no connection: the handshake disconnects as soon as the manifest is read, so - * "connected" is deliberately absent until enrollment gives an Accessory a session to stay in. - */ -export type AccessoryLinkStatus = 'advertising' | 'idle' | 'unreachable' +import type { + AccessoryCompatibility, + AccessoryInspectionError, + AccessoryLinkPhase, +} from 'vescape-core' export interface AccessoryStatusCopy { label: string @@ -18,14 +10,27 @@ export interface AccessoryStatusCopy { tone: 'success' | 'neutral' | 'caution' } -export function accessoryStatusCopy(status: AccessoryLinkStatus): AccessoryStatusCopy { - switch (status) { - case 'advertising': - return { label: 'Nearby', tone: 'success' } - case 'unreachable': +/** + * Rider-facing phrasing for native's link phase. Native decides; this only phrases it. + * + * A dropped link reads as "Connecting", not as a failure: both platforms keep the reconnect alive + * on their own, so a rider who walked out of range is waiting rather than broken. "Not reachable" + * is reserved for a session that actually went wrong. + */ +export function accessoryStatusCopy(phase: AccessoryLinkPhase): AccessoryStatusCopy { + switch (phase) { + case 'connected': + return { label: 'Connected', tone: 'success' } + case 'connecting': + return { label: 'Connecting…', tone: 'neutral' } + case 'handshaking': + return { label: 'Checking…', tone: 'neutral' } + case 'unavailable': return { label: 'Not reachable', tone: 'caution' } + case 'incompatible': + return { label: 'Not supported', tone: 'caution' } case 'idle': - return { label: 'Paired', tone: 'neutral' } + return { label: 'Saved', tone: 'neutral' } } } @@ -59,8 +64,13 @@ export function compatibilityCopy(compatibility: AccessoryCompatibility): { } } -/** Why a handshake produced no manifest, in rider language. */ -export function inspectionErrorCopy(error: AccessoryInspectionError): string { +/** + * Why a handshake produced no manifest, in rider language. + * + * The parameter is widened past the union on purpose: these are native's wire strings, and a code + * this app has no copy for is still worth showing verbatim rather than rendering blank. + */ +export function inspectionErrorCopy(error: AccessoryInspectionError | (string & {})): string { switch (error) { case 'malformed': case 'invalid': @@ -85,5 +95,38 @@ export function inspectionErrorCopy(error: AccessoryInspectionError): string { return 'Cancelled.' case 'busy': return 'Another accessory is being checked right now.' + default: + return error + } +} + +/** + * Why a live session is unhappy, in rider language. + * + * These are native's own wire strings, which overlap the handshake errors but add the ones only a + * session can produce. An unrecognized code falls through to the handshake phrasing rather than + * being hidden — a code this app has no copy for is still worth showing. + */ +export function linkErrorCopy(error: string): string { + switch (error) { + case 'identity-mismatch': + return 'A different accessory answered at this address. Vescape will keep looking for yours.' + case 'unknown-device': + return 'Vescape has not seen this accessory since it was added. Scan for it again.' + case 'stale_request': + case 'request_id_reused': + return 'The accessory and Vescape lost track of each other. The session will restart.' + case 'unknown_capability': + return 'The accessory no longer offers something Vescape was configuring.' + case 'invalid_argument': + return 'The accessory refused a setting Vescape sent.' + case 'not_ready': + return 'The accessory is not ready yet.' + case 'hardware_error': + return 'The accessory reported a hardware problem.' + case 'unsupported_message': + return 'The accessory does not understand what Vescape asked for.' + default: + return inspectionErrorCopy(error) } } diff --git a/src/modules/accessories/screens/AccessoryDetailScreen.tsx b/src/modules/accessories/screens/AccessoryDetailScreen.tsx index db6d1423..1323777c 100644 --- a/src/modules/accessories/screens/AccessoryDetailScreen.tsx +++ b/src/modules/accessories/screens/AccessoryDetailScreen.tsx @@ -1,33 +1,51 @@ +import { useCallback, useState } from 'react' import { ScrollView, StyleSheet, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { PlugsConnectedIcon } from 'phosphor-react-native' import { Text } from '@/components/base/Text' +import { Button } from '@/components/base/Button' import { IconHero } from '@/components/settings/IconHero' import { SettingsSectionTitle } from '@/components/settings/SettingsSectionTitle' import { AccessoryCapabilityRow } from '@/modules/accessories/components/AccessoryCapabilityRow' import { AccessoryCompatibilityNotice } from '@/modules/accessories/components/AccessoryCompatibilityNotice' -import { accessoryStatusCopy } from '@/modules/accessories/lib/accessoryStatus' -import { - accessoryLinkStatus, - useAccessoryDiscoveryStore, -} from '@/modules/accessories/store/accessoryDiscoveryStore' +import { accessoryStatusCopy, linkErrorCopy } from '@/modules/accessories/lib/accessoryStatus' +import { useAccessoryStore, useSavedAccessory } from '@/modules/accessories/store/accessoryStore' import { fmtTimeAgo } from '@/helpers/format' import { theme } from '@/constants/theme' /** - * One Accessory's configuration screen: who it says it is, whether Vescape can drive it, and what - * it offers. + * One Accessory's configuration screen: who it says it is, whether Vescape can drive it, what it + * offers, and where its link stands right now. * * Identity first, because everything saved about an Accessory keys on it. Per-capability setup — * clearance calibration, brake-light behaviour — lives behind each capability in its own slice; * this screen is the place they hang off, and the place that says plainly when they cannot. + * + * Every fact here is native's. The link phase is the one a native session is actually in, which is + * running whether or not this screen was ever opened. */ -export function AccessoryDetailScreen({ accessoryId }: { accessoryId: string }) { - const accessory = useAccessoryDiscoveryStore((s) => - s.accessories.find((a) => a.accessoryId === accessoryId), - ) - const devices = useAccessoryDiscoveryStore((s) => s.devices) +export function AccessoryDetailScreen({ + accessoryId, + onForgotten, +}: { + accessoryId: string + /** Called once the Accessory is gone, so the route that opened this can leave. */ + onForgotten?: () => void +}) { + const accessory = useSavedAccessory(accessoryId) + const forget = useAccessoryStore((s) => s.forget) + const [forgetting, setForgetting] = useState(false) + + const onForget = useCallback(async () => { + setForgetting(true) + try { + await forget(accessoryId) + onForgotten?.() + } finally { + setForgetting(false) + } + }, [accessoryId, forget, onForgotten]) if (!accessory) { return ( @@ -35,56 +53,84 @@ export function AccessoryDetailScreen({ accessoryId }: { accessoryId: string }) ) } - const { manifest } = accessory - const status = accessoryLinkStatus(accessory, devices) + const status = accessoryStatusCopy(accessory.phase) return ( - + - + {accessory.compatibility ? ( + + ) : null} + + {accessory.capabilitiesChanged ? ( + + This accessory now declares different limits than when it was added. Anything calibrated + against the old ones needs checking before it drives the board again. + + ) : null} + + Connection + + + {accessory.error ? ( + + ) : null} + + Identity - - + + - + Capabilities - {manifest.capabilities.length === 0 ? ( + {accessory.capabilities.length === 0 ? ( This accessory declared no capabilities. ) : ( - manifest.capabilities.map((capability) => ( + accessory.capabilities.map((capability) => ( )) )} +