diff --git a/CONTEXT.md b/CONTEXT.md index b078234d..e4c7e183 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -4,6 +4,30 @@ 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 + +**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) + **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.md b/docs/accessories.md new file mode 100644 index 00000000..2c8fa092 --- /dev/null +++ b/docs/accessories.md @@ -0,0 +1,181 @@ +# Accessories + +Design in progress. Most of this is agreed requirements, not implemented behavior. + +**Implemented so far**: discovery, enrollment, the reconnecting session, the ground-clearance reading +and calibration path, and the Remote Tilt binding that acts on it. + +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. + +A ground-clearance capability's row opens its own screen, which shows the live distance in +centimetres and holds the sensor measuring while it is open. Near and far distances, mounting +direction and strength save automatically once they are complete and valid; there is no Save step. +What is saved is durable, keyed on the accessory id plus the capability id, and re-validated against +the manifest on every session. + +A calibrated sensor commands Remote Tilt on its own, natively, with no arming step and no JS in the +loop. Correction is linear between the calibrated far and near distances, clamped at both ends, and +signed by the mounting direction. While a configured sensor is connected the tilt pad becomes a +read-only indicator of the commanded tilt. Everything that can go wrong releases the input through +the pad's existing smooth return — a missing, stale, out-of-range or erroring reading, a dropped +accessory session, and equally an untrusted or silent Board. See +[remote-tilt.md](./remote-tilt.md#command-ownership) for who owns the Board's one remote-input slot. + +Brake-light behavior is implemented as a PoC: braking is derived natively from Board speed and sent +as semantic states, with sensitivity, a parked preference and a parked preview. The thresholds below +are starting values, not validated ones, and no physical LED hardware has been chosen — see +[Brake-light PoC implementation](#brake-light-poc-implementation). + +## Initial scope + +### Capability controls and status + +The capability list contains navigation rows only. Each capability's setup screen holds its **Use** +switch, live status, and settings; hardware metadata sits under Sensor details. The switch defaults +on; native persists it by accessory ID and capability ID before applying a +change. Turning it off preserves calibration and light settings. Disabled clearance capabilities +stop measuring (including setup preview), release sensor tilt through its existing smooth return, +and no longer claim the manual tilt pad. Re-enabling requires fresh measurements. + +A disabled light receives the protocol's `not_riding` state with `parked: off`, regardless of Board +state. Its saved parked preference remains unchanged. Preview cannot override the switch. Loss of +connection still leaves appearance to firmware; the app cannot promise physical darkness offline. + +Sensor setup offers a sampling-rate button for each rate advertised by the device. Native saves +the selected rate per capability and reapplies it on reconnect; changing it preserves the enabled +switch and calibration. The initial preference is 10 Hz. If firmware removes a saved rate, the +nearest supported rate is selected (ties choose the lower rate). The active acknowledged rate is +shown separately from the selection. Hardware range limits are distinct from near/far calibration. + +Light setup displays the requested riding/braking/parked/preview state, or disconnected/no-telemetry +status. Clearance setup shows a **Tilt preview** calculated natively from each fresh reading and +the saved near/far, mounting direction, and strength. It uses the same mapping as the riding +binding and works without a Board connection. Invalid/stale readings or missing calibration show +no percentage. This preview never sends a command or bypasses the riding and connection gates. + +- 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 screen is reached from the Board selector's Accessories section: the accessory row opens that accessory, and its ground-clearance capability row opens the calibration. Capability setup hangs off the accessory rather than replacing it, because one unit may declare several capabilities and only some of them have a screen in this build. +- A reading carries an explicit status and never a substituted number. `out_of_range`, `error`, a stalled stream and measurement standby are four different sentences on the screen; none of them shows the last good distance, and none of them shows the top of the range. A displayed number expires on native's own missing-stream window, so a stream that stops takes the last value off the screen rather than freezing it there. +- A preview dies with the JS runtime: native releases every preview when the bridge module is destroyed, so a reload or crash with the screen open cannot leave the sensor measuring indefinitely. Riding demand is unaffected, because it comes from the Board Session. +- A board in Refloat fault mode is not riding. Fault frames carry zeroed metrics and no engagement, so they release measurement demand rather than leaving the last engaged sample standing. +- Measurement demand is the union of an open sensor screen and riding a board this capability is calibrated for. Leaving the screen while not riding drops the demand and the accessory stops measuring; the BLE session is untouched. Backgrounding the app drops it too. +- Riding is decided natively from the Board Session's own engagement predicate — the same one Idle Pause uses — not from anything JS sends. +- Riding without a complete calibration measures nothing: there would be no binding to consume the samples. +- Saving a calibration that fits the accessory's current manifest is also how the rider accepts declared limits that moved since enrollment. It is the only thing that rewrites the saved capability baseline, and therefore the only thing that clears the "limits changed" warning. +- 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. The PoC smoothing and threshold values are listed below and still need ride-data validation. +- 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 + +- Validate the protocol draft against real firmware on physical hardware. +- Tune brake detection smoothing and sensitivity thresholds using ride data. +- Validate protocol timing defaults under concurrent Board and accessory traffic. + +## Brake-light PoC implementation + +Brake-light configuration opens from its capability row in the Board selector's Accessories +section. Sensitivity and parked off/glow save automatically per accessory ID and capability ID. +They apply to whichever Board is current. No Board-specific light binding is stored. + +Android and iOS derive braking natively from the magnitude of Board speed, so forward and reverse +slowing use the same detector. Motor current is not an input. Initial PoC defaults: + +- Convert km/h to m/s before calculating deceleration. +- Smooth deceleration with a 200 ms first-order filter, using the actual sample interval. +- At sensitivity 50, enter braking at 1 m/s² and hard braking at 3 m/s². +- Sensitivity 1–100 scales both thresholds by `1.5 - sensitivity / 100`. +- Keep the current braking category until deceleration drops below 75% of its entry threshold. +- A non-increasing timestamp or riding sample gap over 500 ms clears filtering and sends unavailable. + The next progressing sample starts from the new baseline. A constant-speed trace stays riding. +- Missing telemetry for 1500 ms sends unavailable and clears history. This light-specific deadline + is stricter than the Board session's disconnect watchdog and tolerates its parked 1 Hz keepalive. +- Board fault frames, polling stop and disconnect clear light telemetry immediately. + +These thresholds need rider and hardware validation in #481. They do not affect Board control or +Remote Tilt. Firmware owns rendering; the app only sends semantic states through the existing +acknowledgement, renewal and lease path. + +Preview sends real `state` commands with `preview: true`. It is refused while native reports riding, +ends when riding begins, and restores current automatic state on exit, backgrounding or JS teardown. +Without Board telemetry, preview explicitly carries `telemetry: "unavailable"`. Native renews the +preview lease while it is open; firmware expires it if the app disappears. + +The rear-light development firmware injects a fake output driver. Its renderer uses red intensity +32 for riding, 180 for braking, and 255 alternating on/off every 250 ms for hard braking. Parked +output is off or intensity 12. Telemetry unavailable is steady 64; expired commands pulse 64 for +100 ms every second. Disconnect and a new session turn output off until a state arrives. Renewing +unchanged appearance preserves blink phase. These are observable fake-driver values, not measured +LED brightness. Physical driver, wiring, locked-screen BLE timing and visible output remain #481. diff --git a/docs/accessory-protocol.md b/docs/accessory-protocol.md new file mode 100644 index 00000000..ee4a21bc --- /dev/null +++ b/docs/accessory-protocol.md @@ -0,0 +1,263 @@ +# Vescape Accessory Protocol v1 + +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, capability recognition, the operational command channel — +`configure`, `state`, acknowledgements, request-id discipline and leases — and the `reading` stream +for `ground_clearance`, including its status discipline, sequence/sample-time progress rules and the +missing-stream timeout. `brake_light` state is driven from Board speed: see the PoC defaults in +[accessories.md](./accessories.md#brake-light-poc-implementation). + +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-accessories`). Change +the fixtures first; three implementations of one wire format drift silently otherwise. + +`session.json` pins four 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; through its `peer` sequences, the +replies an accessory must produce for a stale id, a reused id, and the app's one permitted retry; +and under `readings` and `groundClearance`, every way a sample can fail to be a measurement together +with what a saved calibration turns a distance into. + +The reading cases exist to pin one rule in executable form: **a missing measurement is never a +distance.** A missing `value`, a null one, a textual one, a status this app does not know, and a +number outside the declared window each have a case, and none of them resolves to the top of the +range. + +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 + +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 | +| -------------------------- | ------------------------------------------------------------ | +| Initial sensor rate | 10 Hz; rider can select an advertised rate, 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 + +Braking smoothing and sensitivity thresholds are defined in the app, outside this wire protocol, and +are listed as PoC defaults in [accessories.md](./accessories.md#brake-light-poc-implementation); they +still need rider and hardware validation. 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/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/index.md b/docs/index.md index 235e57bd..495f7642 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,6 +25,8 @@ ### 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: 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..b904374c 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -26,6 +26,170 @@ 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. + +## 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. + +## Ground clearance + +JS asks for measurements and offers numbers; native decides whether the sensor runs and whether the +numbers are a calibration. There is no Save step for the rider: send what they have as they change +it and read the answer. + +| fn | sync | returns | +| ------------------------------------------------------------------------ | ----- | ------------------------------------------------------ | +| `setAccessoryPreview(accessoryId, capabilityId, open)` | sync | void. Demand to _measure_, never to tilt | +| `saveGroundClearanceCalibration(accessoryId, capabilityId, calibration)` | async | `{saved, problem}` — `problem` names the rule it broke | +| `clearGroundClearanceCalibration(accessoryId, capabilityId)` | async | `boolean` — whether a calibration was removed | + +Measurement demand is the **union** of an open preview and the rider riding a board this capability +is calibrated for. Neither alone permits sensor-driven tilt: a preview shows numbers on a parked +board and commands nothing. Dropping both demands sends `configure{enabled:false}`, which stops the +accessory's continuous measurement while its BLE session stays up. Riding is decided natively from +the Board Session's own engagement predicate, never from a value that crossed the bridge. + +`onAccessoryReading` pushes one accepted sample, and **only** while that capability has a preview +open — nothing else in the app consumes single samples. Every sample is range-checked against the +live manifest before it crosses: + +```ts +{ + accessoryId: string + capabilityId: string + seq: number // per capability, restarts with each protocol session + sampleTimeMs: number // the accessory's own monotonic clock; orders samples, nothing else + status: 'ok' | 'out_of_range' | 'error' + valueCm: number | null // non-null ONLY when status is 'ok' + staleAfterMs: number // how long this sample stays evidence, from the acked rate +} +``` + +`staleAfterMs` travels with every sample so a screen can drop the number the moment it stops +describing the ground, without re-deriving native's window. A frozen distance presented as a live one +is the same lie as an invalid reading shown as the maximum range, just slower. + +A preview is the only demand JS owns, so it dies with JS: native releases every preview when the +module is destroyed, because a runtime that reloaded or crashed with the screen open would otherwise +leave the accessory measuring forever — native's own renewals keep the lease alive. Riding demand is +untouched by that, since it comes from the Board Session. + +The one rule everything else rests on: a missing or unreadable measurement is never a distance, and +never the maximum of the declared range. A value outside the declared window arrives as +`out_of_range` with no value rather than clamped to the nearest limit; an `ok` carrying no number, +a null, text, or a status this build does not know all arrive as `error`. + +Each `AccessoryCapability` in the snapshot carries `calibration` (with its own `problem`, re-decided +against the live manifest on every push) and `measuring`, the demand native actually resolved. +Saving a calibration that fits the current manifest is also how the rider accepts declared limits +that moved since enrollment — it rewrites the frozen `capabilities_json` baseline and clears +`capabilitiesChanged`. + +## Ground-clearance tilt + +The binding that turns those readings into Remote Tilt is entirely native: a 100 ms timer inside the +Board Session, not a reaction to samples. A sensor that stops sending produces no events to react to, +and releasing on silence is the whole point. + +| fn | sync | returns | +| -------------------------- | ----- | -------------------------------------- | +| `getGroundClearanceTilt()` | async | `GroundClearanceTiltState` — see below | + +```ts +{ + bound: boolean // a configured ground-clearance Accessory is connected → the tilt pad is read-only + driving: boolean // the binding is commanding tilt right now + release: GroundClearanceRelease | null // why it is not, or null while it is +} +``` + +Polled, not pushed: the only consumer is the tilt pad, which already reads the commanded tilt on its +own interval. `bound` is independent of `driving` — a binding waiting for the rider to set off still +owns the pad, because manual input is not this Board's input method any more. + +`release` is the full list of ways the binding lets go. The first six are the Accessory's own, +decided by the capability runtime; the last five are the Board Session's, and did not exist before +sensor readings could command tilt: + +| release | means | +| ----------------- | -------------------------------------------------------------------- | +| `not-riding` | The Board is not engaged. A parked Board is not corrected. | +| `no-link` | No Accessory session, or one not acknowledging commands. | +| `not-calibrated` | Nothing saved, or what is saved no longer fits the declared limits. | +| `stale` | Samples stopped arriving inside the acked rate's window. | +| `out-of-range` | The sensor answered, and the answer is not a distance. | +| `sensor-error` | The sensor could not measure, or sent something unreadable. | +| `board-untrusted` | The Board is not connected, or its Board Link is not Trusted. | +| `board-stale` | The Board is connected but has stopped answering. | +| `contested` | More than one calibrated ground-clearance capability wants the slot. | +| `board-move` | Board Move holds the remote-input slot. | +| `manual-tilt` | A rider-commanded tilt still holds the slot while the binding arms. | + +Every path that writes the Board's one remote-input slot — the pad, Board Move, and the sensor — goes +through a single native arbiter. `remoteTilt.owner` on the live state and on `getRemoteTiltState()` +names the winner (`none | manual | sensor | move`). See [remote-tilt.md](./remote-tilt.md). + ## Location | fn | sync | returns | @@ -352,14 +516,17 @@ 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`) | +| `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..4a808670 100644 --- a/docs/persistence-operation-inventory.md +++ b/docs/persistence-operation-inventory.md @@ -5,34 +5,59 @@ Every durable SQLite operation used by the app is listed here. `covered` scenari and macOS runs the same fixture through production GRDB seams. A composite operation covers its private leaf statements because the transaction, ordering, and rollback are the observable contract. -| Store | Production operations | Status, executable scenario, or exact reason | -| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Ride Recording | insert frames; insert/update/merge minute buckets; insert markers; insert/merge exclusion ranges | `moving-recording-close-reopen`; `remaining-stores-close-reopen-rollback` covers exclusion merge and late transaction failure | -| Ride Track and recording identity | begin/end recording; insert GPS fixes with buckets; identity-scoped history reads/deletion | `RideTrackPersistenceHostTest` and the Swift persistence host cover GPS-only close/reopen, retained poor accuracy, and late-write rollback; native suites cover lifecycle and identity isolation. Production backup exchange preserves recording end intent and GPS fixes in both directions. | -| Ride History | summary; paged sessions; buckets; frames/range; markers; Board names; profile stats | `precomputed-history-reads` | -| Telemetry maintenance | delete before; delete Board range; delete all-Board range; clear; rebuild buckets | `remaining-stores-close-reopen-rollback` executes the app-used orchestration on both production hosts, including Favorite bucket protection, Board-scoped exclusion preservation, sparse Android frame reconstruction, and rollback when the second range fails | -| Boards | list/get/name lookup; atomic Board+settings upsert; tombstone with settings/warnings/alerts/config/notice cascade | `board-settings-close-reopen` | -| Board settings | list by one/many Boards; upsert/delete | `board-settings-close-reopen`, through atomic Board save/delete | -| App settings | list/get/upsert/delete | covered: `board-settings-close-reopen` | -| Navigation/Group Ride settings | navigation path/profile, direction point, Group Ride identity/target multi-write ordering | `remaining-stores-close-reopen-rollback` covers typed path/profile and atomic Direction Point persistence across reopen; focused navigation controller tests cover ordered path/profile writes | -| Privacy Zones | list/list enabled; typed upsert; enable/disable; delete | `remaining-stores-close-reopen-rollback` covers typed upsert/read/reopen, failed update preservation, and delete | -| Alert Rules | list/list enabled; upsert; enable/disable; delete one/all | `tune-history-alert-close-reopen-rollback`; all-delete is covered by `board-settings-close-reopen` tombstone cascade | -| Tune Profiles | list/get/count; create; rename; save with history; rollback; copy; delete with history | `tune-history-alert-close-reopen-rollback` | -| Favorites | list/get; create; update; delete | `favorite-create-rename-trim-delete-reopen` | -| Favorite Media | list; insert manifest after file publish; delete/reconcile manifest; Favorite cascade | `favorite-create-rename-trim-delete-reopen`; filesystem compensation is in platform unit tests because Room/GRDB host contracts cover SQLite, not platform filesystems | -| Local Diagnostic Events | insert; range query; clear; prune with telemetry retention | `remaining-stores-close-reopen-rollback` covers typed insert/range/reopen, prune, clear, and failed insert propagation on both hosts | -| Board Config Values | exact/latest read; typed upsert; patch; delete; replace baseline plus notice atomically | Covered: `remaining-stores-close-reopen-rollback`; patch is the same typed-row update and has focused config unit coverage | -| Motor Config Values | latest read; typed upsert; delete; replace baseline plus shared notice atomically | Covered: `remaining-stores-close-reopen-rollback` | -| Board Config Change Notice | read; typed upsert; delete; merge Board/Motor diffs | Covered: `remaining-stores-close-reopen-rollback`, including corrupt notice and late baseline rollback; Board tombstone cascade covered by `board-settings-close-reopen` | -| 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 | -| `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. | +| Store | Production operations | Status, executable scenario, or exact reason | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Ride Recording | insert frames; insert/update/merge minute buckets; insert markers; insert/merge exclusion ranges | `moving-recording-close-reopen`; `remaining-stores-close-reopen-rollback` covers exclusion merge and late transaction failure | +| Ride Track and recording identity | begin/end recording; insert GPS fixes with buckets; identity-scoped history reads/deletion | `RideTrackPersistenceHostTest` and the Swift persistence host cover GPS-only close/reopen, retained poor accuracy, and late-write rollback; native suites cover lifecycle and identity isolation. Production backup exchange preserves recording end intent and GPS fixes in both directions. | +| Ride History | summary; paged sessions; buckets; frames/range; markers; Board names; profile stats | `precomputed-history-reads` | +| Telemetry maintenance | delete before; delete Board range; delete all-Board range; clear; rebuild buckets | `remaining-stores-close-reopen-rollback` executes the app-used orchestration on both production hosts, including Favorite bucket protection, Board-scoped exclusion preservation, sparse Android frame reconstruction, and rollback when the second range fails | +| Boards | list/get/name lookup; atomic Board+settings upsert; tombstone with settings/warnings/alerts/config/notice cascade | `board-settings-close-reopen` | +| Board settings | list by one/many Boards; upsert/delete | `board-settings-close-reopen`, through atomic Board save/delete | +| App settings | list/get/upsert/delete | covered: `board-settings-close-reopen` | +| Navigation/Group Ride settings | navigation path/profile, direction point, Group Ride identity/target multi-write ordering | `remaining-stores-close-reopen-rollback` covers typed path/profile and atomic Direction Point persistence across reopen; focused navigation controller tests cover ordered path/profile writes | +| Privacy Zones | list/list enabled; typed upsert; enable/disable; delete | `remaining-stores-close-reopen-rollback` covers typed upsert/read/reopen, failed update preservation, and delete | +| Alert Rules | list/list enabled; upsert; enable/disable; delete one/all | `tune-history-alert-close-reopen-rollback`; all-delete is covered by `board-settings-close-reopen` tombstone cascade | +| Tune Profiles | list/get/count; create; rename; save with history; rollback; copy; delete with history | `tune-history-alert-close-reopen-rollback` | +| Favorites | list/get; create; update; delete | `favorite-create-rename-trim-delete-reopen` | +| Favorite Media | list; insert manifest after file publish; delete/reconcile manifest; Favorite cascade | `favorite-create-rename-trim-delete-reopen`; filesystem compensation is in platform unit tests because Room/GRDB host contracts cover SQLite, not platform filesystems | +| Local Diagnostic Events | insert; range query; clear; prune with telemetry retention | `remaining-stores-close-reopen-rollback` covers typed insert/range/reopen, prune, clear, and failed insert propagation on both hosts | +| Board Config Values | exact/latest read; typed upsert; patch; delete; replace baseline plus notice atomically | Covered: `remaining-stores-close-reopen-rollback`; patch is the same typed-row update and has focused config unit coverage | +| Motor Config Values | latest read; typed upsert; delete; replace baseline plus shared notice atomically | Covered: `remaining-stores-close-reopen-rollback` | +| Board Config Change Notice | read; typed upsert; delete; merge Board/Motor diffs | Covered: `remaining-stores-close-reopen-rollback`, including corrupt notice and late baseline rollback; Board tombstone cascade covered by `board-settings-close-reopen` | +| 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; adopt a new capability baseline; forget with calibrations | `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 | +| Ground-clearance calibration | list/get; save; clear one; cascade on forget | `accessory-enrollment-close-reopen` covers close/reopen of saved near/far/direction/strength, two capabilities on one Accessory staying independent, revalidation leaving both alone, `adoptCapabilities` moving the frozen baseline, and forget taking every calibration in one transaction | +| `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. | All SQLite read/write bridge failures are reported with a sanitized operation name. Full disk, corruption, I/O, cannot-open, and read-only classifications additionally enter the durable app storage failure state and stop storage actions. Ordinary query/schema/domain failures reject their operation without disabling BLE, live gauges, or in-memory Alert evaluation. Startup clears a saved outage only after a real transactional create/write/drop check succeeds. + +### Accessory brake-light settings + +`AccessoryPersistence.saveBrakeLight` / `AccessoryStore.saveBrakeLight` persist sensitivity and +parked preference per accessory/capability in `accessory_brake_light`, schema 46. Forgetting an +accessory deletes its light settings in the enrollment transaction. Saving requires an enrolled +owner in the same transaction. `accessory-persistence-contract.json` drives Room and GRDB +close/reopen and forget-isolation coverage in `AccessoryPersistenceHostTest` and the macOS host. +The complete `test:persistence` gate also exchanges production archives across both platforms. + +### Accessory capability switches + +`AccessoryPersistence.saveCapabilitySettings` / `AccessoryStore.saveCapabilitySettings` store an +independent enabled flag in `accessory_capability_settings` (schema 47). Missing rows default to +enabled; failed reads do not enable hardware. Writes require an enrolled owner. Forget removes its +switches in the enrollment transaction. The shared accessory fixture drives Room/GRDB reopen, +calibration preservation, owner isolation, and rejected orphan-write coverage in +`AccessoryPersistenceHostTest` and the macOS host. The migration manifest includes schema 47. + +Schema 48 adds nullable `sampling_rate_hz` to these same per-capability settings. Existing switches +survive migration with no selected rate, using the initial 10 Hz preference. Rate changes preserve +the enabled flag; enable/disable changes preserve the selected rate. The shared fixture also checks +that selected rates survive close/reopen and remain isolated across accessories. diff --git a/docs/remote-tilt.md b/docs/remote-tilt.md index 71eff992..aa251eac 100644 --- a/docs/remote-tilt.md +++ b/docs/remote-tilt.md @@ -25,7 +25,36 @@ The Board component showcase uses a simulated receiver, including optional 450ms and a counter of received drag commands. It never controls a connected Board. Regression tests cover ownership, stale completions, countdown continuity, and command ordering under delayed responses. -Native limitations remain: return durations are quantized to the existing 100ms controller tick; -the GATT write queue still lacks a write-completion watchdog; Board Move shares its remote-input -slot. This UI rework does not claim to resolve those transport/controller concerns or validate riding -behavior. Native bridge changes require rebuilding the development app on each platform. +## Command ownership + +Refloat has one temporary remote input, and three things in this app want it: the rider's pad, Board +Move, and a calibrated ground-clearance Accessory. A single native arbiter owns that slot; nothing +reaches the tilt or move controllers around it. `remoteTilt.owner` reports the winner +(`none | manual | sensor | move`), derived from the streams themselves rather than remembered, so an +owner cannot outlive the stream that claimed the slot. + +- **Sensor over manual.** While a configured ground-clearance Accessory is connected, manual tilt is + refused natively and the pad renders as a read-only indicator of commanded tilt. Arming also + cancels a tilt the rider was already holding — a lock never ends on its own and would hold the slot + against the binding for the rest of the session. +- **Board Move over a pending release, never over a live correction.** Starting a Board Move drops + any tilt stream still easing down to neutral in one write, because a pending decay interleaving its + packets with move packets is the two of them fighting over one byte. A sensor that is _actively_ + correcting refuses the Move instead: a Board asking for ground-clearance correction is a Board + being ridden, and jogging one is not a request this app passes on. +- **Cancel stays ungated.** It remains the rider's way out whoever owns the slot and whatever the + link trust is. It is not an off switch for the binding: a sensor still holding valid readings takes + the slot back on its next tick, ramped from where the cancel left it. +- **Sensor input never steps.** Sensor-driven commands ease toward their target at the same bounded + rate a cancel eases at, so neither arming mid-ride nor a discontinuous reading — a pothole under the + sensor is a full-range swing in one 20 Hz sample — can hand the firmware an instant angle error. + Steady state still follows the readings exactly; only the rate of change is bounded. + +The binding's own release conditions, Board-side and Accessory-side, are tabulated in +[native-api.md](./native-api.md#ground-clearance-tilt). + +Native limitations remain: return durations are quantized to the existing 100ms controller tick and +the GATT write queue still lacks a write-completion watchdog. Neither the UI rework nor the ownership +arbiter claims to resolve those transport/controller concerns, and no part of the sensor binding has +been validated against a ridden board. Native bridge changes require rebuilding the development app +on each platform. 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/LiveStateMapper.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/LiveStateMapper.kt index e4bd02ce..d3ca76bf 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/LiveStateMapper.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/LiveStateMapper.kt @@ -29,6 +29,7 @@ internal data class VescLiveStateSnapshot( val remoteTiltValue: Int, val remoteTiltPhase: RemoteTiltPhase, val remoteTiltDecay: RemoteTiltDecayProgress?, + val remoteTiltOwner: RemoteInputOwner, val linkIntegrity: LinkIntegrity, val settings: AppSettings, ) @@ -40,11 +41,16 @@ internal fun remoteTiltWire( value: Int, phase: RemoteTiltPhase, decay: RemoteTiltDecayProgress?, + owner: RemoteInputOwner, ): Map? { if (phase == RemoteTiltPhase.Idle) return null return buildMap { put("value", value) put("phase", phase.wireValue) + // Who asked for this tilt. The pad renders the same stream either way, but "the board is + // holding a tilt you did not command" and "the board is holding yours" are not the same + // sentence to read while standing on it. + put("owner", owner.wire) if (decay != null) { put("decay", mapOf("elapsedMs" to decay.elapsedMs, "totalMs" to decay.totalMs)) } @@ -69,6 +75,7 @@ internal fun buildLiveState(snapshot: VescLiveStateSnapshot): Map snapshot.remoteTiltValue, snapshot.remoteTiltPhase, snapshot.remoteTiltDecay, + snapshot.remoteTiltOwner, ), ), "gps" to mapOf( diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/RemoteInputArbiter.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/RemoteInputArbiter.kt new file mode 100644 index 00000000..bf5303b3 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/RemoteInputArbiter.kt @@ -0,0 +1,285 @@ +package expo.modules.vescapecore + +import expo.modules.vescapecore.protocol.REMOTE_TILT_CENTER +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * Who is allowed to write the board's remote-input slot. + * + * Refloat has one temporary remote input and three things in this app want it: the rider's tilt pad, + * Board Move, and a ground-clearance Accessory. Before this existed they were three writers with no + * referee, which is fine only for as long as no two of them are active at once. + * + * @parity /modules/vescape-core/ios/RemoteInputArbiter.swift `RemoteInputOwner` + * @parity /modules/vescape-core/src/index.ts `RemoteTiltOwner` + */ +internal enum class RemoteInputOwner(val wire: String) { + /** Nothing is streaming. The board's input lapses on its own ~1s after the last write. */ + NONE("none"), + + /** The rider's pad, through the bridge. */ + MANUAL("manual"), + + /** A calibrated ground-clearance Accessory, through the Board Session's own tick. */ + SENSOR("sensor"), + + /** Board Move: motor output on a disengaged board, which shares the same slot. */ + MOVE("move"), +} + +/** + * How fast a sensor-driven correction is allowed to move. + * + * The same bound [RemoteTiltController] eases a cancel at, and for the same reason: a self-balancing + * board answers a step change in commanded tilt with a surge. Nothing about the source makes the + * step safer — a pothole under the sensor produces a full-range swing in one 20 Hz sample, and a + * binding arming mid-ride produces one in a single tick. + * + * Steady state is unaffected: the commanded value converges on the reading and then follows it. Only + * the rate of change is bounded, so "linear, clamped, direction-aware correction that follows the + * readings" is still exactly what the board is told. + * + * @parity /modules/vescape-core/ios/RemoteInputArbiter.swift `SENSOR_TILT_SLEW_FULL_RANGE_MS` + */ +private const val SENSOR_TILT_SLEW_FULL_RANGE_MS = REMOTE_TILT_CANCEL_FULL_RANGE_MS + +/** + * The one writer of the Board's remote-input slot. + * + * Every path that commands tilt or movement goes through here, so the question "who is driving the + * board right now" has one answer held in one place instead of being inferred from three + * controllers' private state. [BoardSessionController] owns the instance; nothing else constructs + * one. + * + * Two rules carry the safety of this slice: + * + * - **Board Move displaces a tilt stream, a sensor does not yield to Board Move.** Jogging a board + * is a parked-board command, so a sensor actively correcting a ridden board refuses it outright; + * anything else holding the slot (a rider's tilt, or a sensor release still easing down) is + * dropped to neutral first. Letting a pending decay keep writing while a move streams is the two + * of them fighting over one byte. + * - **A sensor never steps.** [sensorDrive] eases toward its target at + * [SENSOR_TILT_SLEW_FULL_RANGE_MS], so neither arming nor a discontinuous reading can hand the + * firmware an instant full-range angle error. + * + * Releasing is deliberately the existing [RemoteTiltController.cancel] — the smooth return the pad + * already uses — and it is invoked exactly once per engaged→released transition. Calling it every + * tick would restart the ease from a smaller value each time and never arrive. + * + * @parity /modules/vescape-core/ios/RemoteInputArbiter.swift + */ +internal class RemoteInputArbiter( + private val tilt: RemoteTiltController, + private val move: BoardMoveController, + private val nowMs: () -> Long, + /** + * Whether a configured ground-clearance Accessory is connected. + * + * Not the same question as "is the sensor commanding right now". A binding that is bound but + * waiting — parked, or between readings — owns nothing, and without this the slot would look + * free to a manual command that arrives in that window. A manual *lock* taken there never ends + * on its own and the pad is read-only, so the rider has no way to give it back: the binding + * would be refused for the rest of the session. + */ + private val sensorBound: () -> Boolean = { false }, +) { + /** + * Who started the tilt stream that is currently running. Meaningless once it ends, which is why + * [owner] consults the stream itself rather than trusting this. + */ + private var tiltOwner = RemoteInputOwner.NONE + + /** Whether the sensor is currently commanding, as opposed to having been released. */ + private var sensorEngaged = false + + /** Last commanded sensor value and when it was commanded, for the slew limit. */ + private var sensorValue = REMOTE_TILT_CENTER + private var sensorAtMs = 0L + + /** + * Who holds the slot. + * + * Derived, never remembered: a tilt stream that reached neutral has released the slot whether or + * not anyone told this class about it, and a Board Move that stopped has done the same. A + * remembered owner would survive its own stream and lock the slot against everything else. + */ + val owner: RemoteInputOwner + get() = when { + move.isMoving -> RemoteInputOwner.MOVE + tilt.phase == RemoteTiltPhase.Idle -> RemoteInputOwner.NONE + else -> tiltOwner + } + + /** The value the sensor is currently commanding, for tests and for what JS renders. */ + val sensorCommand: Int + get() = if (sensorEngaged) sensorValue else REMOTE_TILT_CENTER + + // MARK: - The rider's pad + + /** + * Manual tilt is refused while anything else holds the slot. + * + * The pad is also made read-only in JS while a ground-clearance binding is bound, but that is + * presentation. This is the rule: a bridge call that arrives anyway — a stale render, a + * mid-flight gesture, a JS bundle that disagrees — commands nothing. + */ + fun manualHold(value: Int): Boolean = claimManual { tilt.hold(value) } + + fun manualLock(value: Int): Boolean = claimManual { tilt.lock(value) } + + fun manualRelease(value: Int, durationMs: Long): Boolean = + claimManual { tilt.release(value, durationMs) } + + private inline fun claimManual(start: () -> Boolean): Boolean { + // Asked before ownership, because a bound binding that is not currently driving leaves the + // slot unowned and would otherwise let a manual command in. + if (sensorBound()) return false + when (owner) { + RemoteInputOwner.SENSOR, RemoteInputOwner.MOVE -> return false + RemoteInputOwner.NONE, RemoteInputOwner.MANUAL -> Unit + } + val started = start() + if (started) tiltOwner = RemoteInputOwner.MANUAL + return started + } + + /** + * Ease whatever is commanded back to neutral, whoever commanded it. + * + * Ungated on purpose. Cancel is the rider's way out and must survive a link that lost trust + * mid-hold; that has always been true of the pad's cancel and stays true with a sensor in the + * picture. A sensor still holding valid readings simply re-engages on its next tick, ramped — + * the cancel is not an off switch for the binding, and does not pretend to be one. + */ + fun cancelTilt(): Boolean { + releaseSensor() + return tilt.cancel() + } + + /** + * Hand the slot back from the rider so a binding that just armed can take it. + * + * A manual lock never ends on its own, so a binding arming under one would wait forever. Arming + * is exactly the moment the pad stops being the rider's, so the held value stops being theirs + * too — eased down, never snapped. + * + * Safe to call on every tick, which is how the binding calls it: an ease already running is left + * alone. Re-cancelling a decay would restart it from a smaller value each time and never arrive, + * and one failed cancel — a transport that blinked — must not strand the lock forever. + */ + fun releaseManual(): Boolean { + if (owner != RemoteInputOwner.MANUAL) return false + if (tilt.phase == RemoteTiltPhase.Decaying) return false + return tilt.cancel() + } + + // MARK: - Ground-clearance sensor + + /** + * Command one sensor-derived tilt value, rate-limited. + * + * Returns false when the slot belongs to something else, so the caller can say which reason the + * rider is looking at. A refusal leaves the sensor disengaged: it does not queue. + */ + fun sensorDrive(target: Int): Boolean { + when (owner) { + RemoteInputOwner.MOVE, RemoteInputOwner.MANUAL -> { + sensorEngaged = false + return false + } + + RemoteInputOwner.NONE, RemoteInputOwner.SENSOR -> Unit + } + val now = nowMs() + // The ramp is measured from what the board is actually being told, which is not always + // neutral at a fresh engage: a release still easing down — this binding's own, or the + // rider's cancel — is a live stream holding a real value. Starting from neutral there would + // step the commanded tilt by the whole of the unfinished decay in one write, which is + // exactly the snap the slew limit exists to prevent. + val from = when { + sensorEngaged -> sensorValue + tilt.phase != RemoteTiltPhase.Idle -> tilt.currentValue + else -> REMOTE_TILT_CENTER + } + val elapsed = if (sensorEngaged) max(0L, now - sensorAtMs) else 0L + val next = slew(from, target.coerceIn(0, 255), elapsed) + sensorEngaged = true + sensorValue = next + sensorAtMs = now + tiltOwner = RemoteInputOwner.SENSOR + return tilt.hold(next) + } + + /** + * Let go of a sensor-driven tilt through the pad's own smooth return. + * + * Idempotent by design: the Board Session calls this on every tick it has no valid reading, and + * only the first one after an engagement actually cancels. + */ + fun sensorRelease(): Boolean { + if (!releaseSensor()) return false + if (owner != RemoteInputOwner.SENSOR) return false + return tilt.cancel() + } + + /** Clears the engaged flag and says whether it had been set. */ + private fun releaseSensor(): Boolean { + if (!sensorEngaged) return false + sensorEngaged = false + sensorValue = REMOTE_TILT_CENTER + return true + } + + // MARK: - Board Move + + /** + * Start a Board Move, taking the slot from any tilt stream that still holds it. + * + * The displaced stream is dropped to neutral rather than eased, because the board a Move is + * meant for is a disengaged one: there is no rider on it for a step to throw, and easing would + * mean up to [REMOTE_TILT_CANCEL_FULL_RANGE_MS] of tilt packets interleaved with move packets. + * A sensor actively correcting says the board *is* being ridden, so that case refuses instead. + */ + fun startMove(input: Int): Boolean { + // A sensor that is *still correcting* says the board is being ridden. One that has already + // let go leaves only the decay tail — and that tail outliving the move is the exact failure + // this is here to prevent, so it gets dropped rather than deferred to. + if (sensorEngaged) return false + if (tilt.phase != RemoteTiltPhase.Idle) tilt.stop() + releaseSensor() + tiltOwner = RemoteInputOwner.NONE + return move.hold(input) + } + + /** Deliberately ungated, exactly as before: a stop must reach the board whatever else is true. */ + fun stopMove(): Boolean = move.stop() + + // MARK: - Teardown + + /** Immediate neutral on both channels. Session teardown only. */ + fun reset() { + releaseSensor() + tiltOwner = RemoteInputOwner.NONE + tilt.stop() + move.stop() + } + + /** + * One step of the slew limit: at most a full range per [SENSOR_TILT_SLEW_FULL_RANGE_MS]. + * + * `elapsed` is the real gap since the last command rather than an assumed tick, so a tick that + * ran late is allowed the movement it was owed instead of stretching the ramp. + */ + private fun slew(from: Int, target: Int, elapsedMs: Long): Int { + val distance = abs(target - from) + if (distance == 0) return target + val fullRange = (255 - REMOTE_TILT_CENTER).toLong() + val allowed = (fullRange * elapsedMs / SENSOR_TILT_SLEW_FULL_RANGE_MS).toInt() + // Never zero: a tick short enough to round the allowance away would freeze the command + // rather than slow it, and the binding would sit at whatever it first commanded. + val step = min(distance, max(1, allowed)) + return if (target > from) from + step else from - step + } +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/RemoteTiltController.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/RemoteTiltController.kt index bc42a3a3..1b279af4 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/RemoteTiltController.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/RemoteTiltController.kt @@ -28,7 +28,7 @@ private const val REMOTE_TILT_REPEAT_MS = 100L * * @parity /modules/vescape-core/ios/RemoteTiltController.swift `REMOTE_TILT_CANCEL_FULL_RANGE_MS` */ -private const val REMOTE_TILT_CANCEL_FULL_RANGE_MS = 600L +internal const val REMOTE_TILT_CANCEL_FULL_RANGE_MS = 600L // @parity /modules/vescape-core/src/index.ts `RemoteTiltPhase` // @parity /modules/vescape-core/ios/RemoteTiltController.swift `RemoteTiltPhase` 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..32e459fc 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,8 @@ 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.accessory.AccessorySessionManager import expo.modules.vescapecore.alerts.AlertFeedback import expo.modules.vescapecore.alerts.normalizedAlertBeepCount import expo.modules.vescapecore.alerts.normalizedAlertRepeatSeconds @@ -193,8 +195,25 @@ class VescapeCoreModule : Module() { "onNavigation", "onRouteProgress", "onWeather", + "onAccessoryDevice", + "onAccessoryScanError", + "onAccessoryState", + "onAccessoryReading", ) + // 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) } + } + + // 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` @@ -369,6 +388,14 @@ 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") } + OnStartObserving("onAccessoryState") { startObserving("onAccessoryState") } + OnStopObserving("onAccessoryState") { stopObserving("onAccessoryState") } + OnStartObserving("onAccessoryReading") { startObserving("onAccessoryReading") } + OnStopObserving("onAccessoryReading") { stopObserving("onAccessoryReading") } OnCreate { val storageOutageEvents = StorageOutageEventBridge( @@ -412,6 +439,16 @@ class VescapeCoreModule : Module() { previewAlertFeedback = null stopAlertTest() cancelActiveProbe(null, "module_destroyed") + 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 + // A preview is the one piece of demand JS owns, so it dies with JS. Without this a runtime + // that reloaded or crashed with the sensor screen open would leave the accessory measuring + // with nobody watching, and native's own renewals would keep the lease alive forever. + AccessorySessionManager.releasePreviews() if (CoreForegroundService.emitEvent != null) { CoreForegroundService.emitEvent = null } @@ -419,6 +456,82 @@ 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) } + } + + // 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) } + } + // @parity /modules/vescape-core/ios/VescapeCoreModule.swift `saveBrakeLightSettings` + // @parity /modules/vescape-core/src/index.ts `saveBrakeLightSettings` + AsyncFunction("saveBrakeLightSettings") { accessoryId: String, capabilityId: String, sensitivity: Int, parked: String, promise: Promise -> + AccessorySessionManager.saveBrakeLight(accessoryId, capabilityId, sensitivity, parked) { promise.resolve(it) } + } + // @parity /modules/vescape-core/ios/VescapeCoreModule.swift `setAccessoryCapabilityEnabled` + // @parity /modules/vescape-core/src/index.ts `setAccessoryCapabilityEnabled` + AsyncFunction("setAccessoryCapabilityEnabled") { accessoryId: String, capabilityId: String, enabled: Boolean, promise: Promise -> + AccessorySessionManager.setCapabilityEnabled(accessoryId, capabilityId, enabled) { promise.resolve(it) } + } + // @parity /modules/vescape-core/ios/VescapeCoreModule.swift `setAccessorySamplingRate` + // @parity /modules/vescape-core/src/index.ts `setAccessorySamplingRate` + AsyncFunction("setAccessorySamplingRate") { accessoryId: String, capabilityId: String, rateHz: Double, promise: Promise -> + AccessorySessionManager.setSamplingRate(accessoryId, capabilityId, rateHz) { promise.resolve(it) } + } + // @parity /modules/vescape-core/ios/VescapeCoreModule.swift `setBrakeLightPreview` + // @parity /modules/vescape-core/src/index.ts `setBrakeLightPreview` + AsyncFunction("setBrakeLightPreview") { accessoryId: String, capabilityId: String, mode: String?, promise: Promise -> + AccessorySessionManager.setLightPreview(accessoryId, capabilityId, mode) { promise.resolve(it) } + } + Function("getAccessories") { AccessorySessionManager.snapshot() } + + // Ground clearance. JS asks for measurements and offers numbers; native decides whether the + // sensor runs and whether the numbers are a calibration. + // @parity /modules/vescape-core/ios/VescapeCoreModule.swift `setAccessoryPreview` + // @parity /modules/vescape-core/src/index.ts `setAccessoryPreview` + Function("setAccessoryPreview") { accessoryId: String, capabilityId: String, open: Boolean -> + AccessorySessionManager.setPreview(accessoryId, capabilityId, open) + } + AsyncFunction("saveGroundClearanceCalibration") { + accessoryId: String, + capabilityId: String, + nearCm: Double, + farCm: Double, + direction: String, + strengthPercent: Int, + promise: Promise, + -> + AccessorySessionManager.saveGroundClearance( + accessoryId = accessoryId, + capabilityId = capabilityId, + nearCm = nearCm, + farCm = farCm, + direction = direction, + strengthPercent = strengthPercent, + ) { promise.resolve(it) } + } + AsyncFunction("clearGroundClearanceCalibration") { + accessoryId: String, + capabilityId: String, + promise: Promise, + -> + AccessorySessionManager.clearGroundClearance(accessoryId, capabilityId) { promise.resolve(it) } + } Function("exitApp") { CoreForegroundService.exitApp(context.applicationContext) } Function("startLocationUpdates") { startLocationUpdates() } Function("stopLocationUpdates") { stopLocationUpdates() } @@ -534,6 +647,11 @@ class VescapeCoreModule : Module() { Log.w(TAG, "Cannot open the download route: ${e.message}") } } + // @parity /modules/vescape-core/ios/VescapeCoreModule.swift `getGroundClearanceTilt` + // @parity /modules/vescape-core/src/index.ts `getGroundClearanceTilt` + AsyncFunction("getGroundClearanceTilt") { + CoreForegroundService.currentGroundClearanceTilt() + }.runOnQueue(Queues.MAIN) // @parity /modules/vescape-core/ios/VescapeCoreModule.swift `getRemoteTiltState` // @parity /modules/vescape-core/src/index.ts `getRemoteTiltState` AsyncFunction("getRemoteTiltState") { 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..f1e2b399 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryDiscovery.kt @@ -0,0 +1,170 @@ +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 + + /** + * 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) { + handler.post { startScanNow(context) } + } + + private fun startScanNow(context: Context) { + stopScanNow() + 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() { + handler.post { stopScanNow() } + } + + private fun stopScanNow() { + 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. + stopScanNow() + 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..c23a1909 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryGattHandshake.kt @@ -0,0 +1,275 @@ +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) { + // 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 { + 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 (uuid != AccessoryProtocol.NOTIFY_UUID) return + handler.post { + if (finished || g !== gatt) 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/AccessoryLink.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryLink.kt new file mode 100644 index 00000000..31303958 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryLink.kt @@ -0,0 +1,621 @@ +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, + /** + * One accepted sample off the reading stream, with the monotonic time it landed. + * + * Handed over rather than buffered here: this class owns the radio and the session, and what a + * distance *means* belongs to the capability that declared it. + */ + private val onReading: (AccessoryReading, receivedAtMs: Long) -> Unit = { _, _ -> }, + /** + * The protocol session this link held is gone. + * + * Fired on every path that clears [sessionId], because sequence numbers restart with the next + * hello: a tracker still holding the old session's newest sample would refuse the new session's + * first ones as duplicates, and a screen would show a distance measured before the accessory + * rebooted. + */ + private val onSessionLost: () -> 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 + + /** + * What each capability last said it actually applied. + * + * The accessory resolves the requested rate against its own list and answers with the one it + * runs at, which is not always the one asked for. Anything derived from the sample cadence — + * the missing-stream window above all — has to use the rate the hardware confirmed, not the + * rate the app hoped for. + */ + private val appliedByCapability = HashMap>() + + /** Rate the accessory acknowledged for [capabilityId], or null before its first ack. */ + fun appliedRateHz(capabilityId: String): Double? = + appliedByCapability[capabilityId]?.get("rateHz")?.toDoubleOrNull()?.takeIf { it.isFinite() && it > 0.0 } + + 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() + + /** + * When each capability's command was last put on the wire, and which ones changed since. + * + * Without these the pump would re-send the moment an ack arrived, turning a 500 ms renewal into + * a continuous command loop at BLE round-trip rate — the accessory's radio never idles and the + * lease is renewed twenty times more often than it needs to be. + */ + private val lastSentAtMs = HashMap() + private val dirty = HashSet() + + /** 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?) { + val movedHandle = deviceId != null && deviceId != this.deviceId + if (movedHandle) this.deviceId = deviceId + if (started && !movedHandle) return + started = true + // A different handle is a different peripheral; the old connection cannot be re-pointed at + // it, so it is torn down and a new one opened. Returning here instead would leave the link + // armed with no connection and no retry — the state this branch exists to avoid. + if (movedHandle) teardown() + 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 + dirty.add(command.capabilityId) + // A changed state goes out immediately rather than waiting for the next renewal tick. + if (phase == AccessoryLinkPhase.CONNECTED) pump() + } + + // 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) { + // The retry timer is the recovery path. Android surfaces adapter state through a + // broadcast this class does not hold, so it re-asks rather than waiting to be told. + // @platform-diff iOS is told directly, through its central's state callback. + 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 + // Nothing an old session applied describes this one. A rate remembered across a reconnect + // would set the stale window for a stream the accessory has not agreed to send yet. + appliedByCapability.clear() + onSessionLost() + 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 + appliedByCapability.clear() + onSessionLost() + 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() + // A fresh session has applied nothing, so every desired command is owed again immediately. + lastSentAtMs.clear() + dirty.addAll(desired.keys) + 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 + // Recorded before the phase change, because this is what the accessory says it is + // actually doing — not what the app asked for. The rate here is the one the stale + // window is measured against. + appliedByCapability[response.capabilityId] = response.applied + 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) + } + + is AccessoryResponse.Sample -> { + // Readings are unacknowledged and renew nothing. A stream that keeps arriving while + // commands go unanswered must not look like a healthy session, so this deliberately + // does not touch [lastAckAtMs], the phase or the pump. + onReading(response.reading, SystemClock.elapsedRealtime()) + } + + AccessoryResponse.Malformed -> fail("malformed") + AccessoryResponse.Ignored -> Unit + } + } + + // MARK: - Request pump + + /** + * Sends the next capability whose command changed, or whose renewal has come due. + * + * Called after every ack as well as on the tick, so a link with several capabilities drains all + * of their due renewals back to back instead of one per tick — with a 2 s lease and a 500 ms + * interval, one-per-tick would let the fourth capability's lease lapse. + */ + private fun pump() { + if (outstanding != null) return + val session = sessionId ?: return + val supported = manifest?.capabilities?.filter { it.supported }?.map { it.id }?.toSet() ?: return + val now = SystemClock.elapsedRealtime() + val capabilityId = desired.keys.firstOrNull { id -> + id in supported && ( + id in dirty || + now - (lastSentAtMs[id] ?: Long.MIN_VALUE / 2) >= AccessorySession.RENEW_INTERVAL_MS + ) + } ?: return + val next = desired.getValue(capabilityId) + // Round-robin: the capability just sent goes to the back, so one capability cannot starve + // another's renewal. + desired.remove(capabilityId) + desired[capabilityId] = next + dirty.remove(capabilityId) + lastSentAtMs[capabilityId] = now + 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/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..d35f3be7 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessoryProtocol.kt @@ -0,0 +1,325 @@ +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.opt("sessionId") as? String) != sessionId || wholeNumber(root.opt("requestId")) != requestId) { + return ManifestResult.Failed(AccessoryHandshakeError.SESSION_MISMATCH) + } + if ((root.opt("type") as? String) != "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 { + wholeNumber(root.opt("protocolVersion")) + ?: 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 { + wholeNumber(offered.opt(it)) + ?: 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 = 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() + // 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()) { + 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 + } + + /** + * 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 + 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/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..5aa9d6ec --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySession.kt @@ -0,0 +1,336 @@ +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 a sample says about itself. The status is carried, never inferred. + * + * There is no fourth case and no "unknown": a line this app cannot read as a measurement resolves + * to [ERROR], because the alternative — quietly treating it as the far end of the range — is a + * board told it has all the clearance in the world at the exact moment its sensor stopped working. + * + * @parity /modules/vescape-core/ios/accessory/AccessorySession.swift `AccessoryReadingStatus` + * @parity /modules/vescape-core/src/index.ts `AccessoryReadingStatus` + */ +enum class AccessoryReadingStatus(val wire: String) { + /** A real measurement. The only status that carries a value. */ + OK("ok"), + + /** The sensor answered, and the answer is not a distance this capability promises. */ + OUT_OF_RANGE("out_of_range"), + + /** The sensor could not measure, or the app could not read what it sent. */ + ERROR("error"); + + companion object { + /** + * Whatever a line claimed, as a status this app can act on. + * + * A status string from the future is [ERROR] rather than a guess. It cannot be [OK] — that + * would invent a measurement — and it cannot be [OUT_OF_RANGE] either, which would claim + * the sensor answered when nobody here knows that it did. + */ + fun fromWire(value: String?): AccessoryReadingStatus = + entries.firstOrNull { it.wire == value } ?: ERROR + } +} + +/** + * One sample from a measurement capability. + * + * [valueCm] exists **only** when [status] is [AccessoryReadingStatus.OK]; the constructor enforces + * it, so there is no way to hold a reading whose status and value disagree. That invariant is the + * whole safety property of this slice: a consumer that has a value has a measurement. + * + * [sampleTimeMs] is the accessory's own monotonic clock since its session began, never comparable + * to a phone timestamp. Freshness is judged on local receipt time; this field only orders samples. + * + * @parity /modules/vescape-core/ios/accessory/AccessorySession.swift `AccessoryReading` + * @parity /modules/vescape-core/src/index.ts `AccessoryReadingEvent` + */ +data class AccessoryReading( + val capabilityId: String, + val seq: Int, + val sampleTimeMs: Long, + val status: AccessoryReadingStatus, + val valueCm: Double?, +) { + init { + require(status == AccessoryReadingStatus.OK || valueCm == null) { + "only an ok reading carries a value" + } + } + + /** + * The same sample judged against the limits the capability declared. + * + * A number outside the declared window is reported as out of range rather than clamped into it. + * Clamping is how a sensor staring at nothing ends up reporting the maximum distance, which is + * exactly the reading that would tell the board it is safe to tilt. + */ + fun withinDeclaredRange(rangeMin: Double?, rangeMax: Double?): AccessoryReading { + val value = valueCm ?: return this + if (rangeMin == null || rangeMax == null) return this + if (value < rangeMin || value > rangeMax) { + return copy(status = AccessoryReadingStatus.OUT_OF_RANGE, valueCm = null) + } + return this + } +} + +/** + * 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() + + /** + * One sample off the unacknowledged reading stream. Answers nothing and renews no lease. + */ + data class Sample(val reading: AccessoryReading) : 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) + } + + "reading" -> parseReading(root) + + else -> Ignored + } + } + + /** + * One sample, or [Ignored] when the envelope is not one. + * + * The envelope fields — capability, sequence, sample time — must all be there, because + * without them a sample cannot be ordered against its neighbours and an unorderable sample + * is not evidence of anything. The *status* is the opposite: whatever it says, this returns + * a reading, because "the sensor sent something this app cannot read" is itself information + * the consumer needs, and dropping it would leave the last good sample standing. + */ + private fun parseReading(root: JSONObject): AccessoryResponse { + val capabilityId = (root.opt("capabilityId") as? String) + ?.takeIf { it.isNotBlank() } ?: return Ignored + val seq = wholeNumber(root.opt("seq")) ?: return Ignored + val sampleTimeMs = wholeNumber(root.opt("sampleTimeMs"))?.toLong() ?: return Ignored + val status = AccessoryReadingStatus.fromWire(root.opt("status") as? String) + // An `ok` is only an `ok` once it produced a finite number. A missing, null or + // non-numeric value demotes the sample to `error` — never to the top of the range. + val value = (root.opt("value") as? Number)?.toDouble()?.takeIf { it.isFinite() } + val resolved = if (status == AccessoryReadingStatus.OK && value == null) { + AccessoryReadingStatus.ERROR + } else { + status + } + return Sample( + AccessoryReading( + capabilityId = capabilityId, + seq = seq, + sampleTimeMs = sampleTimeMs, + status = resolved, + valueCm = if (resolved == AccessoryReadingStatus.OK) value else null, + ), + ) + } + + /** 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..b2ec9999 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt @@ -0,0 +1,894 @@ +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.service.CoreForegroundService +import expo.modules.vescapecore.telemetry.AccessoryBrakeLightEntity +import expo.modules.vescapecore.telemetry.AccessoryCapabilitySettingsEntity +import expo.modules.vescapecore.telemetry.AccessoryGroundClearanceEntity +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 kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +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) + + /** + * Calibration writes, one at a time and in the order the rider asked for them. + * + * The general [scope] fans out across the IO pool, which is right for independent work and wrong + * for this: a save dispatched before a clear can finish after it and put the row back, and two + * saves in quick succession can land out of order and leave the older draft on disk. Both are + * reachable from one screen — the editor saves on a debounce and clears on a tap. + * + * @parity /modules/vescape-core/ios/accessory/AccessorySessionController.swift `saveGroundClearance` + * @platform-diff iOS writes these on the main queue inside `onMain`, which already orders them. + */ + private val calibrationScope = + CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(1)) + + /** + * How many calibration mutations have been *asked for* per capability. + * + * Ordering the writes is not enough on its own: the in-memory runtime and the published snapshot + * are updated after the write, back on the main looper, and an older completion arriving there + * would undo a newer one. Each mutation carries the number it was given and applies nothing if a + * later one has since been asked for. + */ + private val calibrationSeq = HashMap() + + private val links = LinkedHashMap() + private val saved = LinkedHashMap() + private val capabilityEnabled = HashMap() + private val samplingRates = HashMap() + private val capabilityMutations = HashMap() + + /** + * Live ground-clearance state, one per enrolled capability. + * + * Keyed on the Accessory *and* the capability, exactly as the durable row is: one unit may + * declare a nose sensor and a tail sensor, and they share neither a calibration nor a stream. + */ + private val brakeLight = BrakeLightController() + private val lightSaveMutex = Mutex() + private val lightMutations = HashMap() + private val lightExpiry = Runnable { + brakeLight.clear() + reapplyDemand() + publish() + } + + private val groundClearance = GroundClearanceBindingController(SystemClock::elapsedRealtime) + + /** + * The last snapshot built on the main looper. + * + * The bridge's synchronous getter runs on the JS thread while [saved] and [links] are written + * from the main looper; iterating them from two threads is a `ConcurrentModificationException` + * waiting for a badly timed render. Publishing an immutable list instead means the getter never + * touches the live maps. + */ + @Volatile private var published: List> = emptyList() + + 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 store = persistence(app) + val rows = try { + store.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 + } + val calibrations = try { + store.getGroundClearances() + } catch (error: Throwable) { + RecordingStorageFailure.reportRead("accessory_ground_clearance", error) + emptyList() + } + val capabilitySettings = try { + // A failed preference read must not silently re-enable disabled hardware. + store.getCapabilitySettings() + } catch (error: Throwable) { + RecordingStorageFailure.reportRead("accessory_capability_settings", error) + return@launch + } + val lightSettings = try { store.getBrakeLights() } catch (error: Throwable) { + RecordingStorageFailure.reportRead("accessory_brake_light", error) + emptyList() + } + handler.post { + capabilityEnabled.clear() + samplingRates.clear() + capabilitySettings.forEach { + val key = GroundClearanceBindingController.Key(it.accessoryId, it.capabilityId) + capabilityEnabled[key] = it.enabled + it.samplingRateHz?.let { rate -> samplingRates[key] = rate } + } + lightSettings.forEach { brakeLight.configure(BrakeLightController.Key(it.accessoryId, it.capabilityId), BrakeLightSettings(it.sensitivity, it.parked)) } + saved.clear() + rows.forEach { saved[it.accessoryId] = it } + groundClearance.reset(calibrations.map { row -> + GroundClearanceBindingController.Key(row.accessoryId, row.capabilityId) to row.toCalibration() + }) + 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 + link(stored).start(deviceId) + publish() + onResult(mapOf("accessoryId" to accessoryId, "error" to null)) + } + // The first enrollment arrives when no host is running: process-start auto-connect + // already looked and found nothing enrolled. Without this the new link would live + // in a bare app process and die the moment the rider backgrounds the app. + CoreForegroundService.autoConnectAccessories(app) + } + } + } + + /** 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) + // The calibrations went with the row in the same transaction; the live runtimes go + // with them, so a re-enrollment starts from "not set up" rather than from whatever + // this process still happened to be holding. + groundClearance.forget(accessoryId) + brakeLight.forget(accessoryId) + capabilityEnabled.keys.removeAll { it.accessoryId == accessoryId } + samplingRates.keys.removeAll { it.accessoryId == accessoryId } + capabilityMutations.keys.filter { it.accessoryId == accessoryId }.forEach { + capabilityMutations[it] = (capabilityMutations[it] ?: 0L) + 1 + } + for (key in lightMutations.keys.filter { it.accessoryId == accessoryId }) { + lightMutations[key] = (lightMutations[key] ?: 0L) + 1 + } + // A save still in flight for this Accessory must not land on the runtime after the + // rider forgot it. Bumping the counter is what makes its completion a no-op. + for (key in calibrationSeq.keys.filter { it.accessoryId == accessoryId }) { + calibrationSeq[key] = (calibrationSeq[key] ?: 0L) + 1 + } + 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> = published + + 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)) + .map { describeCapability(row.accessoryId, it) }, + // Derived from the frozen baseline rather than remembered in memory: a flag held only + // for the life of the process would clear itself on the next launch, which is the one + // moment the rider is least likely to be looking. + "capabilitiesChanged" to ( + live != null && encodeCapabilityList(live.capabilities) != row.capabilitiesJson + ), + "leaseHeldMs" to link?.lastAckAtMs?.let { SystemClock.elapsedRealtime() - it }, + ) + } + + /** + * One capability as JS sees it, with whatever this app has saved and decided about it. + * + * The saved calibration rides along with the capability rather than in a list of its own: it is + * keyed on the capability and meaningless without it, and a screen that had to join two arrays + * by id would be a place for them to disagree. + * + * `measuring` is the demand native actually resolved, not a restatement of what the screen + * asked for — a preview on a capability with no usable rate is a screen that is open and a + * sensor that is not measuring, and the row should say so. + */ + // @parity /modules/vescape-core/src/index.ts `AccessoryCapability` + private fun describeCapability(accessoryId: String, capability: Map): Map { + val capabilityId = capability["id"] as? String ?: return capability + val base = capability + mapOf( + "enabled" to isCapabilityEnabled(accessoryId, capabilityId), + "samplingRateHz" to links[accessoryId]?.appliedRateHz(capabilityId), + "selectedRateHz" to capabilityFromMap(capability)?.let { + AccessorySession.resolveRateHz(samplingRates[GroundClearanceBindingController.Key(accessoryId, capabilityId)] ?: PREFERRED_RATE_HZ, it.ratesHz) + }, + ) + if (capability["type"] == AccessoryProtocol.TYPE_BRAKE_LIGHT) return base + brakeLight.describe(BrakeLightController.Key(accessoryId, capabilityId)) + return base + (groundClearance.describe(accessoryId, capabilityId) ?: emptyMap()) + } + + private fun isCapabilityEnabled(accessoryId: String, capabilityId: String): Boolean = + capabilityEnabled[GroundClearanceBindingController.Key(accessoryId, capabilityId)] != false + + // @parity /modules/vescape-core/ios/accessory/AccessorySessionController.swift `setCapabilityEnabled` + // @parity /modules/vescape-core/src/index.ts `setAccessoryCapabilityEnabled` + fun setCapabilityEnabled(accessoryId: String, capabilityId: String, enabled: Boolean, onResult: (Boolean) -> Unit) { + updateCapabilitySettings(accessoryId, capabilityId, enabled, null, onResult) + } + + // @parity /modules/vescape-core/ios/accessory/AccessorySessionController.swift `setSamplingRate` + // @parity /modules/vescape-core/src/index.ts `setAccessorySamplingRate` + fun setSamplingRate(accessoryId: String, capabilityId: String, rateHz: Double, onResult: (Boolean) -> Unit) { + updateCapabilitySettings(accessoryId, capabilityId, null, rateHz, onResult) + } + + private fun updateCapabilitySettings(accessoryId: String, capabilityId: String, enabled: Boolean?, rateHz: Double?, onResult: (Boolean) -> Unit) { + handler.post { + val app = appContext ?: return@post onResult(false) + val row = saved[accessoryId] ?: return@post onResult(false) + val capabilities = links[accessoryId]?.manifest?.capabilities + ?: decodeCapabilities(row.capabilitiesJson).mapNotNull(::capabilityFromMap) + val capability = capabilities.firstOrNull { it.id == capabilityId && it.supported } ?: return@post onResult(false) + if (rateHz != null && (capability.type != AccessoryProtocol.TYPE_GROUND_CLEARANCE || !rateHz.isFinite() || rateHz !in capability.ratesHz)) return@post onResult(false) + val key = GroundClearanceBindingController.Key(accessoryId, capabilityId) + val mutation = (capabilityMutations[key] ?: 0L) + 1 + capabilityMutations[key] = mutation + calibrationScope.launch { + val candidate = try { + val store = persistence(app) + val previous = store.getCapabilitySettings().firstOrNull { it.accessoryId == accessoryId && it.capabilityId == capabilityId } + AccessoryCapabilitySettingsEntity(accessoryId, capabilityId, enabled ?: previous?.enabled ?: true, rateHz ?: previous?.samplingRateHz) + .also { store.saveCapabilitySettings(it) } + } catch (error: Throwable) { + RecordingStorageFailure.report("accessory_capability_settings", "write_failed", error) + handler.post { onResult(false) } + return@launch + } + handler.post { + if (saved.containsKey(accessoryId) && capabilityMutations[key] == mutation) { + capabilityEnabled[key] = candidate.enabled + candidate.samplingRateHz?.let { samplingRates[key] = it } + if (!candidate.enabled) brakeLight.preview(BrakeLightController.Key(accessoryId, capabilityId), null) + reapplyDemand() + publish() + } + onResult(true) + } + } + } + } + + private fun publish() { + val snapshot = buildSnapshot() + published = snapshot + emit?.invoke("onAccessoryState", mapOf("accessories" to snapshot)) + } + + // 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) }, + onReading = { reading, at -> onReading(row.accessoryId, reading, at) }, + onSessionLost = { onSessionLost(row.accessoryId) }, + ).also { it.applyDemand(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 + // `capabilitiesJson` is deliberately carried over unchanged. It is the baseline the rider's + // saved settings were validated against, and the snapshot derives "limits changed" by + // comparing the live manifest against it; rewriting it here would answer the question with + // the very thing being questioned. + val row = previous.copy( + name = manifest.name, + firmwareVersion = manifest.firmwareVersion, + protocolVersion = manifest.protocolVersion, + deviceId = deviceId, + lastConnectedAt = System.currentTimeMillis(), + ) + saved[manifest.accessoryId] = row + links[manifest.accessoryId]?.applyDemand(row, manifest) + val app = appContext ?: return + scope.launch { + try { + // Update-only: a handshake completing just as the rider forgets this Accessory must + // not write the row back. + persistence(app).revalidate(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) + } + } + } + + /** + * What every capability of one Accessory should currently be doing. + * + * The whole demand decision lives here and nowhere else. A ground-clearance capability measures + * when someone actually needs the numbers — the rider has its screen open, or the rider is on a + * calibrated board — and sits in the protocol's own measurement standby otherwise. Standby is + * not a pause in the app: `enabled: false` stops the sensor's continuous measurement on the + * accessory while BLE stays up, so leaving the screen genuinely stops measuring rather than + * throwing away samples the hardware is still burning power to produce. + * + * Brake-light state comes from native speed samples or an explicitly parked preview. + */ + private fun AccessoryLink.applyDemand( + 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(samplingRates[GroundClearanceBindingController.Key(row.accessoryId, capability.id)] ?: PREFERRED_RATE_HZ, capability.ratesHz) + ?: continue + setDesired(groundClearance.applyCapability(row.accessoryId, capability, manifest != null, rate, isCapabilityEnabled(row.accessoryId, capability.id))) + } + + AccessoryProtocol.TYPE_BRAKE_LIGHT -> setDesired( + brakeLight.command(BrakeLightController.Key(row.accessoryId, capability.id), isCapabilityEnabled(row.accessoryId, capability.id)) + ) + + else -> Unit + } + } + } + + /** Re-decides demand for every enrolled Accessory, from whatever its session currently knows. */ + private fun reapplyDemand() { + for ((accessoryId, link) in links) { + val row = saved[accessoryId] ?: continue + link.applyDemand(row, link.manifest) + } + } + + // MARK: - Brake light + + fun setLightTelemetry(speedKmh: Double, riding: Boolean, receivedAt: Long = SystemClock.elapsedRealtime()) { + handler.post { + // Keep receive time across the thread hop; queued samples cannot renew stale evidence. + if (SystemClock.elapsedRealtime() - receivedAt >= 1500) return@post + val changed = brakeLight.sample(speedKmh, riding, receivedAt) + handler.removeCallbacks(lightExpiry) + handler.postDelayed(lightExpiry, 1500 - (SystemClock.elapsedRealtime() - receivedAt)) + reapplyDemand() + // Only when the rider would see something different. A steady-speed ride produces one + // sample after another that says the same thing, and publishing each of them would be a + // full snapshot per telemetry sample for no change on screen. + if (changed) publish() + } + } + + fun clearLightTelemetry() { + handler.post { + handler.removeCallbacks(lightExpiry) + brakeLight.clear() + reapplyDemand() + publish() + } + } + + fun setLightPreview(accessoryId: String, capabilityId: String, mode: String?, onResult: (Boolean) -> Unit) { + handler.post { + if (mode != null && !isCapabilityEnabled(accessoryId, capabilityId)) return@post onResult(false) + val accepted = brakeLight.preview(BrakeLightController.Key(accessoryId, capabilityId), mode) + if (accepted) { reapplyDemand(); publish() } + onResult(accepted) + } + } + + fun saveBrakeLight(accessoryId: String, capabilityId: String, sensitivity: Int, parked: String, onResult: (Boolean) -> Unit) { + handler.post { + val app = appContext ?: return@post onResult(false) + val candidate = BrakeLightSettings(sensitivity, parked) + val capabilities = links[accessoryId]?.manifest?.capabilities + ?: saved[accessoryId]?.let { decodeCapabilities(it.capabilitiesJson).mapNotNull(::capabilityFromMap) } + if (!candidate.valid() || capabilities?.any { it.id == capabilityId && it.type == AccessoryProtocol.TYPE_BRAKE_LIGHT && it.supported } != true) return@post onResult(false) + val key = BrakeLightController.Key(accessoryId, capabilityId) + val mutation = (lightMutations[key] ?: 0L) + 1 + lightMutations[key] = mutation + calibrationScope.launch { + lightSaveMutex.withLock { + try { + persistence(app).saveBrakeLight(AccessoryBrakeLightEntity(accessoryId, capabilityId, sensitivity, parked)) + } catch (error: Throwable) { + RecordingStorageFailure.report("accessory_brake_light", "write_failed", error) + handler.post { onResult(false) }; return@withLock + } + handler.post { + if (saved.containsKey(accessoryId) && lightMutations[key] == mutation) { + brakeLight.configure(BrakeLightController.Key(accessoryId, capabilityId), candidate) + reapplyDemand(); publish() + } + onResult(true) + } + } + } + } + } + + // MARK: - Ground clearance + + /** + * The configuration screen for one capability opened or closed. + * + * The only demand JS is allowed to express, and it is a request to *measure*, never to tilt: a + * preview shows numbers on a parked board, and [groundClearanceInput] refuses to drive anything + * that is not being ridden regardless of what this says. + * + * A screen that is gone — backgrounded, unmounted, or its JS runtime killed — stops the sensor, + * which is what "leaving the screen stops measurements" means at the hardware. + */ + fun setPreview(accessoryId: String, capabilityId: String, open: Boolean) { + handler.post { + if (!groundClearance.setPreview(accessoryId, capabilityId, open)) return@post + reapplyDemand() + publish() + } + } + + /** + * Drops every preview, whoever asked for it. + * + * Preview demand lives in this process and the screen that asked for it lives in a JS runtime + * that can disappear without unmounting anything — a reload, a crash, a development refresh. The + * accessory's own lease cannot save it either, because native keeps renewing the configuration + * on the screen's behalf. So the runtime going away has to be the release. + * + * Riding demand is deliberately untouched: it comes from the Board Session, which outlives JS. + * + * @parity /modules/vescape-core/ios/accessory/AccessorySessionController.swift `releasePreviews` + */ + fun releasePreviews() { + handler.post { + groundClearance.releasePreviews() + brakeLight.releasePreviews() + reapplyDemand() + publish() + } + } + + /** + * Board engagement, from the Board Session's own predicate. + * + * Native's, never JS's: this decides whether a sensor runs while the screen is off, and a value + * that arrived over the bridge would stop being true the moment the runtime died. + * + * @parity /modules/vescape-core/ios/accessory/AccessorySessionController.swift `setRiding` + */ + fun setRiding(riding: Boolean) { + // Compared before the hop, not inside it. This arrives with every telemetry sample for the + // whole of a ride, and posting a Runnable per sample to discover that nothing changed is a + // few thousand allocations an hour for no decision. + if (!groundClearance.setRiding(riding)) return + handler.post { + reapplyDemand() + publish() + } + } + + /** + * Saves one calibration, if it is one. + * + * There is no Save button behind this: the screen sends what the rider has so far and native + * decides whether it is complete. Validity is judged against the limits the Accessory declares + * *now*, so a calibration is never written that the hardware in front of the rider would refuse. + * + * Saving a calibration that fits the current manifest is also how the rider accepts limits that + * moved since enrollment: the frozen `capabilities_json` baseline is rewritten to what the + * session just validated against, which is what clears "this Accessory now declares different + * limits". Nothing else in the app may rewrite that baseline. + */ + fun saveGroundClearance( + accessoryId: String, + capabilityId: String, + nearCm: Double, + farCm: Double, + direction: String, + strengthPercent: Int, + onResult: (Map) -> Unit, + ) { + handler.post { + val app = appContext + if (app == null || saved[accessoryId] == null) { + onResult(mapOf("saved" to false, "problem" to "unknown-capability")) + return@post + } + val candidate = GroundClearanceCalibration(nearCm, farCm, direction, strengthPercent) + val problem = groundClearance.validate(accessoryId, capabilityId, candidate) + if (problem != null) { + onResult(mapOf("saved" to false, "problem" to problem.wire)) + return@post + } + val liveCapabilities = links[accessoryId]?.manifest?.capabilities + val key = GroundClearanceBindingController.Key(accessoryId, capabilityId) + val mutation = (calibrationSeq[key] ?: 0L) + 1 + calibrationSeq[key] = mutation + val row = AccessoryGroundClearanceEntity( + accessoryId = accessoryId, + capabilityId = capabilityId, + nearCm = nearCm, + farCm = farCm, + direction = direction, + strengthPercent = strengthPercent, + updatedAt = System.currentTimeMillis(), + ) + calibrationScope.launch { + val store = persistence(app) + try { + store.saveGroundClearance(row) + } catch (error: Throwable) { + // Nothing is applied in memory either. A binding that drove from a calibration + // the database never took would come back uncalibrated on the next launch, with + // the rider believing they had set it. + RecordingStorageFailure.report("accessory_ground_clearance", "write_failed", error) + handler.post { onResult(mapOf("saved" to false, "problem" to "storage-unavailable")) } + return@launch + } + val baseline = liveCapabilities?.let { encodeCapabilityList(it) } + if (baseline != null) { + try { + store.adoptCapabilities(accessoryId, baseline) + } catch (error: Throwable) { + // The calibration is saved and correct; only the warning outlives the + // acceptance, and the next save clears it. + RecordingStorageFailure.report("accessory_revalidate", "write_failed", error) + } + } + handler.post { + // The row is written either way — the writes are ordered, so the newest ask is + // the one on disk. What is refused here is applying an older ask's *result* over + // a newer one in memory, which is how a save that raced a clear used to put the + // calibration back. + if (calibrationSeq[key] != mutation) { + onResult(mapOf("saved" to true, "problem" to null)) + return@post + } + groundClearance.applyCalibration(accessoryId, capabilityId, candidate) + if (baseline != null) { + saved[accessoryId]?.let { saved[accessoryId] = it.copy(capabilitiesJson = baseline) } + } + reapplyDemand() + publish() + onResult(mapOf("saved" to true, "problem" to null)) + } + } + } + } + + /** Drops a calibration. The binding stops driving and the screen goes back to explaining setup. */ + fun clearGroundClearance(accessoryId: String, capabilityId: String, onResult: (Boolean) -> Unit) { + handler.post { + val app = appContext ?: return@post onResult(false) + val key = GroundClearanceBindingController.Key(accessoryId, capabilityId) + val mutation = (calibrationSeq[key] ?: 0L) + 1 + calibrationSeq[key] = mutation + calibrationScope.launch { + val removed = try { + persistence(app).clearGroundClearance(accessoryId, capabilityId) + } catch (error: Throwable) { + RecordingStorageFailure.report("accessory_ground_clearance", "write_failed", error) + handler.post { onResult(false) } + return@launch + } + handler.post { + if (calibrationSeq[key] != mutation) return@post onResult(removed) + groundClearance.clearCalibration(accessoryId, capabilityId) + reapplyDemand() + publish() + onResult(removed) + } + } + } + } + + /** + * What a Remote Tilt binding may do with this capability right now. The seam #479 consumes. + * + * Two outcomes and no third: a scaled, signed input built from a fresh in-range measurement, or + * a named reason to release. Nothing here can be read as "hold the last value" — a consumer that + * gets a release has been told to let go, and why. + * + * @parity /modules/vescape-core/ios/accessory/AccessorySessionController.swift `groundClearanceInput` + */ + fun groundClearanceInput(accessoryId: String, capabilityId: String): GroundClearanceInput { + return groundClearance.input(accessoryId, capabilityId, linkState(accessoryId, capabilityId)) + } + + /** + * Whether a configured ground-clearance Accessory is connected. + * + * What makes the Remote Tilt pad a read-only indicator. Deliberately true even when the bindings + * are [GroundClearanceRelease.CONTESTED] and none of them is driving: a rider whose two sensors + * cancel each other out must not silently get their manual pad back, because the pad is not what + * this board is configured for. + * + * @parity /modules/vescape-core/ios/accessory/AccessorySessionController.swift `groundClearanceBound` + */ + fun groundClearanceBound(): Boolean = groundClearance.bound(::linkState) + + /** + * The single ground-clearance input a Remote Tilt binding may act on, across every Accessory. + * + * v1 binds to whichever Board is connected and has no arbitration between a nose sensor and a + * tail sensor — the eventual hardware has both. Two claimants therefore release rather than + * resolve: choosing one of them would be choosing a correction *direction* on the rider's behalf, + * and the wrong choice tilts the board the wrong way. + * + * @parity /modules/vescape-core/ios/accessory/AccessorySessionController.swift `groundClearanceTilt` + */ + fun groundClearanceTilt(): GroundClearanceInput { + return groundClearance.tilt(::linkState) + } + + private fun linkState(accessoryId: String, capabilityId: String): GroundClearanceBindingController.LinkState { + val link = links[accessoryId] + return GroundClearanceBindingController.LinkState( + connected = link?.phase == AccessoryLinkPhase.CONNECTED, + appliedRateHz = link?.appliedRateHz(capabilityId) ?: 0.0, + ) + } + + /** + * One sample off an Accessory's reading stream. + * + * Range-checked against the limits the *live* manifest declares before anything else sees it, so + * a number the hardware no longer promises is carried onward as `out_of_range` with no value + * rather than as a distance. A sample older than the newest one held is dropped outright. + * + * The bridge only hears about it while a screen is open. Nothing else in the app consumes single + * samples — the tilt binding pulls [groundClearanceInput] on its own cadence — so emitting at + * the sensor's rate with nothing mounted would be pure bridge traffic. + */ + private fun onReading(accessoryId: String, reading: AccessoryReading, receivedAtMs: Long) { + val payload = groundClearance.acceptReading( + accessoryId, + reading, + receivedAtMs, + links[accessoryId]?.appliedRateHz(reading.capabilityId), + ) ?: return + emit?.invoke("onAccessoryReading", payload) + } + + /** + * The protocol session for one Accessory ended. + * + * Sequence numbers restart with the next hello, so anything the tracker still holds would make + * the new session's first samples look like duplicates. The calibration is durable and stays. + */ + private fun onSessionLost(accessoryId: String) { + groundClearance.onSessionLost(accessoryId) + } + + private fun AccessoryGroundClearanceEntity.toCalibration() = + GroundClearanceCalibration(nearCm, farCm, direction, strengthPercent) + + /** `docs/accessory-protocol.md` PoC default, resolved against whatever the manifest offers. */ + private const val PREFERRED_RATE_HZ = 10.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/accessory/BrakeLight.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/BrakeLight.kt new file mode 100644 index 00000000..a0d1ba81 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/BrakeLight.kt @@ -0,0 +1,87 @@ +package expo.modules.vescapecore.accessory + +import kotlin.math.abs + +/** Speed is km/h, clock is monotonic. PoC defaults documented in docs/accessories.md. + * @parity /modules/vescape-core/ios/accessory/BrakeLight.swift + */ +class BrakeLightDetector { + private var previousSpeed: Double? = null + private var previousAt: Long? = null + private var deceleration = 0.0 + var mode: String? = null + private set + + fun clear() { previousSpeed = null; previousAt = null; deceleration = 0.0; mode = null } + + fun sample(speedKmh: Double, riding: Boolean, at: Long, sensitivity: Int) { + if (!speedKmh.isFinite()) { clear(); return } + val speed = abs(speedKmh) / 3.6 + val oldSpeed = previousSpeed + val dt = previousAt?.let { at - it } + previousSpeed = speed + previousAt = at + if (!riding) { deceleration = 0.0; mode = "not_riding"; return } + if (oldSpeed == null || dt == null) { mode = "riding"; return } + if (dt <= 0 || dt > 500) { deceleration = 0.0; mode = null; return } + val seconds = dt / 1000.0 + val alpha = seconds / (0.2 + seconds) + deceleration += alpha * ((oldSpeed - speed) / seconds - deceleration) + val scale = 1.5 - sensitivity / 100.0 + val braking = scale * if (mode == "braking") 0.75 else 1.0 + val hard = scale * if (mode == "hard_braking") 2.25 else 3.0 + mode = when { deceleration >= hard -> "hard_braking"; deceleration >= braking -> "braking"; else -> "riding" } + } +} + +/** Persisted per Accessory capability; automatic state follows whichever Board is current. + * @parity /modules/vescape-core/ios/accessory/BrakeLight.swift `BrakeLightSettings` + * @parity /modules/vescape-core/src/index.ts `BrakeLightSettings` + */ +data class BrakeLightSettings(val sensitivity: Int = 50, val parked: String = "off") { + fun valid() = sensitivity in 1..100 && parked in setOf("off", "glow") + fun toMap(): Map = mapOf("sensitivity" to sensitivity, "parked" to parked) +} + +/** @parity /modules/vescape-core/ios/accessory/BrakeLight.swift `BrakeLightController` */ +class BrakeLightController { + data class Key(val accessoryId: String, val capabilityId: String) + private class Light(var settings: BrakeLightSettings = BrakeLightSettings()) { + val detector = BrakeLightDetector() + var preview: String? = null + } + private val lights = linkedMapOf() + private var riding = false + fun configure(key: Key, settings: BrakeLightSettings) { lights.getOrPut(key) { Light() }.settings = settings } + fun forget(accessoryId: String) { lights.keys.removeAll { it.accessoryId == accessoryId } } + /** Returns whether anything a screen renders changed, so an unchanged sample publishes nothing. */ + fun sample(speed: Double, engaged: Boolean, at: Long): Boolean { + riding = engaged + var changed = false + lights.values.forEach { light -> + // Riding ends a preview: the rider is on the board and the light follows the board. + if (engaged && light.preview != null) { light.preview = null; changed = true } + val before = light.detector.mode + light.detector.sample(speed, engaged, at, light.settings.sensitivity) + if (light.detector.mode != before) changed = true + } + return changed + } + fun clear() { riding = false; lights.values.forEach { it.detector.clear() } } + fun releasePreviews() { lights.values.forEach { it.preview = null } } + fun preview(key: Key, mode: String?): Boolean { + if (mode != null && (riding || mode !in MODES)) return false + val light = lights[key] ?: return false + light.preview = mode + return true + } + /** @parity /modules/vescape-core/src/index.ts `AccessoryCapability` */ + fun describe(key: Key): Map = lights.getOrPut(key) { Light() }.let { + mapOf("brakeLight" to it.settings.toMap(), "lightMode" to it.detector.mode, "lightPreview" to it.preview) + } + fun command(key: Key, enabled: Boolean = true): AccessoryCommand.State = lights.getOrPut(key) { Light() }.let { + if (!enabled) return AccessoryCommand.State(key.capabilityId, "available", "not_riding", "off", false) + AccessoryCommand.State(key.capabilityId, if(it.detector.mode == null) "unavailable" else "available", it.preview ?: it.detector.mode, it.settings.parked, it.preview != null) + } + companion object { val MODES = setOf("riding", "braking", "hard_braking", "not_riding") } +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/ClearancePreviewLog.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/ClearancePreviewLog.kt new file mode 100644 index 00000000..43274807 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/ClearancePreviewLog.kt @@ -0,0 +1,49 @@ +package expo.modules.vescapecore.accessory + +/** Short-lived display history. Control still consumes every original reading. + * @parity /modules/vescape-core/ios/accessory/ClearancePreviewLog.swift + * @parity /modules/vescape-core/src/index.ts `ClearancePreviewDiagnostics` + */ +class ClearancePreviewLog { + private data class Sample(val at: Long, val time: Long, val seq: Long, val value: Double?) + private val samples = ArrayDeque() + private var emittedAt: Long? = null + private var chartAt: Long? = null + fun reset() { samples.clear(); emittedAt = null; chartAt = null } + fun record(at: Long, time: Long, seq: Long, value: Double?) { + samples.addLast(Sample(at, time, seq, value)) + while (samples.size > 601 || (samples.firstOrNull()?.at ?: at) < at - 20_000) samples.removeFirst() + } + fun shouldEmit(at: Long): Boolean { + if (emittedAt?.let { at - it < 100 } == true) return false + emittedAt = at + return true + } + fun snapshot(at: Long): Map? { + if (chartAt?.let { at - it < 250 } == true) return null + chartAt = at + val segments = mutableListOf>() + var segment = mutableListOf() + var previous: Sample? = null + var dropped = 0L + for (sample in samples) { + val gap = previous?.let { sample.seq != it.seq + 1 || sample.at - it.at > 300 } == true + previous?.let { dropped += (sample.seq - it.seq - 1).coerceAtLeast(0) } + if (gap || sample.value == null) { + if (segment.isNotEmpty()) segments.add(segment) + segment = mutableListOf() + } + sample.value?.let { segment.add(sample.time.toDouble()); segment.add(it) } + previous = sample + } + if (segment.isNotEmpty()) segments.add(segment) + val span = samples.lastOrNull()?.at?.minus(samples.first().at) ?: 0 + return mapOf( + "segments" to segments, + "deliveredHz" to if (span > 0) (samples.size - 1) * 1000.0 / span else 0.0, + "dropped" to dropped, + "invalid" to samples.count { it.value == null }, + "samples" to samples.size, + ) + } +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt new file mode 100644 index 00000000..43ee20fb --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt @@ -0,0 +1,675 @@ +package expo.modules.vescapecore.accessory + +import expo.modules.vescapecore.RemoteInputArbiter +import expo.modules.vescapecore.RemoteInputOwner +import expo.modules.vescapecore.protocol.REMOTE_TILT_CENTER +import expo.modules.vescapecore.runtime.Cancellable +import kotlin.math.max +import kotlin.math.roundToInt +import kotlin.math.roundToLong + +/** + * The ground-clearance capability: the rider's calibration, the sample stream it reads, and the + * one number a Remote Tilt binding is allowed to act on. + * + * Pure and clock-free on purpose — every timestamp arrives as a parameter — so the rules below can + * be asserted against `shared/fixtures/accessory-protocol/session.json` without a radio, a sensor + * or a board. [AccessorySessionManager] owns the wiring; this file owns the arithmetic. + * + * The property everything else rests on: **a missing measurement is never a distance.** Not the top + * of the range, not the last good value, not zero. A sensor that stopped answering releases the + * input, and so does one answering with something this app cannot read. + * + * @parity /modules/vescape-core/ios/accessory/GroundClearance.swift + * @parity /modules/vescape-core/src/index.ts `GroundClearanceCalibration` + */ +object GroundClearance { + /** Floor under the missing-stream timeout, `docs/accessory-protocol.md` PoC defaults. */ + const val MISSING_STREAM_FLOOR_MS = 300L + + /** A binding that commands nothing is not a binding, so zero strength is not a calibration. */ + const val MIN_STRENGTH_PERCENT = 1 + const val MAX_STRENGTH_PERCENT = 100 + + /** + * How long a capability may go without a sample before its input is released. + * + * Three sample periods, floored: at 20 Hz the floor is what matters, and a slow rate gets room + * for two dropped samples rather than being declared dead by a fixed 300 ms it never had a + * chance to meet. + */ + fun staleAfterMs(rateHz: Double): Long { + if (!rateHz.isFinite() || rateHz <= 0.0) return MISSING_STREAM_FLOOR_MS + return max(MISSING_STREAM_FLOOR_MS, (3_000.0 / rateHz).roundToLong()) + } + + /** + * The Refloat remote-input byte one signed correction asks for. + * + * The scale is the pad's: 128 is neutral and 255 is full nose-up, so a correction of 1.0 is the + * same command a rider dragging the pad to its right edge would send. Defined here rather than + * at the call site because both platforms and the tests have to agree on it byte for byte. + * + * A non-finite input is neutral, not a clamp to an extreme. Nothing should be able to produce + * one — [GroundClearanceCalibration.tiltInput] returns 0.0 for a non-finite distance — but the + * one place that decides what a board is told is not where to find out. + */ + fun tiltCommand(tiltInput: Double): Int { + if (!tiltInput.isFinite()) return REMOTE_TILT_CENTER + val span = 255 - REMOTE_TILT_CENTER + return (REMOTE_TILT_CENTER + tiltInput.coerceIn(-1.0, 1.0) * span).roundToInt().coerceIn(0, 255) + } +} + +/** + * Which way a mounted sensor corrects. + * + * Kept as a wire string in [GroundClearanceCalibration] rather than parsed on the way in: a saved + * row written by a newer build must be *rejected* as incomplete, not crash the session that read + * it, and an unparsed direction is exactly the incomplete calibration the rider needs to fix. + * + * @parity /modules/vescape-core/ios/accessory/GroundClearance.swift `GroundClearanceDirection` + * @parity /modules/vescape-core/src/index.ts `GroundClearanceDirection` + */ +enum class GroundClearanceDirection(val wire: String) { + /** Sensor at the nose: losing clearance there is answered by lifting the nose. */ + NOSE("nose"), + + /** Sensor at the tail: the same loss is answered by lifting the tail. */ + TAIL("tail"); + + companion object { + fun fromWire(value: String?): GroundClearanceDirection? = + entries.firstOrNull { it.wire == value } + } +} + +/** + * What the rider calibrated for one ground-clearance capability. + * + * There is no partial state and no Save step: this is written when it is complete and valid, and a + * calibration that is not both drives nothing. [farCm] is where correction starts and [nearCm] is + * where it is at full strength, so `near < far` always — less clearance means more correction. + * + * @parity /modules/vescape-core/ios/accessory/GroundClearance.swift `GroundClearanceCalibration` + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `AccessoryGroundClearanceEntity` + * @parity /modules/vescape-core/src/index.ts `GroundClearanceCalibration` + */ +data class GroundClearanceCalibration( + val nearCm: Double, + val farCm: Double, + /** Raw wire value. Anything [GroundClearanceDirection] does not know makes this incomplete. */ + val direction: String, + val strengthPercent: Int, +) { + /** + * What is wrong with this calibration, or null when nothing is. + * + * A reason rather than a boolean because the same absence — nothing saved, nothing driving — + * has to be explained differently depending on which rule it broke, and native is the only + * place that knows the rules. A screen that re-derived them would be a second definition of + * "valid" that could disagree with the one the binding actually uses. + * + * The declared window is part of the test, not just the numbers' own order. An accessory whose + * firmware narrowed its range is still the same accessory, and a calibration made against the + * old numbers has to stop driving rather than be silently squeezed into the new ones. + */ + fun problem(rangeMin: Double?, rangeMax: Double?): GroundClearanceProblem? { + if (!nearCm.isFinite() || !farCm.isFinite()) return GroundClearanceProblem.NOT_A_NUMBER + if (nearCm >= farCm) return GroundClearanceProblem.NEAR_NOT_BELOW_FAR + if (GroundClearanceDirection.fromWire(direction) == null) { + return GroundClearanceProblem.UNKNOWN_DIRECTION + } + if (strengthPercent < GroundClearance.MIN_STRENGTH_PERCENT) { + return GroundClearanceProblem.STRENGTH_OUT_OF_BOUNDS + } + if (strengthPercent > GroundClearance.MAX_STRENGTH_PERCENT) { + return GroundClearanceProblem.STRENGTH_OUT_OF_BOUNDS + } + if (rangeMin != null && nearCm < rangeMin) return GroundClearanceProblem.OUTSIDE_DECLARED_RANGE + if (rangeMax != null && farCm > rangeMax) return GroundClearanceProblem.OUTSIDE_DECLARED_RANGE + return null + } + + /** Whether this is a calibration the hardware in front of us can actually be driven to. */ + fun isComplete(rangeMin: Double?, rangeMax: Double?): Boolean = problem(rangeMin, rangeMax) == null + + /** + * The signed Remote Tilt input one measured distance calls for, in -1..1. + * + * Positive lifts the nose. Outside `[near, far]` the value saturates rather than extrapolating: + * a sensor reading closer than the near distance is already asking for everything there is, and + * one reading past the far distance is asking for nothing. + */ + fun tiltInput(valueCm: Double): Double { + if (!valueCm.isFinite()) return 0.0 + val span = farCm - nearCm + if (span <= 0.0) return 0.0 + val fraction = ((farCm - valueCm) / span).coerceIn(0.0, 1.0) + val magnitude = fraction * (strengthPercent.toDouble() / 100.0) + return when (GroundClearanceDirection.fromWire(direction)) { + GroundClearanceDirection.NOSE -> magnitude + GroundClearanceDirection.TAIL -> -magnitude + null -> 0.0 + } + } +} + +/** + * Why a calibration is not one yet. + * + * The rider is mid-edit far more often than they are finished, so "not saved" is the normal state + * of this screen and needs a sentence, not a silence. + * + * @parity /modules/vescape-core/ios/accessory/GroundClearance.swift `GroundClearanceProblem` + * @parity /modules/vescape-core/src/index.ts `GroundClearanceProblem` + */ +enum class GroundClearanceProblem(val wire: String) { + /** A distance that is not a finite number. A row written by a broken build reads as this. */ + NOT_A_NUMBER("not-a-number"), + + /** Less clearance must mean more correction, so the near distance has to be the smaller one. */ + NEAR_NOT_BELOW_FAR("near-not-below-far"), + + /** A mounting position this build does not know. A newer build wrote it; this one cannot use it. */ + UNKNOWN_DIRECTION("unknown-direction"), + + /** Zero commands nothing and past full commands something the pad cannot express. */ + STRENGTH_OUT_OF_BOUNDS("strength-out-of-bounds"), + + /** Outside what the accessory currently says it can measure. Recalibrate against the new limits. */ + OUTSIDE_DECLARED_RANGE("outside-declared-range"), +} + +/** + * Why a ground-clearance binding is not commanding anything. + * + * Carried rather than collapsed to a bare null so the consumer — and the rider's screen — can say + * which of these it is. "The sensor is reporting an error" and "the rider has not calibrated yet" + * look identical as an absent number and are nothing alike to explain. + * + * @parity /modules/vescape-core/ios/accessory/GroundClearance.swift `GroundClearanceRelease` + * @parity /modules/vescape-core/src/index.ts `GroundClearanceRelease` + */ +enum class GroundClearanceRelease(val wire: String) { + DISABLED("disabled"), + /** Sensor-driven tilt is for riding. A parked board is not corrected. */ + NOT_RIDING("not-riding"), + + /** + * The Board is not connected, or its link is not Trusted. + * + * Decided by the Board Session, not here: this file knows what the sensor is saying and nothing + * about whether the thing on the other end is the Board the rider thinks it is. + */ + BOARD_UNTRUSTED("board-untrusted"), + + /** + * The Board is connected but has stopped answering. + * + * Riding is read off telemetry, so telemetry that stopped is evidence that has stopped being + * evidence. Holding the last engaged frame's worth of permission would let a sensor keep tilting + * a Board nobody can hear. + */ + BOARD_STALE("board-stale"), + + /** + * More than one calibrated ground-clearance capability wants the tilt channel. + * + * The PoC deliberately has no arbitration between a nose sensor and a tail sensor, and picking + * one of them arbitrarily would be picking a correction direction arbitrarily. Two claimants is + * a configuration the rider has to resolve, not one this app guesses its way through. + */ + CONTESTED("contested"), + + /** Board Move holds the remote-input slot. Both cannot write it, and a jog is the parked one. */ + BOARD_MOVE("board-move"), + + /** + * A rider-commanded tilt still holds the slot. + * + * Only reachable in the moment a binding arms under a tilt that was started before it: the pad + * refuses new manual input for as long as a binding is bound, and the arming itself cancels + * whatever was held. It is named because an unexplained silent second is worse than a sentence. + */ + MANUAL_TILT("manual-tilt"), + + /** No session, or a session that is not acknowledging commands. */ + NO_LINK("no-link"), + + /** Nothing saved, or what is saved no longer fits the limits the accessory declares. */ + NOT_CALIBRATED("not-calibrated"), + + /** Samples stopped arriving. The accessory may still be connected; it is not measuring. */ + STALE("stale"), + + /** The sensor answered, and the answer is not a distance. */ + OUT_OF_RANGE("out-of-range"), + + /** The sensor could not measure, or sent something this app cannot read as a measurement. */ + SENSOR_ERROR("sensor-error"), +} + +/** + * The only thing a tilt binding is allowed to see. + * + * Two cases and no third: either there is a calibrated, fresh, in-range measurement and a number + * to command, or there is a reason to let go. Nothing here can be read as "hold the last value" — + * the type has no way to express it. + * + * @parity /modules/vescape-core/ios/accessory/GroundClearance.swift `GroundClearanceInput` + */ +sealed class GroundClearanceInput { + /** A live measurement, already scaled by the rider's strength and mounting direction. */ + data class Drive(val tiltInput: Double, val valueCm: Double) : GroundClearanceInput() + + /** Release any held input, smoothly, and command nothing until a [Drive] arrives. */ + data class Release(val reason: GroundClearanceRelease) : GroundClearanceInput() +} + +/** + * Per-capability sample bookkeeping: what the newest accepted sample was, and when it landed here. + * + * Deliberately *not* a ring buffer. Nothing in this slice looks backwards — the screen shows the + * newest number and a tilt binding acts on the newest number — so a history would be a buffer whose + * only job is to grow. [AccessoryLink] already coalesces commands; readings need the same + * treatment, which is one slot. + * + * Two clocks, kept apart on purpose. [AccessoryReading.sampleTimeMs] is the accessory's own uptime + * and only ever compared to other samples from the same session; freshness is judged on + * [latestAtMs], this phone's monotonic receipt time. Subtracting one from the other would be a + * latency measurement across two unsynchronised clocks. + * + * @parity /modules/vescape-core/ios/accessory/GroundClearance.swift `AccessoryReadingTracker` + */ +class AccessoryReadingTracker { + /** Newest accepted sample, already range-checked. Null until one arrives in this session. */ + var latest: AccessoryReading? = null + private set + + /** Local monotonic receipt time of [latest]. */ + var latestAtMs: Long? = null + private set + + private var lastSeq: Int? = null + private var lastSampleTimeMs: Long? = null + + /** + * Takes one sample if it is newer than what is held, and says whether it was taken. + * + * Sequence numbers increase across measurement pauses inside a session, so a jump is normal and + * only a repeat or a step backwards is a duplicate. A sample time that went backwards is refused + * even when the sequence advanced: the two disagree, and a disagreeing accessory is not one to + * take a distance from. + */ + fun accept(reading: AccessoryReading, receivedAtMs: Long): Boolean { + val previousSeq = lastSeq + if (previousSeq != null && reading.seq <= previousSeq) return false + val previousTime = lastSampleTimeMs + if (previousTime != null && reading.sampleTimeMs < previousTime) return false + lastSeq = reading.seq + lastSampleTimeMs = reading.sampleTimeMs + latest = reading + latestAtMs = receivedAtMs + return true + } + + /** A new protocol session restarts sequence numbers, so nothing from the old one may survive. */ + fun reset() { + latest = null + latestAtMs = null + lastSeq = null + lastSampleTimeMs = null + } + + /** Whether a sample landed recently enough to still describe the ground under the board. */ + fun isFresh(nowMs: Long, staleAfterMs: Long): Boolean { + val at = latestAtMs ?: return false + return nowMs - at < staleAfterMs + } +} + +/** + * One enrolled ground-clearance capability's live state: what is saved for it, who wants it + * measuring, and what its samples currently amount to. + * + * Demand is arbitrated here rather than anywhere a screen can reach. Two independent reasons to + * measure — the rider is riding a calibrated board, or the rider has the configuration screen open + * — and their union is what the accessory is told. Neither of them alone is permission to *tilt*: + * [input] refuses on anything but riding, which is what keeps a preview from moving a parked board. + * + * @parity /modules/vescape-core/ios/accessory/GroundClearance.swift `GroundClearanceRuntime` + */ +internal class GroundClearanceRuntime(val capabilityId: String) { + var enabled: Boolean = true + /** Saved calibration, or null while the rider has not finished one. */ + var calibration: GroundClearanceCalibration? = null + + /** Limits from the live manifest. Null while no session is established. */ + var rangeMin: Double? = null + var rangeMax: Double? = null + + /** Rate actually acknowledged for this capability, which sets the stale window. */ + var rateHz: Double = 0.0 + + /** The configuration screen is open and wants to show live numbers. */ + var previewOpen: Boolean = false + + /** The Board is connected and engaged. Set from the Board session, never from JS. */ + var riding: Boolean = false + + val tracker = AccessoryReadingTracker() + val previewLog = ClearancePreviewLog() + + /** Whether what is saved still fits what the accessory currently declares. */ + val isCalibrated: Boolean + get() = calibration?.isComplete(rangeMin, rangeMax) == true + + /** + * Whether the accessory should be measuring at all. + * + * Riding without a calibration measures nothing, because nothing could act on the result: the + * sensor would burn power to produce samples with no binding behind them. Preview measures + * regardless — that is how the rider *gets* a calibration. + */ + val measurementDemanded: Boolean + get() = enabled && (previewOpen || (riding && isCalibrated)) + + /** + * What a tilt binding may do right now. + * + * Ordered by what the rider most needs to hear. Not riding comes first because it is the normal + * resting state and not a fault; the sensor's own problems come last, when everything that + * would have consumed them is in place. + */ + fun input(nowMs: Long, linkConnected: Boolean): GroundClearanceInput { + if (!enabled) return GroundClearanceInput.Release(GroundClearanceRelease.DISABLED) + if (!riding) return GroundClearanceInput.Release(GroundClearanceRelease.NOT_RIDING) + if (!linkConnected) return GroundClearanceInput.Release(GroundClearanceRelease.NO_LINK) + val saved = calibration?.takeIf { it.isComplete(rangeMin, rangeMax) } + ?: return GroundClearanceInput.Release(GroundClearanceRelease.NOT_CALIBRATED) + val reading = tracker.latest + if (reading == null || !tracker.isFresh(nowMs, GroundClearance.staleAfterMs(rateHz))) { + return GroundClearanceInput.Release(GroundClearanceRelease.STALE) + } + return when (reading.status) { + AccessoryReadingStatus.OUT_OF_RANGE -> + GroundClearanceInput.Release(GroundClearanceRelease.OUT_OF_RANGE) + + AccessoryReadingStatus.ERROR -> + GroundClearanceInput.Release(GroundClearanceRelease.SENSOR_ERROR) + + AccessoryReadingStatus.OK -> { + // Unreachable by construction — an `ok` without a value cannot be built — but a + // release is the honest answer to a reading that somehow has none, and it costs one + // branch to never have to trust that. + val value = reading.valueCm + ?: return GroundClearanceInput.Release(GroundClearanceRelease.SENSOR_ERROR) + GroundClearanceInput.Drive(saved.tiltInput(value), value) + } + } + } + + /** Everything a fresh protocol session invalidates. Calibration is durable and stays. */ + fun onSessionLost() { + tracker.reset() + previewLog.reset() + rateHz = 0.0 + } +} + +/** + * Owns every live ground-clearance binding across enrolled Accessories. + * + * The generic session coordinator supplies connection facts and carries protocol commands; this + * controller owns capability state, demand, readings, calibration application, and claimant + * selection. Keeping those decisions here prevents a new capability from growing another parallel + * subsystem inside `AccessorySessionManager`. + * + * @parity /modules/vescape-core/ios/accessory/GroundClearance.swift `GroundClearanceBindingController` + */ +internal class GroundClearanceBindingController( + private val nowMs: () -> Long, +) { + data class Key(val accessoryId: String, val capabilityId: String) + + data class LinkState(val connected: Boolean, val appliedRateHz: Double) + + private val runtimes = LinkedHashMap() + + @Volatile private var riding = false + + fun reset(calibrations: Iterable>) { + runtimes.clear() + calibrations.forEach { (key, calibration) -> runtime(key).calibration = calibration } + } + + private fun runtime(accessoryId: String, capabilityId: String): GroundClearanceRuntime = + runtime(Key(accessoryId, capabilityId)) + + private fun runtime(key: Key): GroundClearanceRuntime = + runtimes.getOrPut(key) { GroundClearanceRuntime(key.capabilityId) } + + fun applyCapability( + accessoryId: String, + capability: AccessoryCapability, + liveManifest: Boolean, + rateHz: Double, + enabled: Boolean = true, + ): AccessoryCommand.Configure { + val state = runtime(accessoryId, capability.id) + if (state.enabled != enabled) state.onSessionLost() + state.enabled = enabled + if (liveManifest) { + state.rangeMin = capability.rangeMin + state.rangeMax = capability.rangeMax + } + state.riding = riding + return AccessoryCommand.Configure(capability.id, state.measurementDemanded, rateHz) + } + + fun setPreview(accessoryId: String, capabilityId: String, open: Boolean): Boolean { + val state = runtime(accessoryId, capabilityId) + if (state.previewOpen == open) return false + state.previewLog.reset() + state.previewOpen = open + return true + } + + fun releasePreviews(): Boolean { + var changed = false + runtimes.values.forEach { state -> + if (state.previewOpen) { + state.previewOpen = false + changed = true + } + } + return changed + } + + fun setRiding(value: Boolean): Boolean { + if (riding == value) return false + riding = value + return true + } + + fun validate( + accessoryId: String, + capabilityId: String, + calibration: GroundClearanceCalibration, + ): GroundClearanceProblem? { + val state = runtime(accessoryId, capabilityId) + return calibration.problem(state.rangeMin, state.rangeMax) + } + + fun applyCalibration(accessoryId: String, capabilityId: String, calibration: GroundClearanceCalibration) { + runtime(accessoryId, capabilityId).calibration = calibration + } + + fun clearCalibration(accessoryId: String, capabilityId: String) { + runtime(accessoryId, capabilityId).calibration = null + } + + fun describe(accessoryId: String, capabilityId: String): Map? { + val state = runtimes[Key(accessoryId, capabilityId)] ?: return null + return mapOf( + "calibration" to state.calibration?.let { + mapOf( + "nearCm" to it.nearCm, + "farCm" to it.farCm, + "direction" to it.direction, + "strengthPercent" to it.strengthPercent, + "problem" to it.problem(state.rangeMin, state.rangeMax)?.wire, + ) + }, + "measuring" to state.measurementDemanded, + ) + } + + fun input(accessoryId: String, capabilityId: String, link: LinkState): GroundClearanceInput { + val state = runtimes[Key(accessoryId, capabilityId)] + ?: return GroundClearanceInput.Release(GroundClearanceRelease.NOT_CALIBRATED) + state.rateHz = link.appliedRateHz + return state.input(nowMs(), link.connected) + } + + private fun boundCapabilities(link: (String, String) -> LinkState): List = + runtimes.entries + .filter { (key, state) -> state.enabled && state.isCalibrated && link(key.accessoryId, key.capabilityId).connected } + .map { it.key } + + fun bound(link: (String, String) -> LinkState): Boolean = boundCapabilities(link).isNotEmpty() + + fun tilt(link: (String, String) -> LinkState): GroundClearanceInput { + val bound = boundCapabilities(link) + if (bound.size > 1) return GroundClearanceInput.Release(GroundClearanceRelease.CONTESTED) + val key = bound.firstOrNull() + ?: return GroundClearanceInput.Release( + if (runtimes.isNotEmpty() && runtimes.values.none { it.enabled }) GroundClearanceRelease.DISABLED + else if (runtimes.values.any { it.enabled && it.isCalibrated }) GroundClearanceRelease.NO_LINK + else GroundClearanceRelease.NOT_CALIBRATED, + ) + return input(key.accessoryId, key.capabilityId, link(key.accessoryId, key.capabilityId)) + } + + fun acceptReading( + accessoryId: String, + reading: AccessoryReading, + receivedAtMs: Long, + appliedRateHz: Double?, + ): Map? { + val state = runtimes[Key(accessoryId, reading.capabilityId)] ?: return null + if (!state.enabled) return null + if (appliedRateHz != null) state.rateHz = appliedRateHz + val checked = reading.withinDeclaredRange(state.rangeMin, state.rangeMax) + if (!state.tracker.accept(checked, receivedAtMs) || !state.previewOpen) return null + state.previewLog.record(receivedAtMs, checked.sampleTimeMs, checked.seq.toLong(), checked.valueCm) + if (!state.previewLog.shouldEmit(receivedAtMs)) return null + return mapOf( + "diagnostics" to state.previewLog.snapshot(receivedAtMs), + "accessoryId" to accessoryId, + "capabilityId" to checked.capabilityId, + "seq" to checked.seq, + "sampleTimeMs" to checked.sampleTimeMs, + "status" to checked.status.wire, + "valueCm" to checked.valueCm, + "staleAfterMs" to GroundClearance.staleAfterMs(state.rateHz), + // Preview the same mapping as riding, without granting permission to drive. + "tiltPreviewPercent" to checked.valueCm?.let { value -> + state.calibration?.takeIf { state.isCalibrated }?.tiltInput(value)?.times(100.0) + }, + ) + } + + fun onSessionLost(accessoryId: String) { + runtimes.forEach { (key, state) -> if (key.accessoryId == accessoryId) state.onSessionLost() } + } + + fun forget(accessoryId: String) { + runtimes.keys.removeAll { it.accessoryId == accessoryId } + } +} + +/** + * Board-side lifecycle and arbitration for the ground-clearance Accessory Binding. + * @parity /modules/vescape-core/ios/accessory/GroundClearance.swift `BoardGroundClearanceBinding` + */ +internal class BoardGroundClearanceBinding( + private val remoteInput: RemoteInputArbiter, + private val boundInput: () -> Boolean, + private val tiltInput: () -> GroundClearanceInput, +) { + /** @parity /modules/vescape-core/ios/accessory/GroundClearance.swift `tickMs` */ + companion object { + const val TICK_MS = 100L + } + + data class BoardInput(val commandsTrusted: Boolean, val telemetryFresh: Boolean) + + private var scheduled: Cancellable? = null + private var schedule: (((() -> Unit)) -> Cancellable)? = null + private var boardInput: (() -> BoardInput)? = null + private var bound = false + private var release: GroundClearanceRelease? = GroundClearanceRelease.NOT_CALIBRATED + + fun start(schedule: ((() -> Unit)) -> Cancellable, boardInput: () -> BoardInput) { + if (scheduled != null) return + this.schedule = schedule + this.boardInput = boardInput + scheduleNext() + } + + private fun scheduleNext() { + scheduled = schedule?.invoke { + tick(requireNotNull(boardInput).invoke()) + scheduleNext() + } + } + + fun stop() { + scheduled?.cancel() + scheduled = null + schedule = null + boardInput = null + remoteInput.sensorRelease() + bound = false + release = GroundClearanceRelease.BOARD_UNTRUSTED + } + + internal fun tick(board: BoardInput) { + bound = boundInput() + // Every tick, not just the arming one. A manual tilt that survives into a bound session — + // one taken in the window before the pad learned it was read-only, or one whose arming-time + // cancel failed on a transport that blinked — is a lock that never ends by itself, and the + // read-only pad has no Cancel for the rider to press. `releaseManual` no-ops once the ease + // is running, so repeating it costs nothing. + if (bound) remoteInput.releaseManual() + + val input = when { + !board.commandsTrusted -> GroundClearanceInput.Release(GroundClearanceRelease.BOARD_UNTRUSTED) + !board.telemetryFresh -> GroundClearanceInput.Release(GroundClearanceRelease.BOARD_STALE) + remoteInput.owner == RemoteInputOwner.MOVE -> GroundClearanceInput.Release(GroundClearanceRelease.BOARD_MOVE) + remoteInput.owner == RemoteInputOwner.MANUAL -> GroundClearanceInput.Release(GroundClearanceRelease.MANUAL_TILT) + else -> tiltInput() + } + when (input) { + is GroundClearanceInput.Drive -> { + release = if (remoteInput.sensorDrive(GroundClearance.tiltCommand(input.tiltInput))) { + null + } else { + GroundClearanceRelease.BOARD_UNTRUSTED + } + } + is GroundClearanceInput.Release -> { + remoteInput.sensorRelease() + release = input.reason + } + } + } + + fun state(): Map = mapOf( + "bound" to bound, + "driving" to (remoteInput.owner == RemoteInputOwner.SENSOR), + "release" to release?.wire, + ) +} 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..906b413a 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,7 @@ package expo.modules.vescapecore.connection +import expo.modules.vescapecore.accessory.AccessorySessionManager +import expo.modules.vescapecore.accessory.BoardGroundClearanceBinding import expo.modules.vescapecore.service.foregroundServiceType import expo.modules.vescapecore.service.ACTION_CONNECT_FROM_NOTIFICATION import expo.modules.vescapecore.service.ACTION_DISCONNECT_FROM_NOTIFICATION @@ -58,6 +60,8 @@ import expo.modules.vescapecore.config.RefloatConfigProtocolResult import expo.modules.vescapecore.config.RefloatConfigSchemaParser import expo.modules.vescapecore.protocol.RefloatTelemetry import expo.modules.vescapecore.BoardMoveController +import expo.modules.vescapecore.RemoteInputArbiter +import expo.modules.vescapecore.RemoteInputOwner import expo.modules.vescapecore.RemoteTiltController import expo.modules.vescapecore.RiderPresence import expo.modules.vescapecore.service.SessionConfig @@ -165,6 +169,7 @@ import expo.modules.vescapecore.telemetry.METRIC_MAX_DUTY import expo.modules.vescapecore.telemetry.PrivacyZoneEntity import expo.modules.vescapecore.telemetry.SocMedianWindow import expo.modules.vescapecore.telemetry.TelemetryCapture +import expo.modules.vescapecore.telemetry.isRefloatEngaged import expo.modules.vescapecore.telemetry.TelemetryPipeline import expo.modules.vescapecore.telemetry.TelemetryRepository import expo.modules.vescapecore.telemetry.isInsideAnyPrivacyZone @@ -262,6 +267,24 @@ internal class BoardSessionController(private val service: CoreForegroundService generation = { BoardMoveGeneration.forBaseVersion(boardConfig?.refloatBaseVersion) }, send = { payload, urgent -> transport.sendRemoteInput(payload, urgent) }, ) + + /** + * The single writer of the Board's remote-input slot: the rider's pad, Board Move, and a + * ground-clearance Accessory all reach the two controllers above only through this. + * + * @parity /modules/vescape-core/ios/connection/BoardSessionController.swift `remoteInput` + */ + private val remoteInput = RemoteInputArbiter( + tilt = remoteTiltController, + move = boardMoveController, + nowMs = { SystemClock.elapsedRealtime() }, + sensorBound = AccessorySessionManager::groundClearanceBound, + ) + private val groundClearanceBinding = BoardGroundClearanceBinding( + remoteInput, + AccessorySessionManager::groundClearanceBound, + AccessorySessionManager::groundClearanceTilt, + ) private val notificationController by lazy { NotificationController( service = service, @@ -963,7 +986,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 +1014,7 @@ private var wearAutoLaunchOnConnect = true return } stop.onSuccess() - if (!gpsMonitor.active && !groupRideObserver.active) { + if (!gpsMonitor.active && !groupRideObserver.active && !AccessorySessionManager.hasSessions()) { isStoppingService = true service.stopSelf() } @@ -1026,10 +1053,7 @@ private var wearAutoLaunchOnConnect = true fun stopGroupRideObserve() { CoreForegroundService.pendingGroupRideUrl = null groupRideObserver.stop() - if (boardConfig == null && !gpsMonitor.active) { - isStoppingService = true - service.stopSelf() - } + stopIfIdle() } fun createGroupRide(riderId: String, riderName: String, riderColor: String?, name: String?, lat: Double, lng: Double) { @@ -1083,7 +1107,9 @@ private var wearAutoLaunchOnConnect = true CoreForegroundService.pendingGpsStart = false stopLocationUpdates() emitState() - if (boardConfig == null && !groupRideObserver.active) { + // Accessory sessions are one of the things that keep this host alive, so the decision goes + // through `stopIfIdle` rather than a second copy of the same condition that forgets them. + if (boardConfig == null && !groupRideObserver.active && !AccessorySessionManager.hasSessions()) { isStoppingService = true service.stopSelf() } else { @@ -1186,6 +1212,7 @@ private var wearAutoLaunchOnConnect = true return foregroundServiceType( boardActive = boardConfig != null, gpsActive = gpsMonitor.active, + accessoryActive = AccessorySessionManager.hasSessions(), ) } @@ -1484,9 +1511,16 @@ private var wearAutoLaunchOnConnect = true // persisting or aggregating it would poison Ride History with a frame of zeros. The // session bookkeeping above it still runs: the board answered, so it is ready and // must not be torn down as unresponsive just because it is faulting. + // + // It also ends riding as far as Accessories are concerned. A fault frame carries + // zeroed metrics and no engagement, so returning without saying so would leave the + // last engaged sample standing and keep a sensor measuring — and eligible to drive + // tilt — for as long as the board keeps faulting. telemetryPipeline.noteResponse(parsed, sessionToken) markBoardReady() startLinkIntegrityProbe(sessionToken) + AccessorySessionManager.setRiding(false) + AccessorySessionManager.clearLightTelemetry() onRefloatFaultFrame(parsed.faultCode) return } @@ -1523,6 +1557,11 @@ private var wearAutoLaunchOnConnect = true // First sample of the session also drives the first sparkline frame immediately. liveSeriesEmitter.primeLiveSeriesIfNeeded() updateIdlePause(processed.capture) + // Measurement demand follows the Board's own engagement, not the recorder's: a + // rider with recording turned off is still riding. #479 reads the arbitrated input + // back out of the same runtime to drive Remote Tilt. + AccessorySessionManager.setRiding(isRefloatEngaged(processed.capture.state)) + AccessorySessionManager.setLightTelemetry(parsed.speed, isRefloatEngaged(processed.capture.state)) // Skip persistence while paused; live display, watch, and presence keep running off the // paths above. When recording is off, recordTelemetry is already a no-op. if (!idlePauseDetector.isPaused) { @@ -2200,6 +2239,7 @@ private var wearAutoLaunchOnConnect = true idlePauseDetector.reset() pollingLoop.start(session, sessionToken, transport) liveSeriesEmitter.start() + startGroundClearanceTilt(sessionToken) } /** @@ -2217,6 +2257,14 @@ private var wearAutoLaunchOnConnect = true private fun stopPolling() { pollingLoop.stop() + // The binding reads riding off telemetry, so a Board that stopped being polled is a Board + // that stopped being evidence. The tick is what releases the tilt, so it outlives the poll + // loop by exactly one pass: `stopGroundClearanceTilt` cancels before the timer dies. + stopGroundClearanceTilt() + // No telemetry means no evidence of riding. An Accessory left measuring on the strength of + // the last sample before the Board went away would keep its sensor running indefinitely. + AccessorySessionManager.setRiding(false) + AccessorySessionManager.clearLightTelemetry() idlePauseDetector.reset() telemetryPipeline.cancelStaleWatchdog() liveSeriesEmitter.stop() @@ -2385,13 +2433,13 @@ private var wearAutoLaunchOnConnect = true } fun setRemoteTilt(value: Int): Boolean = - firmwareCommandsTrusted() && remoteTiltController.hold(value) + firmwareCommandsTrusted() && remoteInput.manualHold(value) fun lockRemoteTilt(value: Int): Boolean = - firmwareCommandsTrusted() && remoteTiltController.lock(value) + firmwareCommandsTrusted() && remoteInput.manualLock(value) fun releaseRemoteTilt(value: Int, durationMs: Long): Boolean = - firmwareCommandsTrusted() && remoteTiltController.release(value, durationMs) + firmwareCommandsTrusted() && remoteInput.manualRelease(value, durationMs) /** * Eases the active tilt back to neutral rather than snapping — a step to neutral from a large @@ -2400,7 +2448,60 @@ private var wearAutoLaunchOnConnect = true // Cancellation must remain available if link trust changes during an active tilt. // @parity /modules/vescape-core/ios/connection/BoardSessionController.swift `stopRemoteTilt` fun stopRemoteTilt(): Boolean = - remoteTiltController.cancel() + remoteInput.cancelTilt() + + // MARK: - Ground-clearance tilt + + /** + * How often the ground-clearance binding re-decides what the Board is told. + * + * The same 100 ms [RemoteTiltController] repeats a held value on, so a decision never sits + * unsent for longer than the stream it feeds. It is a *timer*, not a reaction to samples, and + * that is the point: a sensor that stops sending produces no events to react to, and releasing + * on silence is the behaviour this whole slice exists for. + * + * @parity /modules/vescape-core/ios/connection/BoardSessionController.swift `startGroundClearanceTilt` + */ + private fun startGroundClearanceTilt(session: BoardSession) { + groundClearanceBinding.start( + schedule = { tick -> + scheduler.postDelayedForSession( + session, + BoardGroundClearanceBinding.TICK_MS, + ::isCurrentBoardSession, + ) { tick() } + }, + boardInput = { + BoardGroundClearanceBinding.BoardInput( + commandsTrusted = firmwareCommandsTrusted(), + telemetryFresh = telemetry != null && !isTelemetryStale(), + ) + }, + ) + } + + /** + * Stops the binding and lets go of anything it was commanding. + * + * The release happens here rather than being left to the next tick, because the next tick is the + * thing being cancelled. A binding whose timer was stopped while it held a tilt would leave the + * Board holding that tilt until the firmware's own ~1s remote-input timeout. + */ + private fun stopGroundClearanceTilt() { + groundClearanceBinding.stop() + } + + /** + * What the binding is doing, for the Remote Tilt pad to render. + * + * Read synchronously off the bridge by the pad's own poll rather than pushed as live state: the + * only consumer is a screen that is already polling the commanded tilt at the same rate, and a + * 10 Hz event carrying a release reason that mostly does not change would be pure bridge traffic. + * + * @parity /modules/vescape-core/ios/connection/BoardSessionController.swift `groundClearanceTiltState` + * @parity /modules/vescape-core/src/index.ts `GroundClearanceTiltState` + */ + fun groundClearanceTiltState(): Map = groundClearanceBinding.state() /** * The board's lights as its last echo reported them, or `null` while this session has never @@ -2509,7 +2610,11 @@ private var wearAutoLaunchOnConnect = true publishBoardLights() } - fun startBoardMove(input: Int): Boolean = boardMoveController.hold(input) + /** + * Board Move takes the remote-input slot from any tilt stream still holding it, and is refused + * outright while a sensor is correcting — see [RemoteInputArbiter.startMove]. + */ + fun startBoardMove(input: Int): Boolean = remoteInput.startMove(input) /** * A wrist Board Move tick (ADR-0033). Direction only — the phone applies the rider's strength @@ -2584,7 +2689,7 @@ private var wearAutoLaunchOnConnect = true // Deliberately ungated: a stop must reach the board even if the link lost trust mid-hold, // otherwise the rider's release does nothing and the board coasts to the firmware timeout. - fun stopBoardMove(): Boolean = boardMoveController.stop() + fun stopBoardMove(): Boolean = remoteInput.stopMove() /** * The live position Navigation starts a path from. See `LocationTracker.riderPosition`. @@ -2598,6 +2703,7 @@ private var wearAutoLaunchOnConnect = true remoteTiltController.currentValue, remoteTiltController.phase, remoteTiltController.decayProgress, + remoteInput.owner, ) // @parity /modules/vescape-core/ios/connection/BoardSessionController.swift `sendPayloadWithRetry` @@ -2624,8 +2730,8 @@ private var wearAutoLaunchOnConnect = true private fun stopCurrentBoardSession(emitDisconnected: Boolean) { // Final write so the persisted last battery is fresh, not up to 30s stale. persistLastBattery(latestBatterySoc, telemetry?.batteryVoltage, nowMs(), force = true) - remoteTiltController.stop() - boardMoveController.stop() + stopGroundClearanceTilt() + remoteInput.reset() flushTelemetryDiagnostics("stop") configController.onSessionTerminated("Board session stopped during Refloat config op") val stoppedConfig = boardConfig @@ -3184,6 +3290,7 @@ private var wearAutoLaunchOnConnect = true remoteTiltValue = remoteTiltController.currentValue, remoteTiltPhase = remoteTiltController.phase, remoteTiltDecay = remoteTiltController.decayProgress, + remoteTiltOwner = remoteInput.owner, linkIntegrity = boardSession?.linkIntegrity ?: LinkIntegrity.Unknown, settings = settings, ) 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..286bca2f 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, @@ -467,6 +496,14 @@ class CoreForegroundService : Service() { fun currentRemoteTiltState(): Map? = instance?.controller?.remoteTiltState() + /** + * What the ground-clearance binding is doing, or an unbound state while no Board Session is + * up — with no Board there is no tilt channel for a sensor to hold. + */ + fun currentGroundClearanceTilt(): Map = + instance?.controller?.groundClearanceTiltState() + ?: mapOf("bound" to false, "driving" to false, "release" to null) + /** Live rider position for Navigation; null while the service is not up. */ fun currentRiderPosition(): LocationSnapshot? = instance?.controller?.riderPosition() @@ -542,6 +579,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 +599,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..70b2fe57 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AccessoryPersistence.kt @@ -0,0 +1,90 @@ +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. [enrolledAt] is preserved when the row already exists: re-adding an Accessory the + * rider already has is not a new enrollment. + */ + 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 + } + + /** + * Refreshes what the last handshake observed, for an Accessory that is still enrolled. + * + * Update-only, and deliberately not an upsert: a handshake that completes just as the rider + * forgets the Accessory would otherwise resurrect the row it just deleted, and the next launch + * would auto-connect hardware the rider removed. A single UPDATE is a no-op on a missing row. + * + * `capabilities_json` is **not** touched. It is the baseline the rider's saved settings were + * validated against, so it stays put until a capability's own setup accepts the new limits; + * overwriting it here would make the "limits changed" warning disappear on the next launch. + */ + suspend fun revalidate(accessory: SavedAccessoryEntity): Boolean = + dao.revalidateAccessory( + accessoryId = accessory.accessoryId, + name = accessory.name, + firmwareVersion = accessory.firmwareVersion, + protocolVersion = accessory.protocolVersion, + deviceId = accessory.deviceId, + connectedAt = accessory.lastConnectedAt, + ) > 0 + + /** + * Adopts the capability set the current manifest declares as the new baseline. + * + * The counterpart to [revalidate] leaving `capabilities_json` alone. That preservation is what + * keeps "this Accessory now declares different limits" alive across a restart; this is the rider + * answering it, by saving a calibration that fits what the hardware says today. Update-only for + * the same reason revalidation is. + */ + suspend fun adoptCapabilities(accessoryId: String, capabilitiesJson: String): Boolean = + dao.adoptAccessoryCapabilities(accessoryId, capabilitiesJson) > 0 + + /** Forgetting takes the enrollment and every calibration made against it, in one transaction. */ + suspend fun forget(accessoryId: String): Boolean = dao.forgetAccessory(accessoryId) > 0 + + suspend fun getBrakeLights(): List = dao.getBrakeLights() + + suspend fun getCapabilitySettings(): List = dao.getAccessoryCapabilitySettings() + + suspend fun saveCapabilitySettings(settings: AccessoryCapabilitySettingsEntity) = dao.saveAccessoryCapabilitySettings(settings) + + suspend fun saveBrakeLight(settings: AccessoryBrakeLightEntity) = dao.saveBrakeLight(settings) + + suspend fun getGroundClearances(): List = dao.getGroundClearances() + + suspend fun getGroundClearance(accessoryId: String, capabilityId: String): AccessoryGroundClearanceEntity? = + dao.getGroundClearance(accessoryId, capabilityId) + + /** + * Saves one complete calibration. + * + * There is no Save button behind this and no draft state in the table: the screen calls it when + * what the rider has entered is complete and valid, so every row here was usable at the moment it + * was written. Validity against the *current* manifest is re-decided on every session. + */ + suspend fun saveGroundClearance(calibration: AccessoryGroundClearanceEntity) { + dao.upsertGroundClearance(calibration) + } + + suspend fun clearGroundClearance(accessoryId: String, capabilityId: String): Boolean = + dao.deleteGroundClearance(accessoryId, capabilityId) > 0 +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/IdlePauseDetector.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/IdlePauseDetector.kt index d70a6a1d..02ed356f 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/IdlePauseDetector.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/IdlePauseDetector.kt @@ -5,6 +5,21 @@ internal const val IDLE_PAUSE_POLL_INTERVAL_MS = 1_000L internal enum class IdlePauseTransition { Paused, Resumed } +/** + * Whether the Board is carrying a rider, from one Refloat state word. + * + * RUNNING, TILTBACK and WHEELSLIP are all engaged — a board balancing at a standstill is being + * ridden, and one in tiltback is being ridden badly. Everything else, including the ready state a + * board sits in on the ground, is not. + * + * The one place this is decided. Idle Pause and Accessory measurement demand both ask it, and a + * second copy of the nibble arithmetic would be a second definition of "riding" that could drift. + * + * @parity /modules/vescape-core/ios/telemetry/IdlePauseDetector.swift `isRefloatEngaged` + */ +// GET_ALLDATA packs state_compat in the lower nibble and saturation in the upper nibble. +internal fun isRefloatEngaged(state: Int): Boolean = (state and 0x0f) in 1..3 + /** * Pauses recording on the first disengaged Refloat sample and resumes on the first engaged sample. * RUNNING, TILTBACK, and WHEELSLIP remain engaged, including while balancing at zero speed. @@ -18,8 +33,7 @@ internal class IdlePauseDetector { val isPaused: Boolean get() = paused fun onSample(state: Int): IdlePauseTransition? { - // GET_ALLDATA packs state_compat in the lower nibble and saturation in the upper nibble. - val nextPaused = (state and 0x0f) !in 1..3 + val nextPaused = !isRefloatEngaged(state) if (nextPaused == paused) return null paused = nextPaused return if (paused) IdlePauseTransition.Paused else IdlePauseTransition.Resumed 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..da4e500b 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,133 @@ 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 + + /** + * Forgetting, with everything the rider calibrated against this Accessory. + * + * One transaction, and the calibration goes first. Deleting the identity alone would leave rows + * nothing can reach and nothing can clean up — and re-adding the same hardware later would find + * them and drive the board to numbers the rider set for a mounting position they have since + * changed. "Forget" means forget. + */ + @Transaction + suspend fun forgetAccessory(accessoryId: String): Int { + deleteGroundClearances(accessoryId) + deleteBrakeLights(accessoryId) + deleteAccessoryCapabilitySettings(accessoryId) + return deleteAccessory(accessoryId) + } + + /** + * Adopts what the last handshake declared as the new baseline for saved settings. + * + * Separate from [revalidateAccessory] on purpose: that one deliberately preserves the baseline so + * the "declared limits changed" warning survives a restart. This is the other half — the rider + * saved a calibration that fits the *current* manifest, which is the moment the new limits stop + * being a change to warn about and start being the limits. + */ + @Query("UPDATE accessories SET capabilities_json = :capabilitiesJson WHERE accessory_id = :accessoryId") + suspend fun adoptAccessoryCapabilities(accessoryId: String, capabilitiesJson: String): Int + + // Ground-clearance calibration, keyed on the Accessory *and* the capability. + @Query("SELECT * FROM accessory_brake_light ORDER BY accessory_id, capability_id") + suspend fun getBrakeLights(): List + + @Query("SELECT * FROM accessory_capability_settings ORDER BY accessory_id, capability_id") + suspend fun getAccessoryCapabilitySettings(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAccessoryCapabilitySettings(settings: AccessoryCapabilitySettingsEntity) + + @Transaction + suspend fun saveAccessoryCapabilitySettings(settings: AccessoryCapabilitySettingsEntity) { + check(getAccessory(settings.accessoryId) != null) { "Accessory is no longer enrolled" } + upsertAccessoryCapabilitySettings(settings) + } + + @Query("DELETE FROM accessory_capability_settings WHERE accessory_id = :accessoryId") + suspend fun deleteAccessoryCapabilitySettings(accessoryId: String): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertBrakeLight(settings: AccessoryBrakeLightEntity) + + @Transaction + suspend fun saveBrakeLight(settings: AccessoryBrakeLightEntity) { + check(getAccessory(settings.accessoryId) != null) { "Accessory is no longer enrolled" } + upsertBrakeLight(settings) + } + + @Query("DELETE FROM accessory_brake_light WHERE accessory_id = :accessoryId") + suspend fun deleteBrakeLights(accessoryId: String): Int + + // @parity /modules/vescape-core/ios/telemetry/AccessoryPersistence.swift `GroundClearanceStore` + + @Query("SELECT * FROM accessory_ground_clearance ORDER BY accessory_id ASC, capability_id ASC") + suspend fun getGroundClearances(): List + + @Query( + "SELECT * FROM accessory_ground_clearance WHERE accessory_id = :accessoryId AND capability_id = :capabilityId LIMIT 1", + ) + suspend fun getGroundClearance(accessoryId: String, capabilityId: String): AccessoryGroundClearanceEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertGroundClearance(calibration: AccessoryGroundClearanceEntity) + + @Query( + "DELETE FROM accessory_ground_clearance WHERE accessory_id = :accessoryId AND capability_id = :capabilityId", + ) + suspend fun deleteGroundClearance(accessoryId: String, capabilityId: String): Int + + @Query("DELETE FROM accessory_ground_clearance WHERE accessory_id = :accessoryId") + suspend fun deleteGroundClearances(accessoryId: String): Int + + /** + * Records what the last handshake observed, for a row that still exists. + * + * Update-only on purpose: a handshake landing just after the rider forgot the Accessory must not + * resurrect it. `capabilities_json` and `enrolled_at` are left alone — the first is the baseline + * saved settings were validated against, the second is when the rider added it. + */ + @Query( + """ + UPDATE accessories + SET name = :name, + firmware_version = :firmwareVersion, + protocol_version = :protocolVersion, + device_id = :deviceId, + last_connected_at = :connectedAt + WHERE accessory_id = :accessoryId + """, + ) + suspend fun revalidateAccessory( + accessoryId: String, + name: String, + firmwareVersion: String, + protocolVersion: Int?, + 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..f79dd306 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,88 @@ 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?, +) + +/** + * What the rider calibrated for one ground-clearance capability. + * + * Keyed on the Accessory *and* the capability, never on the Accessory alone: the protocol lets one + * unit declare several measurement capabilities, and the eventual hardware has a nose sensor and a + * tail sensor on the same board. Collapsing this onto the Accessory row would make those two share + * a calibration, which is the one thing they can never do. + * + * There is no partial row and no draft. A calibration is written when it is complete and valid, so + * anything stored here was a usable calibration at the moment it was saved. Whether it is still one + * is decided against the live manifest every session — a firmware that narrowed its measurement + * range invalidates a row it no longer fits, and the rider is asked to set it again rather than + * having their numbers quietly squeezed into the new limits. + * + * @parity /modules/vescape-core/ios/telemetry/AccessoryPersistence.swift `SavedGroundClearance` + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt `GroundClearanceCalibration` + */ +@Entity(tableName = "accessory_ground_clearance", primaryKeys = ["accessory_id", "capability_id"]) +data class AccessoryGroundClearanceEntity( + @ColumnInfo(name = "accessory_id") val accessoryId: String, + /** Stable within the Accessory and across firmware updates, exactly as the manifest declares it. */ + @ColumnInfo(name = "capability_id") val capabilityId: String, + /** Clearance at which correction is at full strength. Always below [farCm]. */ + @ColumnInfo(name = "near_cm") val nearCm: Double, + /** Clearance at which correction starts. Above it nothing is commanded. */ + @ColumnInfo(name = "far_cm") val farCm: Double, + /** Raw wire value for where the sensor is mounted. A value this app cannot read is incomplete. */ + val direction: String, + /** Maximum Remote Tilt input this binding may command, as a percentage. */ + @ColumnInfo(name = "strength_percent") val strengthPercent: Int, + @ColumnInfo(name = "updated_at") val updatedAt: Long, +) + +/** @parity /modules/vescape-core/ios/telemetry/AccessoryPersistence.swift `SavedBrakeLight` */ +@Entity(tableName = "accessory_brake_light", primaryKeys = ["accessory_id", "capability_id"]) +data class AccessoryBrakeLightEntity( + @ColumnInfo(name = "accessory_id") val accessoryId: String, + @ColumnInfo(name = "capability_id") val capabilityId: String, + val sensitivity: Int, + val parked: String, +) + +/** @parity /modules/vescape-core/ios/telemetry/AccessoryPersistence.swift `SavedAccessoryCapabilitySettings` */ +@Entity(tableName = "accessory_capability_settings", primaryKeys = ["accessory_id", "capability_id"]) +data class AccessoryCapabilitySettingsEntity( + @ColumnInfo(name = "accessory_id") val accessoryId: String, + @ColumnInfo(name = "capability_id") val capabilityId: String, + val enabled: Boolean, + @ColumnInfo(name = "sampling_rate_hz") val samplingRateHz: Double? = null, +) 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..f3409fd0 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,63 @@ 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(), + ) + } + + /** + * Ground-clearance calibration, keyed on the Accessory and the capability it was made for. + * + * A table rather than a column on `accessories`: one unit may declare several measurement + * capabilities — the eventual hardware has a nose sensor and a tail sensor on one board — and + * they cannot share near/far distances or a correction direction. + */ + internal val MIGRATION_44_45 = migration(44, 45) { db -> + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS accessory_ground_clearance ( + accessory_id TEXT NOT NULL, + capability_id TEXT NOT NULL, + near_cm REAL NOT NULL, + far_cm REAL NOT NULL, + direction TEXT NOT NULL, + strength_percent INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY(accessory_id, capability_id) + ) + """.trimIndent(), + ) + } + + internal val MIGRATION_45_46 = migration(45, 46) { db -> db.execSQL("CREATE TABLE IF NOT EXISTS accessory_brake_light (accessory_id TEXT NOT NULL, capability_id TEXT NOT NULL, sensitivity INTEGER NOT NULL, parked TEXT NOT NULL, PRIMARY KEY(accessory_id, capability_id))") } + + // @parity /modules/vescape-core/ios/telemetry/PersistenceSchema.swift `createAccessoryCapabilitySettings` + internal val MIGRATION_46_47 = migration(46, 47) { db -> + db.execSQL("CREATE TABLE IF NOT EXISTS accessory_capability_settings (accessory_id TEXT NOT NULL, capability_id TEXT NOT NULL, enabled INTEGER NOT NULL, PRIMARY KEY(accessory_id, capability_id))") + } + + // @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `v48_accessory_sampling_rate` + internal val MIGRATION_47_48 = migration(47, 48) { db -> + db.execSQL("ALTER TABLE accessory_capability_settings ADD COLUMN sampling_rate_hz REAL") + } + /** Every migration registered with Room, in the graph's production order. */ val all = listOf( MIGRATION_3_4, @@ -1539,6 +1596,11 @@ internal object TelemetryMigrations { MIGRATION_40_41, MIGRATION_41_42, MIGRATION_42_43, + MIGRATION_43_44, + MIGRATION_44_45, + MIGRATION_45_46, + MIGRATION_46_47, + MIGRATION_47_48, ) } 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..6a9580e4 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 = 48 /** Production Room schema/DAO, portable to JVM hosts. Android open/migration lifecycle stays in [TelemetryDatabase]. */ @Database( @@ -16,7 +16,10 @@ 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, + AccessoryGroundClearanceEntity::class, + AccessoryBrakeLightEntity::class, + AccessoryCapabilitySettingsEntity::class, ], version = TELEMETRY_DATABASE_VERSION, exportSchema = false, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/RemoteInputArbiterTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/RemoteInputArbiterTest.kt new file mode 100644 index 00000000..95bfbad1 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/RemoteInputArbiterTest.kt @@ -0,0 +1,370 @@ +package expo.modules.vescapecore + +import expo.modules.vescapecore.accessory.GroundClearance +import expo.modules.vescapecore.accessory.BoardGroundClearanceBinding +import expo.modules.vescapecore.accessory.GroundClearanceInput +import expo.modules.vescapecore.accessory.GroundClearanceRelease +import expo.modules.vescapecore.connection.BoardTransport +import expo.modules.vescapecore.protocol.BOARD_MOVE_INPUT_MAX +import expo.modules.vescapecore.protocol.BoardMoveGeneration +import expo.modules.vescapecore.protocol.REMOTE_TILT_CENTER +import expo.modules.vescapecore.protocol.buildBoardMoveCommand +import expo.modules.vescapecore.protocol.buildRemoteTiltCommand +import expo.modules.vescapecore.runtime.TestScheduler +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Who is allowed to write the Board's remote-input slot, and what happens at every handover. + * + * These are the ownership regressions #479 asks for. The failures they describe are all the same + * shape: two of the three writers active at once, each repeating its own value on its own tick, so + * the Board receives an alternating stream and does neither thing. On a ridden Board that is not a + * glitch, it is a rider on the floor — which is why the interesting assertions here are about what + * is *not* sent. + * + * @parity /modules/vescape-core/ios/RemoteInputArbiterTests.swift + */ +class RemoteInputArbiterTest { + private val scheduler = TestScheduler() + private val sent = mutableListOf() + private var transport: BoardTransport? = BoardTransport.Direct + private var canMove = true + + private val tilt = RemoteTiltController( + scheduler = scheduler, + transport = { transport }, + send = { payload, _ -> sent.add(payload); true }, + ) + private val move = BoardMoveController( + scheduler = scheduler, + transport = { transport }, + canMove = { canMove }, + generation = { BoardMoveGeneration.Remote }, + send = { payload, _ -> sent.add(payload); true }, + ) + private var sensorBound = false + private val arbiter = RemoteInputArbiter( + tilt = tilt, + move = move, + nowMs = { scheduler.currentTimeMs }, + sensorBound = { sensorBound }, + ) + + private fun tiltPacket(value: Int) = buildRemoteTiltCommand(BoardTransport.Direct, value) + + private fun movePacket(input: Int) = + buildBoardMoveCommand(BoardTransport.Direct, BoardMoveGeneration.Remote, input) + + /** Drives the sensor at full strength for long enough that the slew limit is no longer the story. */ + private fun settleSensorAt(target: Int) { + repeat(12) { + arbiter.sensorDrive(target) + scheduler.advance(100) + } + assertEquals(target, arbiter.sensorCommand) + } + + @Test + fun sensorRampsToItsTargetInsteadOfSteppingToIt() { + // A pothole under the sensor produces a full-range swing in one sample. Handing that to the + // firmware as a single step is the same angle error a snapped cancel would be. + arbiter.sensorDrive(255) + val first = arbiter.sensorCommand + assertTrue("first command must leave neutral", first > REMOTE_TILT_CENTER) + assertTrue("first command must not be the full swing", first < 255) + + settleSensorAt(255) + } + + @Test + fun sensorFollowsItsReadingsOnceRamped() { + settleSensorAt(200) + + // Small changes inside the slew allowance land exactly, so steady tracking is not distorted. + scheduler.advance(100) + arbiter.sensorDrive(198) + assertEquals(198, arbiter.sensorCommand) + } + + @Test + fun manualTiltIsRefusedWhileTheSensorIsDriving() { + settleSensorAt(200) + val before = sent.size + + assertFalse(arbiter.manualHold(40)) + assertFalse(arbiter.manualLock(40)) + assertFalse(arbiter.manualRelease(40, 1_000)) + assertEquals("a refused manual command writes nothing", before, sent.size) + assertEquals(RemoteInputOwner.SENSOR, arbiter.owner) + } + + @Test + fun sensorIsRefusedWhileBoardMoveHoldsTheSlot() { + assertTrue(arbiter.startMove(BOARD_MOVE_INPUT_MAX)) + sent.clear() + + assertFalse(arbiter.sensorDrive(255)) + assertEquals(RemoteInputOwner.MOVE, arbiter.owner) + + // Nothing but move packets reach the board while it is jogging. + scheduler.advance(300) + assertTrue(sent.isNotEmpty()) + assertTrue(sent.all { it.contentEquals(movePacket(BOARD_MOVE_INPUT_MAX)) }) + } + + @Test + fun boardMoveIsNotOverwrittenByAPendingSensorDecay() { + settleSensorAt(255) + // The rider steps off: the binding releases and the smooth return starts. + arbiter.sensorRelease() + assertEquals(RemoteTiltPhase.Decaying, tilt.phase) + + // Board Move, requested while that return is still easing down. + sent.clear() + assertTrue(arbiter.startMove(-BOARD_MOVE_INPUT_MAX)) + assertEquals(RemoteInputOwner.MOVE, arbiter.owner) + + // One neutral tilt hands the slot back, and after it the board hears nothing but the move. + assertArrayEquals(tiltPacket(REMOTE_TILT_CENTER), sent.first()) + scheduler.advance(600) + val afterHandover = sent.drop(1) + assertTrue(afterHandover.isNotEmpty()) + assertTrue( + "a pending decay must not keep writing over Board Move", + afterHandover.all { it.contentEquals(movePacket(-BOARD_MOVE_INPUT_MAX)) }, + ) + } + + @Test + fun boardMoveIsRefusedWhileTheSensorIsCorrecting() { + settleSensorAt(220) + sent.clear() + + // A board asking for ground-clearance correction is a board being ridden, and jogging one is + // not a request this app passes on. + assertFalse(arbiter.startMove(BOARD_MOVE_INPUT_MAX)) + assertEquals(RemoteInputOwner.SENSOR, arbiter.owner) + assertTrue(sent.none { it.contentEquals(movePacket(BOARD_MOVE_INPUT_MAX)) }) + } + + @Test + fun sensorReleaseEasesOutOnceRatherThanRestartingEveryTick() { + settleSensorAt(255) + + assertTrue(arbiter.sensorRelease()) + val total = tilt.decayProgress?.totalMs + assertEquals(600L, total) + + // The Board Session calls this on every tick it has no valid reading. Only the first cancels; + // a repeat would re-ease from a smaller value and the return would never arrive. + scheduler.advance(200) + assertFalse(arbiter.sensorRelease()) + assertEquals(total, tilt.decayProgress?.totalMs) + + scheduler.advance(400) + assertEquals(RemoteTiltPhase.Idle, tilt.phase) + assertEquals(RemoteInputOwner.NONE, arbiter.owner) + assertArrayEquals(tiltPacket(REMOTE_TILT_CENTER), sent.last()) + } + + @Test + fun aBindingArmingTakesBackALockedManualTilt() { + assertTrue(arbiter.manualLock(255)) + assertEquals(RemoteInputOwner.MANUAL, arbiter.owner) + + // A lock never ends on its own, so without this the binding would wait for the slot forever. + assertTrue(arbiter.releaseManual()) + assertEquals(RemoteTiltPhase.Decaying, tilt.phase) + scheduler.advance(600) + assertEquals(RemoteInputOwner.NONE, arbiter.owner) + + assertTrue(arbiter.sensorDrive(200)) + assertEquals(RemoteInputOwner.SENSOR, arbiter.owner) + } + + @Test + fun manualTiltKeepsItsSlotWhileNoSensorIsDriving() { + assertTrue(arbiter.manualHold(200)) + assertEquals(RemoteInputOwner.MANUAL, arbiter.owner) + assertTrue(arbiter.manualHold(210)) + assertArrayEquals(tiltPacket(200), sent.first()) + } + + @Test + fun cancelReleasesWhoeverHeldTheSlot() { + settleSensorAt(255) + + assertTrue(arbiter.cancelTilt()) + assertEquals(RemoteTiltPhase.Decaying, tilt.phase) + // Cancel is not an off switch for the binding: a sensor still holding valid readings takes + // the slot back on its next tick, ramped from where the cancel left it. + assertEquals(REMOTE_TILT_CENTER, arbiter.sensorCommand) + scheduler.advance(100) + val eased = tilt.currentValue + assertTrue("the cancel must have eased some of the tilt off", eased in 1 until 255) + assertTrue(arbiter.sensorDrive(255)) + assertTrue("re-engaging must resume from the eased value, never step", arbiter.sensorCommand >= eased) + assertNotEquals(255, arbiter.sensorCommand) + } + + @Test + fun aSensorReEngagingMidReleaseResumesFromTheStreamRatherThanNeutral() { + settleSensorAt(255) + // One bad reading releases; the reading after it is good again, which is an ordinary minute + // of riding past a puddle, not an exotic case. + assertTrue(arbiter.sensorRelease()) + scheduler.advance(200) + val eased = tilt.currentValue + assertTrue("the release must have eased some of the tilt off", eased in 1 until 255) + + sent.clear() + assertTrue(arbiter.sensorDrive(255)) + scheduler.advance(100) + // Resuming from neutral here would hand the firmware the whole unfinished decay as one step + // — a ~100-count drop on a board with a rider on it, which is the surge a snapped cancel + // would cause and the reason nothing in this class is allowed to step. + assertTrue("re-engage must not step down to neutral", arbiter.sensorCommand >= eased) + assertTrue(sent.isNotEmpty()) + assertTrue( + "no packet may drop the commanded tilt back toward neutral", + sent.none { it.contentEquals(tiltPacket(REMOTE_TILT_CENTER)) }, + ) + } + + @Test + fun manualTiltIsRefusedWhileABindingIsBoundEvenWithTheSlotFree() { + // Bound but not driving: parked, or between readings. The slot is genuinely free, and + // without the bound check a manual *lock* taken here would never end on its own — and the + // pad is read-only by then, so the rider has no Cancel to press. + sensorBound = true + assertEquals(RemoteInputOwner.NONE, arbiter.owner) + + assertFalse(arbiter.manualHold(200)) + assertFalse(arbiter.manualLock(200)) + assertFalse(arbiter.manualRelease(200, 1_000)) + assertTrue("a refused manual command writes nothing", sent.isEmpty()) + assertEquals(RemoteInputOwner.NONE, arbiter.owner) + + // The binding can still take the slot it was holding open. + assertTrue(arbiter.sensorDrive(200)) + assertEquals(RemoteInputOwner.SENSOR, arbiter.owner) + } + + @Test + fun aBoundBindingKeepsReleasingAManualTiltItDidNotCatchWhenItArmed() { + // A lock taken in the window before the pad learned it was read-only, or one whose + // arming-time cancel failed on a transport that blinked. + assertTrue(arbiter.manualLock(255)) + assertEquals(RemoteInputOwner.MANUAL, arbiter.owner) + sensorBound = true + + val binding = BoardGroundClearanceBinding( + remoteInput = arbiter, + boundInput = { true }, + tiltInput = { GroundClearanceInput.Drive(1.0, 5.0) }, + ) + val board = BoardGroundClearanceBinding.BoardInput(commandsTrusted = true, telemetryFresh = true) + + // Already bound on the first tick, so there is no unbound→bound transition to catch it. + binding.tick(board) + assertEquals("manual-tilt", binding.state()["release"]) + assertEquals(RemoteTiltPhase.Decaying, tilt.phase) + val total = tilt.decayProgress?.totalMs + + // Repeating the release must not restart the ease, or it would shrink toward zero forever. + scheduler.advance(200) + binding.tick(board) + assertEquals(total, tilt.decayProgress?.totalMs) + + scheduler.advance(600) + binding.tick(board) + assertEquals("the binding takes the slot once the ease finishes", RemoteInputOwner.SENSOR, arbiter.owner) + assertNull(binding.state()["release"]) + } + + @Test + fun resetLeavesNothingStreamingOnEitherChannel() { + settleSensorAt(255) + arbiter.reset() + + assertEquals(RemoteInputOwner.NONE, arbiter.owner) + assertArrayEquals(tiltPacket(REMOTE_TILT_CENTER), sent[sent.size - 2]) + assertArrayEquals(movePacket(0), sent.last()) + + scheduler.advance(1_000) + val afterReset = sent.size + scheduler.advance(1_000) + assertEquals("nothing repeats after a reset", afterReset, sent.size) + } + + @Test + fun aLostTransportEndsTheSensorStreamRatherThanHoldingItsLastValue() { + settleSensorAt(255) + transport = null + + // The repeat loop is the sole sender; with no transport it clears itself, and the arbiter's + // derived owner follows the stream rather than remembering a claim it can no longer serve. + scheduler.advance(100) + assertEquals(RemoteTiltPhase.Idle, tilt.phase) + assertEquals(RemoteInputOwner.NONE, arbiter.owner) + } + + @Test + fun correctionMapsOntoThePadsOwnScaleAndSaturates() { + assertEquals(REMOTE_TILT_CENTER, GroundClearance.tiltCommand(0.0)) + assertEquals(255, GroundClearance.tiltCommand(1.0)) + assertEquals(1, GroundClearance.tiltCommand(-1.0)) + // Nothing upstream can produce these; the one place that decides what a board is told is not + // where to find that out. + assertEquals(255, GroundClearance.tiltCommand(4.0)) + assertEquals(1, GroundClearance.tiltCommand(-4.0)) + assertEquals(REMOTE_TILT_CENTER, GroundClearance.tiltCommand(Double.NaN)) + } + + @Test + fun boardBindingOwnsTickCancellationAndBoardReasonPrecedence() { + var sourceReads = 0 + val binding = BoardGroundClearanceBinding( + remoteInput = arbiter, + boundInput = { sourceReads += 1; true }, + tiltInput = { GroundClearanceInput.Drive(1.0, 5.0) }, + ) + fun schedule(tick: () -> Unit) = + scheduler.postDelayed(BoardGroundClearanceBinding.TICK_MS, tick) + + binding.start(::schedule) { + BoardGroundClearanceBinding.BoardInput(commandsTrusted = false, telemetryFresh = false) + } + scheduler.advance(BoardGroundClearanceBinding.TICK_MS) + assertEquals("board trust wins over stale telemetry and a valid sensor", "board-untrusted", binding.state()["release"]) + assertEquals(RemoteInputOwner.NONE, arbiter.owner) + + binding.stop() + val readsAfterStop = sourceReads + binding.start(::schedule) { + BoardGroundClearanceBinding.BoardInput(commandsTrusted = true, telemetryFresh = false) + } + scheduler.advance(BoardGroundClearanceBinding.TICK_MS) + assertEquals("board-stale", binding.state()["release"]) + assertEquals(readsAfterStop + 1, sourceReads) + + binding.stop() + binding.start(::schedule) { + BoardGroundClearanceBinding.BoardInput(commandsTrusted = true, telemetryFresh = true) + } + scheduler.advance(BoardGroundClearanceBinding.TICK_MS * 2) + assertEquals(RemoteInputOwner.SENSOR, arbiter.owner) + binding.stop() + assertEquals(REMOTE_TILT_CENTER, arbiter.sensorCommand) + assertEquals(RemoteTiltPhase.Decaying, tilt.phase) + val finalReads = sourceReads + scheduler.advance(BoardGroundClearanceBinding.TICK_MS * 2) + assertEquals("a stopped session cannot receive its old callback", finalReads, sourceReads) + } +} 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/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/accessory/BrakeLightTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/BrakeLightTest.kt new file mode 100644 index 00000000..85fff176 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/BrakeLightTest.kt @@ -0,0 +1,51 @@ +package expo.modules.vescapecore.accessory +import org.junit.Assert.* +import org.junit.Test + +/** @parity /modules/vescape-core/ios/accessory/BrakeLightTests.swift */ +class BrakeLightTest { + @Test fun forwardAndReverseDecelerationAndSteadySpeed() { + for (direction in listOf(1, -1)) { + val detector = BrakeLightDetector() + for (n in 0..10) detector.sample(direction * 36.0, true, n * 100L, 50) + assertEquals("riding", detector.mode) + for (n in 1..10) detector.sample(direction * (36.0 - n * 0.72), true, 1000L + n * 100, 50) + assertEquals("braking", detector.mode) + for (n in 1..8) detector.sample(direction * (28.8 - n * 1.8), true, 2000L + n * 100, 50) + assertEquals("hard_braking", detector.mode) + } + } + @Test fun gapInvalidSpeedAndParkedClearHistory() { + val detector = BrakeLightDetector() + detector.sample(36.0, true, 0, 50) + detector.sample(0.0, true, 1000, 50) + assertNull(detector.mode) + detector.sample(0.0, true, 1100, 50) + assertEquals("riding", detector.mode) + detector.sample(Double.NaN, true, 1200, 50) + assertNull(detector.mode) + detector.sample(0.0, false, 1300, 50) + assertEquals("not_riding", detector.mode) + } + @Test fun sensitivityChangesThresholdAndPreviewRestoresCurrentState() { + val gentle = BrakeLightDetector(); val resistant = BrakeLightDetector() + for (n in 0..15) { + gentle.sample(36.0 - n * 0.36, true, n * 100L, 100) + resistant.sample(36.0 - n * 0.36, true, n * 100L, 1) + } + assertEquals("braking", gentle.mode); assertEquals("riding", resistant.mode) + val controller = BrakeLightController(); val key = BrakeLightController.Key("a", "rear") + controller.configure(key, BrakeLightSettings(50, "glow")) + assertTrue(controller.preview(key, "hard_braking")) + assertEquals("unavailable", controller.command(key).telemetry) + assertTrue(controller.command(key).preview) + controller.releasePreviews() + assertNull(controller.command(key).mode) + controller.sample(10.0, true, 100) + assertFalse(controller.preview(key, "braking")) + assertEquals("riding", controller.command(key).mode) + controller.clear() + assertEquals("unavailable", controller.command(key).telemetry) + assertEquals("glow", controller.command(key).parked) + } +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/ClearancePreviewLogTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/ClearancePreviewLogTest.kt new file mode 100644 index 00000000..4aeafe89 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/ClearancePreviewLogTest.kt @@ -0,0 +1,41 @@ +package expo.modules.vescapecore.accessory + +import org.junit.Assert.* +import org.junit.Test + +/** @parity /modules/vescape-core/ios/accessory/ClearancePreviewLogTests.swift */ +class ClearancePreviewLogTest { + @Test fun invalidAndMissingSamplesBreakChartWithoutInventingDistance() { + val log = ClearancePreviewLog() + log.record(0, 0, 1, 10.0) + log.record(50, 50, 2, null) + log.record(100, 100, 3, 12.0) + log.record(200, 200, 5, 14.0) + val snapshot = log.snapshot(200)!! + assertEquals(listOf(listOf(0.0, 10.0), listOf(100.0, 12.0), listOf(200.0, 14.0)), snapshot["segments"]) + assertEquals(1L, snapshot["dropped"]) + assertEquals(1, snapshot["invalid"]) + assertEquals(15.0, snapshot["deliveredHz"]) + } + @Test fun displayThrottleDoesNotDropHistoryAndResetStartsFresh() { + val log = ClearancePreviewLog() + for (i in 0L..6L) { + log.record(i * 50, i * 50, i + 1, 10.0) + assertEquals(i % 2 == 0L, log.shouldEmit(i * 50)) + } + assertEquals(7, log.snapshot(300)!!["samples"]) + assertNull(log.snapshot(400)) + log.reset() + log.record(450, 0, 1, 8.0) + assertTrue(log.shouldEmit(450)) + assertEquals(1, log.snapshot(450)!!["samples"]) + } + @Test fun windowAndCapacityBoundMemory() { + val log = ClearancePreviewLog() + for (i in 0L..1000L) log.record(i * 50, i * 50, i + 1, 10.0) + assertEquals(401, log.snapshot(50_000)!!["samples"]) + log.reset() + for (i in 0L..1000L) log.record(i, i, i + 1, 10.0) + assertEquals(601, log.snapshot(1000)!!["samples"]) + } +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/GroundClearanceTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/GroundClearanceTest.kt new file mode 100644 index 00000000..25cade30 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/GroundClearanceTest.kt @@ -0,0 +1,345 @@ +package expo.modules.vescapecore.accessory + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The ground-clearance contract, driven by `shared/fixtures/accessory-protocol/session.json`: what + * a sample decodes to, what the declared range does to it, which samples are accepted, and what a + * saved calibration turns a distance into. + * + * The property most of these cases exist to defend is one sentence: **a missing measurement is + * never a distance.** Every way a reading can fail to be one — no value, a null value, a textual + * value, a status from a newer firmware, a number outside the declared window — has a case here, + * and all of them end at `error` or `out_of_range` with no value attached. None of them ends at the + * top of the range, which is the reading that would tell a board it is safe to tilt. + * + * @parity /modules/vescape-core/ios/accessory/GroundClearanceTests.swift + */ +class GroundClearanceTest { + @Test + fun disablingPreservesCalibrationButStopsPreviewAndReleasesTheBinding() { + val controller = GroundClearanceBindingController { 1000L } + val capability = AccessoryCapability("clearance", "ground_clearance", true, "cm", 3.0, 100.0, listOf(10.0)) + val link = GroundClearanceBindingController.LinkState(true, 10.0) + controller.applyCapability("accessory", capability, true, 10.0) + controller.applyCalibration("accessory", capability.id, GroundClearanceCalibration(5.0, 20.0, "nose", 60)) + controller.setRiding(true) + controller.setPreview("accessory", capability.id, true) + assertTrue(controller.applyCapability("accessory", capability, true, 10.0).enabled) + assertTrue(controller.bound { _, _ -> link }) + assertFalse(controller.applyCapability("accessory", capability, true, 10.0, enabled = false).enabled) + assertFalse(controller.bound { _, _ -> link }) + assertEquals(GroundClearanceInput.Release(GroundClearanceRelease.DISABLED), controller.tilt { _, _ -> link }) + assertTrue(controller.describe("accessory", capability.id)?.get("calibration") != null) + assertTrue(controller.applyCapability("accessory", capability, true, 10.0, enabled = true).enabled) + assertEquals(GroundClearanceInput.Release(GroundClearanceRelease.STALE), controller.tilt { _, _ -> link }) + } + + private val fixture = AccessoryFixtures.load("session.json") + private val sessionId = fixture.getString("sessionId") + private val readings = fixture.getJSONObject("readings") + private val groundClearance = fixture.getJSONObject("groundClearance") + + private fun declaredRange(owner: org.json.JSONObject): Pair { + val range = owner.getJSONObject("declaredRange") + return range.getDouble("min") to range.getDouble("max") + } + + @Test + fun everySampleDecodesExactlyAsTheFixturePinsIt() { + val cases = readings.getJSONArray("decode") + assertTrue("fixture must carry reading 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) + + if (case.optBoolean("ignored")) { + assertEquals(name, AccessoryResponse.Ignored, parsed) + continue + } + val expected = case.getJSONObject("reading") + val sample = parsed as? AccessoryResponse.Sample ?: error("$name: expected a sample, got $parsed") + val reading = sample.reading + assertEquals(name, expected.getString("capabilityId"), reading.capabilityId) + assertEquals(name, expected.getInt("seq"), reading.seq) + assertEquals(name, expected.getLong("sampleTimeMs"), reading.sampleTimeMs) + assertEquals(name, expected.getString("status"), reading.status.wire) + if (expected.isNull("valueCm")) { + assertNull(name, reading.valueCm) + } else { + assertEquals(name, expected.getDouble("valueCm"), reading.valueCm!!, 1e-9) + } + } + } + + @Test + fun aValueOutsideTheDeclaredWindowIsOutOfRangeRatherThanClamped() { + val (min, max) = declaredRange(readings) + val capabilityId = readings.getString("capabilityId") + val cases = readings.getJSONArray("rangeCheck") + for (i in 0 until cases.length()) { + val case = cases.getJSONObject(i) + val name = case.getString("name") + val reading = AccessoryReading( + capabilityId = capabilityId, + seq = 1, + sampleTimeMs = 100, + status = AccessoryReadingStatus.OK, + valueCm = case.getDouble("valueCm"), + ).withinDeclaredRange(min, max) + assertEquals(name, case.getString("resolvedStatus"), reading.status.wire) + if (case.isNull("resolvedValueCm")) { + // The whole point: a number the hardware no longer promises loses its value rather + // than being squeezed to the nearest limit. + assertNull(name, reading.valueCm) + } else { + assertEquals(name, case.getDouble("resolvedValueCm"), reading.valueCm!!, 1e-9) + } + } + } + + @Test + fun onlyASampleNewerThanTheOneHeldIsAccepted() { + val capabilityId = readings.getString("capabilityId") + val cases = readings.getJSONArray("acceptance") + for (i in 0 until cases.length()) { + val case = cases.getJSONObject(i) + val name = case.getString("name") + val tracker = AccessoryReadingTracker() + if (!case.isNull("previous")) { + val previous = case.getJSONObject("previous") + assertTrue( + "$name: seeding the previous sample must succeed", + tracker.accept( + AccessoryReading( + capabilityId, previous.getInt("seq"), previous.getLong("sampleTimeMs"), + AccessoryReadingStatus.OK, 10.0, + ), + receivedAtMs = 1_000, + ), + ) + } + val accepted = tracker.accept( + AccessoryReading( + capabilityId, case.getInt("seq"), case.getLong("sampleTimeMs"), + AccessoryReadingStatus.OK, 11.0, + ), + receivedAtMs = 2_000, + ) + assertEquals(name, case.getBoolean("accepted"), accepted) + } + } + + @Test + fun aFreshSessionKeepsNothingFromTheOldOne() { + // Sequence numbers restart with the next hello. Without the reset the new session's first + // samples would be refused as duplicates and the screen would sit on a distance measured + // before the accessory rebooted. + val tracker = AccessoryReadingTracker() + val ok = AccessoryReading("clearance", 40, 9_000, AccessoryReadingStatus.OK, 12.0) + assertTrue(tracker.accept(ok, receivedAtMs = 1_000)) + tracker.reset() + assertNull(tracker.latest) + assertTrue(tracker.accept(ok.copy(seq = 1, sampleTimeMs = 50), receivedAtMs = 2_000)) + } + + @Test + fun freshnessIsJudgedOnTheRateTheAccessoryConfirmed() { + val cases = readings.getJSONArray("staleAfterMs") + for (i in 0 until cases.length()) { + val case = cases.getJSONObject(i) + assertEquals( + case.getString("name"), + case.getLong("staleAfterMs"), + GroundClearance.staleAfterMs(case.getDouble("rateHz")), + ) + } + // An unacknowledged rate is not a reason to widen the window; the floor still applies. + assertEquals(GroundClearance.MISSING_STREAM_FLOOR_MS, GroundClearance.staleAfterMs(0.0)) + assertEquals(GroundClearance.MISSING_STREAM_FLOOR_MS, GroundClearance.staleAfterMs(Double.NaN)) + } + + @Test + fun aCalibrationIsCompleteOnlyWhenEveryRuleHolds() { + val (min, max) = declaredRange(groundClearance) + val cases = groundClearance.getJSONArray("validity") + for (i in 0 until cases.length()) { + val case = cases.getJSONObject(i) + val name = case.getString("name") + val spec = case.getJSONObject("calibration") + val calibration = GroundClearanceCalibration( + nearCm = if (spec.isNull("nearCm")) Double.NaN else spec.getDouble("nearCm"), + farCm = if (spec.isNull("farCm")) Double.NaN else spec.getDouble("farCm"), + direction = spec.getString("direction"), + strengthPercent = spec.getInt("strengthPercent"), + ) + assertEquals(name, case.getBoolean("valid"), calibration.isComplete(min, max)) + val problem = calibration.problem(min, max)?.wire + if (case.isNull("problem")) assertNull(name, problem) else { + assertEquals(name, case.getString("problem"), problem) + } + } + } + + @Test + fun oneDistanceBecomesTheSignedInputTheFixturePins() { + val cases = groundClearance.getJSONArray("tilt") + for (i in 0 until cases.length()) { + val case = cases.getJSONObject(i) + val spec = case.getJSONObject("calibration") + val calibration = GroundClearanceCalibration( + nearCm = spec.getDouble("nearCm"), + farCm = spec.getDouble("farCm"), + direction = spec.getString("direction"), + strengthPercent = spec.getInt("strengthPercent"), + ) + assertEquals( + case.getString("name"), + case.getDouble("tiltInput"), + calibration.tiltInput(case.getDouble("valueCm")), + 1e-9, + ) + } + } + + @Test + fun measurementIsDemandedByAPreviewOrByRidingACalibratedBoard() { + val runtime = GroundClearanceRuntime("clearance") + runtime.rangeMin = 3.0 + runtime.rangeMax = 100.0 + assertFalse("nothing wants it", runtime.measurementDemanded) + + runtime.previewOpen = true + assertTrue("a preview measures even uncalibrated — that is how a calibration is made", runtime.measurementDemanded) + + runtime.previewOpen = false + runtime.riding = true + // Riding an uncalibrated sensor measures nothing: there is no binding to consume the samples, + // so the accessory would burn power producing them for nobody. + assertFalse("riding without a calibration has no consumer", runtime.measurementDemanded) + + runtime.calibration = GroundClearanceCalibration(5.0, 20.0, "nose", 60) + assertTrue(runtime.measurementDemanded) + + // A firmware that narrowed its range invalidates the saved numbers, and with them the demand. + runtime.rangeMin = 8.0 + assertFalse("a calibration that no longer fits drives nothing", runtime.measurementDemanded) + } + + @Test + fun aTiltBindingIsReleasedWithANamedReasonForEveryWayTheInputCanFail() { + val runtime = GroundClearanceRuntime("clearance") + runtime.rangeMin = 3.0 + runtime.rangeMax = 100.0 + runtime.rateHz = 20.0 + + fun input(now: Long = 1_000, connected: Boolean = true) = runtime.input(now, connected) + + assertEquals( + GroundClearanceInput.Release(GroundClearanceRelease.NOT_RIDING), + input(), + ) + runtime.riding = true + assertEquals( + GroundClearanceInput.Release(GroundClearanceRelease.NO_LINK), + input(connected = false), + ) + assertEquals( + GroundClearanceInput.Release(GroundClearanceRelease.NOT_CALIBRATED), + input(), + ) + + runtime.calibration = GroundClearanceCalibration(5.0, 20.0, "nose", 100) + // Calibrated, connected, riding — and no sample has ever arrived. That is stale, not zero. + assertEquals(GroundClearanceInput.Release(GroundClearanceRelease.STALE), input()) + + runtime.tracker.accept( + AccessoryReading("clearance", 1, 100, AccessoryReadingStatus.OK, 12.5), + receivedAtMs = 1_000, + ) + assertEquals(GroundClearanceInput.Drive(0.5, 12.5), input(now = 1_100)) + // Past the missing-stream window the same sample is no longer evidence of anything. + assertEquals(GroundClearanceInput.Release(GroundClearanceRelease.STALE), input(now = 1_400)) + + runtime.tracker.accept( + AccessoryReading("clearance", 2, 150, AccessoryReadingStatus.OUT_OF_RANGE, null), + receivedAtMs = 1_400, + ) + assertEquals( + GroundClearanceInput.Release(GroundClearanceRelease.OUT_OF_RANGE), + input(now = 1_450), + ) + + runtime.tracker.accept( + AccessoryReading("clearance", 3, 200, AccessoryReadingStatus.ERROR, null), + receivedAtMs = 1_500, + ) + assertEquals( + GroundClearanceInput.Release(GroundClearanceRelease.SENSOR_ERROR), + input(now = 1_550), + ) + } + + @Test + fun anOkReadingWithoutAValueCannotBeConstructed() { + // The type refuses the pairing that would make a status and a number disagree, which is what + // lets every consumer treat "has a value" as "is a measurement". + val thrown = runCatching { + AccessoryReading("clearance", 1, 100, AccessoryReadingStatus.OUT_OF_RANGE, 12.0) + } + assertTrue("a non-ok reading must not carry a value", thrown.isFailure) + } + + @Test + fun bindingControllerPreservesDemandLimitsAndContestedOwnership() { + val controller = GroundClearanceBindingController { 1_100 } + fun capability(id: String, min: Double = 3.0, max: Double = 100.0) = AccessoryCapability( + id, AccessoryProtocol.TYPE_GROUND_CLEARANCE, true, "cm", min, max, listOf(20.0), + ) + controller.applyCapability("front", capability("clearance"), liveManifest = true, rateHz = 20.0) + controller.applyCalibration("front", "clearance", GroundClearanceCalibration(5.0, 30.0, "nose", 60)) + controller.setRiding(true) + controller.applyCapability("front", capability("clearance", 10.0, 20.0), liveManifest = false, rateHz = 20.0) + assertEquals(null, (controller.describe("front", "clearance")?.get("calibration") as Map<*, *>)["problem"]) + + assertTrue(controller.setPreview("front", "clearance", true)) + assertTrue(controller.releasePreviews()) + assertEquals(true, controller.describe("front", "clearance")?.get("measuring")) + + controller.applyCapability("rear", capability("clearance"), liveManifest = true, rateHz = 20.0) + controller.applyCalibration("rear", "clearance", GroundClearanceCalibration(5.0, 30.0, "tail", 60)) + controller.applyCapability("rear", capability("clearance"), liveManifest = false, rateHz = 20.0) + val connected = { _: String, _: String -> GroundClearanceBindingController.LinkState(true, 20.0) } + assertTrue(controller.bound(connected)) + assertEquals( + GroundClearanceInput.Release(GroundClearanceRelease.CONTESTED), + controller.tilt(connected), + ) + } + + @Test + fun bindingControllerEmitsReadingsOnlyForPreviewAndInvalidatesThemWithTheSession() { + val controller = GroundClearanceBindingController { 1_100 } + val capability = AccessoryCapability( + "clearance", AccessoryProtocol.TYPE_GROUND_CLEARANCE, true, "cm", 3.0, 100.0, listOf(20.0), + ) + controller.applyCapability("sensor", capability, liveManifest = true, rateHz = 20.0) + controller.applyCalibration("sensor", "clearance", GroundClearanceCalibration(5.0, 20.0, "nose", 100)) + controller.setRiding(true) + controller.applyCapability("sensor", capability, liveManifest = false, rateHz = 20.0) + val reading = AccessoryReading("clearance", 1, 100, AccessoryReadingStatus.OK, 12.5) + assertNull(controller.acceptReading("sensor", reading, 1_000, 20.0)) + controller.setPreview("sensor", "clearance", true) + assertTrue(controller.acceptReading("sensor", reading.copy(seq = 2), 1_000, 20.0) != null) + controller.onSessionLost("sensor") + assertEquals( + GroundClearanceInput.Release(GroundClearanceRelease.STALE), + controller.input("sensor", "clearance", GroundClearanceBindingController.LinkState(true, 20.0)), + ) + } +} 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..f5eb049c --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/AccessoryMigrationTest.kt @@ -0,0 +1,95 @@ +package expo.modules.vescapecore.telemetry + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The schema edges that made Accessories, and the rider's calibration for them, 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, that what the rider calibrated for it is keyed + * on the capability as well, 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` + * @parity /modules/vescape-core/ios/telemetry/PersistenceSchema.swift `createAccessoryGroundClearance` + */ +class AccessoryMigrationTest { + private fun migrationSql(step: TelemetryMigrationStep): List { + val sql = mutableListOf() + val db = object : TelemetryMigrationDatabase { + override fun execSQL(statement: String) { sql += statement } + override fun hasColumn(tableName: String, columnName: String) = false + } + step.migrate(db) + return sql + } + + private fun migrationSql(): List = migrationSql(TelemetryMigrations.MIGRATION_43_44) + + @Test + fun theAccessoryEdgesAreContiguousThroughSamplingSettings() { + assertEquals(48, TELEMETRY_DATABASE_VERSION) + assertEquals(43, TelemetryMigrations.MIGRATION_43_44.startVersion) + assertEquals(44, TelemetryMigrations.MIGRATION_43_44.endVersion) + assertEquals(44, TelemetryMigrations.MIGRATION_44_45.startVersion) + assertEquals(45, TelemetryMigrations.MIGRATION_44_45.endVersion) + assertEquals(45, TelemetryMigrations.MIGRATION_45_46.startVersion) + assertEquals(46, TelemetryMigrations.MIGRATION_45_46.endVersion) + assertEquals(46, TelemetryMigrations.MIGRATION_46_47.startVersion) + assertEquals(47, TelemetryMigrations.MIGRATION_46_47.endVersion) + assertEquals(47, TelemetryMigrations.MIGRATION_47_48.startVersion) + assertEquals(TELEMETRY_DATABASE_VERSION, TelemetryMigrations.MIGRATION_47_48.endVersion) + assertEquals(TelemetryMigrations.all.last(), TelemetryMigrations.MIGRATION_47_48) + } + + @Test + fun aCalibrationIsKeyedOnTheAccessoryAndTheCapability() { + val create = migrationSql(TelemetryMigrations.MIGRATION_44_45).single() + // The composite key is the point: one unit may declare a nose sensor and a tail sensor, and + // they cannot share near/far distances or a correction direction. Keying on the Accessory alone + // would make the second one overwrite the first. + assertTrue(create, create.contains("PRIMARY KEY(accessory_id, capability_id)")) + for (column in listOf( + "accessory_id TEXT NOT NULL", + "capability_id TEXT NOT NULL", + "near_cm REAL NOT NULL", + "far_cm REAL NOT NULL", + "direction TEXT NOT NULL", + "strength_percent INTEGER NOT NULL", + "updated_at INTEGER NOT NULL", + )) { + assertTrue("missing `$column`", create.contains(column)) + } + // Same reconciliation reason as the Accessories table: a database restored from iOS already + // holds it under the GRDB migration id. + assertTrue(create, create.contains("CREATE TABLE IF NOT EXISTS accessory_ground_clearance")) + } + + @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/RemoteInputArbiter.swift b/modules/vescape-core/ios/RemoteInputArbiter.swift new file mode 100644 index 00000000..2505fe43 --- /dev/null +++ b/modules/vescape-core/ios/RemoteInputArbiter.swift @@ -0,0 +1,276 @@ +import Foundation + +/// Who is allowed to write the board's remote-input slot. +/// +/// Refloat has one temporary remote input and three things in this app want it: the rider's tilt +/// pad, Board Move, and a ground-clearance Accessory. Before this existed they were three writers +/// with no referee, which is fine only for as long as no two of them are active at once. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/RemoteInputArbiter.kt `RemoteInputOwner` +/// @parity /modules/vescape-core/src/index.ts `RemoteTiltOwner` +internal enum RemoteInputOwner: String { + /// Nothing is streaming. The board's input lapses on its own ~1s after the last write. + case none + /// The rider's pad, through the bridge. + case manual + /// A calibrated ground-clearance Accessory, through the Board Session's own tick. + case sensor + /// Board Move: motor output on a disengaged board, which shares the same slot. + case move + + var wire: String { rawValue } +} + +/// How fast a sensor-driven correction is allowed to move. +/// +/// The same bound `RemoteTiltController` eases a cancel at, and for the same reason: a +/// self-balancing board answers a step change in commanded tilt with a surge. Nothing about the +/// source makes the step safer — a pothole under the sensor produces a full-range swing in one 20 Hz +/// sample, and a binding arming mid-ride produces one in a single tick. +/// +/// Steady state is unaffected: the commanded value converges on the reading and then follows it. +/// Only the rate of change is bounded, so "linear, clamped, direction-aware correction that follows +/// the readings" is still exactly what the board is told. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/RemoteInputArbiter.kt `SENSOR_TILT_SLEW_FULL_RANGE_MS` +private let SENSOR_TILT_SLEW_FULL_RANGE_MS = REMOTE_TILT_CANCEL_FULL_RANGE_MS + +/// The one writer of the Board's remote-input slot. +/// +/// Every path that commands tilt or movement goes through here, so the question "who is driving the +/// board right now" has one answer held in one place instead of being inferred from three +/// controllers' private state. `BoardSessionController` owns the instance; nothing else constructs +/// one. +/// +/// Two rules carry the safety of this slice: +/// +/// - **Board Move displaces a tilt stream, a sensor does not yield to Board Move.** Jogging a board +/// is a parked-board command, so a sensor actively correcting a ridden board refuses it outright; +/// anything else holding the slot (a rider's tilt, or a sensor release still easing down) is +/// dropped to neutral first. Letting a pending decay keep writing while a move streams is the two +/// of them fighting over one byte. +/// - **A sensor never steps.** `sensorDrive` eases toward its target at +/// `SENSOR_TILT_SLEW_FULL_RANGE_MS`, so neither arming nor a discontinuous reading can hand the +/// firmware an instant full-range angle error. +/// +/// Releasing is deliberately the existing `RemoteTiltController.cancel()` — the smooth return the +/// pad already uses — and it is invoked exactly once per engaged→released transition. Calling it +/// every tick would restart the ease from a smaller value each time and never arrive. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/RemoteInputArbiter.kt +internal final class RemoteInputArbiter { + private let tilt: RemoteTiltController + private let move: BoardMoveController + private let nowMs: () -> Int64 + + /// Whether a configured ground-clearance Accessory is connected. + /// + /// Not the same question as "is the sensor commanding right now". A binding that is bound but + /// waiting — parked, or between readings — owns nothing, and without this the slot would look free + /// to a manual command that arrives in that window. A manual *lock* taken there never ends on its + /// own and the pad is read-only, so the rider has no way to give it back: the binding would be + /// refused for the rest of the session. + private let sensorBound: () -> Bool + + /// Who started the tilt stream that is currently running. Meaningless once it ends, which is why + /// `owner` consults the stream itself rather than trusting this. + private var tiltOwner: RemoteInputOwner = .none + + /// Whether the sensor is currently commanding, as opposed to having been released. + private var sensorEngaged = false + + /// Last commanded sensor value and when it was commanded, for the slew limit. + private var sensorValue = REMOTE_TILT_CENTER + private var sensorAtMs: Int64 = 0 + + init( + tilt: RemoteTiltController, + move: BoardMoveController, + nowMs: @escaping () -> Int64, + sensorBound: @escaping () -> Bool = { false } + ) { + self.tilt = tilt + self.move = move + self.nowMs = nowMs + self.sensorBound = sensorBound + } + + /// Who holds the slot. + /// + /// Derived, never remembered: a tilt stream that reached neutral has released the slot whether or + /// not anyone told this class about it, and a Board Move that stopped has done the same. A + /// remembered owner would survive its own stream and lock the slot against everything else. + var owner: RemoteInputOwner { + if move.isMoving { return .move } + if tilt.phase == .idle { return .none } + return tiltOwner + } + + /// The value the sensor is currently commanding, for tests and for what JS renders. + var sensorCommand: Int { sensorEngaged ? sensorValue : REMOTE_TILT_CENTER } + + // MARK: - The rider's pad + + /// Manual tilt is refused while anything else holds the slot. + /// + /// The pad is also made read-only in JS while a ground-clearance binding is bound, but that is + /// presentation. This is the rule: a bridge call that arrives anyway — a stale render, a + /// mid-flight gesture, a JS bundle that disagrees — commands nothing. + @discardableResult + func manualHold(_ value: Int) -> Bool { claimManual { self.tilt.hold(value) } } + + @discardableResult + func manualLock(_ value: Int) -> Bool { claimManual { self.tilt.lock(value) } } + + @discardableResult + func manualRelease(_ value: Int, durationMs: Int64) -> Bool { + claimManual { self.tilt.release(value, durationMs: durationMs) } + } + + private func claimManual(_ start: () -> Bool) -> Bool { + // Asked before ownership, because a bound binding that is not currently driving leaves the slot + // unowned and would otherwise let a manual command in. + if sensorBound() { return false } + switch owner { + case .sensor, .move: return false + case .none, .manual: break + } + let started = start() + if started { tiltOwner = .manual } + return started + } + + /// Ease whatever is commanded back to neutral, whoever commanded it. + /// + /// Ungated on purpose. Cancel is the rider's way out and must survive a link that lost trust + /// mid-hold; that has always been true of the pad's cancel and stays true with a sensor in the + /// picture. A sensor still holding valid readings simply re-engages on its next tick, ramped — + /// the cancel is not an off switch for the binding, and does not pretend to be one. + @discardableResult + func cancelTilt() -> Bool { + _ = releaseSensor() + return tilt.cancel() + } + + /// Hand the slot back from the rider so a binding that just armed can take it. + /// + /// A manual lock never ends on its own, so a binding arming under one would wait forever. Arming + /// is exactly the moment the pad stops being the rider's, so the held value stops being theirs + /// too — eased down, never snapped. + /// + /// Safe to call on every tick, which is how the binding calls it: an ease already running is left + /// alone. Re-cancelling a decay would restart it from a smaller value each time and never arrive, + /// and one failed cancel — a transport that blinked — must not strand the lock forever. + @discardableResult + func releaseManual() -> Bool { + guard owner == .manual else { return false } + guard tilt.phase != .decaying else { return false } + return tilt.cancel() + } + + // MARK: - Ground-clearance sensor + + /// Command one sensor-derived tilt value, rate-limited. + /// + /// Returns false when the slot belongs to something else, so the caller can say which reason the + /// rider is looking at. A refusal leaves the sensor disengaged: it does not queue. + @discardableResult + func sensorDrive(_ target: Int) -> Bool { + switch owner { + case .move, .manual: + sensorEngaged = false + return false + case .none, .sensor: + break + } + let now = nowMs() + // The ramp is measured from what the board is actually being told, which is not always neutral + // at a fresh engage: a release still easing down — this binding's own, or the rider's cancel — + // is a live stream holding a real value. Starting from neutral there would step the commanded + // tilt by the whole of the unfinished decay in one write, which is exactly the snap the slew + // limit exists to prevent. + let from: Int + if sensorEngaged { + from = sensorValue + } else if tilt.phase != .idle { + from = tilt.currentValue + } else { + from = REMOTE_TILT_CENTER + } + let elapsed = sensorEngaged ? max(0, now - sensorAtMs) : 0 + let next = slew(from: from, target: min(max(target, 0), 255), elapsedMs: elapsed) + sensorEngaged = true + sensorValue = next + sensorAtMs = now + tiltOwner = .sensor + return tilt.hold(next) + } + + /// Let go of a sensor-driven tilt through the pad's own smooth return. + /// + /// Idempotent by design: the Board Session calls this on every tick it has no valid reading, and + /// only the first one after an engagement actually cancels. + @discardableResult + func sensorRelease() -> Bool { + guard releaseSensor() else { return false } + guard owner == .sensor else { return false } + return tilt.cancel() + } + + /// Clears the engaged flag and says whether it had been set. + private func releaseSensor() -> Bool { + guard sensorEngaged else { return false } + sensorEngaged = false + sensorValue = REMOTE_TILT_CENTER + return true + } + + // MARK: - Board Move + + /// Start a Board Move, taking the slot from any tilt stream that still holds it. + /// + /// The displaced stream is dropped to neutral rather than eased, because the board a Move is meant + /// for is a disengaged one: there is no rider on it for a step to throw, and easing would mean up + /// to `REMOTE_TILT_CANCEL_FULL_RANGE_MS` of tilt packets interleaved with move packets. A sensor + /// actively correcting says the board *is* being ridden, so that case refuses instead. + @discardableResult + func startMove(_ input: Int) -> Bool { + // A sensor that is *still correcting* says the board is being ridden. One that has already let + // go leaves only the decay tail — and that tail outliving the move is the exact failure this is + // here to prevent, so it gets dropped rather than deferred to. + if sensorEngaged { return false } + if tilt.phase != .idle { _ = tilt.stop() } + _ = releaseSensor() + tiltOwner = .none + return move.hold(input) + } + + /// Deliberately ungated, exactly as before: a stop must reach the board whatever else is true. + @discardableResult + func stopMove() -> Bool { move.stop() } + + // MARK: - Teardown + + /// Immediate neutral on both channels. Session teardown only. + func reset() { + _ = releaseSensor() + tiltOwner = .none + _ = tilt.stop() + _ = move.stop() + } + + /// One step of the slew limit: at most a full range per `SENSOR_TILT_SLEW_FULL_RANGE_MS`. + /// + /// `elapsedMs` is the real gap since the last command rather than an assumed tick, so a tick that + /// ran late is allowed the movement it was owed instead of stretching the ramp. + private func slew(from: Int, target: Int, elapsedMs: Int64) -> Int { + let distance = abs(target - from) + if distance == 0 { return target } + let fullRange = Int64(255 - REMOTE_TILT_CENTER) + let allowed = Int(fullRange * elapsedMs / SENSOR_TILT_SLEW_FULL_RANGE_MS) + // Never zero: a tick short enough to round the allowance away would freeze the command rather + // than slow it, and the binding would sit at whatever it first commanded. + let step = min(distance, max(1, allowed)) + return target > from ? from + step : from - step + } +} diff --git a/modules/vescape-core/ios/RemoteInputArbiterTests.swift b/modules/vescape-core/ios/RemoteInputArbiterTests.swift new file mode 100644 index 00000000..01f619d2 --- /dev/null +++ b/modules/vescape-core/ios/RemoteInputArbiterTests.swift @@ -0,0 +1,357 @@ +import XCTest + +@testable import VescapeCore + +/// Who is allowed to write the Board's remote-input slot, and what happens at every handover. +/// +/// These are the ownership regressions #479 asks for. The failures they describe are all the same +/// shape: two of the three writers active at once, each repeating its own value on its own tick, so +/// the Board receives an alternating stream and does neither thing. On a ridden Board that is not a +/// glitch, it is a rider on the floor — which is why the interesting assertions here are about what +/// is *not* sent. +/// +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/RemoteInputArbiterTest.kt +final class RemoteInputArbiterTests: XCTestCase { + private var sent: [[UInt8]] = [] + private var transport: BoardTransport? = .direct + private var canMove = true + private var sensorBound = false + private var scheduler = TestScheduler() + private var tilt: RemoteTiltController! + private var move: BoardMoveController! + private var arbiter: RemoteInputArbiter! + + override func setUp() { + super.setUp() + sent = [] + transport = .direct + canMove = true + scheduler = TestScheduler() + tilt = RemoteTiltController( + transport: { self.transport }, + send: { payload, _ in + self.sent.append(payload) + return true + }, + scheduler: scheduler + ) + move = BoardMoveController( + transport: { self.transport }, + canMove: { self.canMove }, + generation: { .remote }, + send: { payload, _ in + self.sent.append(payload) + return true + }, + scheduler: scheduler + ) + sensorBound = false + arbiter = RemoteInputArbiter( + tilt: tilt, + move: move, + nowMs: { self.scheduler.currentTimeMs }, + sensorBound: { self.sensorBound } + ) + } + + private func tiltPacket(_ value: Int) -> [UInt8] { + buildRemoteTiltCommand(transport: .direct, value: value) + } + + private func movePacket(_ input: Int) -> [UInt8] { + buildBoardMoveCommand(transport: .direct, generation: .remote, input: input) + } + + /// Drives the sensor at full strength for long enough that the slew limit is no longer the story. + private func settleSensor(at target: Int, file: StaticString = #filePath, line: UInt = #line) { + for _ in 0..<12 { + _ = arbiter.sensorDrive(target) + scheduler.advance(100) + } + XCTAssertEqual(arbiter.sensorCommand, target, file: file, line: line) + } + + func testSensorRampsToItsTargetInsteadOfSteppingToIt() { + // A pothole under the sensor produces a full-range swing in one sample. Handing that to the + // firmware as a single step is the same angle error a snapped cancel would be. + _ = arbiter.sensorDrive(255) + let first = arbiter.sensorCommand + XCTAssertGreaterThan(first, REMOTE_TILT_CENTER, "first command must leave neutral") + XCTAssertLessThan(first, 255, "first command must not be the full swing") + + settleSensor(at: 255) + } + + func testSensorFollowsItsReadingsOnceRamped() { + settleSensor(at: 200) + + // Small changes inside the slew allowance land exactly, so steady tracking is not distorted. + scheduler.advance(100) + _ = arbiter.sensorDrive(198) + XCTAssertEqual(arbiter.sensorCommand, 198) + } + + func testManualTiltIsRefusedWhileTheSensorIsDriving() { + settleSensor(at: 200) + let before = sent.count + + XCTAssertFalse(arbiter.manualHold(40)) + XCTAssertFalse(arbiter.manualLock(40)) + XCTAssertFalse(arbiter.manualRelease(40, durationMs: 1_000)) + XCTAssertEqual(sent.count, before, "a refused manual command writes nothing") + XCTAssertEqual(arbiter.owner, .sensor) + } + + func testSensorIsRefusedWhileBoardMoveHoldsTheSlot() { + XCTAssertTrue(arbiter.startMove(BOARD_MOVE_INPUT_MAX)) + sent.removeAll() + + XCTAssertFalse(arbiter.sensorDrive(255)) + XCTAssertEqual(arbiter.owner, .move) + + // Nothing but move packets reach the board while it is jogging. + scheduler.advance(300) + XCTAssertFalse(sent.isEmpty) + XCTAssertTrue(sent.allSatisfy { $0 == movePacket(BOARD_MOVE_INPUT_MAX) }) + } + + func testBoardMoveIsNotOverwrittenByAPendingSensorDecay() { + settleSensor(at: 255) + // The rider steps off: the binding releases and the smooth return starts. + _ = arbiter.sensorRelease() + XCTAssertEqual(tilt.phase, .decaying) + + // Board Move, requested while that return is still easing down. + sent.removeAll() + XCTAssertTrue(arbiter.startMove(-BOARD_MOVE_INPUT_MAX)) + XCTAssertEqual(arbiter.owner, .move) + + // One neutral tilt hands the slot back, and after it the board hears nothing but the move. + XCTAssertEqual(sent.first, tiltPacket(REMOTE_TILT_CENTER)) + scheduler.advance(600) + let afterHandover = Array(sent.dropFirst()) + XCTAssertFalse(afterHandover.isEmpty) + XCTAssertTrue( + afterHandover.allSatisfy { $0 == movePacket(-BOARD_MOVE_INPUT_MAX) }, + "a pending decay must not keep writing over Board Move" + ) + } + + func testBoardMoveIsRefusedWhileTheSensorIsCorrecting() { + settleSensor(at: 220) + sent.removeAll() + + // A board asking for ground-clearance correction is a board being ridden, and jogging one is not + // a request this app passes on. + XCTAssertFalse(arbiter.startMove(BOARD_MOVE_INPUT_MAX)) + XCTAssertEqual(arbiter.owner, .sensor) + XCTAssertFalse(sent.contains(movePacket(BOARD_MOVE_INPUT_MAX))) + } + + func testSensorReleaseEasesOutOnceRatherThanRestartingEveryTick() { + settleSensor(at: 255) + + XCTAssertTrue(arbiter.sensorRelease()) + let total = tilt.decayProgress?.totalMs + XCTAssertEqual(total, 600) + + // The Board Session calls this on every tick it has no valid reading. Only the first cancels; a + // repeat would re-ease from a smaller value and the return would never arrive. + scheduler.advance(200) + XCTAssertFalse(arbiter.sensorRelease()) + XCTAssertEqual(tilt.decayProgress?.totalMs, total) + + scheduler.advance(400) + XCTAssertEqual(tilt.phase, .idle) + XCTAssertEqual(arbiter.owner, RemoteInputOwner.none) + XCTAssertEqual(sent.last, tiltPacket(REMOTE_TILT_CENTER)) + } + + func testABindingArmingTakesBackALockedManualTilt() { + XCTAssertTrue(arbiter.manualLock(255)) + XCTAssertEqual(arbiter.owner, .manual) + + // A lock never ends on its own, so without this the binding would wait for the slot forever. + XCTAssertTrue(arbiter.releaseManual()) + XCTAssertEqual(tilt.phase, .decaying) + scheduler.advance(600) + XCTAssertEqual(arbiter.owner, RemoteInputOwner.none) + + XCTAssertTrue(arbiter.sensorDrive(200)) + XCTAssertEqual(arbiter.owner, .sensor) + } + + func testManualTiltKeepsItsSlotWhileNoSensorIsDriving() { + XCTAssertTrue(arbiter.manualHold(200)) + XCTAssertEqual(arbiter.owner, .manual) + XCTAssertTrue(arbiter.manualHold(210)) + XCTAssertEqual(sent.first, tiltPacket(200)) + } + + func testCancelReleasesWhoeverHeldTheSlot() { + settleSensor(at: 255) + + XCTAssertTrue(arbiter.cancelTilt()) + XCTAssertEqual(tilt.phase, .decaying) + // Cancel is not an off switch for the binding: a sensor still holding valid readings takes the + // slot back on its next tick, ramped from where the cancel left it. + XCTAssertEqual(arbiter.sensorCommand, REMOTE_TILT_CENTER) + scheduler.advance(100) + let eased = tilt.currentValue + XCTAssertTrue(eased > 0 && eased < 255, "the cancel must have eased some of the tilt off") + XCTAssertTrue(arbiter.sensorDrive(255)) + XCTAssertGreaterThanOrEqual( + arbiter.sensorCommand, eased, "re-engaging must resume from the eased value, never step") + XCTAssertNotEqual(arbiter.sensorCommand, 255) + } + + func testASensorReEngagingMidReleaseResumesFromTheStreamRatherThanNeutral() { + settleSensor(at: 255) + // One bad reading releases; the reading after it is good again, which is an ordinary minute of + // riding past a puddle, not an exotic case. + XCTAssertTrue(arbiter.sensorRelease()) + scheduler.advance(200) + let eased = tilt.currentValue + XCTAssertTrue(eased > 0 && eased < 255, "the release must have eased some of the tilt off") + + sent = [] + XCTAssertTrue(arbiter.sensorDrive(255)) + scheduler.advance(100) + // Resuming from neutral here would hand the firmware the whole unfinished decay as one step — a + // ~100-count drop on a board with a rider on it, which is the surge a snapped cancel would cause + // and the reason nothing in this class is allowed to step. + XCTAssertGreaterThanOrEqual( + arbiter.sensorCommand, eased, "re-engage must not step down to neutral") + XCTAssertFalse(sent.isEmpty) + XCTAssertFalse( + sent.contains(tiltPacket(REMOTE_TILT_CENTER)), + "no packet may drop the commanded tilt back toward neutral") + } + + func testManualTiltIsRefusedWhileABindingIsBoundEvenWithTheSlotFree() { + // Bound but not driving: parked, or between readings. The slot is genuinely free, and without + // the bound check a manual *lock* taken here would never end on its own — and the pad is + // read-only by then, so the rider has no Cancel to press. + sensorBound = true + XCTAssertEqual(arbiter.owner, RemoteInputOwner.none) + + XCTAssertFalse(arbiter.manualHold(200)) + XCTAssertFalse(arbiter.manualLock(200)) + XCTAssertFalse(arbiter.manualRelease(200, durationMs: 1_000)) + XCTAssertTrue(sent.isEmpty, "a refused manual command writes nothing") + XCTAssertEqual(arbiter.owner, RemoteInputOwner.none) + + // The binding can still take the slot it was holding open. + XCTAssertTrue(arbiter.sensorDrive(200)) + XCTAssertEqual(arbiter.owner, .sensor) + } + + func testABoundBindingKeepsReleasingAManualTiltItDidNotCatchWhenItArmed() { + // A lock taken in the window before the pad learned it was read-only, or one whose arming-time + // cancel failed on a transport that blinked. + XCTAssertTrue(arbiter.manualLock(255)) + XCTAssertEqual(arbiter.owner, .manual) + sensorBound = true + + let binding = BoardGroundClearanceBinding( + remoteInput: arbiter, + boundInput: { true }, + tiltInput: { .drive(tiltInput: 1.0, valueCm: 5.0) }) + let board = BoardGroundClearanceBinding.BoardInput(commandsTrusted: true, telemetryFresh: true) + + // Already bound on the first tick, so there is no unbound→bound transition to catch it. + binding.tick(board) + XCTAssertEqual(binding.state()["release"] as? String, "manual-tilt") + XCTAssertEqual(tilt.phase, .decaying) + let total = tilt.decayProgress?.totalMs + + // Repeating the release must not restart the ease, or it would shrink toward zero forever. + scheduler.advance(200) + binding.tick(board) + XCTAssertEqual(tilt.decayProgress?.totalMs, total) + + scheduler.advance(600) + binding.tick(board) + XCTAssertEqual(arbiter.owner, .sensor, "the binding takes the slot once the ease finishes") + XCTAssertNil(binding.state()["release"] as? String) + } + + func testResetLeavesNothingStreamingOnEitherChannel() { + settleSensor(at: 255) + arbiter.reset() + + XCTAssertEqual(arbiter.owner, RemoteInputOwner.none) + XCTAssertEqual(sent[sent.count - 2], tiltPacket(REMOTE_TILT_CENTER)) + XCTAssertEqual(sent.last, movePacket(0)) + + scheduler.advance(1_000) + let afterReset = sent.count + scheduler.advance(1_000) + XCTAssertEqual(sent.count, afterReset, "nothing repeats after a reset") + } + + func testALostTransportEndsTheSensorStreamRatherThanHoldingItsLastValue() { + settleSensor(at: 255) + transport = nil + + // The repeat loop is the sole sender; with no transport it clears itself, and the arbiter's + // derived owner follows the stream rather than remembering a claim it can no longer serve. + scheduler.advance(100) + XCTAssertEqual(tilt.phase, .idle) + XCTAssertEqual(arbiter.owner, RemoteInputOwner.none) + } + + func testCorrectionMapsOntoThePadsOwnScaleAndSaturates() { + XCTAssertEqual(GroundClearance.tiltCommand(tiltInput: 0), REMOTE_TILT_CENTER) + XCTAssertEqual(GroundClearance.tiltCommand(tiltInput: 1), 255) + XCTAssertEqual(GroundClearance.tiltCommand(tiltInput: -1), 1) + // Nothing upstream can produce these; the one place that decides what a board is told is not + // where to find that out. + XCTAssertEqual(GroundClearance.tiltCommand(tiltInput: 4), 255) + XCTAssertEqual(GroundClearance.tiltCommand(tiltInput: -4), 1) + XCTAssertEqual(GroundClearance.tiltCommand(tiltInput: .nan), REMOTE_TILT_CENTER) + } + + func testBoardBindingOwnsTickCancellationAndBoardReasonPrecedence() { + var sourceReads = 0 + let binding = BoardGroundClearanceBinding( + remoteInput: arbiter, + boundInput: { + sourceReads += 1 + return true + }, + tiltInput: { .drive(tiltInput: 1, valueCm: 5) }) + let schedule = { (tick: @escaping () -> Void) -> Cancellable in + self.scheduler.postDelayed(BoardGroundClearanceBinding.tickMs, tick) + } + + binding.start(schedule: schedule) { + BoardGroundClearanceBinding.BoardInput(commandsTrusted: false, telemetryFresh: false) + } + scheduler.advance(BoardGroundClearanceBinding.tickMs) + XCTAssertEqual(binding.state()["release"] as? String, "board-untrusted") + XCTAssertEqual(arbiter.owner, .none) + + binding.stop() + let readsAfterStop = sourceReads + binding.start(schedule: schedule) { + BoardGroundClearanceBinding.BoardInput(commandsTrusted: true, telemetryFresh: false) + } + scheduler.advance(BoardGroundClearanceBinding.tickMs) + XCTAssertEqual(binding.state()["release"] as? String, "board-stale") + XCTAssertEqual(sourceReads, readsAfterStop + 1) + + binding.stop() + binding.start(schedule: schedule) { + BoardGroundClearanceBinding.BoardInput(commandsTrusted: true, telemetryFresh: true) + } + scheduler.advance(BoardGroundClearanceBinding.tickMs * 2) + XCTAssertEqual(arbiter.owner, .sensor) + binding.stop() + XCTAssertEqual(arbiter.sensorCommand, REMOTE_TILT_CENTER) + XCTAssertEqual(tilt.phase, .decaying) + let finalReads = sourceReads + scheduler.advance(BoardGroundClearanceBinding.tickMs * 2) + XCTAssertEqual(sourceReads, finalReads, "a stopped session cannot receive its old callback") + } +} diff --git a/modules/vescape-core/ios/RemoteTiltController.swift b/modules/vescape-core/ios/RemoteTiltController.swift index b4279a0e..413de67a 100644 --- a/modules/vescape-core/ios/RemoteTiltController.swift +++ b/modules/vescape-core/ios/RemoteTiltController.swift @@ -15,7 +15,7 @@ private let REMOTE_TILT_REPEAT_MS: Int64 = 100 /// Cancel therefore eases at a bounded rate instead of snapping. /// /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/RemoteTiltController.kt `REMOTE_TILT_CANCEL_FULL_RANGE_MS` -private let REMOTE_TILT_CANCEL_FULL_RANGE_MS: Int64 = 600 +internal let REMOTE_TILT_CANCEL_FULL_RANGE_MS: Int64 = 600 /// @parity /modules/vescape-core/src/index.ts `RemoteTiltPhase` /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/RemoteTiltController.kt `RemoteTiltPhase` diff --git a/modules/vescape-core/ios/VescapeCoreModule.swift b/modules/vescape-core/ios/VescapeCoreModule.swift index 304ba852..2a8f06b1 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", "onAccessoryState", "onAccessoryReading") // 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,31 @@ 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") } + OnStartObserving("onAccessoryState") { self.observedEvents.insert("onAccessoryState") } + OnStopObserving("onAccessoryState") { self.observedEvents.remove("onAccessoryState") } + OnStartObserving("onAccessoryReading") { self.observedEvents.insert("onAccessoryReading") } + OnStopObserving("onAccessoryReading") { self.observedEvents.remove("onAccessoryReading") } 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) + } + + // 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()) @@ -269,6 +292,16 @@ public class VescapeCoreModule: Module { self.observedEvents.removeAll() self.cancelActiveProbe(reason: "module_destroyed") self.stopAlertTest() + 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 + // A preview is the one piece of demand JS owns, so it dies with JS. Without this a runtime + // that reloaded or crashed with the sensor screen open would leave the accessory measuring + // with nobody watching, and native's own renewals would keep the lease alive forever. + AccessorySessionController.shared.releasePreviews() } // MARK: Scan @@ -281,6 +314,89 @@ 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) } + } + + // 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) } + } + + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `saveBrakeLightSettings` + // @parity /modules/vescape-core/src/index.ts `saveBrakeLightSettings` + AsyncFunction("saveBrakeLightSettings") { (accessoryId: String, capabilityId: String, sensitivity: Int, parked: String, promise: Promise) in + AccessorySessionController.shared.saveBrakeLight(accessoryId: accessoryId, capabilityId: capabilityId, sensitivity: sensitivity, parked: parked) { promise.resolve($0) } + } + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `setAccessoryCapabilityEnabled` + // @parity /modules/vescape-core/src/index.ts `setAccessoryCapabilityEnabled` + AsyncFunction("setAccessoryCapabilityEnabled") { (accessoryId: String, capabilityId: String, enabled: Bool, promise: Promise) in + AccessorySessionController.shared.setCapabilityEnabled(accessoryId: accessoryId, capabilityId: capabilityId, enabled: enabled) { promise.resolve($0) } + } + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `setAccessorySamplingRate` + // @parity /modules/vescape-core/src/index.ts `setAccessorySamplingRate` + AsyncFunction("setAccessorySamplingRate") { (accessoryId: String, capabilityId: String, rateHz: Double, promise: Promise) in + AccessorySessionController.shared.setSamplingRate(accessoryId: accessoryId, capabilityId: capabilityId, rateHz: rateHz) { promise.resolve($0) } + } + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `setBrakeLightPreview` + // @parity /modules/vescape-core/src/index.ts `setBrakeLightPreview` + AsyncFunction("setBrakeLightPreview") { (accessoryId: String, capabilityId: String, mode: String?, promise: Promise) in + AccessorySessionController.shared.setLightPreview(accessoryId: accessoryId, capabilityId: capabilityId, mode: mode) { promise.resolve($0) } + } + Function("getAccessories") { + AccessorySessionController.shared.snapshot() + } + + // Ground clearance. JS asks for measurements and offers numbers; native decides whether the + // sensor runs and whether the numbers are a calibration. + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `setAccessoryPreview` + // @parity /modules/vescape-core/src/index.ts `setAccessoryPreview` + Function("setAccessoryPreview") { (accessoryId: String, capabilityId: String, open: Bool) in + AccessorySessionController.shared.setPreview( + accessoryId: accessoryId, capabilityId: capabilityId, open: open) + } + + AsyncFunction("saveGroundClearanceCalibration") { + ( + accessoryId: String, capabilityId: String, nearCm: Double, farCm: Double, direction: String, + strengthPercent: Int, promise: Promise + ) in + AccessorySessionController.shared.saveGroundClearance( + accessoryId: accessoryId, capabilityId: capabilityId, nearCm: nearCm, farCm: farCm, + direction: direction, strengthPercent: strengthPercent) { promise.resolve($0) } + } + + AsyncFunction("clearGroundClearanceCalibration") { + (accessoryId: String, capabilityId: String, promise: Promise) in + AccessorySessionController.shared.clearGroundClearance( + accessoryId: accessoryId, capabilityId: capabilityId) { promise.resolve($0) } + } + // MARK: Location Function("startLocationUpdates") { @@ -478,6 +594,11 @@ public class VescapeCoreModule: Module { self.liveState() } + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `getGroundClearanceTilt` + // @parity /modules/vescape-core/src/index.ts `getGroundClearanceTilt` + AsyncFunction("getGroundClearanceTilt") { () -> [String: Any?] in + self.coordinator.groundClearanceTiltState() + }.runOnQueue(.main) // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `getRemoteTiltState` // @parity /modules/vescape-core/src/index.ts `getRemoteTiltState` AsyncFunction("getRemoteTiltState") { () -> [String: Any?]? in diff --git a/modules/vescape-core/ios/accessory/AccessoryDiscovery.swift b/modules/vescape-core/ios/accessory/AccessoryDiscovery.swift new file mode 100644 index 00000000..bd8b461e --- /dev/null +++ b/modules/vescape-core/ios/accessory/AccessoryDiscovery.swift @@ -0,0 +1,319 @@ +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)? + /// 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() { + 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() + } + } + + func 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) { + 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() } + + 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) + } + } + + /// Abandons an inspection the rider walked away from. The caller still gets its one answer. + func cancelInspection() { + 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( + 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 { + // `.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"]) + } + // 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( + 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.. Void + private let onManifest: (AccessoryManifest, String) -> Void + /// One accepted sample off the reading stream, with the monotonic time it landed. + /// + /// Handed over rather than buffered here: this class owns the radio and the session, and what a + /// distance *means* belongs to the capability that declared it. + private let onReading: (AccessoryReading, TimeInterval) -> Void + /// The protocol session this link held is gone. + /// + /// Fired on every path that clears `sessionId`, because sequence numbers restart with the next + /// hello: a tracker still holding the old session's newest sample would refuse the new session's + /// first ones as duplicates, and a screen would show a distance measured before the accessory + /// rebooted. + private let onSessionLost: () -> Void + + /// What each capability last said it actually applied. + /// + /// The accessory resolves the requested rate against its own list and answers with the one it + /// runs at, which is not always the one asked for. Anything derived from the sample cadence — the + /// missing-stream window above all — has to use the rate the hardware confirmed, not the rate the + /// app hoped for. + private var appliedByCapability: [String: [String: String]] = [:] + + /// Rate the accessory acknowledged for `capabilityId`, or nil before its first ack. + func appliedRateHz(_ capabilityId: String) -> Double? { + guard let text = appliedByCapability[capabilityId]?["rateHz"], let rate = Double(text), + rate.isFinite, rate > 0 + else { return nil } + return rate + } + + 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] = [:] + + /// When each capability's command was last put on the wire, and which ones changed since. + /// + /// Without these the pump would re-send the moment an ack arrived, turning a 500 ms renewal into + /// a continuous command loop at BLE round-trip rate — the accessory's radio never idles and the + /// lease is renewed twenty times more often than it needs to be. + private var lastSentAt: [String: TimeInterval] = [:] + private var dirty: Set = [] + + 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, + onReading: @escaping (AccessoryReading, TimeInterval) -> Void = { _, _ in }, + onSessionLost: @escaping () -> Void = {} + ) { + self.accessoryId = accessoryId + self.central = central + self.onChanged = onChanged + self.onManifest = onManifest + self.onReading = onReading + self.onSessionLost = onSessionLost + } + + var deviceId: String? { peripheral?.identifier.uuidString } + + /// Starts, or re-points at a newly discovered peripheral. Idempotent. + func start(peripheral: CBPeripheral?) { + let movedHandle = peripheral != nil && peripheral?.identifier != self.peripheral?.identifier + if movedHandle { self.peripheral = peripheral } + guard !started || movedHandle else { return } + started = true + // A different peripheral cannot be reached through the old connection, so it is torn down and + // a new one opened. Returning here instead would leave the link armed with no connection and + // no retry — the state this branch exists to avoid. + if movedHandle { + let next = self.peripheral + teardown() + self.peripheral = next + } + connect() + } + + /// The radio became usable. + /// + /// A link whose first connect was refused because Bluetooth was off is armed but idle, and + /// `start` will not take it any further — it is already started. This is the moment it can + /// proceed, and CoreBluetooth reports it straight to the controller's central delegate. + /// + /// @platform-diff Android has no equivalent hook here and re-asks on its retry timer instead. + func onRadioAvailable() { + guard started, peripheral != nil, phase == .connecting || phase == .idle else { return } + retryWork?.cancel() + retryWork = nil + 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 + dirty.insert(command.capabilityId) + // 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") + // Armed, not abandoned: `onRadioAvailable` normally picks this up, and the retry is the + // backstop for a state change that never arrives. + scheduleRetry() + 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 + // Nothing an old session applied describes this one. A rate remembered across a reconnect would + // set the stale window for a stream the accessory has not agreed to send yet. + appliedByCapability.removeAll() + onSessionLost() + 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 + appliedByCapability.removeAll() + onSessionLost() + 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() + // A fresh session has applied nothing, so every desired command is owed again immediately. + lastSentAt.removeAll() + dirty.formUnion(desiredOrder) + 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, let capabilityId, _, let applied): + guard let pending = outstanding, pending.requestId == requestId else { return } + requestTimeoutWork?.cancel(); requestTimeoutWork = nil + outstanding = nil + // Recorded before the phase change, because this is what the accessory says it is actually + // doing — not what the app asked for. The rate here is the one the stale window is measured + // against. + appliedByCapability[capabilityId] = applied + 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 .sample(let reading): + // Readings are unacknowledged and renew nothing. A stream that keeps arriving while commands + // go unanswered must not look like a healthy session, so this deliberately does not touch + // `lastAckAt`, the phase or the pump. + onReading(reading, ProcessInfo.processInfo.systemUptime) + + case .malformed: fail("malformed") + case .ignored: break + } + } + + // MARK: - Request pump + + /// Sends the next capability whose command changed, or whose renewal has come due. + /// + /// Called after every ack as well as on the tick, so a link with several capabilities drains all + /// of their due renewals back to back instead of one per tick — with a 2 s lease and a 500 ms + /// interval, one-per-tick would let the fourth capability's lease lapse. + private func pump() { + guard outstanding == nil, let session = sessionId, let manifest else { return } + let supported = Set(manifest.capabilities.filter(\.supported).map(\.id)) + let now = ProcessInfo.processInfo.systemUptime + let interval = TimeInterval(AccessorySession.renewIntervalMs) / 1000 + guard + let capabilityId = desiredOrder.first(where: { id in + guard supported.contains(id) else { return false } + if dirty.contains(id) { return true } + guard let sent = lastSentAt[id] else { return true } + return now - sent >= interval + }), + 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) + dirty.remove(capabilityId) + lastSentAt[capabilityId] = now + 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.. 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..61dfaed5 --- /dev/null +++ b/modules/vescape-core/ios/accessory/AccessoryProtocol.swift @@ -0,0 +1,340 @@ +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)]}" + } + + /// 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 { + 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 } + 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"]) + + 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() + } + + /// 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 + } + + 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/ios/accessory/AccessorySession.swift b/modules/vescape-core/ios/accessory/AccessorySession.swift new file mode 100644 index 00000000..b3fef9f8 --- /dev/null +++ b/modules/vescape-core/ios/accessory/AccessorySession.swift @@ -0,0 +1,283 @@ +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 a sample says about itself. The status is carried, never inferred. +/// +/// There is no fourth case and no "unknown": a line this app cannot read as a measurement resolves +/// to `error`, because the alternative — quietly treating it as the far end of the range — is a +/// board told it has all the clearance in the world at the exact moment its sensor stopped working. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySession.kt `AccessoryReadingStatus` +/// @parity /modules/vescape-core/src/index.ts `AccessoryReadingStatus` +enum AccessoryReadingStatus: String { + /// A real measurement. The only status that carries a value. + case ok + /// The sensor answered, and the answer is not a distance this capability promises. + case outOfRange = "out_of_range" + /// The sensor could not measure, or the app could not read what it sent. + case error + + /// Whatever a line claimed, as a status this app can act on. + /// + /// A status string from the future is `error` rather than a guess. It cannot be `ok` — that would + /// invent a measurement — and it cannot be `outOfRange` either, which would claim the sensor + /// answered when nobody here knows that it did. + static func fromWire(_ value: String?) -> AccessoryReadingStatus { + guard let value, let known = AccessoryReadingStatus(rawValue: value) else { return .error } + return known + } +} + +/// One sample from a measurement capability. +/// +/// `valueCm` exists **only** when `status` is `.ok`; the initialiser enforces it, so there is no way +/// to hold a reading whose status and value disagree. That invariant is the whole safety property of +/// this slice: a consumer that has a value has a measurement. +/// +/// `sampleTimeMs` is the accessory's own monotonic clock since its session began, never comparable +/// to a phone timestamp. Freshness is judged on local receipt time; this field only orders samples. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySession.kt `AccessoryReading` +/// @parity /modules/vescape-core/src/index.ts `AccessoryReadingEvent` +struct AccessoryReading: Equatable { + let capabilityId: String + let seq: Int + let sampleTimeMs: Int64 + let status: AccessoryReadingStatus + let valueCm: Double? + + init(capabilityId: String, seq: Int, sampleTimeMs: Int64, status: AccessoryReadingStatus, valueCm: Double?) { + self.capabilityId = capabilityId + self.seq = seq + self.sampleTimeMs = sampleTimeMs + self.status = status + // Not a precondition that crashes a background BLE callback: the impossible pairing is resolved + // the only safe way there is, by dropping the value rather than the status. + self.valueCm = status == .ok ? valueCm : nil + } + + /// The same sample judged against the limits the capability declared. + /// + /// A number outside the declared window is reported as out of range rather than clamped into it. + /// Clamping is how a sensor staring at nothing ends up reporting the maximum distance, which is + /// exactly the reading that would tell the board it is safe to tilt. + func withinDeclaredRange(rangeMin: Double?, rangeMax: Double?) -> AccessoryReading { + guard let value = valueCm, let rangeMin, let rangeMax else { return self } + guard value < rangeMin || value > rangeMax else { return self } + return AccessoryReading( + capabilityId: capabilityId, seq: seq, sampleTimeMs: sampleTimeMs, status: .outOfRange, + valueCm: nil) + } +} + +/// 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) + /// One sample off the unacknowledged reading stream. Answers nothing and renews no lease. + case sample(AccessoryReading) + 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) + + case "reading": + return parseReading(root) + + default: + return .ignored + } + } + + /// One sample, or `.ignored` when the envelope is not one. + /// + /// The envelope fields — capability, sequence, sample time — must all be there, because without + /// them a sample cannot be ordered against its neighbours and an unorderable sample is not + /// evidence of anything. The *status* is the opposite: whatever it says, this returns a reading, + /// because "the sensor sent something this app cannot read" is itself information the consumer + /// needs, and dropping it would leave the last good sample standing. + private static func parseReading(_ root: [String: Any]) -> AccessoryResponse { + guard let capabilityId = root["capabilityId"] as? String, !capabilityId.isEmpty, + let seq = wholeNumber(root["seq"]), let sampleTimeMs = wholeNumber(root["sampleTimeMs"]) + else { return .ignored } + let status = AccessoryReadingStatus.fromWire(root["status"] as? String) + // An `ok` is only an `ok` once it produced a finite number. A missing, null or non-numeric + // value demotes the sample to `error` — never to the top of the range. `as? NSNumber` also + // excludes strings, which is what makes `"value":"12.4"` an error rather than a distance. + var value: Double? + if let number = root["value"] as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue.isFinite + { + value = number.doubleValue + } + let resolved: AccessoryReadingStatus = (status == .ok && value == nil) ? .error : status + return .sample( + AccessoryReading( + capabilityId: capabilityId, seq: seq, sampleTimeMs: Int64(sampleTimeMs), status: resolved, + valueCm: resolved == .ok ? value : nil)) + } + + 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..7dd76d95 --- /dev/null +++ b/modules/vescape-core/ios/accessory/AccessorySessionController.swift @@ -0,0 +1,822 @@ +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 = 10 + + 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 capabilityEnabled: [GroundClearanceBindingController.Key: Bool] = [:] + private var samplingRates: [GroundClearanceBindingController.Key: Double] = [:] + private var order: [String] = [] + private var store: AccessoryStore { AccessoryStore.shared } + + /// Live ground-clearance state, one per enrolled capability. + /// + /// Keyed on the Accessory *and* the capability, exactly as the durable row is: one unit may + /// declare a nose sensor and a tail sensor, and they share neither a calibration nor a stream. + private let brakeLight = BrakeLightController() + private var lightExpiry: DispatchWorkItem? + + private let groundClearance = GroundClearanceBindingController( + nowMs: { Int64(ProcessInfo.processInfo.systemUptime * 1000) }) + + /// 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, + // `?? nil` flattens `Any??`: without it the encoder receives a boxed optional rather than + // the array, and every enrollment would record an empty capability set. + capabilitiesJson: Self.encodeCapabilities(manifest["capabilities"] ?? nil), + enrolledAt: Int64(Date().timeIntervalSince1970 * 1000), + lastConnectedAt: nil) + self.onMain { + do { + let stored = try self.store.upsert(row) + self.remember(stored) + 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 } + // The calibrations went with the row in the same transaction; the live runtimes go with them, + // so a re-enrollment starts from "not set up" rather than from whatever this process still + // happened to be holding. + self.groundClearance.forget(accessoryId) + self.brakeLight.forget(accessoryId) + self.capabilityEnabled = self.capabilityEnabled.filter { $0.key.accessoryId != accessoryId } + self.samplingRates = self.samplingRates.filter { $0.key.accessoryId != 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)) + .map { self.describeCapability(row.accessoryId, $0) }, + // Derived from the frozen baseline rather than remembered in memory: a flag held only for + // the life of the process would clear itself on the next launch, which is the one moment + // the rider is least likely to be looking. + "capabilitiesChanged": live.map { + Self.encodeCapabilities($0.capabilities.map { $0.toMap() }) != row.capabilitiesJson + } ?? false, + "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()]) + } + + /// One capability as JS sees it, with whatever this app has saved and decided about it. + /// + /// The saved calibration rides along with the capability rather than in a list of its own: it is + /// keyed on the capability and meaningless without it, and a screen that had to join two arrays by + /// id would be a place for them to disagree. + /// + /// `measuring` is the demand native actually resolved, not a restatement of what the screen asked + /// for — a preview on a capability with no usable rate is a screen that is open and a sensor that + /// is not measuring, and the row should say so. + /// @parity /modules/vescape-core/src/index.ts `AccessoryCapability` + private func describeCapability(_ accessoryId: String, _ capability: [String: Any?]) -> [String: + Any?] + { + guard let capabilityId = capability["id"] as? String else { return capability } + let capability = capability.merging([ + "enabled": isCapabilityEnabled(accessoryId, capabilityId), + "samplingRateHz": links[accessoryId]?.appliedRateHz(capabilityId), + "selectedRateHz": AccessorySession.resolveRateHz( + requested: samplingRates[.init(accessoryId: accessoryId, capabilityId: capabilityId)] ?? Self.preferredRateHz, + ratesHz: capability["ratesHz"] as? [Double] ?? []), + ]) { _, value in value } + if capability["type"] as? String == AccessoryProtocol.typeBrakeLight, + let id = capability["id"] as? String { + return capability.merging(brakeLight.describe(.init(accessoryId: accessoryId, capabilityId: id))) { _, value in value } + } + guard let binding = groundClearance.describe(accessoryId, capabilityId) + else { return capability } + return capability.merging(binding) { _, bindingValue in bindingValue } + } + + 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 + } + do { + // A failed preference read must not silently re-enable disabled hardware. + let settings = try store.capabilitySettings() + capabilityEnabled = Dictionary(uniqueKeysWithValues: settings.map { + (GroundClearanceBindingController.Key(accessoryId: $0.accessoryId, capabilityId: $0.capabilityId), $0.enabled) + }) + samplingRates.removeAll() + for setting in settings { + samplingRates[.init(accessoryId: setting.accessoryId, capabilityId: setting.capabilityId)] = setting.samplingRateHz + } + } catch { + RecordingStorageFailure.reportRead(operation: "accessory_capability_settings", error: error) + return + } + let calibrations: [SavedGroundClearance] + do { + calibrations = try store.groundClearances() + } catch { + // The Accessories still connect. A calibration that could not be read is reported and treated + // as absent, which shows the rider "not set up" rather than driving the board from numbers + // this process never actually saw. + RecordingStorageFailure.reportRead(operation: "accessory_ground_clearance", error: error) + calibrations = [] + } + do { + for settings in try store.brakeLights() { + brakeLight.configure(.init(accessoryId: settings.accessoryId, capabilityId: settings.capabilityId), .init(sensitivity: settings.sensitivity, parked: settings.parked)) + } + } catch { RecordingStorageFailure.reportRead(operation: "accessory_brake_light", error: error) } + saved.removeAll() + order.removeAll() + groundClearance.reset(calibrations.map { row in + ( + GroundClearanceBindingController.Key( + accessoryId: row.accessoryId, capabilityId: row.capabilityId), + GroundClearanceCalibration( + nearCm: row.nearCm, farCm: row.farCm, direction: row.direction, + strengthPercent: row.strengthPercent) + ) + }) + 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 { + let accessoryId = row.accessoryId + link = AccessoryLink( + accessoryId: accessoryId, + central: central, + onChanged: { [weak self] in self?.publish() }, + onManifest: { [weak self] manifest, deviceId in + self?.onManifestValidated(manifest, deviceId: deviceId) + }, + onReading: { [weak self] reading, receivedAt in + self?.onReading(accessoryId, reading, receivedAt) + }, + onSessionLost: { [weak self] in self?.onSessionLost(accessoryId) }) + links[row.accessoryId] = link + } + applyDemand(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 } + // `capabilitiesJson` is deliberately carried over unchanged. It is the baseline the rider's + // saved settings were validated against, and the snapshot derives "limits changed" by comparing + // the live manifest against it; rewriting it here would answer the question with the very thing + // being questioned. + let row = SavedAccessory( + accessoryId: previous.accessoryId, + name: manifest.name, + firmwareVersion: manifest.firmwareVersion, + protocolVersion: manifest.protocolVersion, + deviceId: deviceId, + capabilitiesJson: previous.capabilitiesJson, + enrolledAt: previous.enrolledAt, + lastConnectedAt: Int64(Date().timeIntervalSince1970 * 1000)) + remember(row) + if let link = links[manifest.accessoryId] { + applyDemand(to: link, row: row, manifest: manifest) + } + do { + // Update-only: a handshake completing just as the rider forgets this Accessory must not write + // the row back. + try store.revalidate(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) + } + } + + /// What every capability of one Accessory should currently be doing. + /// + /// The whole demand decision lives here and nowhere else. A ground-clearance capability measures + /// when someone actually needs the numbers — the rider has its screen open, or the rider is on a + /// calibrated board — and sits in the protocol's own measurement standby otherwise. Standby is not + /// a pause in the app: `enabled: false` stops the sensor's continuous measurement on the accessory + /// while BLE stays up, so leaving the screen genuinely stops measuring rather than throwing away + /// samples the hardware is still burning power to produce. + /// + /// Brake-light state comes from native speed samples or an explicitly parked preview. + private func applyDemand( + 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: samplingRates[.init(accessoryId: row.accessoryId, capabilityId: capability.id)] ?? Self.preferredRateHz, ratesHz: capability.ratesHz) + else { continue } + link.setDesired( + groundClearance.applyCapability( + accessoryId: row.accessoryId, capability: capability, liveManifest: manifest != nil, + rateHz: rate, enabled: isCapabilityEnabled(row.accessoryId, capability.id))) + case AccessoryProtocol.typeBrakeLight: + link.setDesired(brakeLight.command(.init(accessoryId: row.accessoryId, capabilityId: capability.id), enabled: isCapabilityEnabled(row.accessoryId, capability.id))) + default: + continue + } + } + } + + /// Re-decides demand for every enrolled Accessory, from whatever its session currently knows. + private func reapplyDemand() { + for (accessoryId, link) in links { + guard let row = saved[accessoryId] else { continue } + applyDemand(to: link, row: row, manifest: link.manifest) + } + } + + // MARK: - Brake light + + private func isCapabilityEnabled(_ accessoryId: String, _ capabilityId: String) -> Bool { + capabilityEnabled[.init(accessoryId: accessoryId, capabilityId: capabilityId)] != false + } + + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt `setCapabilityEnabled` + /// @parity /modules/vescape-core/src/index.ts `setAccessoryCapabilityEnabled` + func setCapabilityEnabled(accessoryId: String, capabilityId: String, enabled: Bool, onResult: @escaping (Bool) -> Void) { + updateCapabilitySettings(accessoryId: accessoryId, capabilityId: capabilityId, enabled: enabled, rateHz: nil, onResult: onResult) + } + + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt `setSamplingRate` + /// @parity /modules/vescape-core/src/index.ts `setAccessorySamplingRate` + func setSamplingRate(accessoryId: String, capabilityId: String, rateHz: Double, onResult: @escaping (Bool) -> Void) { + updateCapabilitySettings(accessoryId: accessoryId, capabilityId: capabilityId, enabled: nil, rateHz: rateHz, onResult: onResult) + } + + private func updateCapabilitySettings(accessoryId: String, capabilityId: String, enabled: Bool?, rateHz: Double?, onResult: @escaping (Bool) -> Void) { + onMain { + guard let row = self.saved[accessoryId] else { return onResult(false) } + let capabilities = self.links[accessoryId]?.manifest?.capabilities ?? Self.capabilitiesFrom(json: row.capabilitiesJson) + guard let capability = capabilities.first(where: { $0.id == capabilityId && $0.supported }) else { return onResult(false) } + if let rateHz, capability.type != AccessoryProtocol.typeGroundClearance || !rateHz.isFinite || !capability.ratesHz.contains(rateHz) { return onResult(false) } + let key = GroundClearanceBindingController.Key(accessoryId: accessoryId, capabilityId: capabilityId) + let candidate = SavedAccessoryCapabilitySettings(accessoryId: accessoryId, capabilityId: capabilityId, + enabled: enabled ?? self.isCapabilityEnabled(accessoryId, capabilityId), samplingRateHz: rateHz ?? self.samplingRates[key]) + do { + try self.store.saveCapabilitySettings(candidate) + } catch { + RecordingStorageFailure.report(operation: "accessory_capability_settings", category: "write_failed", error: error) + return onResult(false) + } + self.capabilityEnabled[key] = candidate.enabled + self.samplingRates[key] = candidate.samplingRateHz + if !candidate.enabled { _ = self.brakeLight.preview(.init(accessoryId: accessoryId, capabilityId: capabilityId), mode: nil) } + self.reapplyDemand() + self.publish() + onResult(true) + } + } + + func setLightTelemetry(speedKmh: Double, riding: Bool) { + let receivedAt = Int64(ProcessInfo.processInfo.systemUptime * 1000) + onMain { + guard Int64(ProcessInfo.processInfo.systemUptime * 1000) - receivedAt < 1500 else { return } + let changed = self.brakeLight.sample(speed: speedKmh, engaged: riding, at: receivedAt) + self.lightExpiry?.cancel() + let expiry = DispatchWorkItem { [weak self] in self?.clearLightTelemetry() } + self.lightExpiry = expiry + DispatchQueue.main.asyncAfter(deadline: .now() + Double(1500 - (Int64(ProcessInfo.processInfo.systemUptime * 1000) - receivedAt)) / 1000, execute: expiry) + self.reapplyDemand() + // Only when the rider would see something different. A steady-speed ride produces one sample + // after another that says the same thing, and publishing each of them would be a full snapshot + // per telemetry sample for no change on screen. + if changed { self.publish() } + } + } + + func clearLightTelemetry() { + onMain { + self.lightExpiry?.cancel(); self.lightExpiry = nil + self.brakeLight.clear(); self.reapplyDemand(); self.publish() + } + } + + func setLightPreview(accessoryId: String, capabilityId: String, mode: String?, onResult: @escaping (Bool) -> Void) { + onMain { + if mode != nil && !self.isCapabilityEnabled(accessoryId, capabilityId) { return onResult(false) } + let accepted = self.brakeLight.preview(.init(accessoryId: accessoryId, capabilityId: capabilityId), mode: mode) + if accepted { self.reapplyDemand(); self.publish() } + onResult(accepted) + } + } + + func saveBrakeLight(accessoryId: String, capabilityId: String, sensitivity: Int, parked: String, onResult: @escaping (Bool) -> Void) { + onMain { + let candidate = BrakeLightSettings(sensitivity: sensitivity, parked: parked) + let capabilities = self.links[accessoryId]?.manifest?.capabilities + ?? self.saved[accessoryId].map { Self.capabilitiesFrom(json: $0.capabilitiesJson) } ?? [] + guard candidate.valid, capabilities.contains(where: { $0.id == capabilityId && $0.type == AccessoryProtocol.typeBrakeLight && $0.supported }) else { return onResult(false) } + do { + try self.store.saveBrakeLight(.init(accessoryId: accessoryId, capabilityId: capabilityId, sensitivity: sensitivity, parked: parked)) + self.brakeLight.configure(.init(accessoryId: accessoryId, capabilityId: capabilityId), candidate) + self.reapplyDemand(); self.publish(); onResult(true) + } catch { + RecordingStorageFailure.report(operation: "accessory_brake_light", category: "write_failed", error: error) + onResult(false) + } + } + } + + // MARK: - Ground clearance + + /// The configuration screen for one capability opened or closed. + /// + /// The only demand JS is allowed to express, and it is a request to *measure*, never to tilt: a + /// preview shows numbers on a parked board, and `groundClearanceInput` refuses to drive anything + /// that is not being ridden regardless of what this says. + /// + /// A screen that is gone — backgrounded, unmounted, or its JS runtime killed — stops the sensor, + /// which is what "leaving the screen stops measurements" means at the hardware. + func setPreview(accessoryId: String, capabilityId: String, open: Bool) { + onMain { + guard self.groundClearance.setPreview(accessoryId, capabilityId, open: open) else { return } + self.reapplyDemand() + self.publish() + } + } + + /// Drops every preview, whoever asked for it. + /// + /// Preview demand lives in this process and the screen that asked for it lives in a JS runtime + /// that can disappear without unmounting anything — a reload, a crash, a development refresh. The + /// accessory's own lease cannot save it either, because native keeps renewing the configuration on + /// the screen's behalf. So the runtime going away has to be the release. + /// + /// Riding demand is deliberately untouched: it comes from the Board Session, which outlives JS. + /// + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt `releasePreviews` + func releasePreviews() { + onMain { + self.groundClearance.releasePreviews() + self.brakeLight.releasePreviews() + self.reapplyDemand() + self.publish() + } + } + + /// Board engagement, from the Board Session's own predicate. + /// + /// Native's, never JS's: this decides whether a sensor runs while the screen is off, and a value + /// that arrived over the bridge would stop being true the moment the runtime died. + /// + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt `setRiding` + func setRiding(_ riding: Bool) { + onMain { + // Compared before anything is re-applied. This arrives with every telemetry sample for the + // whole of a ride, and re-deriving demand per sample to discover that nothing changed would + // re-send the same command to every enrolled Accessory at telemetry rate. + guard self.groundClearance.setRiding(riding) else { return } + self.reapplyDemand() + self.publish() + } + } + + /// Saves one calibration, if it is one. + /// + /// There is no Save button behind this: the screen sends what the rider has so far and native + /// decides whether it is complete. Validity is judged against the limits the Accessory declares + /// *now*, so a calibration is never written that the hardware in front of the rider would refuse. + /// + /// Saving a calibration that fits the current manifest is also how the rider accepts limits that + /// moved since enrollment: the frozen `capabilitiesJson` baseline is rewritten to what the session + /// just validated against, which is what clears "this Accessory now declares different limits". + /// Nothing else in the app may rewrite that baseline. + func saveGroundClearance( + accessoryId: String, capabilityId: String, nearCm: Double, farCm: Double, direction: String, + strengthPercent: Int, onResult: @escaping ([String: Any?]) -> Void + ) { + onMain { + guard let row = self.saved[accessoryId] else { + return onResult(["saved": false, "problem": "unknown-capability"]) + } + let candidate = GroundClearanceCalibration( + nearCm: nearCm, farCm: farCm, direction: direction, strengthPercent: strengthPercent) + if let problem = self.groundClearance.validate(accessoryId, capabilityId, candidate) { + return onResult(["saved": false, "problem": problem.rawValue]) + } + do { + try self.store.saveGroundClearance( + SavedGroundClearance( + accessoryId: accessoryId, capabilityId: capabilityId, nearCm: nearCm, farCm: farCm, + direction: direction, strengthPercent: strengthPercent, + updatedAt: Int64(Date().timeIntervalSince1970 * 1000))) + } catch { + // Nothing is applied in memory either. A binding that drove from a calibration the database + // never took would come back uncalibrated on the next launch, with the rider believing they + // had set it. + RecordingStorageFailure.report( + operation: "accessory_ground_clearance", category: "write_failed", error: error) + return onResult(["saved": false, "problem": "storage-unavailable"]) + } + if let live = self.links[accessoryId]?.manifest?.capabilities { + let baseline = Self.encodeCapabilities(live.map { $0.toMap() }) + do { + try self.store.adoptCapabilities(accessoryId, capabilitiesJson: baseline) + self.remember( + SavedAccessory( + accessoryId: row.accessoryId, name: row.name, firmwareVersion: row.firmwareVersion, + protocolVersion: row.protocolVersion, deviceId: row.deviceId, + capabilitiesJson: baseline, enrolledAt: row.enrolledAt, + lastConnectedAt: row.lastConnectedAt)) + } catch { + // The calibration is saved and correct; only the warning outlives the acceptance, and the + // next save clears it. + RecordingStorageFailure.report( + operation: "accessory_revalidate", category: "write_failed", error: error) + } + } + self.groundClearance.applyCalibration(accessoryId, capabilityId, candidate) + self.reapplyDemand() + self.publish() + onResult(["saved": true, "problem": nil]) + } + } + + /// Drops a calibration. The binding stops driving and the screen goes back to explaining setup. + func clearGroundClearance( + accessoryId: String, capabilityId: String, onResult: @escaping (Bool) -> Void + ) { + onMain { + let removed: Bool + do { + removed = try self.store.clearGroundClearance(accessoryId, capabilityId) + } catch { + RecordingStorageFailure.report( + operation: "accessory_ground_clearance", category: "write_failed", error: error) + return onResult(false) + } + self.groundClearance.clearCalibration(accessoryId, capabilityId) + self.reapplyDemand() + self.publish() + onResult(removed) + } + } + + /// What a Remote Tilt binding may do with this capability right now. The seam #479 consumes. + /// + /// Two outcomes and no third: a scaled, signed input built from a fresh in-range measurement, or a + /// named reason to release. Nothing here can be read as "hold the last value" — a consumer that + /// gets a release has been told to let go, and why. + /// + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt `groundClearanceInput` + func groundClearanceInput(accessoryId: String, capabilityId: String) -> GroundClearanceInput { + groundClearance.input(accessoryId, capabilityId, link: linkState(accessoryId, capabilityId)) + } + + /// Whether a configured ground-clearance Accessory is connected. + /// + /// What makes the Remote Tilt pad a read-only indicator. Deliberately true even when the bindings + /// are `GroundClearanceRelease.contested` and none of them is driving: a rider whose two sensors + /// cancel each other out must not silently get their manual pad back, because the pad is not what + /// this board is configured for. + /// + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt `groundClearanceBound` + func groundClearanceBound() -> Bool { groundClearance.bound(linkState) } + + /// The single ground-clearance input a Remote Tilt binding may act on, across every Accessory. + /// + /// v1 binds to whichever Board is connected and has no arbitration between a nose sensor and a + /// tail sensor — the eventual hardware has both. Two claimants therefore release rather than + /// resolve: choosing one of them would be choosing a correction *direction* on the rider's behalf, + /// and the wrong choice tilts the board the wrong way. + /// + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt `groundClearanceTilt` + func groundClearanceTilt() -> GroundClearanceInput { + groundClearance.tilt(linkState) + } + + private func linkState(_ accessoryId: String, _ capabilityId: String) + -> GroundClearanceBindingController.LinkState + { + let link = links[accessoryId] + return GroundClearanceBindingController.LinkState( + connected: link?.phase == .connected, appliedRateHz: link?.appliedRateHz(capabilityId) ?? 0) + } + + /// One sample off an Accessory's reading stream. + /// + /// Range-checked against the limits the *live* manifest declares before anything else sees it, so + /// a number the hardware no longer promises is carried onward as `out_of_range` with no value + /// rather than as a distance. A sample older than the newest one held is dropped outright. + /// + /// The bridge only hears about it while a screen is open. Nothing else in the app consumes single + /// samples — the tilt binding pulls `groundClearanceInput` on its own cadence — so emitting at the + /// sensor's rate with nothing mounted would be pure bridge traffic. + private func onReading( + _ accessoryId: String, _ reading: AccessoryReading, _ receivedAt: TimeInterval + ) { + guard let payload = groundClearance.acceptReading( + accessoryId, reading, receivedAtMs: Int64(receivedAt * 1000), + appliedRateHz: links[accessoryId]?.appliedRateHz(reading.capabilityId)) + else { return } + emit?("onAccessoryReading", payload) + } + + /// The protocol session for one Accessory ended. + /// + /// Sequence numbers restart with the next hello, so anything the tracker still holds would make + /// the new session's first samples look like duplicates. The calibration is durable and stays. + private func onSessionLost(_ accessoryId: String) { + groundClearance.onSessionLost(accessoryId) + } + + // 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 and their first connect was refused. They + // are already started, so `start` would decline them — this is the hook that resumes them. + order.compactMap { saved[$0] }.forEach { start($0) } + links.values.forEach { $0.onRadioAvailable() } + } + + /// 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/accessory/BrakeLight.swift b/modules/vescape-core/ios/accessory/BrakeLight.swift new file mode 100644 index 00000000..d87318b2 --- /dev/null +++ b/modules/vescape-core/ios/accessory/BrakeLight.swift @@ -0,0 +1,128 @@ +import Foundation + +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/BrakeLight.kt +final class BrakeLightDetector { + private var previousSpeed: Double? + private var previousAt: Int64? + private var deceleration = 0.0 + private(set) var mode: String? + func clear() { + previousSpeed = nil + previousAt = nil + deceleration = 0 + mode = nil + } + func sample(speedKmh: Double, riding: Bool, at: Int64, sensitivity: Int) { + guard speedKmh.isFinite else { + clear() + return + } + let speed = abs(speedKmh) / 3.6 + let oldSpeed = previousSpeed + let dt = previousAt.map { at - $0 } + previousSpeed = speed + previousAt = at + guard riding else { + deceleration = 0 + mode = "not_riding" + return + } + guard let oldSpeed, let dt else { + mode = "riding" + return + } + guard dt > 0 && dt <= 500 else { + deceleration = 0 + mode = nil + return + } + let seconds = Double(dt) / 1000 + let alpha = seconds / (0.2 + seconds) + deceleration += alpha * ((oldSpeed - speed) / seconds - deceleration) + let scale = 1.5 - Double(sensitivity) / 100 + let braking = scale * (mode == "braking" ? 0.75 : 1) + let hard = scale * (mode == "hard_braking" ? 2.25 : 3) + mode = deceleration >= hard ? "hard_braking" : deceleration >= braking ? "braking" : "riding" + } +} + +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/BrakeLight.kt `BrakeLightSettings` +/// @parity /modules/vescape-core/src/index.ts `BrakeLightSettings` +struct BrakeLightSettings { + var sensitivity = 50 + var parked = "off" + var valid: Bool { (1...100).contains(sensitivity) && ["off", "glow"].contains(parked) } + func toMap() -> [String: Any?] { ["sensitivity": sensitivity, "parked": parked] } +} + +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/BrakeLight.kt `BrakeLightController` +final class BrakeLightController { + struct Key: Hashable { + let accessoryId: String + let capabilityId: String + } + private final class Light { + var settings = BrakeLightSettings() + let detector = BrakeLightDetector() + var preview: String? + } + private var lights: [Key: Light] = [:] + private var riding = false + private func light(_ key: Key) -> Light { + if let light = lights[key] { return light } + let light = Light() + lights[key] = light + return light + } + func configure(_ key: Key, _ settings: BrakeLightSettings) { light(key).settings = settings } + func forget(_ accessoryId: String) { + lights = lights.filter { $0.key.accessoryId != accessoryId } + } + /// Returns whether anything a screen renders changed, so an unchanged sample publishes nothing. + @discardableResult + func sample(speed: Double, engaged: Bool, at: Int64) -> Bool { + riding = engaged + var changed = false + for light in lights.values { + // Riding ends a preview: the rider is on the board and the light follows the board. + if engaged, light.preview != nil { + light.preview = nil + changed = true + } + let before = light.detector.mode + light.detector.sample( + speedKmh: speed, riding: engaged, at: at, sensitivity: light.settings.sensitivity) + if light.detector.mode != before { changed = true } + } + return changed + } + func clear() { + riding = false + lights.values.forEach { $0.detector.clear() } + } + func releasePreviews() { lights.values.forEach { $0.preview = nil } } + func preview(_ key: Key, mode: String?) -> Bool { + if let mode, riding || !Self.modes.contains(mode) { return false } + guard let light = lights[key] else { return false } + light.preview = mode + return true + } + /// @parity /modules/vescape-core/src/index.ts `AccessoryCapability` + func describe(_ key: Key) -> [String: Any?] { + let light = light(key) + return [ + "brakeLight": light.settings.toMap(), "lightMode": light.detector.mode, + "lightPreview": light.preview, + ] + } + func command(_ key: Key, enabled: Bool = true) -> AccessoryCommand { + let light = light(key) + if !enabled { return .state(capabilityId: key.capabilityId, telemetry: "available", mode: "not_riding", parked: "off", preview: false) } + return .state( + capabilityId: key.capabilityId, + telemetry: light.detector.mode == nil ? "unavailable" : "available", + mode: light.preview ?? light.detector.mode, parked: light.settings.parked, + preview: light.preview != nil) + } + static let modes = ["riding", "braking", "hard_braking", "not_riding"] +} diff --git a/modules/vescape-core/ios/accessory/BrakeLightTests.swift b/modules/vescape-core/ios/accessory/BrakeLightTests.swift new file mode 100644 index 00000000..81544de7 --- /dev/null +++ b/modules/vescape-core/ios/accessory/BrakeLightTests.swift @@ -0,0 +1,73 @@ +import XCTest + +@testable import VescapeCore + +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/BrakeLightTest.kt +final class BrakeLightTests: XCTestCase { + func testForwardReverseAndConstantSpeed() { + for direction in [1.0, -1.0] { + let detector = BrakeLightDetector() + for n in 0...10 { + detector.sample(speedKmh: direction * 36, riding: true, at: Int64(n * 100), sensitivity: 50) + } + XCTAssertEqual(detector.mode, "riding") + for n in 1...10 { + detector.sample( + speedKmh: direction * (36 - Double(n) * 0.72), riding: true, at: Int64(1000 + n * 100), + sensitivity: 50) + } + XCTAssertEqual(detector.mode, "braking") + for n in 1...8 { + detector.sample( + speedKmh: direction * (28.8 - Double(n) * 1.8), riding: true, at: Int64(2000 + n * 100), + sensitivity: 50) + } + XCTAssertEqual(detector.mode, "hard_braking") + } + } + func testGapsInvalidSpeedAndParkedClearHistory() { + let detector = BrakeLightDetector() + detector.sample(speedKmh: 36, riding: true, at: 0, sensitivity: 50) + detector.sample(speedKmh: 0, riding: true, at: 1000, sensitivity: 50) + XCTAssertNil(detector.mode) + detector.sample(speedKmh: 0, riding: true, at: 1100, sensitivity: 50) + XCTAssertEqual(detector.mode, "riding") + detector.sample(speedKmh: .nan, riding: true, at: 1200, sensitivity: 50) + XCTAssertNil(detector.mode) + detector.sample(speedKmh: 0, riding: false, at: 1300, sensitivity: 50) + XCTAssertEqual(detector.mode, "not_riding") + } + func testSensitivityAndPreviewRestore() { + let gentle = BrakeLightDetector() + let resistant = BrakeLightDetector() + for n in 0...15 { + gentle.sample( + speedKmh: 36 - Double(n) * 0.36, riding: true, at: Int64(n * 100), sensitivity: 100) + resistant.sample( + speedKmh: 36 - Double(n) * 0.36, riding: true, at: Int64(n * 100), sensitivity: 1) + } + XCTAssertEqual(gentle.mode, "braking") + XCTAssertEqual(resistant.mode, "riding") + let controller = BrakeLightController() + let key = BrakeLightController.Key(accessoryId: "a", capabilityId: "rear") + controller.configure(key, .init(sensitivity: 50, parked: "glow")) + XCTAssertTrue(controller.preview(key, mode: "hard_braking")) + XCTAssertEqual( + controller.command(key), + .state( + capabilityId: "rear", telemetry: "unavailable", mode: "hard_braking", parked: "glow", + preview: true)) + controller.releasePreviews() + XCTAssertEqual( + controller.command(key), + .state( + capabilityId: "rear", telemetry: "unavailable", mode: nil, parked: "glow", preview: false)) + controller.sample(speed: 10, engaged: true, at: 100) + XCTAssertFalse(controller.preview(key, mode: "braking")) + controller.clear() + XCTAssertEqual( + controller.command(key), + .state( + capabilityId: "rear", telemetry: "unavailable", mode: nil, parked: "glow", preview: false)) + } +} diff --git a/modules/vescape-core/ios/accessory/ClearancePreviewLog.swift b/modules/vescape-core/ios/accessory/ClearancePreviewLog.swift new file mode 100644 index 00000000..3cd7e6d6 --- /dev/null +++ b/modules/vescape-core/ios/accessory/ClearancePreviewLog.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Short-lived display history. Control still consumes every original reading. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/ClearancePreviewLog.kt +/// @parity /modules/vescape-core/src/index.ts `ClearancePreviewDiagnostics` +final class ClearancePreviewLog { + private struct Sample { let at: Int64; let time: Int64; let seq: Int64; let value: Double? } + private var samples: [Sample] = [] + private var emittedAt: Int64? + private var chartAt: Int64? + func reset() { samples.removeAll(); emittedAt = nil; chartAt = nil } + func record(at: Int64, time: Int64, seq: Int64, value: Double?) { + samples.append(Sample(at: at, time: time, seq: seq, value: value)) + samples.removeAll { $0.at < at - 20_000 } + if samples.count > 601 { samples.removeFirst(samples.count - 601) } + } + func shouldEmit(at: Int64) -> Bool { + if let emittedAt, at - emittedAt < 100 { return false } + emittedAt = at + return true + } + func snapshot(at: Int64) -> [String: Any?]? { + if let chartAt, at - chartAt < 250 { return nil } + chartAt = at + var segments: [[Double]] = [] + var segment: [Double] = [] + var previous: Sample? + var dropped: Int64 = 0 + for sample in samples { + let gap = previous.map { sample.seq != $0.seq + 1 || sample.at - $0.at > 300 } ?? false + if let previous { dropped += max(0, sample.seq - previous.seq - 1) } + if gap || sample.value == nil { + if !segment.isEmpty { segments.append(segment) } + segment = [] + } + if let value = sample.value { segment.append(Double(sample.time)); segment.append(value) } + previous = sample + } + if !segment.isEmpty { segments.append(segment) } + let span = (samples.last?.at ?? 0) - (samples.first?.at ?? 0) + return [ + "segments": segments, + "deliveredHz": span > 0 ? Double(samples.count - 1) * 1000 / Double(span) : 0, + "dropped": dropped, + "invalid": samples.filter { $0.value == nil }.count, + "samples": samples.count, + ] + } +} diff --git a/modules/vescape-core/ios/accessory/ClearancePreviewLogTests.swift b/modules/vescape-core/ios/accessory/ClearancePreviewLogTests.swift new file mode 100644 index 00000000..c77d5c3b --- /dev/null +++ b/modules/vescape-core/ios/accessory/ClearancePreviewLogTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import VescapeCore + +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/ClearancePreviewLogTest.kt +final class ClearancePreviewLogTests: XCTestCase { + func testInvalidAndMissingSamplesBreakChartWithoutInventingDistance() { + let log = ClearancePreviewLog() + log.record(at: 0, time: 0, seq: 1, value: 10) + log.record(at: 50, time: 50, seq: 2, value: nil) + log.record(at: 100, time: 100, seq: 3, value: 12) + log.record(at: 200, time: 200, seq: 5, value: 14) + let snapshot = log.snapshot(at: 200)! + XCTAssertEqual(snapshot["segments"] as? [[Double]], [[0, 10], [100, 12], [200, 14]]) + XCTAssertEqual(snapshot["dropped"] as? Int64, 1) + XCTAssertEqual(snapshot["invalid"] as? Int, 1) + XCTAssertEqual(snapshot["deliveredHz"] as? Double, 15) + } + func testDisplayThrottleDoesNotDropHistoryAndResetStartsFresh() { + let log = ClearancePreviewLog() + for i: Int64 in 0...6 { + log.record(at: i * 50, time: i * 50, seq: i + 1, value: 10) + XCTAssertEqual(log.shouldEmit(at: i * 50), i % 2 == 0) + } + XCTAssertEqual(log.snapshot(at: 300)!["samples"] as? Int, 7) + XCTAssertNil(log.snapshot(at: 400)) + log.reset() + log.record(at: 450, time: 0, seq: 1, value: 8) + XCTAssertTrue(log.shouldEmit(at: 450)) + XCTAssertEqual(log.snapshot(at: 450)!["samples"] as? Int, 1) + } + func testWindowAndCapacityBoundMemory() { + let log = ClearancePreviewLog() + for i: Int64 in 0...1000 { log.record(at: i * 50, time: i * 50, seq: i + 1, value: 10) } + XCTAssertEqual(log.snapshot(at: 50_000)!["samples"] as? Int, 401) + log.reset() + for i: Int64 in 0...1000 { log.record(at: i, time: i, seq: i + 1, value: 10) } + XCTAssertEqual(log.snapshot(at: 1000)!["samples"] as? Int, 601) + } +} diff --git a/modules/vescape-core/ios/accessory/GroundClearance.swift b/modules/vescape-core/ios/accessory/GroundClearance.swift new file mode 100644 index 00000000..53e1b1e6 --- /dev/null +++ b/modules/vescape-core/ios/accessory/GroundClearance.swift @@ -0,0 +1,618 @@ +import Foundation + +/// The ground-clearance capability: the rider's calibration, the sample stream it reads, and the +/// one number a Remote Tilt binding is allowed to act on. +/// +/// Pure and clock-free on purpose — every timestamp arrives as a parameter — so the rules below can +/// be asserted against `shared/fixtures/accessory-protocol/session.json` without a radio, a sensor +/// or a board. `AccessorySessionController` owns the wiring; this file owns the arithmetic. +/// +/// The property everything else rests on: **a missing measurement is never a distance.** Not the top +/// of the range, not the last good value, not zero. A sensor that stopped answering releases the +/// input, and so does one answering with something this app cannot read. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt +/// @parity /modules/vescape-core/src/index.ts `GroundClearanceCalibration` +enum GroundClearance { + /// Floor under the missing-stream timeout, `docs/accessory-protocol.md` PoC defaults. + static let missingStreamFloorMs: Int64 = 300 + + /// A binding that commands nothing is not a binding, so zero strength is not a calibration. + static let minStrengthPercent = 1 + static let maxStrengthPercent = 100 + + /// How long a capability may go without a sample before its input is released. + /// + /// Three sample periods, floored: at 20 Hz the floor is what matters, and a slow rate gets room + /// for two dropped samples rather than being declared dead by a fixed 300 ms it never had a + /// chance to meet. + static func staleAfterMs(rateHz: Double) -> Int64 { + guard rateHz.isFinite, rateHz > 0 else { return missingStreamFloorMs } + return max(missingStreamFloorMs, Int64((3_000.0 / rateHz).rounded())) + } + + /// The Refloat remote-input byte one signed correction asks for. + /// + /// The scale is the pad's: 128 is neutral and 255 is full nose-up, so a correction of 1.0 is the + /// same command a rider dragging the pad to its right edge would send. Defined here rather than at + /// the call site because both platforms and the tests have to agree on it byte for byte. + /// + /// A non-finite input is neutral, not a clamp to an extreme. Nothing should be able to produce one + /// — `GroundClearanceCalibration.tiltInput` returns 0 for a non-finite distance — but the one + /// place that decides what a board is told is not where to find out. + static func tiltCommand(tiltInput: Double) -> Int { + guard tiltInput.isFinite else { return REMOTE_TILT_CENTER } + let span = Double(255 - REMOTE_TILT_CENTER) + let scaled = Double(REMOTE_TILT_CENTER) + min(max(tiltInput, -1.0), 1.0) * span + return min(max(Int(scaled.rounded()), 0), 255) + } +} + +/// Which way a mounted sensor corrects. +/// +/// Kept as a wire string in `GroundClearanceCalibration` rather than parsed on the way in: a saved +/// row written by a newer build must be *rejected* as incomplete, not crash the session that read +/// it, and an unparsed direction is exactly the incomplete calibration the rider needs to fix. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt `GroundClearanceDirection` +/// @parity /modules/vescape-core/src/index.ts `GroundClearanceDirection` +enum GroundClearanceDirection: String { + /// Sensor at the nose: losing clearance there is answered by lifting the nose. + case nose + /// Sensor at the tail: the same loss is answered by lifting the tail. + case tail + + static func fromWire(_ value: String?) -> GroundClearanceDirection? { + guard let value else { return nil } + return GroundClearanceDirection(rawValue: value) + } +} + +/// What the rider calibrated for one ground-clearance capability. +/// +/// There is no partial state and no Save step: this is written when it is complete and valid, and a +/// calibration that is not both drives nothing. `farCm` is where correction starts and `nearCm` is +/// where it is at full strength, so `near < far` always — less clearance means more correction. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt `GroundClearanceCalibration` +/// @parity /modules/vescape-core/ios/telemetry/AccessoryPersistence.swift `SavedGroundClearance` +/// @parity /modules/vescape-core/src/index.ts `GroundClearanceCalibration` +struct GroundClearanceCalibration: Equatable { + let nearCm: Double + let farCm: Double + /// Raw wire value. Anything `GroundClearanceDirection` does not know makes this incomplete. + let direction: String + let strengthPercent: Int + + /// What is wrong with this calibration, or nil when nothing is. + /// + /// A reason rather than a boolean because the same absence — nothing saved, nothing driving — has + /// to be explained differently depending on which rule it broke, and native is the only place + /// that knows the rules. A screen that re-derived them would be a second definition of "valid" + /// that could disagree with the one the binding actually uses. + /// + /// The declared window is part of the test, not just the numbers' own order. An accessory whose + /// firmware narrowed its range is still the same accessory, and a calibration made against the + /// old numbers has to stop driving rather than be silently squeezed into the new ones. + func problem(rangeMin: Double?, rangeMax: Double?) -> GroundClearanceProblem? { + guard nearCm.isFinite, farCm.isFinite else { return .notANumber } + guard nearCm < farCm else { return .nearNotBelowFar } + guard GroundClearanceDirection.fromWire(direction) != nil else { return .unknownDirection } + guard strengthPercent >= GroundClearance.minStrengthPercent else { return .strengthOutOfBounds } + guard strengthPercent <= GroundClearance.maxStrengthPercent else { return .strengthOutOfBounds } + if let rangeMin, nearCm < rangeMin { return .outsideDeclaredRange } + if let rangeMax, farCm > rangeMax { return .outsideDeclaredRange } + return nil + } + + /// Whether this is a calibration the hardware in front of us can actually be driven to. + func isComplete(rangeMin: Double?, rangeMax: Double?) -> Bool { + problem(rangeMin: rangeMin, rangeMax: rangeMax) == nil + } + + /// The signed Remote Tilt input one measured distance calls for, in -1...1. + /// + /// Positive lifts the nose. Outside `[near, far]` the value saturates rather than extrapolating: + /// a sensor reading closer than the near distance is already asking for everything there is, and + /// one reading past the far distance is asking for nothing. + func tiltInput(valueCm: Double) -> Double { + guard valueCm.isFinite else { return 0 } + let span = farCm - nearCm + guard span > 0 else { return 0 } + let fraction = min(max((farCm - valueCm) / span, 0), 1) + let magnitude = fraction * (Double(strengthPercent) / 100.0) + switch GroundClearanceDirection.fromWire(direction) { + case .nose: return magnitude + case .tail: return -magnitude + case nil: return 0 + } + } +} + +/// Why a calibration is not one yet. +/// +/// The rider is mid-edit far more often than they are finished, so "not saved" is the normal state +/// of this screen and needs a sentence, not a silence. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt `GroundClearanceProblem` +/// @parity /modules/vescape-core/src/index.ts `GroundClearanceProblem` +enum GroundClearanceProblem: String { + /// A distance that is not a finite number. A row written by a broken build reads as this. + case notANumber = "not-a-number" + /// Less clearance must mean more correction, so the near distance has to be the smaller one. + case nearNotBelowFar = "near-not-below-far" + /// A mounting position this build does not know. A newer build wrote it; this one cannot use it. + case unknownDirection = "unknown-direction" + /// Zero commands nothing and past full commands something the pad cannot express. + case strengthOutOfBounds = "strength-out-of-bounds" + /// Outside what the accessory currently says it can measure. Recalibrate against the new limits. + case outsideDeclaredRange = "outside-declared-range" +} + +/// Why a ground-clearance binding is not commanding anything. +/// +/// Carried rather than collapsed to a bare nil so the consumer — and the rider's screen — can say +/// which of these it is. "The sensor is reporting an error" and "the rider has not calibrated yet" +/// look identical as an absent number and are nothing alike to explain. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt `GroundClearanceRelease` +/// @parity /modules/vescape-core/src/index.ts `GroundClearanceRelease` +enum GroundClearanceRelease: String { + case disabled + /// Sensor-driven tilt is for riding. A parked board is not corrected. + case notRiding = "not-riding" + /// The Board is not connected, or its link is not Trusted. + /// + /// Decided by the Board Session, not here: this file knows what the sensor is saying and nothing + /// about whether the thing on the other end is the Board the rider thinks it is. + case boardUntrusted = "board-untrusted" + /// The Board is connected but has stopped answering. + /// + /// Riding is read off telemetry, so telemetry that stopped is evidence that has stopped being + /// evidence. Holding the last engaged frame's worth of permission would let a sensor keep tilting + /// a Board nobody can hear. + case boardStale = "board-stale" + /// More than one calibrated ground-clearance capability wants the tilt channel. + /// + /// The PoC deliberately has no arbitration between a nose sensor and a tail sensor, and picking + /// one of them arbitrarily would be picking a correction direction arbitrarily. Two claimants is a + /// configuration the rider has to resolve, not one this app guesses its way through. + case contested + /// Board Move holds the remote-input slot. Both cannot write it, and a jog is the parked one. + case boardMove = "board-move" + /// A rider-commanded tilt still holds the slot. + /// + /// Only reachable in the moment a binding arms under a tilt that was started before it: the pad + /// refuses new manual input for as long as a binding is bound, and the arming itself cancels + /// whatever was held. It is named because an unexplained silent second is worse than a sentence. + case manualTilt = "manual-tilt" + /// No session, or a session that is not acknowledging commands. + case noLink = "no-link" + /// Nothing saved, or what is saved no longer fits the limits the accessory declares. + case notCalibrated = "not-calibrated" + /// Samples stopped arriving. The accessory may still be connected; it is not measuring. + case stale + /// The sensor answered, and the answer is not a distance. + case outOfRange = "out-of-range" + /// The sensor could not measure, or sent something this app cannot read as a measurement. + case sensorError = "sensor-error" +} + +/// The only thing a tilt binding is allowed to see. +/// +/// Two cases and no third: either there is a calibrated, fresh, in-range measurement and a number to +/// command, or there is a reason to let go. Nothing here can be read as "hold the last value" — the +/// type has no way to express it. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt `GroundClearanceInput` +enum GroundClearanceInput: Equatable { + /// A live measurement, already scaled by the rider's strength and mounting direction. + case drive(tiltInput: Double, valueCm: Double) + /// Release any held input, smoothly, and command nothing until a `drive` arrives. + case release(reason: GroundClearanceRelease) +} + +/// Per-capability sample bookkeeping: what the newest accepted sample was, and when it landed here. +/// +/// Deliberately *not* a ring buffer. Nothing in this slice looks backwards — the screen shows the +/// newest number and a tilt binding acts on the newest number — so a history would be a buffer whose +/// only job is to grow. `AccessoryLink` already coalesces commands; readings need the same +/// treatment, which is one slot. +/// +/// Two clocks, kept apart on purpose. `AccessoryReading.sampleTimeMs` is the accessory's own uptime +/// and only ever compared to other samples from the same session; freshness is judged on +/// `latestAtMs`, this phone's monotonic receipt time. Subtracting one from the other would be a +/// latency measurement across two unsynchronised clocks. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt `AccessoryReadingTracker` +final class AccessoryReadingTracker { + /// Newest accepted sample, already range-checked. Nil until one arrives in this session. + private(set) var latest: AccessoryReading? + /// Local monotonic receipt time of `latest`, in milliseconds. + private(set) var latestAtMs: Int64? + + private var lastSeq: Int? + private var lastSampleTimeMs: Int64? + + /// Takes one sample if it is newer than what is held, and says whether it was taken. + /// + /// Sequence numbers increase across measurement pauses inside a session, so a jump is normal and + /// only a repeat or a step backwards is a duplicate. A sample time that went backwards is refused + /// even when the sequence advanced: the two disagree, and a disagreeing accessory is not one to + /// take a distance from. + @discardableResult + func accept(_ reading: AccessoryReading, receivedAtMs: Int64) -> Bool { + if let previousSeq = lastSeq, reading.seq <= previousSeq { return false } + if let previousTime = lastSampleTimeMs, reading.sampleTimeMs < previousTime { return false } + lastSeq = reading.seq + lastSampleTimeMs = reading.sampleTimeMs + latest = reading + latestAtMs = receivedAtMs + return true + } + + /// A new protocol session restarts sequence numbers, so nothing from the old one may survive. + func reset() { + latest = nil + latestAtMs = nil + lastSeq = nil + lastSampleTimeMs = nil + } + + /// Whether a sample landed recently enough to still describe the ground under the board. + func isFresh(nowMs: Int64, staleAfterMs: Int64) -> Bool { + guard let at = latestAtMs else { return false } + return nowMs - at < staleAfterMs + } +} + +/// One enrolled ground-clearance capability's live state: what is saved for it, who wants it +/// measuring, and what its samples currently amount to. +/// +/// Demand is arbitrated here rather than anywhere a screen can reach. Two independent reasons to +/// measure — the rider is riding a calibrated board, or the rider has the configuration screen open +/// — and their union is what the accessory is told. Neither of them alone is permission to *tilt*: +/// `input` refuses on anything but riding, which is what keeps a preview from moving a parked board. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt `GroundClearanceRuntime` +final class GroundClearanceRuntime { + let capabilityId: String + + /// Saved calibration, or nil while the rider has not finished one. + var calibration: GroundClearanceCalibration? + + /// Limits from the live manifest. Nil while no session is established. + var rangeMin: Double? + var rangeMax: Double? + + /// Rate actually acknowledged for this capability, which sets the stale window. + var rateHz: Double = 0 + + /// The configuration screen is open and wants to show live numbers. + var previewOpen = false + + /// The Board is connected and engaged. Set from the Board session, never from JS. + var riding = false + + let tracker = AccessoryReadingTracker() + let previewLog = ClearancePreviewLog() + + init(capabilityId: String) { self.capabilityId = capabilityId } + + /// Whether what is saved still fits what the accessory currently declares. + var isCalibrated: Bool { + calibration?.isComplete(rangeMin: rangeMin, rangeMax: rangeMax) == true + } + + /// Whether the accessory should be measuring at all. + /// + /// Riding without a calibration measures nothing, because nothing could act on the result: the + /// sensor would burn power to produce samples with no binding behind them. Preview measures + /// regardless — that is how the rider *gets* a calibration. + var enabled = true + var measurementDemanded: Bool { enabled && (previewOpen || (riding && isCalibrated)) } + + /// What a tilt binding may do right now. + /// + /// Ordered by what the rider most needs to hear. Not riding comes first because it is the normal + /// resting state and not a fault; the sensor's own problems come last, when everything that would + /// have consumed them is in place. + func input(nowMs: Int64, linkConnected: Bool) -> GroundClearanceInput { + if !enabled { return .release(reason: .disabled) } + guard riding else { return .release(reason: .notRiding) } + guard linkConnected else { return .release(reason: .noLink) } + guard let saved = calibration, saved.isComplete(rangeMin: rangeMin, rangeMax: rangeMax) else { + return .release(reason: .notCalibrated) + } + guard let reading = tracker.latest, + tracker.isFresh(nowMs: nowMs, staleAfterMs: GroundClearance.staleAfterMs(rateHz: rateHz)) + else { return .release(reason: .stale) } + + switch reading.status { + case .outOfRange: return .release(reason: .outOfRange) + case .error: return .release(reason: .sensorError) + case .ok: + // Unreachable by construction — an `ok` without a value cannot be built — but a release is + // the honest answer to a reading that somehow has none, and it costs one branch to never have + // to trust that. + guard let value = reading.valueCm else { return .release(reason: .sensorError) } + return .drive(tiltInput: saved.tiltInput(valueCm: value), valueCm: value) + } + } + + /// Everything a fresh protocol session invalidates. Calibration is durable and stays. + func onSessionLost() { + tracker.reset() + previewLog.reset() + rateHz = 0 + } +} + +/// Owns every live ground-clearance binding across enrolled Accessories. +/// +/// The generic session coordinator supplies connection facts and protocol commands. Capability +/// state, demand, readings, calibration application, and claimant selection live here. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt `GroundClearanceBindingController` +final class GroundClearanceBindingController { + struct Key: Hashable { + let accessoryId: String + let capabilityId: String + } + + struct LinkState { + let connected: Bool + let appliedRateHz: Double + } + + private let nowMs: () -> Int64 + private var runtimes: [Key: GroundClearanceRuntime] = [:] + private var riding = false + + init(nowMs: @escaping () -> Int64) { self.nowMs = nowMs } + + func reset(_ calibrations: [(Key, GroundClearanceCalibration)]) { + runtimes.removeAll() + for (key, calibration) in calibrations { runtime(key).calibration = calibration } + } + + private func runtime(_ accessoryId: String, _ capabilityId: String) -> GroundClearanceRuntime { + runtime(Key(accessoryId: accessoryId, capabilityId: capabilityId)) + } + + private func runtime(_ key: Key) -> GroundClearanceRuntime { + if let existing = runtimes[key] { return existing } + let created = GroundClearanceRuntime(capabilityId: key.capabilityId) + runtimes[key] = created + return created + } + + func applyCapability( + accessoryId: String, capability: AccessoryCapability, liveManifest: Bool, rateHz: Double, + enabled: Bool = true + ) -> AccessoryCommand { + let state = runtime(accessoryId, capability.id) + if state.enabled != enabled { state.onSessionLost() } + state.enabled = enabled + if liveManifest { + state.rangeMin = capability.rangeMin + state.rangeMax = capability.rangeMax + } + state.riding = riding + return .configure(capabilityId: capability.id, enabled: state.measurementDemanded, rateHz: rateHz) + } + + func setPreview(_ accessoryId: String, _ capabilityId: String, open: Bool) -> Bool { + let state = runtime(accessoryId, capabilityId) + guard state.previewOpen != open else { return false } + state.previewLog.reset() + state.previewOpen = open + return true + } + + func releasePreviews() -> Bool { + var changed = false + for state in runtimes.values where state.previewOpen { + state.previewOpen = false + changed = true + } + return changed + } + + func setRiding(_ value: Bool) -> Bool { + guard riding != value else { return false } + riding = value + return true + } + + func validate( + _ accessoryId: String, _ capabilityId: String, _ calibration: GroundClearanceCalibration + ) -> GroundClearanceProblem? { + let state = runtime(accessoryId, capabilityId) + return calibration.problem(rangeMin: state.rangeMin, rangeMax: state.rangeMax) + } + + func applyCalibration( + _ accessoryId: String, _ capabilityId: String, _ calibration: GroundClearanceCalibration + ) { runtime(accessoryId, capabilityId).calibration = calibration } + + func clearCalibration(_ accessoryId: String, _ capabilityId: String) { + runtime(accessoryId, capabilityId).calibration = nil + } + + func describe(_ accessoryId: String, _ capabilityId: String) -> [String: Any?]? { + guard let state = runtimes[Key(accessoryId: accessoryId, capabilityId: capabilityId)] else { + return nil + } + let calibration: [String: Any?]? = state.calibration.map { + [ + "nearCm": $0.nearCm, + "farCm": $0.farCm, + "direction": $0.direction, + "strengthPercent": $0.strengthPercent, + "problem": $0.problem(rangeMin: state.rangeMin, rangeMax: state.rangeMax)?.rawValue, + ] + } + return ["calibration": calibration, "measuring": state.measurementDemanded] + } + + func input(_ accessoryId: String, _ capabilityId: String, link: LinkState) -> GroundClearanceInput { + guard let state = runtimes[Key(accessoryId: accessoryId, capabilityId: capabilityId)] else { + return .release(reason: .notCalibrated) + } + state.rateHz = link.appliedRateHz + return state.input(nowMs: nowMs(), linkConnected: link.connected) + } + + private func boundCapabilities(_ link: (String, String) -> LinkState) -> [Key] { + runtimes.compactMap { key, state in + state.enabled && state.isCalibrated && link(key.accessoryId, key.capabilityId).connected ? key : nil + } + } + + func bound(_ link: (String, String) -> LinkState) -> Bool { !boundCapabilities(link).isEmpty } + + func tilt(_ link: (String, String) -> LinkState) -> GroundClearanceInput { + let bound = boundCapabilities(link) + if bound.count > 1 { return .release(reason: .contested) } + guard let key = bound.first else { + if !runtimes.isEmpty && !runtimes.values.contains(where: { $0.enabled }) { return .release(reason: .disabled) } + return .release(reason: runtimes.values.contains(where: { $0.enabled && $0.isCalibrated }) ? .noLink : .notCalibrated) + } + return input(key.accessoryId, key.capabilityId, link: link(key.accessoryId, key.capabilityId)) + } + + func acceptReading( + _ accessoryId: String, _ reading: AccessoryReading, receivedAtMs: Int64, appliedRateHz: Double? + ) -> [String: Any?]? { + let key = Key(accessoryId: accessoryId, capabilityId: reading.capabilityId) + guard let state = runtimes[key] else { return nil } + guard state.enabled else { return nil } + if let appliedRateHz { state.rateHz = appliedRateHz } + let checked = reading.withinDeclaredRange(rangeMin: state.rangeMin, rangeMax: state.rangeMax) + guard state.tracker.accept(checked, receivedAtMs: receivedAtMs), state.previewOpen else { return nil } + state.previewLog.record(at: receivedAtMs, time: checked.sampleTimeMs, seq: Int64(checked.seq), value: checked.valueCm) + guard state.previewLog.shouldEmit(at: receivedAtMs) else { return nil } + return [ + "diagnostics": state.previewLog.snapshot(at: receivedAtMs), + "accessoryId": accessoryId, + "capabilityId": checked.capabilityId, + "seq": checked.seq, + "sampleTimeMs": checked.sampleTimeMs, + "status": checked.status.rawValue, + "valueCm": checked.valueCm, + "staleAfterMs": GroundClearance.staleAfterMs(rateHz: state.rateHz), + // Preview the same mapping as riding, without granting permission to drive. + "tiltPreviewPercent": checked.valueCm.flatMap { value in + state.isCalibrated ? state.calibration.map { $0.tiltInput(valueCm: value) * 100 } : nil + }, + ] + } + + func onSessionLost(_ accessoryId: String) { + for (key, state) in runtimes where key.accessoryId == accessoryId { state.onSessionLost() } + } + + func forget(_ accessoryId: String) { + runtimes = runtimes.filter { $0.key.accessoryId != accessoryId } + } +} + +/// Board-side lifecycle and arbitration for the ground-clearance Accessory Binding. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt `BoardGroundClearanceBinding` +final class BoardGroundClearanceBinding { + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt `TICK_MS` + static let tickMs: Int64 = 100 + + struct BoardInput { + let commandsTrusted: Bool + let telemetryFresh: Bool + } + + private let remoteInput: RemoteInputArbiter + private let boundInput: () -> Bool + private let tiltInput: () -> GroundClearanceInput + private var scheduled: Cancellable? + private var schedule: ((@escaping () -> Void) -> Cancellable)? + private var boardInput: (() -> BoardInput)? + private var bound = false + private var release: GroundClearanceRelease? = .notCalibrated + + init( + remoteInput: RemoteInputArbiter, + boundInput: @escaping () -> Bool, + tiltInput: @escaping () -> GroundClearanceInput + ) { + self.remoteInput = remoteInput + self.boundInput = boundInput + self.tiltInput = tiltInput + } + + func start( + schedule: @escaping (@escaping () -> Void) -> Cancellable, + boardInput: @escaping () -> BoardInput + ) { + guard scheduled == nil else { return } + self.schedule = schedule + self.boardInput = boardInput + scheduleNext() + } + + private func scheduleNext() { + scheduled = schedule? { [weak self] in + guard let self, let boardInput = self.boardInput else { return } + self.tick(boardInput()) + self.scheduleNext() + } + } + + func stop() { + scheduled?.cancel() + scheduled = nil + schedule = nil + boardInput = nil + _ = remoteInput.sensorRelease() + bound = false + release = .boardUntrusted + } + + func tick(_ board: BoardInput) { + bound = boundInput() + // Every tick, not just the arming one. A manual tilt that survives into a bound session — one + // taken in the window before the pad learned it was read-only, or one whose arming-time cancel + // failed on a transport that blinked — is a lock that never ends by itself, and the read-only + // pad has no Cancel for the rider to press. `releaseManual` no-ops once the ease is running, so + // repeating it costs nothing. + if bound { _ = remoteInput.releaseManual() } + + let input: GroundClearanceInput + if !board.commandsTrusted { + input = .release(reason: .boardUntrusted) + } else if !board.telemetryFresh { + input = .release(reason: .boardStale) + } else { + switch remoteInput.owner { + case .move: input = .release(reason: .boardMove) + case .manual: input = .release(reason: .manualTilt) + case .none, .sensor: input = tiltInput() + } + } + + switch input { + case .drive(let tiltInput, _): + release = remoteInput.sensorDrive(GroundClearance.tiltCommand(tiltInput: tiltInput)) + ? nil : .boardUntrusted + case .release(let reason): + _ = remoteInput.sensorRelease() + release = reason + } + } + + func state() -> [String: Any?] { + [ + "bound": bound, + "driving": remoteInput.owner == .sensor, + "release": release?.rawValue, + ] + } +} diff --git a/modules/vescape-core/ios/accessory/GroundClearanceTests.swift b/modules/vescape-core/ios/accessory/GroundClearanceTests.swift new file mode 100644 index 00000000..854b7d6b --- /dev/null +++ b/modules/vescape-core/ios/accessory/GroundClearanceTests.swift @@ -0,0 +1,328 @@ +import XCTest + +@testable import VescapeCore + +/// The ground-clearance contract, driven by `shared/fixtures/accessory-protocol/session.json`: what +/// a sample decodes to, what the declared range does to it, which samples are accepted, and what a +/// saved calibration turns a distance into. +/// +/// The property most of these cases exist to defend is one sentence: **a missing measurement is +/// never a distance.** Every way a reading can fail to be one — no value, a null value, a textual +/// value, a status from a newer firmware, a number outside the declared window — has a case here, +/// and all of them end at `error` or `outOfRange` with no value attached. None of them ends at the +/// top of the range, which is the reading that would tell a board it is safe to tilt. +/// +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/accessory/GroundClearanceTest.kt +final class GroundClearanceTests: XCTestCase { + func testDisablingPreservesCalibrationButStopsPreviewAndReleasesTheBinding() { + let controller = GroundClearanceBindingController(nowMs: { 1000 }) + let capability = AccessoryCapability(id: "clearance", type: "ground_clearance", supported: true, unit: "cm", rangeMin: 3, rangeMax: 100, ratesHz: [10]) + let link = GroundClearanceBindingController.LinkState(connected: true, appliedRateHz: 10) + _ = controller.applyCapability(accessoryId: "accessory", capability: capability, liveManifest: true, rateHz: 10) + controller.applyCalibration("accessory", capability.id, GroundClearanceCalibration(nearCm: 5, farCm: 20, direction: "nose", strengthPercent: 60)) + _ = controller.setRiding(true) + _ = controller.setPreview("accessory", capability.id, open: true) + XCTAssertEqual(controller.applyCapability(accessoryId: "accessory", capability: capability, liveManifest: true, rateHz: 10), .configure(capabilityId: capability.id, enabled: true, rateHz: 10)) + XCTAssertTrue(controller.bound { _, _ in link }) + XCTAssertEqual(controller.applyCapability(accessoryId: "accessory", capability: capability, liveManifest: true, rateHz: 10, enabled: false), .configure(capabilityId: capability.id, enabled: false, rateHz: 10)) + XCTAssertFalse(controller.bound { _, _ in link }) + XCTAssertEqual(controller.tilt { _, _ in link }, .release(reason: .disabled)) + XCTAssertNotNil(controller.describe("accessory", capability.id)?["calibration"] ?? nil) + _ = controller.applyCapability(accessoryId: "accessory", capability: capability, liveManifest: true, rateHz: 10, enabled: true) + XCTAssertEqual(controller.tilt { _, _ in link }, .release(reason: .stale)) + } + + private func fixture() throws -> [String: Any] { try AccessoryFixtures.load("session.json") } + private func readings() throws -> [String: Any] { + try XCTUnwrap(fixture()["readings"] as? [String: Any]) + } + private func groundClearance() throws -> [String: Any] { + try XCTUnwrap(fixture()["groundClearance"] as? [String: Any]) + } + + private func declaredRange(_ owner: [String: Any]) throws -> (Double, Double) { + let range = try XCTUnwrap(owner["declaredRange"] as? [String: Any]) + return ( + try XCTUnwrap((range["min"] as? NSNumber)?.doubleValue), + try XCTUnwrap((range["max"] as? NSNumber)?.doubleValue) + ) + } + + func testEverySampleDecodesExactlyAsTheFixturePinsIt() throws { + let sessionId = try XCTUnwrap(fixture()["sessionId"] as? String) + let cases = try XCTUnwrap(readings()["decode"] as? [[String: Any]]) + XCTAssertFalse(cases.isEmpty, "fixture must carry reading 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["ignored"] as? Bool) == true { + XCTAssertEqual(parsed, .ignored, name) + continue + } + let expected = try XCTUnwrap(entry["reading"] as? [String: Any], name) + guard case .sample(let reading) = parsed else { + XCTFail("\(name): expected a sample, got \(parsed)") + continue + } + XCTAssertEqual(reading.capabilityId, expected["capabilityId"] as? String, name) + XCTAssertEqual(reading.seq, expected["seq"] as? Int, name) + XCTAssertEqual(reading.sampleTimeMs, (expected["sampleTimeMs"] as? NSNumber)?.int64Value, name) + XCTAssertEqual(reading.status.rawValue, expected["status"] as? String, name) + XCTAssertEqual(reading.valueCm, (expected["valueCm"] as? NSNumber)?.doubleValue, name) + } + } + + func testAValueOutsideTheDeclaredWindowIsOutOfRangeRatherThanClamped() throws { + let readings = try readings() + let (min, max) = try declaredRange(readings) + let capabilityId = try XCTUnwrap(readings["capabilityId"] as? String) + for entry in try XCTUnwrap(readings["rangeCheck"] as? [[String: Any]]) { + let name = (entry["name"] as? String) ?? "?" + let reading = AccessoryReading( + capabilityId: capabilityId, seq: 1, sampleTimeMs: 100, status: .ok, + valueCm: try XCTUnwrap((entry["valueCm"] as? NSNumber)?.doubleValue, name) + ).withinDeclaredRange(rangeMin: min, rangeMax: max) + + XCTAssertEqual(reading.status.rawValue, entry["resolvedStatus"] as? String, name) + // The whole point: a number the hardware no longer promises loses its value rather than being + // squeezed to the nearest limit. + XCTAssertEqual(reading.valueCm, (entry["resolvedValueCm"] as? NSNumber)?.doubleValue, name) + } + } + + func testOnlyASampleNewerThanTheOneHeldIsAccepted() throws { + let readings = try readings() + let capabilityId = try XCTUnwrap(readings["capabilityId"] as? String) + for entry in try XCTUnwrap(readings["acceptance"] as? [[String: Any]]) { + let name = (entry["name"] as? String) ?? "?" + let tracker = AccessoryReadingTracker() + if let previous = entry["previous"] as? [String: Any] { + XCTAssertTrue( + tracker.accept( + AccessoryReading( + capabilityId: capabilityId, + seq: try XCTUnwrap(previous["seq"] as? Int, name), + sampleTimeMs: try XCTUnwrap((previous["sampleTimeMs"] as? NSNumber)?.int64Value, name), + status: .ok, valueCm: 10), + receivedAtMs: 1_000), + "\(name): seeding the previous sample must succeed") + } + let accepted = tracker.accept( + AccessoryReading( + capabilityId: capabilityId, + seq: try XCTUnwrap(entry["seq"] as? Int, name), + sampleTimeMs: try XCTUnwrap((entry["sampleTimeMs"] as? NSNumber)?.int64Value, name), + status: .ok, valueCm: 11), + receivedAtMs: 2_000) + XCTAssertEqual(accepted, entry["accepted"] as? Bool, name) + } + } + + func testAFreshSessionKeepsNothingFromTheOldOne() { + // Sequence numbers restart with the next hello. Without the reset the new session's first + // samples would be refused as duplicates and the screen would sit on a distance measured before + // the accessory rebooted. + let tracker = AccessoryReadingTracker() + let ok = AccessoryReading( + capabilityId: "clearance", seq: 40, sampleTimeMs: 9_000, status: .ok, valueCm: 12) + XCTAssertTrue(tracker.accept(ok, receivedAtMs: 1_000)) + tracker.reset() + XCTAssertNil(tracker.latest) + XCTAssertTrue( + tracker.accept( + AccessoryReading( + capabilityId: "clearance", seq: 1, sampleTimeMs: 50, status: .ok, valueCm: 12), + receivedAtMs: 2_000)) + } + + func testFreshnessIsJudgedOnTheRateTheAccessoryConfirmed() throws { + for entry in try XCTUnwrap(readings()["staleAfterMs"] as? [[String: Any]]) { + let name = (entry["name"] as? String) ?? "?" + XCTAssertEqual( + GroundClearance.staleAfterMs( + rateHz: try XCTUnwrap((entry["rateHz"] as? NSNumber)?.doubleValue, name)), + (entry["staleAfterMs"] as? NSNumber)?.int64Value, name) + } + // An unacknowledged rate is not a reason to widen the window; the floor still applies. + XCTAssertEqual(GroundClearance.staleAfterMs(rateHz: 0), GroundClearance.missingStreamFloorMs) + XCTAssertEqual(GroundClearance.staleAfterMs(rateHz: .nan), GroundClearance.missingStreamFloorMs) + } + + func testACalibrationIsCompleteOnlyWhenEveryRuleHolds() throws { + let groundClearance = try groundClearance() + let (min, max) = try declaredRange(groundClearance) + for entry in try XCTUnwrap(groundClearance["validity"] as? [[String: Any]]) { + let name = (entry["name"] as? String) ?? "?" + let spec = try XCTUnwrap(entry["calibration"] as? [String: Any], name) + let calibration = GroundClearanceCalibration( + nearCm: (spec["nearCm"] as? NSNumber)?.doubleValue ?? .nan, + farCm: (spec["farCm"] as? NSNumber)?.doubleValue ?? .nan, + direction: try XCTUnwrap(spec["direction"] as? String, name), + strengthPercent: try XCTUnwrap(spec["strengthPercent"] as? Int, name)) + XCTAssertEqual( + calibration.isComplete(rangeMin: min, rangeMax: max), entry["valid"] as? Bool, name) + XCTAssertEqual( + calibration.problem(rangeMin: min, rangeMax: max)?.rawValue, entry["problem"] as? String, + name) + } + } + + func testOneDistanceBecomesTheSignedInputTheFixturePins() throws { + for entry in try XCTUnwrap(groundClearance()["tilt"] as? [[String: Any]]) { + let name = (entry["name"] as? String) ?? "?" + let spec = try XCTUnwrap(entry["calibration"] as? [String: Any], name) + let calibration = GroundClearanceCalibration( + nearCm: try XCTUnwrap((spec["nearCm"] as? NSNumber)?.doubleValue, name), + farCm: try XCTUnwrap((spec["farCm"] as? NSNumber)?.doubleValue, name), + direction: try XCTUnwrap(spec["direction"] as? String, name), + strengthPercent: try XCTUnwrap(spec["strengthPercent"] as? Int, name)) + XCTAssertEqual( + calibration.tiltInput( + valueCm: try XCTUnwrap((entry["valueCm"] as? NSNumber)?.doubleValue, name)), + try XCTUnwrap((entry["tiltInput"] as? NSNumber)?.doubleValue, name), + accuracy: 1e-9, name) + } + } + + func testMeasurementIsDemandedByAPreviewOrByRidingACalibratedBoard() { + let runtime = GroundClearanceRuntime(capabilityId: "clearance") + runtime.rangeMin = 3 + runtime.rangeMax = 100 + XCTAssertFalse(runtime.measurementDemanded, "nothing wants it") + + runtime.previewOpen = true + XCTAssertTrue( + runtime.measurementDemanded, + "a preview measures even uncalibrated — that is how a calibration is made") + + runtime.previewOpen = false + runtime.riding = true + // Riding an uncalibrated sensor measures nothing: there is no binding to consume the samples, so + // the accessory would burn power producing them for nobody. + XCTAssertFalse(runtime.measurementDemanded, "riding without a calibration has no consumer") + + runtime.calibration = GroundClearanceCalibration( + nearCm: 5, farCm: 20, direction: "nose", strengthPercent: 60) + XCTAssertTrue(runtime.measurementDemanded) + + // A firmware that narrowed its range invalidates the saved numbers, and with them the demand. + runtime.rangeMin = 8 + XCTAssertFalse(runtime.measurementDemanded, "a calibration that no longer fits drives nothing") + } + + func testATiltBindingIsReleasedWithANamedReasonForEveryWayTheInputCanFail() { + let runtime = GroundClearanceRuntime(capabilityId: "clearance") + runtime.rangeMin = 3 + runtime.rangeMax = 100 + runtime.rateHz = 20 + + XCTAssertEqual(runtime.input(nowMs: 1_000, linkConnected: true), .release(reason: .notRiding)) + runtime.riding = true + XCTAssertEqual(runtime.input(nowMs: 1_000, linkConnected: false), .release(reason: .noLink)) + XCTAssertEqual( + runtime.input(nowMs: 1_000, linkConnected: true), .release(reason: .notCalibrated)) + + runtime.calibration = GroundClearanceCalibration( + nearCm: 5, farCm: 20, direction: "nose", strengthPercent: 100) + // Calibrated, connected, riding — and no sample has ever arrived. That is stale, not zero. + XCTAssertEqual(runtime.input(nowMs: 1_000, linkConnected: true), .release(reason: .stale)) + + runtime.tracker.accept( + AccessoryReading( + capabilityId: "clearance", seq: 1, sampleTimeMs: 100, status: .ok, valueCm: 12.5), + receivedAtMs: 1_000) + XCTAssertEqual( + runtime.input(nowMs: 1_100, linkConnected: true), .drive(tiltInput: 0.5, valueCm: 12.5)) + // Past the missing-stream window the same sample is no longer evidence of anything. + XCTAssertEqual(runtime.input(nowMs: 1_400, linkConnected: true), .release(reason: .stale)) + + runtime.tracker.accept( + AccessoryReading( + capabilityId: "clearance", seq: 2, sampleTimeMs: 150, status: .outOfRange, valueCm: nil), + receivedAtMs: 1_400) + XCTAssertEqual(runtime.input(nowMs: 1_450, linkConnected: true), .release(reason: .outOfRange)) + + runtime.tracker.accept( + AccessoryReading( + capabilityId: "clearance", seq: 3, sampleTimeMs: 200, status: .error, valueCm: nil), + receivedAtMs: 1_500) + XCTAssertEqual(runtime.input(nowMs: 1_550, linkConnected: true), .release(reason: .sensorError)) + } + + func testANonOkReadingCannotHoldAValue() { + // The type refuses the pairing that would make a status and a number disagree, which is what + // lets every consumer treat "has a value" as "is a measurement". + let reading = AccessoryReading( + capabilityId: "clearance", seq: 1, sampleTimeMs: 100, status: .outOfRange, valueCm: 12) + XCTAssertNil(reading.valueCm) + } + + func testBindingControllerPreservesDemandLimitsAndContestedOwnership() throws { + let controller = GroundClearanceBindingController(nowMs: { 1_100 }) + func capability(_ id: String, min: Double = 3, max: Double = 100) -> AccessoryCapability { + AccessoryCapability( + id: id, type: AccessoryProtocol.typeGroundClearance, supported: true, unit: "cm", + rangeMin: min, rangeMax: max, ratesHz: [20]) + } + _ = controller.applyCapability( + accessoryId: "front", capability: capability("clearance"), liveManifest: true, rateHz: 20) + controller.applyCalibration( + "front", "clearance", + GroundClearanceCalibration(nearCm: 5, farCm: 30, direction: "nose", strengthPercent: 60)) + XCTAssertTrue(controller.setRiding(true)) + _ = controller.applyCapability( + accessoryId: "front", capability: capability("clearance", min: 10, max: 20), + liveManifest: false, rateHz: 20) + let description = try XCTUnwrap(controller.describe("front", "clearance")) + let calibration = try XCTUnwrap(description["calibration"] as? [String: Any?]) + XCTAssertNil(calibration["problem"] ?? nil) + + XCTAssertTrue(controller.setPreview("front", "clearance", open: true)) + XCTAssertTrue(controller.releasePreviews()) + XCTAssertEqual(controller.describe("front", "clearance")?["measuring"] as? Bool, true) + + _ = controller.applyCapability( + accessoryId: "rear", capability: capability("clearance"), liveManifest: true, rateHz: 20) + controller.applyCalibration( + "rear", "clearance", + GroundClearanceCalibration(nearCm: 5, farCm: 30, direction: "tail", strengthPercent: 60)) + _ = controller.applyCapability( + accessoryId: "rear", capability: capability("clearance"), liveManifest: false, rateHz: 20) + let connected = { (_: String, _: String) in + GroundClearanceBindingController.LinkState(connected: true, appliedRateHz: 20) + } + XCTAssertTrue(controller.bound(connected)) + XCTAssertEqual(controller.tilt(connected), .release(reason: .contested)) + } + + func testBindingControllerEmitsOnlyForPreviewAndInvalidatesSessionReadings() { + let controller = GroundClearanceBindingController(nowMs: { 1_100 }) + let capability = AccessoryCapability( + id: "clearance", type: AccessoryProtocol.typeGroundClearance, supported: true, unit: "cm", + rangeMin: 3, rangeMax: 100, ratesHz: [20]) + _ = controller.applyCapability( + accessoryId: "sensor", capability: capability, liveManifest: true, rateHz: 20) + controller.applyCalibration( + "sensor", "clearance", + GroundClearanceCalibration(nearCm: 5, farCm: 20, direction: "nose", strengthPercent: 100)) + _ = controller.setRiding(true) + _ = controller.applyCapability( + accessoryId: "sensor", capability: capability, liveManifest: false, rateHz: 20) + let reading = AccessoryReading( + capabilityId: "clearance", seq: 1, sampleTimeMs: 100, status: .ok, valueCm: 12.5) + XCTAssertNil(controller.acceptReading("sensor", reading, receivedAtMs: 1_000, appliedRateHz: 20)) + _ = controller.setPreview("sensor", "clearance", open: true) + let next = AccessoryReading( + capabilityId: "clearance", seq: 2, sampleTimeMs: 110, status: .ok, valueCm: 12.5) + XCTAssertNotNil(controller.acceptReading("sensor", next, receivedAtMs: 1_000, appliedRateHz: 20)) + controller.onSessionLost("sensor") + XCTAssertEqual( + controller.input( + "sensor", "clearance", + link: GroundClearanceBindingController.LinkState(connected: true, appliedRateHz: 20)), + .release(reason: .stale)) + } +} diff --git a/modules/vescape-core/ios/connection/BoardSessionController.swift b/modules/vescape-core/ios/connection/BoardSessionController.swift index 4ac78c2b..74329a0e 100644 --- a/modules/vescape-core/ios/connection/BoardSessionController.swift +++ b/modules/vescape-core/ios/connection/BoardSessionController.swift @@ -154,6 +154,21 @@ internal final class BoardSessionController: VescGattListener { }, scheduler: scheduler ) + + /// The single writer of the Board's remote-input slot: the rider's pad, Board Move, and a + /// ground-clearance Accessory all reach the two controllers above only through this. + /// + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt `remoteInput` + private lazy var remoteInput = RemoteInputArbiter( + tilt: remoteTiltController, + move: boardMoveController, + nowMs: { Int64(ProcessInfo.processInfo.systemUptime * 1000) }, + sensorBound: { AccessorySessionController.shared.groundClearanceBound() } + ) + private lazy var groundClearanceBinding = BoardGroundClearanceBinding( + remoteInput: remoteInput, + boundInput: { AccessorySessionController.shared.groundClearanceBound() }, + tiltInput: { AccessorySessionController.shared.groundClearanceTilt() }) /// The clock this session stamps and compares its data against. Wall time for every real session; /// a replay swaps in its own for the session's lifetime so a warmed-up playback writes a timeline /// that agrees with itself. Never read directly — go through `nowMs()`. @@ -545,6 +560,10 @@ internal final class BoardSessionController: VescGattListener { var wire: [String: Any?] = [ "value": remoteTiltController.currentValue, "phase": phase.wireValue, + // Who asked for this tilt. The pad renders the same stream either way, but "the board is + // holding a tilt you did not command" and "the board is holding yours" are not the same + // sentence to read while standing on it. + "owner": remoteInput.owner.wire, ] if let decay = remoteTiltController.decayProgress { wire["decay"] = ["elapsedMs": decay.elapsedMs, "totalMs": decay.totalMs] @@ -552,6 +571,56 @@ internal final class BoardSessionController: VescGattListener { return wire } + // MARK: - Ground-clearance tilt + + /// How often the ground-clearance binding re-decides what the Board is told. + /// + /// The same 100 ms `RemoteTiltController` repeats a held value on, so a decision never sits unsent + /// for longer than the stream it feeds. It is a *timer*, not a reaction to samples, and that is the + /// point: a sensor that stops sending produces no events to react to, and releasing on silence is + /// the behaviour this whole slice exists for. + /// + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt `startGroundClearanceTilt` + private func startGroundClearanceTilt(session: BoardSession) { + let scheduler = scheduler + groundClearanceBinding.start( + schedule: { [weak self] tick in + scheduler.postDelayedForSession( + session, delayMs: BoardGroundClearanceBinding.tickMs, + isCurrent: { [weak self] in $0 === self?.session } + ) { _ in tick() } + }, + boardInput: { [weak self] in + guard let self else { + return BoardGroundClearanceBinding.BoardInput(commandsTrusted: false, telemetryFresh: false) + } + return BoardGroundClearanceBinding.BoardInput( + commandsTrusted: self.firmwareCommandsTrusted(), + telemetryFresh: self.latestTelemetry != nil && !self.isTelemetryStale()) + }) + } + + /// Stops the binding and lets go of anything it was commanding. + /// + /// The release happens here rather than being left to the next tick, because the next tick is the + /// thing being cancelled. A binding whose timer was stopped while it held a tilt would leave the + /// Board holding that tilt until the firmware's own ~1s remote-input timeout. + private func stopGroundClearanceTilt() { + groundClearanceBinding.stop() + } + + /// What the binding is doing, for the Remote Tilt pad to render. + /// + /// Read synchronously off the bridge by the pad's own poll rather than pushed as live state: the + /// only consumer is a screen that is already polling the commanded tilt at the same rate, and a + /// 10 Hz event carrying a release reason that mostly does not change would be pure bridge traffic. + /// + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt `groundClearanceTiltState` + /// @parity /modules/vescape-core/src/index.ts `GroundClearanceTiltState` + func groundClearanceTiltState() -> [String: Any?] { + groundClearanceBinding.state() + } + /// The board's lights as its last echo reported them, or `nil` while this session has never heard /// one — the board is not saying, so JS shows nothing rather than a guess. /// @@ -638,17 +707,17 @@ internal final class BoardSessionController: VescGattListener { /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt `setRemoteTilt` func setRemoteTilt(value: Int) -> Bool { - firmwareCommandsTrusted() && remoteTiltController.hold(value) + firmwareCommandsTrusted() && remoteInput.manualHold(value) } /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt `lockRemoteTilt` func lockRemoteTilt(value: Int) -> Bool { - firmwareCommandsTrusted() && remoteTiltController.lock(value) + firmwareCommandsTrusted() && remoteInput.manualLock(value) } /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt `releaseRemoteTilt` func releaseRemoteTilt(value: Int, durationMs: Int64) -> Bool { - firmwareCommandsTrusted() && remoteTiltController.release(value, durationMs: durationMs) + firmwareCommandsTrusted() && remoteInput.manualRelease(value, durationMs: durationMs) } /// Eases the active tilt back to neutral rather than snapping — a step to neutral from a large @@ -657,19 +726,21 @@ internal final class BoardSessionController: VescGattListener { /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt `stopRemoteTilt` // Cancellation must remain available if link trust changes during an active tilt. func stopRemoteTilt() -> Bool { - remoteTiltController.cancel() + remoteInput.cancelTilt() } /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt `startBoardMove` + /// Board Move takes the remote-input slot from any tilt stream still holding it, and is refused + /// outright while a sensor is correcting — see `RemoteInputArbiter.startMove`. func startBoardMove(input: Int) -> Bool { - boardMoveController.hold(input) + remoteInput.startMove(input) } /// Deliberately ungated: a stop must reach the board even if the link lost trust mid-hold, /// otherwise the rider's release does nothing and the board coasts to the firmware timeout. /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt `stopBoardMove` func stopBoardMove() -> Bool { - boardMoveController.stop() + remoteInput.stopMove() } private func firmwareCommandsTrusted() -> Bool { @@ -1336,8 +1407,8 @@ internal final class BoardSessionController: VescGattListener { // Nothing left to resurrect: drop the trapdoor so the next cold start stays BLE-free (ADR 0034). SessionResumeStore.shared.clear() clearPendingResume() - boardMoveController.stop() - _ = remoteTiltController.stop() + stopGroundClearanceTilt() + remoteInput.reset() // Final write so the persisted last battery is fresh, not up to 30s stale (runs before config clears). persistLastBattery(percent: latestBatterySoc, voltage: latestBatteryVoltage, now: nowMs(), force: true) latestBatterySoc = nil @@ -1812,6 +1883,13 @@ internal final class BoardSessionController: VescGattListener { // Refloat fault mode: a state signal with zeroed metrics, never a Telemetry Sample. It // opens/extends a VESC Fault Occurrence and stops here — persisting or aggregating it would // poison Ride History with a frame of zeros. + // + // It also ends riding as far as Accessories are concerned. A fault frame carries zeroed + // metrics and no engagement, so falling through to the normal path would leave the last + // engaged sample standing and keep a sensor measuring — and eligible to drive tilt — for as + // long as the board keeps faulting. + AccessorySessionController.shared.setRiding(false) + AccessorySessionController.shared.clearLightTelemetry() onRefloatFaultFrame(telemetry.faultCode) return } @@ -2241,6 +2319,11 @@ internal final class BoardSessionController: VescGattListener { if let capture = telemetryCapture(telemetry) { updateIdlePause(capture) + // Measurement demand follows the Board's own engagement, not the recorder's: a rider with + // recording turned off is still riding. #479 reads the arbitrated input back out of the same + // runtime to drive Remote Tilt. + AccessorySessionController.shared.setRiding(isRefloatEngaged(state: capture.telemetry.state)) + AccessorySessionController.shared.setLightTelemetry(speedKmh: telemetry.speed, riding: isRefloatEngaged(state: capture.telemetry.state)) // Skip persistence while idle-paused; the live tick, series, and Live Activity above keep // running off the ~1 Hz keepalive. When recording is off, recordTelemetry is already a no-op. if !idlePauseDetector.isPaused { @@ -2724,11 +2807,20 @@ internal final class BoardSessionController: VescGattListener { bmsPayload: config?.hasBms == true ? bmsPayload() : nil, pollIntervalMs: effectivePollIntervalMs() ) + startGroundClearanceTilt(session: session) } private func stopPolling() { polling = false pollingLoop.stop() + // The binding reads riding off telemetry, so a Board that stopped being polled is a Board that + // stopped being evidence. The tick is what releases the tilt, so it outlives the poll loop by + // exactly one pass: `stopGroundClearanceTilt` cancels before the timer dies. + stopGroundClearanceTilt() + // No telemetry means no evidence of riding. An Accessory left measuring on the strength of the + // last sample before the Board went away would keep its sensor running indefinitely. + AccessorySessionController.shared.setRiding(false) + AccessorySessionController.shared.clearLightTelemetry() cancelStaleWatchdog() idlePauseDetector.reset() liveSeries.stop() 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..6fef07b2 --- /dev/null +++ b/modules/vescape-core/ios/telemetry/AccessoryPersistence.swift @@ -0,0 +1,333 @@ +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? +} + +/// What the rider calibrated for one ground-clearance capability. +/// +/// Keyed on the Accessory *and* the capability, never on the Accessory alone: the protocol lets one +/// unit declare several measurement capabilities, and the eventual hardware has a nose sensor and a +/// tail sensor on the same board. Collapsing this onto the Accessory row would make those two share +/// a calibration, which is the one thing they can never do. +/// +/// There is no partial row and no draft. A calibration is written when it is complete and valid, so +/// anything stored here was a usable calibration at the moment it was saved. Whether it is still one +/// is decided against the live manifest every session. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `AccessoryGroundClearanceEntity` +/// @parity /modules/vescape-core/ios/accessory/GroundClearance.swift `GroundClearanceCalibration` +struct SavedGroundClearance: Equatable { + let accessoryId: String + /// Stable within the Accessory and across firmware updates, exactly as the manifest declares it. + let capabilityId: String + /// Clearance at which correction is at full strength. Always below `farCm`. + let nearCm: Double + /// Clearance at which correction starts. Above it nothing is commanded. + let farCm: Double + /// Raw wire value for where the sensor is mounted. A value this app cannot read is incomplete. + let direction: String + /// Maximum Remote Tilt input this binding may command, as a percentage. + let strengthPercent: Int + let updatedAt: 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) + } + } + + private struct GroundClearanceRecord: Codable, FetchableRecord, PersistableRecord { + static let databaseTableName = "accessory_ground_clearance" + let accessoryId: String + let capabilityId: String + let nearCm: Double + let farCm: Double + let direction: String + let strengthPercent: Int + let updatedAt: Int64 + + enum CodingKeys: String, CodingKey { + case accessoryId = "accessory_id" + case capabilityId = "capability_id" + case nearCm = "near_cm" + case farCm = "far_cm" + case direction + case strengthPercent = "strength_percent" + case updatedAt = "updated_at" + } + + init(_ calibration: SavedGroundClearance) { + accessoryId = calibration.accessoryId + capabilityId = calibration.capabilityId + nearCm = calibration.nearCm + farCm = calibration.farCm + direction = calibration.direction + strengthPercent = calibration.strengthPercent + updatedAt = calibration.updatedAt + } + + var calibration: SavedGroundClearance { + .init( + accessoryId: accessoryId, capabilityId: capabilityId, nearCm: nearCm, farCm: farCm, + direction: direction, strengthPercent: strengthPercent, updatedAt: updatedAt) + } + } + + static func createTables(_ db: Database) throws { + try PersistenceSchema.createAccessories(db) + try PersistenceSchema.createAccessoryGroundClearance(db) + try PersistenceSchema.createAccessoryBrakeLight(db) + try PersistenceSchema.createAccessoryCapabilitySettings(db) + if try !db.columns(in: "accessory_capability_settings").contains(where: { $0.name == "sampling_rate_hz" }) { + try db.execute(sql: "ALTER TABLE accessory_capability_settings ADD COLUMN sampling_rate_hz REAL") + } + } + + 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. `enrolledAt` is preserved when the row already exists: re-adding an Accessory the + /// rider already has is not a new enrollment. + @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 + } + } + + /// Forgetting takes the enrollment and every calibration made against it, in one transaction. + /// + /// The calibration goes first. Deleting the identity alone would leave rows nothing can reach and + /// nothing can clean up — and re-adding the same hardware later would find them and drive the + /// board to numbers the rider set for a mounting position they have since changed. + @discardableResult + func forget(_ accessoryId: String) throws -> Bool { + try writer().write { db in + try db.execute( + sql: "DELETE FROM accessory_ground_clearance WHERE accessory_id = ?", + arguments: [accessoryId]) + try db.execute(sql: "DELETE FROM accessory_brake_light WHERE accessory_id = ?", arguments: [accessoryId]) + try db.execute(sql: "DELETE FROM accessory_capability_settings WHERE accessory_id = ?", arguments: [accessoryId]) + return try Record.deleteOne(db, key: ["accessory_id": accessoryId]) + } + } + + /// Adopts the capability set the current manifest declares as the new baseline. + /// + /// The counterpart to `revalidate` leaving `capabilities_json` alone. That preservation is what + /// keeps "this Accessory now declares different limits" alive across a restart; this is the rider + /// answering it, by saving a calibration that fits what the hardware says today. + @discardableResult + func adoptCapabilities(_ accessoryId: String, capabilitiesJson: String) throws -> Bool { + try writer().write { db in + try db.execute( + sql: "UPDATE accessories SET capabilities_json = ? WHERE accessory_id = ?", + arguments: [capabilitiesJson, accessoryId]) + return db.changesCount > 0 + } + } + + func brakeLights() throws -> [SavedBrakeLight] { + try writer().read { db in try SavedBrakeLight.order(Column("accessory_id"), Column("capability_id")).fetchAll(db) } + } + + func capabilitySettings() throws -> [SavedAccessoryCapabilitySettings] { + try writer().read { db in + try SavedAccessoryCapabilitySettings.order(Column("accessory_id"), Column("capability_id")).fetchAll(db) + } + } + + func saveCapabilitySettings(_ settings: SavedAccessoryCapabilitySettings) throws { + try writer().write { db in + guard try Record.fetchOne(db, key: ["accessory_id": settings.accessoryId]) != nil else { throw WriterUnavailable() } + try settings.save(db) + } + } + + func saveBrakeLight(_ settings: SavedBrakeLight) throws { + try writer().write { db in + guard try Record.fetchOne(db, key: ["accessory_id": settings.accessoryId]) != nil else { throw WriterUnavailable() } + try settings.save(db) + } + } + + // MARK: - Ground-clearance calibration + + func groundClearances() throws -> [SavedGroundClearance] { + try writer().read { db in + try GroundClearanceRecord + .order(Column("accessory_id"), Column("capability_id")) + .fetchAll(db).map(\.calibration) + } + } + + func groundClearance(_ accessoryId: String, _ capabilityId: String) throws -> SavedGroundClearance? + { + try writer().read { db in + try GroundClearanceRecord.fetchOne( + db, key: ["accessory_id": accessoryId, "capability_id": capabilityId])?.calibration + } + } + + /// Saves one complete calibration. + /// + /// There is no Save button behind this and no draft state in the table: the screen calls it when + /// what the rider has entered is complete and valid, so every row here was usable at the moment + /// it was written. Validity against the *current* manifest is re-decided on every session. + func saveGroundClearance(_ calibration: SavedGroundClearance) throws { + try writer().write { db in try GroundClearanceRecord(calibration).save(db) } + } + + @discardableResult + func clearGroundClearance(_ accessoryId: String, _ capabilityId: String) throws -> Bool { + try writer().write { db in + try GroundClearanceRecord.deleteOne( + db, key: ["accessory_id": accessoryId, "capability_id": capabilityId]) + } + } + + /// Refreshes what the last handshake observed, for an Accessory that is still enrolled. + /// + /// Update-only, and deliberately not an upsert: a handshake that completes just as the rider + /// forgets the Accessory would otherwise resurrect the row it just deleted, and the next launch + /// would auto-connect hardware the rider removed. A single UPDATE is a no-op on a missing row. + /// + /// `capabilities_json` is **not** touched. It is the baseline the rider's saved settings were + /// validated against, so it stays put until a capability's own setup accepts the new limits; + /// overwriting it here would make the "limits changed" warning disappear on the next launch. + @discardableResult + func revalidate(_ accessory: SavedAccessory) throws -> Bool { + try writer().write { db in + try db.execute( + sql: "UPDATE accessories SET name = ?, firmware_version = ?, protocol_version = ?, device_id = ?, last_connected_at = ? WHERE accessory_id = ?", + arguments: [ + accessory.name, accessory.firmwareVersion, accessory.protocolVersion, + accessory.deviceId, accessory.lastConnectedAt, accessory.accessoryId, + ]) + return db.changesCount > 0 + } + } +} + +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `AccessoryBrakeLightEntity` +struct SavedBrakeLight: Codable, FetchableRecord, PersistableRecord, Equatable { + static let databaseTableName = "accessory_brake_light" + let accessoryId: String + let capabilityId: String + let sensitivity: Int + let parked: String + enum CodingKeys: String, CodingKey { + case accessoryId = "accessory_id", capabilityId = "capability_id", sensitivity, parked + } +} + +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `AccessoryCapabilitySettingsEntity` +struct SavedAccessoryCapabilitySettings: Codable, FetchableRecord, PersistableRecord, Equatable { + static let databaseTableName = "accessory_capability_settings" + let accessoryId: String + let capabilityId: String + let enabled: Bool + var samplingRateHz: Double? = nil + enum CodingKeys: String, CodingKey { + case accessoryId = "accessory_id", capabilityId = "capability_id", enabled + case samplingRateHz = "sampling_rate_hz" + } +} diff --git a/modules/vescape-core/ios/telemetry/DatabaseBackupManager.swift b/modules/vescape-core/ios/telemetry/DatabaseBackupManager.swift index 50059d04..d4a681b5 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 = 48 /// 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/IdlePauseDetector.swift b/modules/vescape-core/ios/telemetry/IdlePauseDetector.swift index 101c7ee9..5764f2f4 100644 --- a/modules/vescape-core/ios/telemetry/IdlePauseDetector.swift +++ b/modules/vescape-core/ios/telemetry/IdlePauseDetector.swift @@ -8,6 +8,19 @@ internal enum IdlePauseTransition { case resumed } +/// Whether the Board is carrying a rider, from one Refloat state word. +/// +/// RUNNING, TILTBACK and WHEELSLIP are all engaged — a board balancing at a standstill is being +/// ridden, and one in tiltback is being ridden badly. Everything else, including the ready state a +/// board sits in on the ground, is not. +/// +/// The one place this is decided. Idle Pause and Accessory measurement demand both ask it, and a +/// second copy of the nibble arithmetic would be a second definition of "riding" that could drift. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/IdlePauseDetector.kt `isRefloatEngaged` +// GET_ALLDATA packs state_compat in the lower nibble and saturation in the upper nibble. +internal func isRefloatEngaged(state: Int) -> Bool { (1...3).contains(state & 0x0f) } + /// Pauses recording on the first disengaged Refloat sample and resumes on the first engaged sample. /// RUNNING, TILTBACK, and WHEELSLIP remain engaged, including while balancing at zero speed. /// Paused polling stays at ~1 Hz, so detecting engagement can take about a second (ADR-0021). @@ -19,8 +32,7 @@ internal final class IdlePauseDetector { var isPaused: Bool { paused } func onSample(state: Int) -> IdlePauseTransition? { - // GET_ALLDATA packs state_compat in the lower nibble and saturation in the upper nibble. - let nextPaused = !(1...3).contains(state & 0x0f) + let nextPaused = !isRefloatEngaged(state: state) guard nextPaused != paused else { return nil } paused = nextPaused return paused ? .paused : .resumed diff --git a/modules/vescape-core/ios/telemetry/PersistenceSchema.swift b/modules/vescape-core/ios/telemetry/PersistenceSchema.swift index 402e5080..8601ddbb 100644 --- a/modules/vescape-core/ios/telemetry/PersistenceSchema.swift +++ b/modules/vescape-core/ios/telemetry/PersistenceSchema.swift @@ -49,4 +49,33 @@ 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)") + } + + /// Ground-clearance calibration, keyed on the Accessory and the capability it was made for. + /// + /// A table rather than a column on `accessories`: one unit may declare several measurement + /// capabilities — the eventual hardware has a nose sensor and a tail sensor on one board — and + /// they cannot share near/far distances or a correction direction. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryMigrations.kt `MIGRATION_44_45` + static func createAccessoryGroundClearance(_ db: Database) throws { + try db.execute(sql: "CREATE TABLE IF NOT EXISTS accessory_ground_clearance (accessory_id TEXT NOT NULL, capability_id TEXT NOT NULL, near_cm REAL NOT NULL, far_cm REAL NOT NULL, direction TEXT NOT NULL, strength_percent INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY(accessory_id, capability_id))") + } + + /// Brake-light behaviour, keyed the same way and for the same reason: one unit may carry more + /// than one light, and sensitivity and the parked preference belong to a light, not to a unit. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryMigrations.kt `MIGRATION_45_46` + static func createAccessoryBrakeLight(_ db: Database) throws { + try db.execute(sql: "CREATE TABLE IF NOT EXISTS accessory_brake_light (accessory_id TEXT NOT NULL, capability_id TEXT NOT NULL, sensitivity INTEGER NOT NULL, parked TEXT NOT NULL, PRIMARY KEY(accessory_id, capability_id))") + } + + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryMigrations.kt `MIGRATION_46_47` + static func createAccessoryCapabilitySettings(_ db: Database) throws { + try db.execute(sql: "CREATE TABLE IF NOT EXISTS accessory_capability_settings (accessory_id TEXT NOT NULL, capability_id TEXT NOT NULL, enabled INTEGER NOT NULL, PRIMARY KEY(accessory_id, capability_id))") + } } diff --git a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift index 9a31f1fb..62ef22ce 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift @@ -825,6 +825,27 @@ 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) + } + + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryMigrations.kt `MIGRATION_44_45` + migrator.registerMigration("v45_accessory_ground_clearance") { db in + try PersistenceSchema.createAccessoryGroundClearance(db) + } + + migrator.registerMigration("v46_accessory_brake_light") { db in + try PersistenceSchema.createAccessoryBrakeLight(db) + } + migrator.registerMigration("v47_accessory_capability_settings") { db in + try PersistenceSchema.createAccessoryCapabilitySettings(db) + } + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryMigrations.kt `MIGRATION_47_48` + migrator.registerMigration("v48_accessory_sampling_rate") { db in + try db.execute(sql: "ALTER TABLE accessory_capability_settings ADD COLUMN sampling_rate_hz REAL") + } + 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..92f10eb7 --- /dev/null +++ b/modules/vescape-core/persistence-jvm/src/test/kotlin/expo/modules/vescapecore/telemetry/AccessoryPersistenceHostTest.kt @@ -0,0 +1,264 @@ +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. + * + * The second scenario covers what the rider calibrated for it: keyed on the capability as well, so a + * nose sensor and a tail sensor on one unit never share numbers, and taken with the Accessory when + * it is forgotten. + * + * @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 observed = enrolled.copy( + name = spec.getString("renamedTo"), + firmwareVersion = spec.getString("updatedFirmwareVersion"), + deviceId = spec.getString("movedDeviceId"), + capabilitiesJson = spec.getString("changedCapabilitiesJson"), + enrolledAt = spec.getLong("reEnrolledAt"), + lastConnectedAt = spec.getLong("connectedAt"), + ) + assertTrue(store.revalidate(observed)) + assertEquals(2, store.getAccessories().size) + + // Update-only: a handshake landing after the rider forgot an Accessory must not recreate it. + assertFalse(store.revalidate(observed.copy(accessoryId = "not-enrolled"))) + assertEquals(2, store.getAccessories().size) + db.close() + + db = open() + store = AccessoryPersistence(db.telemetryDao()) + val persisted = store.getAccessory(spec.getString("accessoryId"))!! + assertEquals(spec.getString("renamedTo"), persisted.name) + assertEquals(spec.getString("updatedFirmwareVersion"), persisted.firmwareVersion) + assertEquals(spec.getString("movedDeviceId"), persisted.deviceId) + assertEquals(spec.getLong("connectedAt"), persisted.lastConnectedAt) + // Reading a manifest again is not adding the Accessory again. + assertEquals(spec.getLong("enrolledAt"), persisted.enrolledAt) + // The baseline the rider's saved settings were validated against survives revalidation. It is + // what the "declared limits changed" warning is derived from, so overwriting it here would make + // the warning vanish on the next launch. + assertEquals(spec.getString("capabilitiesJson"), persisted.capabilitiesJson) + + // 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) + } + + @Test fun calibrationSurvivesRestartAndIsForgottenWithItsAccessory(): Unit = runBlocking { + val contract = fixture() + val spec = contract.getJSONObject("accessory") + val other = contract.getJSONObject("other") + val clearance = contract.getJSONObject("groundClearance") + val accessoryId = spec.getString("accessoryId") + val otherId = other.getString("accessoryId") + val nose = clearance.getString("capabilityId") + val tail = clearance.getString("tailCapabilityId") + + val path = Files.createTempFile("vescape-ground-clearance", ".db") + Files.deleteIfExists(path) + fun open() = Room.databaseBuilder(path.toString()) + .setDriver(BundledSQLiteDriver()) + .build() + + fun row(owner: String, capabilityId: String, json: JSONObject) = AccessoryGroundClearanceEntity( + accessoryId = owner, + capabilityId = capabilityId, + nearCm = json.getDouble("nearCm"), + farCm = json.getDouble("farCm"), + direction = json.getString("direction"), + strengthPercent = json.getInt("strengthPercent"), + updatedAt = json.getLong("updatedAt"), + ) + + var db = open() + var store = AccessoryPersistence(db.telemetryDao()) + for (owner in listOf(spec, other)) { + store.upsert( + SavedAccessoryEntity( + accessoryId = owner.getString("accessoryId"), + name = owner.getString("name"), + firmwareVersion = owner.getString("firmwareVersion"), + protocolVersion = owner.getInt("protocolVersion"), + deviceId = owner.getString("deviceId"), + capabilitiesJson = owner.getString("capabilitiesJson"), + enrolledAt = owner.getLong("enrolledAt"), + lastConnectedAt = null, + ), + ) + } + val light = contract.getJSONObject("brakeLight") + val lightRow = AccessoryBrakeLightEntity(accessoryId, light.getString("capabilityId"), light.getInt("sensitivity"), light.getString("parked")) + store.saveBrakeLight(lightRow) + store.saveBrakeLight(lightRow.copy(accessoryId = otherId)) + val switchSpec = contract.getJSONObject("capabilitySettings") + val switchRow = AccessoryCapabilitySettingsEntity(accessoryId, nose, switchSpec.getBoolean("enabled"), switchSpec.getDouble("samplingRateHz")) + val otherSwitch = switchRow.copy(accessoryId = otherId, samplingRateHz = switchSpec.getDouble("otherSamplingRateHz")) + store.saveCapabilitySettings(switchRow) + store.saveCapabilitySettings(otherSwitch) + store.saveGroundClearance(row(accessoryId, nose, clearance.getJSONObject("calibration"))) + store.saveGroundClearance(row(accessoryId, tail, clearance.getJSONObject("tailCalibration"))) + store.saveGroundClearance( + row(otherId, other.getString("capabilityId"), other.getJSONObject("calibration")), + ) + db.close() + + // Settings survive restart: the whole reason this is a table and not process state. + db = open() + store = AccessoryPersistence(db.telemetryDao()) + assertEquals(listOf(lightRow, lightRow.copy(accessoryId = otherId)).sortedBy { it.accessoryId }, store.getBrakeLights()) + assertEquals(listOf(switchRow, otherSwitch).sortedBy { it.accessoryId }, store.getCapabilitySettings()) + val reopened = store.getGroundClearance(accessoryId, nose)!! + assertEquals(clearance.getJSONObject("calibration").getDouble("nearCm"), reopened.nearCm, 0.0) + assertEquals(clearance.getJSONObject("calibration").getDouble("farCm"), reopened.farCm, 0.0) + assertEquals(clearance.getJSONObject("calibration").getString("direction"), reopened.direction) + assertEquals( + clearance.getJSONObject("calibration").getInt("strengthPercent"), + reopened.strengthPercent, + ) + assertEquals(3, store.getGroundClearances().size) + + // The composite key doing its job: recalibrating the nose sensor leaves the tail sensor alone. + // Keyed on the Accessory alone, the second row would have overwritten the first. + store.saveGroundClearance(row(accessoryId, nose, clearance.getJSONObject("recalibrated"))) + assertEquals(3, store.getGroundClearances().size) + assertEquals( + clearance.getJSONObject("recalibrated").getDouble("nearCm"), + store.getGroundClearance(accessoryId, nose)!!.nearCm, + 0.0, + ) + assertEquals( + clearance.getJSONObject("tailCalibration").getString("direction"), + store.getGroundClearance(accessoryId, tail)!!.direction, + ) + + // Reading a manifest again must not disturb what the rider set. + assertTrue( + store.revalidate( + store.getAccessory(accessoryId)!!.copy( + name = spec.getString("renamedTo"), + lastConnectedAt = spec.getLong("connectedAt"), + ), + ), + ) + assertEquals(3, store.getGroundClearances().size) + // ...and it must not move the baseline either. Only accepting new limits does that. + assertEquals(spec.getString("capabilitiesJson"), store.getAccessory(accessoryId)!!.capabilitiesJson) + + // Toggling preserves calibration and the other accessory's switch. + store.saveCapabilitySettings(switchRow.copy(enabled = true)) + assertEquals(switchRow.samplingRateHz, store.getCapabilitySettings().first { it.accessoryId == accessoryId }.samplingRateHz) + assertEquals(3, store.getGroundClearances().size) + assertEquals(otherSwitch, store.getCapabilitySettings().first { it.accessoryId == otherId }) + store.saveCapabilitySettings(switchRow) + + // Accepting limits that moved, which is what saving a fitting calibration means. + assertTrue(store.adoptCapabilities(accessoryId, spec.getString("changedCapabilitiesJson"))) + assertEquals( + spec.getString("changedCapabilitiesJson"), + store.getAccessory(accessoryId)!!.capabilitiesJson, + ) + assertFalse(store.adoptCapabilities("not-enrolled", spec.getString("capabilitiesJson"))) + db.close() + + db = open() + store = AccessoryPersistence(db.telemetryDao()) + assertTrue(store.clearGroundClearance(accessoryId, tail)) + assertFalse(store.clearGroundClearance(accessoryId, tail)) + assertEquals(2, store.getGroundClearances().size) + + // Forgetting takes the Accessory and every calibration made against it, and nothing else. + assertTrue(store.forget(accessoryId)) + assertEquals(listOf(lightRow.copy(accessoryId = otherId)), store.getBrakeLights()) + assertEquals(listOf(otherSwitch), store.getCapabilitySettings()) + var rejectedOrphan = false + try { store.saveCapabilitySettings(switchRow) } catch (_: IllegalStateException) { rejectedOrphan = true } + assertTrue(rejectedOrphan) + db.close() + + db = open() + store = AccessoryPersistence(db.telemetryDao()) + assertNull(store.getGroundClearance(accessoryId, nose)) + assertEquals( + listOf(otherId), + store.getGroundClearances().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..b13ad6f5 100644 --- a/modules/vescape-core/persistence-macos/main.swift +++ b/modules/vescape-core/persistence-macos/main.swift @@ -979,6 +979,270 @@ 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 observedAccessory = savedAccessory( + accessorySpec, + overrides: [ + "name": accessorySpec["renamedTo"]!, + "firmwareVersion": accessorySpec["updatedFirmwareVersion"]!, + "deviceId": accessorySpec["movedDeviceId"]!, + "capabilitiesJson": accessorySpec["changedCapabilitiesJson"]!, + "enrolledAt": accessorySpec["reEnrolledAt"]!, + "lastConnectedAt": accessorySpec["connectedAt"]!, + ]) +let revalidated = try accessoryStore.revalidate(observedAccessory) +try require(revalidated, "Accessory revalidate") +let afterRevalidation = try accessoryStore.accessories() +try require(afterRevalidation.count == 2, "rename duplicated an Accessory") + +// Update-only: a handshake landing after the rider forgot an Accessory must not recreate it. +let revalidatedUnknown = try accessoryStore.revalidate( + savedAccessory(accessorySpec, overrides: ["accessoryId": "not-enrolled"])) +try require(!revalidatedUnknown, "revalidate invented an Accessory") +let afterUnknownRevalidation = try accessoryStore.accessories() +try require(afterUnknownRevalidation.count == 2, "revalidate added a row") +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?.name == accessorySpec["renamedTo"] as? String, "Accessory rename reopen") +try require( + persistedAccessory?.firmwareVersion == accessorySpec["updatedFirmwareVersion"] as? String, + "Accessory firmware reopen") +// Reading a manifest again is not adding the Accessory again. +try require( + persistedAccessory?.enrolledAt == Int64(int(accessorySpec["enrolledAt"])), + "Accessory enrolledAt moved") +// The baseline the rider's saved settings were validated against survives revalidation. It is what +// the "declared limits changed" warning is derived from, so overwriting it here would make the +// warning vanish on the next launch. +try require( + persistedAccessory?.capabilitiesJson == accessorySpec["capabilitiesJson"] as? String, + "Accessory capability baseline overwritten") +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) + +// Ground-clearance calibration, keyed on the Accessory *and* the capability. +// +// The composite key is the point: one unit declaring a nose sensor and a tail sensor keeps two +// independent calibrations, and re-saving one must leave the other exactly as it was. Revalidating a +// manifest must not disturb either; forgetting the Accessory must take both, because a re-enrollment +// that inherited old numbers would drive the board to a mounting position the rider has since +// changed. +// +// @parity /modules/vescape-core/persistence-jvm/src/test/kotlin/expo/modules/vescapecore/telemetry/AccessoryPersistenceHostTest.kt `calibrationSurvivesRestartAndIsForgottenWithItsAccessory` +let clearanceSpec = accessoryFixture["groundClearance"] as! [String: Any] +let clearanceOwner = accessorySpec["accessoryId"] as! String +let clearanceOtherOwner = otherAccessorySpec["accessoryId"] as! String +let noseCapability = clearanceSpec["capabilityId"] as! String +let tailCapability = clearanceSpec["tailCapabilityId"] as! String +let noseCalibrationSpec = clearanceSpec["calibration"] as! [String: Any] +let noseRecalibratedSpec = clearanceSpec["recalibrated"] as! [String: Any] +let tailCalibrationSpec = clearanceSpec["tailCalibration"] as! [String: Any] + +func savedCalibration(_ owner: String, _ capabilityId: String, _ spec: [String: Any]) + -> SavedGroundClearance +{ + SavedGroundClearance( + accessoryId: owner, capabilityId: capabilityId, + nearCm: (spec["nearCm"] as! NSNumber).doubleValue, + farCm: (spec["farCm"] as! NSNumber).doubleValue, + direction: spec["direction"] as! String, + strengthPercent: (spec["strengthPercent"] as! NSNumber).intValue, + updatedAt: Int64(int(spec["updatedAt"]))) +} + +let clearanceURL = FileManager.default.temporaryDirectory + .appendingPathComponent("vescape-ground-clearance-\(UUID().uuidString).db") +var clearanceQueue: DatabaseQueue? = try DatabaseQueue(path: clearanceURL.path) +try TelemetryDatabase.migrator.migrate(clearanceQueue!) +var clearanceStore = AccessoryStore(dbWriter: clearanceQueue!) +try clearanceStore.upsert(savedAccessory(accessorySpec)) +try clearanceStore.upsert(savedAccessory(otherAccessorySpec)) +try clearanceStore.saveGroundClearance( + savedCalibration(clearanceOwner, noseCapability, noseCalibrationSpec)) +try clearanceStore.saveGroundClearance( + savedCalibration(clearanceOwner, tailCapability, tailCalibrationSpec)) +try clearanceStore.saveGroundClearance( + savedCalibration( + clearanceOtherOwner, otherAccessorySpec["capabilityId"] as! String, + otherAccessorySpec["calibration"] as! [String: Any])) +try clearanceQueue!.close() + +// Settings survive restart: the whole reason this is a table and not process state. +clearanceQueue = try DatabaseQueue(path: clearanceURL.path) +clearanceStore = AccessoryStore(dbWriter: clearanceQueue!) +let reopenedCalibration = try clearanceStore.groundClearance(clearanceOwner, noseCapability) +try require( + reopenedCalibration == savedCalibration(clearanceOwner, noseCapability, noseCalibrationSpec), + "calibration did not survive close/reopen") +let reopenedCalibrationCount = try clearanceStore.groundClearances().count +try require(reopenedCalibrationCount == 3, "calibration reopen count") + +// Recalibrating the nose sensor leaves the tail sensor alone. Keyed on the Accessory alone, the +// second row would have overwritten the first. +try clearanceStore.saveGroundClearance( + savedCalibration(clearanceOwner, noseCapability, noseRecalibratedSpec)) +let afterRecalibration = try clearanceStore.groundClearances() +try require(afterRecalibration.count == 3, "recalibration added a row") +let recalibratedNose = try clearanceStore.groundClearance(clearanceOwner, noseCapability) +try require( + recalibratedNose?.nearCm == (noseRecalibratedSpec["nearCm"] as! NSNumber).doubleValue, + "recalibration did not take") +let untouchedTail = try clearanceStore.groundClearance(clearanceOwner, tailCapability) +try require( + untouchedTail?.direction == tailCalibrationSpec["direction"] as? String, + "recalibrating one capability disturbed another") + +// Reading a manifest again must not disturb what the rider set, or move the frozen baseline. +let clearanceRevalidated = try clearanceStore.revalidate( + savedAccessory(accessorySpec, overrides: ["name": accessorySpec["renamedTo"]!])) +try require(clearanceRevalidated, "calibration-scenario revalidate") +let afterClearanceRevalidation = try clearanceStore.groundClearances() +try require(afterClearanceRevalidation.count == 3, "revalidate disturbed a calibration") +let baselineAfterRevalidation = try clearanceStore.accessory(clearanceOwner) +try require( + baselineAfterRevalidation?.capabilitiesJson == accessorySpec["capabilitiesJson"] as? String, + "revalidate moved the capability baseline") + +// Accepting limits that moved, which is what saving a fitting calibration means. +let adopted = try clearanceStore.adoptCapabilities( + clearanceOwner, capabilitiesJson: accessorySpec["changedCapabilitiesJson"] as! String) +try require(adopted, "adoptCapabilities on an enrolled Accessory") +let baselineAfterAdoption = try clearanceStore.accessory(clearanceOwner) +try require( + baselineAfterAdoption?.capabilitiesJson == accessorySpec["changedCapabilitiesJson"] as? String, + "adoptCapabilities did not rewrite the baseline") +let adoptedUnknown = try clearanceStore.adoptCapabilities( + "not-enrolled", capabilitiesJson: accessorySpec["capabilitiesJson"] as! String) +try require(!adoptedUnknown, "adoptCapabilities invented an Accessory") +try clearanceQueue!.close() + +clearanceQueue = try DatabaseQueue(path: clearanceURL.path) +clearanceStore = AccessoryStore(dbWriter: clearanceQueue!) +let clearedTail = try clearanceStore.clearGroundClearance(clearanceOwner, tailCapability) +try require(clearedTail, "clear calibration") +let clearedTailAgain = try clearanceStore.clearGroundClearance(clearanceOwner, tailCapability) +try require(!clearedTailAgain, "clearing twice reported a second removal") +let afterClear = try clearanceStore.groundClearances() +try require(afterClear.count == 2, "clear removed the wrong row") + +let lightSpec = accessoryFixture["brakeLight"] as! [String: Any] +let lightSettings = SavedBrakeLight(accessoryId: clearanceOwner, capabilityId: lightSpec["capabilityId"] as! String, sensitivity: lightSpec["sensitivity"] as! Int, parked: lightSpec["parked"] as! String) +try clearanceStore.saveBrakeLight(lightSettings) +let otherLightSettings = SavedBrakeLight(accessoryId: clearanceOtherOwner, capabilityId: lightSettings.capabilityId, sensitivity: lightSettings.sensitivity, parked: lightSettings.parked) +try clearanceStore.saveBrakeLight(otherLightSettings) +let switchSpec = accessoryFixture["capabilitySettings"] as! [String: Any] +let switchSettings = SavedAccessoryCapabilitySettings(accessoryId: clearanceOwner, capabilityId: noseCapability, enabled: switchSpec["enabled"] as! Bool, samplingRateHz: (switchSpec["samplingRateHz"] as! NSNumber).doubleValue) +let otherSwitchSettings = SavedAccessoryCapabilitySettings(accessoryId: clearanceOtherOwner, capabilityId: noseCapability, enabled: switchSettings.enabled, samplingRateHz: (switchSpec["otherSamplingRateHz"] as! NSNumber).doubleValue) +try clearanceStore.saveCapabilitySettings(switchSettings) +try clearanceStore.saveCapabilitySettings(otherSwitchSettings) +try clearanceQueue!.close() +clearanceQueue = try DatabaseQueue(path: clearanceURL.path) +clearanceStore = AccessoryStore(dbWriter: clearanceQueue!) +let reopenedLights = try clearanceStore.brakeLights() +try require(reopenedLights == [lightSettings, otherLightSettings].sorted { $0.accessoryId < $1.accessoryId }, "light settings survive reopen independently") +let reopenedSwitches = try clearanceStore.capabilitySettings() +try require(reopenedSwitches == [switchSettings, otherSwitchSettings].sorted { $0.accessoryId < $1.accessoryId }, "capability switches survive reopen independently") +let calibrationBeforeToggle = try clearanceStore.groundClearance(clearanceOwner, noseCapability) +try clearanceStore.saveCapabilitySettings(.init(accessoryId: clearanceOwner, capabilityId: noseCapability, enabled: true, samplingRateHz: switchSettings.samplingRateHz)) +let switchesAfterToggle = try clearanceStore.capabilitySettings() +try require(switchesAfterToggle.first { $0.accessoryId == clearanceOwner }?.samplingRateHz == switchSettings.samplingRateHz, "toggle preserves sampling rate") +let calibrationAfterToggle = try clearanceStore.groundClearance(clearanceOwner, noseCapability) +try require(calibrationBeforeToggle == calibrationAfterToggle, "toggling must preserve calibration") +try clearanceStore.saveCapabilitySettings(switchSettings) + +// Forgetting takes the Accessory and every calibration made against it, and nothing else. +let clearanceForgotten = try clearanceStore.forget(clearanceOwner) +try require(clearanceForgotten, "calibration-scenario forget") +try clearanceQueue!.close() + +clearanceQueue = try DatabaseQueue(path: clearanceURL.path) +clearanceStore = AccessoryStore(dbWriter: clearanceQueue!) +let orphanCalibration = try clearanceStore.groundClearance(clearanceOwner, noseCapability) +try require(orphanCalibration == nil, "forget left a calibration behind") +let survivingLights = try clearanceStore.brakeLights() +try require(survivingLights == [otherLightSettings], "forget removes only owned light settings") +let survivingSwitches = try clearanceStore.capabilitySettings() +try require(survivingSwitches == [otherSwitchSettings], "forget removes only owned capability switches") +var rejectedOrphanSwitch = false +do { try clearanceStore.saveCapabilitySettings(switchSettings) } catch { rejectedOrphanSwitch = true } +try require(rejectedOrphanSwitch, "cannot save switches for a forgotten accessory") +let survivingCalibrations = try clearanceStore.groundClearances() +try require( + survivingCalibrations.map(\.accessoryId) == [clearanceOtherOwner], + "forget removed another Accessory's calibration") +try clearanceQueue!.close() +try? FileManager.default.removeItem(at: clearanceURL) + 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..919f0590 --- /dev/null +++ b/modules/vescape-core/shared/accessory-persistence-contract.json @@ -0,0 +1,68 @@ +{ + "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 revalidation 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` and its original `capabilitiesJson` baseline intact. Revalidation is update-only, so it must not recreate a row the rider just forgot. `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, + "capabilityId": "rear_clearance", + "calibration": { + "nearCm": 6, + "farCm": 22, + "direction": "tail", + "strengthPercent": 55, + "updatedAt": 1700000400000 + } + }, + "groundClearance": { + "$comment": "What the rider calibrated, keyed on the Accessory *and* the capability. The scenario proves the composite key does real work: one unit declaring a nose sensor and a tail sensor keeps two independent calibrations, and re-saving one leaves the other exactly as it was. Revalidating a manifest must not disturb a calibration; forgetting the Accessory must take every calibration made against it, because a re-enrollment that inherited old numbers would drive the board to a mounting position the rider has since changed. `adoptCapabilities` is the other half of the frozen baseline: saving a calibration that fits the current manifest is how the rider accepts limits that moved, and it is the only operation allowed to rewrite `capabilities_json`.", + "capabilityId": "clearance", + "tailCapabilityId": "clearance_tail", + "calibration": { + "nearCm": 5, + "farCm": 20, + "direction": "nose", + "strengthPercent": 60, + "updatedAt": 1700000100000 + }, + "recalibrated": { + "nearCm": 7.5, + "farCm": 24, + "direction": "nose", + "strengthPercent": 80, + "updatedAt": 1700000200000 + }, + "tailCalibration": { + "nearCm": 4, + "farCm": 18, + "direction": "tail", + "strengthPercent": 40, + "updatedAt": 1700000300000 + } + }, + "brakeLight": { + "capabilityId": "rear_light", + "sensitivity": 73, + "parked": "glow" + }, + "capabilitySettings": { "enabled": false, "samplingRateHz": 20, "otherSamplingRateHz": 30 } +} diff --git a/modules/vescape-core/shared/migration-fixture-manifest.json b/modules/vescape-core/shared/migration-fixture-manifest.json index 7a80118f..4798718f 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, 45, 46, 47, 48 ], "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, 45, 46, 47, 48 ], - "unsupportedStarts": [1, 2, 37, 38, 39, 44], + "unsupportedStarts": [1, 2, 37, 38, 39, 49], "historicalVariants": [ { "version": 22, "source": "10deb46c^", "shape": "tune_profiles_without_icon_or_color" } ], @@ -41,7 +41,12 @@ "v40_vesc_faults", "v41_board_deleted_at", "v42_telemetry_board_id", - "v43_ride_track" + "v43_ride_track", + "v44_accessories", + "v45_accessory_ground_clearance", + "v46_accessory_brake_light", + "v47_accessory_capability_settings", + "v48_accessory_sampling_rate" ], "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 9600e2dd..c25aa653 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -33,6 +33,399 @@ 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[] + /** Saved switch, independent of calibration and current measurement demand. + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt `describeCapability` + * @parity /modules/vescape-core/ios/accessory/AccessorySessionController.swift `describeCapability` + */ + enabled?: boolean + /** Acknowledged sensor rate, null before configuration is acknowledged. + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt `describeCapability` + * @parity /modules/vescape-core/ios/accessory/AccessorySessionController.swift `describeCapability` + */ + samplingRateHz?: number | null + /** Saved rate resolved against the manifest; 10 Hz preferred until the rider chooses. + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt `describeCapability` + * @parity /modules/vescape-core/ios/accessory/AccessorySessionController.swift `describeCapability` + */ + selectedRateHz?: number | null + /** + * What the rider has saved for this capability, or null when they have not finished a setup. + * + * Rides along with the capability rather than in a list of its own: it is keyed on the capability + * and meaningless without it. Absent on capability types that have nothing to calibrate, and on + * a manifest read by `inspectAccessory`, which reads hardware rather than saved settings. + */ + calibration?: GroundClearanceCalibration | null + /** + * Whether native currently has this capability measuring. + * + * The demand native actually resolved, not a restatement of what a screen asked for: a preview on + * a capability with no usable rate is a screen that is open and a sensor that is not measuring. + */ + measuring?: boolean + /** @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/BrakeLight.kt `describe` + * @parity /modules/vescape-core/ios/accessory/BrakeLight.swift `describe` + */ + brakeLight?: BrakeLightSettings + lightMode?: BrakeLightMode | null + lightPreview?: BrakeLightMode | null +} + +/** + * Which way a mounted ground-clearance sensor corrects. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt `GroundClearanceDirection` + * @parity /modules/vescape-core/ios/accessory/GroundClearance.swift `GroundClearanceDirection` + */ +export type GroundClearanceDirection = 'nose' | 'tail' + +/** @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/BrakeLight.kt `BrakeLightSettings` + * @parity /modules/vescape-core/ios/accessory/BrakeLight.swift `BrakeLightSettings` + */ +export interface BrakeLightSettings { + sensitivity: number + parked: 'off' | 'glow' +} +/** @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/BrakeLight.kt `MODES` + * @parity /modules/vescape-core/ios/accessory/BrakeLight.swift `modes` + */ +export type BrakeLightMode = 'riding' | 'braking' | 'hard_braking' | 'not_riding' + +/** + * Why a calibration is not one yet. Native's verdict, never re-derived here: a second definition of + * "valid" in JS could disagree with the one the binding actually uses. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt `GroundClearanceProblem` + * @parity /modules/vescape-core/ios/accessory/GroundClearance.swift `GroundClearanceProblem` + */ +export type GroundClearanceProblem = + | 'not-a-number' + | 'near-not-below-far' + | 'unknown-direction' + | 'strength-out-of-bounds' + | 'outside-declared-range' + +/** + * What the rider calibrated for one ground-clearance capability. + * + * `farCm` is where correction starts and `nearCm` is where it is at full strength, so `near < far` + * always — less clearance means more correction. `problem` is re-decided against the *live* manifest + * on every push: a firmware that narrowed its measurement range turns a saved calibration into one + * that needs redoing, and this says which rule it now breaks. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt `GroundClearanceCalibration` + * @parity /modules/vescape-core/ios/accessory/GroundClearance.swift `GroundClearanceCalibration` + */ +export interface GroundClearanceCalibration { + nearCm: number + farCm: number + /** Raw wire value. A direction this build does not know makes the calibration incomplete. */ + direction: GroundClearanceDirection | (string & {}) + strengthPercent: number + /** Null while this calibration still fits what the Accessory declares. */ + problem: GroundClearanceProblem | null +} + +/** + * Why the ground-clearance binding is not commanding tilt. + * + * Carried rather than collapsed to a bare "off" because these are nothing alike to explain: a rider + * who has not calibrated, a sensor that is erroring, a Board whose link stopped being trusted and a + * pair of sensors that cancel each other out all read as the same absent number and need four + * different sentences. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt `GroundClearanceRelease` + * @parity /modules/vescape-core/ios/accessory/GroundClearance.swift `GroundClearanceRelease` + */ +export type GroundClearanceRelease = + | 'disabled' + | 'not-riding' + | 'no-link' + | 'not-calibrated' + | 'stale' + | 'out-of-range' + | 'sensor-error' + | 'board-untrusted' + | 'board-stale' + | 'contested' + | 'board-move' + | 'manual-tilt' + +/** + * What the ground-clearance Remote Tilt binding is doing, as native decided it. + * + * `bound` is what makes the tilt pad a read-only indicator: a configured Accessory is connected, so + * manual input is not this Board's input method any more. It is deliberately independent of + * `driving` — a binding waiting for the rider to set off is still the thing that owns the pad. + * + * Nothing here is a request. JS renders it; native decided it and will keep deciding it with the + * screen closed, the app backgrounded, or the JS runtime dead. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/BoardSessionController.kt `groundClearanceTiltState` + * @parity /modules/vescape-core/ios/connection/BoardSessionController.swift `groundClearanceTiltState` + */ +export interface GroundClearanceTiltState { + /** A configured ground-clearance Accessory is connected. The manual pad is read-only. */ + bound: boolean + /** The binding is commanding tilt right now. */ + driving: boolean + /** Why it is not, or `null` while it is. */ + release: GroundClearanceRelease | null +} + +/** + * What a sample says about itself. Carried, never inferred. + * + * There is no fourth case and no "unknown": a line the app cannot read as a measurement is `error`, + * because the alternative — quietly treating it as the far end of the range — is a board told it has + * all the clearance in the world at the exact moment its sensor stopped working. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySession.kt `AccessoryReadingStatus` + * @parity /modules/vescape-core/ios/accessory/AccessorySession.swift `AccessoryReadingStatus` + */ +export type AccessoryReadingStatus = 'ok' | 'out_of_range' | 'error' + +/** Native-owned 20-second preview window. Invalid/missing samples split chart segments. + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/ClearancePreviewLog.kt + * @parity /modules/vescape-core/ios/accessory/ClearancePreviewLog.swift + */ +export interface ClearancePreviewDiagnostics { + /** Each segment is flattened sampleTimeMs/valueCm pairs. */ + segments: number[][] + deliveredHz: number + dropped: number + invalid: number + samples: number +} + +/** + * One accepted sample, pushed while a capability's configuration screen is open. + * + * `valueCm` is non-null **only** when `status` is `ok`. Native enforces that before this crosses the + * bridge, so a reading with a number is a measurement and a reading without one is never a distance. + * + * `sampleTimeMs` is the accessory's own monotonic clock since its session began. It orders samples + * against each other and nothing else — subtracting it from a phone timestamp compares two + * unsynchronised clocks. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/AccessorySessionManager.kt `onReading` + * @parity /modules/vescape-core/ios/accessory/AccessorySessionController.swift `onReading` + */ + +export interface AccessoryReadingEvent { + /** Calibration-derived Remote Tilt percentage, independent of Board connection/engagement. + * Null for invalid readings or missing calibration. Never a command acknowledgement. + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/accessory/GroundClearance.kt `acceptReading` + * @parity /modules/vescape-core/ios/accessory/GroundClearance.swift `acceptReading` + */ + tiltPreviewPercent?: number | null + diagnostics?: ClearancePreviewDiagnostics | null + accessoryId: string + capabilityId: string + seq: number + sampleTimeMs: number + status: AccessoryReadingStatus + valueCm: number | null + /** + * How long this sample stays evidence, from the rate the accessory confirmed. + * + * A screen showing the number must stop showing it when this elapses without another sample. The + * window is native's — derived from the acknowledged rate, floored at the protocol's missing-stream + * default — and travels with the sample so JS never re-derives it. + */ + staleAfterMs: number +} + +/** What `saveGroundClearanceCalibration` decided. `problem` says why nothing was saved. */ +export interface GroundClearanceSaveResult { + saved: boolean + problem: GroundClearanceProblem | 'unknown-capability' | 'storage-unavailable' | null +} + +/** + * 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 +} + +/** + * 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` @@ -542,6 +935,19 @@ export type ScanPhase = ScanStatus */ export type RemoteTiltPhase = 'idle' | 'holding' | 'decaying' | 'locked' +/** + * Who asked for the tilt the Board is currently holding. + * + * Refloat has one temporary remote input and three things want it — the pad, Board Move, and a + * ground-clearance Accessory — so native arbitrates and reports the winner. The pad renders the same + * stream either way, but "the Board is holding a tilt you did not command" is not the same sentence + * as "the Board is holding yours". + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/RemoteInputArbiter.kt `RemoteInputOwner` + * @parity /modules/vescape-core/ios/RemoteInputArbiter.swift `RemoteInputOwner` + */ +export type RemoteTiltOwner = 'none' | 'manual' | 'sensor' | 'move' + export interface RemoteTiltDecay { elapsedMs: number totalMs: number @@ -551,6 +957,8 @@ export interface RemoteTiltDecay { export interface RemoteTiltState { value: number phase: Exclude + /** Who commanded it. `move` never appears here — Board Move does not stream a tilt. */ + owner?: RemoteTiltOwner /** Present only while native is executing a release decay. */ decay?: RemoteTiltDecay } @@ -2129,6 +2537,14 @@ 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 + /** Every enrolled Accessory and its native link state, on every change and on subscribe. */ + onAccessoryState: (event: AccessoryStateEvent) => void + /** One accepted measurement sample, only while that capability's screen asked for a preview. */ + onAccessoryReading: (event: AccessoryReadingEvent) => void } interface NativeEventEmitter void>> { @@ -2146,6 +2562,44 @@ interface NativeEventEmitter & { scan(): void stopScan(): void + startAccessoryScan(): void + stopAccessoryScan(): void + cancelAccessoryInspection(): void + inspectAccessory(deviceId: string): Promise + enrollAccessory(deviceId: string): Promise + forgetAccessory(accessoryId: string): Promise + getAccessories(): SavedAccessory[] + setAccessoryCapabilityEnabled( + accessoryId: string, + capabilityId: string, + enabled: boolean, + ): Promise + setAccessorySamplingRate( + accessoryId: string, + capabilityId: string, + rateHz: number, + ): Promise + saveBrakeLightSettings( + accessoryId: string, + capabilityId: string, + sensitivity: number, + parked: string, + ): Promise + setBrakeLightPreview( + accessoryId: string, + capabilityId: string, + mode: BrakeLightMode | null, + ): Promise + setAccessoryPreview(accessoryId: string, capabilityId: string, open: boolean): void + saveGroundClearanceCalibration( + accessoryId: string, + capabilityId: string, + nearCm: number, + farCm: number, + direction: string, + strengthPercent: number, + ): Promise + clearGroundClearanceCalibration(accessoryId: string, capabilityId: string): Promise exitApp(): void startLocationUpdates(): void stopLocationUpdates(): void @@ -2210,6 +2664,7 @@ type VescapeCoreNativeModule = NativeEventEmitter & { clearDeviceCredential(): void openAppUpdate(): void getRemoteTiltState(): Promise + getGroundClearanceTilt(): Promise setSelectedBoard(boardId: string | null): void setCompanionPresenceEnabled(enabled: boolean): Promise getCompanionPresenceBoards(): Promise @@ -2370,6 +2825,115 @@ 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() +} + +/** + * 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 and calibrations with it. + * + * One transaction natively, calibrations first. Re-adding the same hardware later starts from "not + * set up" rather than from numbers the rider set for a mounting position they have since changed. + */ +export function forgetAccessory(accessoryId: string): Promise { + return native.forgetAccessory(accessoryId) +} + +/** + * Ask native to keep one measurement capability running while its screen is open. + * + * A request to *measure*, never to tilt. Native's arbitration takes the union of this and the rider + * actually riding a calibrated board; a preview alone never permits sensor-driven tilt. Closing the + * screen — or backgrounding the app — drops the demand, and the accessory stops its continuous + * measurement while keeping BLE up. + */ +export function setAccessoryPreview( + accessoryId: string, + capabilityId: string, + open: boolean, +): void { + native.setAccessoryPreview(accessoryId, capabilityId, open) +} + +/** + * Offer a ground-clearance calibration. Native saves it if it is a complete and valid one. + * + * There is no Save step for the rider: send what they have as they change it, and native answers + * with whether it took and, if not, which rule it broke. Validity is judged against the limits the + * Accessory declares right now, and a calibration that fits them is also how the rider accepts + * limits that moved since enrollment. + */ +export function saveGroundClearanceCalibration( + accessoryId: string, + capabilityId: string, + calibration: { + nearCm: number + farCm: number + direction: GroundClearanceDirection + strengthPercent: number + }, +): Promise { + return native.saveGroundClearanceCalibration( + accessoryId, + capabilityId, + calibration.nearCm, + calibration.farCm, + calibration.direction, + calibration.strengthPercent, + ) +} + +/** Drop a calibration. The binding stops driving and the screen goes back to explaining setup. */ +export function clearGroundClearanceCalibration( + accessoryId: string, + capabilityId: string, +): Promise { + return native.clearGroundClearanceCalibration(accessoryId, capabilityId) +} + +/** 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() @@ -2767,6 +3331,21 @@ export async function getRemoteTiltState(): Promise { return native.getRemoteTiltState() } +/** + * What the ground-clearance Remote Tilt binding is doing. + * + * Polled rather than pushed: the only consumer is the tilt pad, which already reads the commanded + * tilt on its own interval, and a 10 Hz event carrying a release reason that mostly does not change + * would be bridge traffic for nothing. + * + * @parity /modules/vescape-core/ios/VescapeCoreModule.swift `getGroundClearanceTilt` + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `getGroundClearanceTilt` + */ +export async function getGroundClearanceTilt(): Promise { + if (E2E_ENABLED) return { bound: false, driving: false, release: null } + return native.getGroundClearanceTilt() +} + /** Persist native auto-connect target. Native can use this while JS is frozen. */ export function setSelectedBoard(boardId: string | null): void { if (E2E_ENABLED) { @@ -3436,6 +4015,36 @@ 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 addAccessoryStateListener( + cb: (event: AccessoryStateEvent) => void, +): EventSubscription { + return emitter.addListener('onAccessoryState', cb) +} + +/** + * Live measurement samples for whichever capabilities asked for a preview. + * + * Native only pushes while `setAccessoryPreview` is open for that capability, so subscribing without + * asking for measurements is silent rather than merely quiet. + */ +export function addAccessoryReadingListener( + cb: (event: AccessoryReadingEvent) => void, +): EventSubscription { + return emitter.addListener('onAccessoryReading', cb) +} + export function addErrorListener(cb: (event: ErrorEvent) => void): EventSubscription { return emitter.addListener('onError', cb) } @@ -3627,3 +4236,51 @@ export function addGroupRideErrorListener( ): EventSubscription { return emitter.addListener('onGroupRideError', cb) } + +/** @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `saveBrakeLightSettings` + * @parity /modules/vescape-core/ios/VescapeCoreModule.swift `saveBrakeLightSettings` + */ +export function saveBrakeLightSettings( + accessoryId: string, + capabilityId: string, + settings: BrakeLightSettings, +): Promise { + return native.saveBrakeLightSettings( + accessoryId, + capabilityId, + settings.sensitivity, + settings.parked, + ) +} + +/** @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `setAccessoryCapabilityEnabled` + * @parity /modules/vescape-core/ios/VescapeCoreModule.swift `setAccessoryCapabilityEnabled` + */ +export function setAccessoryCapabilityEnabled( + accessoryId: string, + capabilityId: string, + enabled: boolean, +): Promise { + return native.setAccessoryCapabilityEnabled(accessoryId, capabilityId, enabled) +} + +/** @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `setAccessorySamplingRate` + * @parity /modules/vescape-core/ios/VescapeCoreModule.swift `setAccessorySamplingRate` + */ +export function setAccessorySamplingRate( + accessoryId: string, + capabilityId: string, + rateHz: number, +): Promise { + return native.setAccessorySamplingRate(accessoryId, capabilityId, rateHz) +} +/** @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `setBrakeLightPreview` + * @parity /modules/vescape-core/ios/VescapeCoreModule.swift `setBrakeLightPreview` + */ +export function setBrakeLightPreview( + accessoryId: string, + capabilityId: string, + mode: BrakeLightMode | null, +): Promise { + return native.setBrakeLightPreview(accessoryId, capabilityId, mode) +} diff --git a/scripts/no-swallowed-errors.test.ts b/scripts/no-swallowed-errors.test.ts index f8fad5f7..a3a01012 100644 --- a/scripts/no-swallowed-errors.test.ts +++ b/scripts/no-swallowed-errors.test.ts @@ -178,16 +178,26 @@ function nativeViolations(path: string, text: string): string[] { .map((match) => `${path}:${lineAt(text, match.index!)}`) } -test('production failure suppressions require an explicit owner or reason', () => { - const js = JS_ROOTS.flatMap((root) => - sourceFiles(root, new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])), - ) - const native = NATIVE_ROOTS.flatMap((root) => sourceFiles(root, new Set(['.swift', '.kt']))) - expect([ - ...js.flatMap((path) => noopCatchViolations(path, readFileSync(path, 'utf8'))), - ...native.flatMap((path) => nativeViolations(path, readFileSync(path, 'utf8'))), - ]).toEqual([]) -}) +// Reads and lexes every source file in the repo, so it scales with the codebase and with whatever +// disk the runner got. Well under a second locally, but it has already tripped Bun's 5s default on +// a cold CI runner — the budget is explicit so a slow machine fails the build only when it is +// genuinely stuck. +const SCAN_TIMEOUT_MS = 60_000 + +test( + 'production failure suppressions require an explicit owner or reason', + () => { + const js = JS_ROOTS.flatMap((root) => + sourceFiles(root, new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])), + ) + const native = NATIVE_ROOTS.flatMap((root) => sourceFiles(root, new Set(['.swift', '.kt']))) + expect([ + ...js.flatMap((path) => noopCatchViolations(path, readFileSync(path, 'utf8'))), + ...native.flatMap((path) => nativeViolations(path, readFileSync(path, 'utf8'))), + ]).toEqual([]) + }, + SCAN_TIMEOUT_MS, +) test('suppression scanner accepts only nearby markers in real comments', () => { expect( 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/shared/fixtures/accessory-protocol/session.json b/shared/fixtures/accessory-protocol/session.json new file mode 100644 index 00000000..6f54f425 --- /dev/null +++ b/shared/fixtures/accessory-protocol/session.json @@ -0,0 +1,751 @@ +{ + "$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\":\"telemetry\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\",\"speedKmh\":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 + } + ], + "readings": { + "$comment": "Ground-clearance readings: the unacknowledged sample stream, and the single rule the whole feature rests on — a sample that is not a measurement never becomes a distance. `ok` is the only status that carries a value, and the decode cases below pin what happens to every way a line can fail to be one: a missing value, a non-numeric value, a status this app does not know. All of them resolve to `error`, never to the top of the declared range. The firmware answers to `encode`; the app answers to `decode`, `rangeCheck`, `acceptance` and `staleAfterMs`.", + "capabilityId": "clearance", + "declaredRange": { + "min": 3, + "max": 100 + }, + "encode": [ + { + "name": "a measured sample carries its value in centimetres", + "seq": 1, + "sampleTimeMs": 125, + "status": "ok", + "valueCm": 12.4, + "line": "{\"type\":\"reading\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\",\"seq\":1,\"sampleTimeMs\":125,\"status\":\"ok\",\"value\":12.4}" + }, + { + "name": "nothing in reach is out of range and carries no value", + "seq": 2, + "sampleTimeMs": 175, + "status": "out_of_range", + "valueCm": null, + "line": "{\"type\":\"reading\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\",\"seq\":2,\"sampleTimeMs\":175,\"status\":\"out_of_range\"}" + }, + { + "name": "a sensor that could not measure reports an error and carries no value", + "seq": 3, + "sampleTimeMs": 225, + "status": "error", + "valueCm": null, + "line": "{\"type\":\"reading\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\",\"seq\":3,\"sampleTimeMs\":225,\"status\":\"error\"}" + }, + { + "name": "a whole-centimetre value still prints as a decimal", + "seq": 4, + "sampleTimeMs": 275, + "status": "ok", + "valueCm": 9.0, + "line": "{\"type\":\"reading\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\",\"seq\":4,\"sampleTimeMs\":275,\"status\":\"ok\",\"value\":9.0}" + } + ], + "decode": [ + { + "name": "a measured sample decodes to a distance in centimetres", + "line": "{\"type\":\"reading\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\",\"seq\":1,\"sampleTimeMs\":125,\"status\":\"ok\",\"value\":12.4}", + "reading": { + "capabilityId": "clearance", + "seq": 1, + "sampleTimeMs": 125, + "status": "ok", + "valueCm": 12.4 + } + }, + { + "name": "out of range decodes without a value", + "line": "{\"type\":\"reading\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\",\"seq\":2,\"sampleTimeMs\":175,\"status\":\"out_of_range\"}", + "reading": { + "capabilityId": "clearance", + "seq": 2, + "sampleTimeMs": 175, + "status": "out_of_range", + "valueCm": null + } + }, + { + "name": "an error decodes without a value", + "line": "{\"type\":\"reading\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\",\"seq\":3,\"sampleTimeMs\":225,\"status\":\"error\"}", + "reading": { + "capabilityId": "clearance", + "seq": 3, + "sampleTimeMs": 225, + "status": "error", + "valueCm": null + } + }, + { + "name": "an ok carrying no value is an error, never the maximum distance", + "line": "{\"type\":\"reading\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\",\"seq\":4,\"sampleTimeMs\":275,\"status\":\"ok\"}", + "reading": { + "capabilityId": "clearance", + "seq": 4, + "sampleTimeMs": 275, + "status": "error", + "valueCm": null + } + }, + { + "name": "an ok whose value is null is an error, never the maximum distance", + "line": "{\"type\":\"reading\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\",\"seq\":5,\"sampleTimeMs\":325,\"status\":\"ok\",\"value\":null}", + "reading": { + "capabilityId": "clearance", + "seq": 5, + "sampleTimeMs": 325, + "status": "error", + "valueCm": null + } + }, + { + "name": "an ok whose value is text is an error, never the maximum distance", + "line": "{\"type\":\"reading\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\",\"seq\":6,\"sampleTimeMs\":375,\"status\":\"ok\",\"value\":\"12.4\"}", + "reading": { + "capabilityId": "clearance", + "seq": 6, + "sampleTimeMs": 375, + "status": "error", + "valueCm": null + } + }, + { + "name": "a status this app does not know is an error, never a distance", + "line": "{\"type\":\"reading\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\",\"seq\":7,\"sampleTimeMs\":425,\"status\":\"saturated\",\"value\":12.4}", + "reading": { + "capabilityId": "clearance", + "seq": 7, + "sampleTimeMs": 425, + "status": "error", + "valueCm": null + } + }, + { + "name": "a reading with no status at all is an error", + "line": "{\"type\":\"reading\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\",\"seq\":8,\"sampleTimeMs\":475}", + "reading": { + "capabilityId": "clearance", + "seq": 8, + "sampleTimeMs": 475, + "status": "error", + "valueCm": null + } + }, + { + "name": "a reading from another session is ignored and renews no freshness", + "line": "{\"type\":\"reading\",\"sessionId\":\"1a2b3c4d-0000-4000-8000-000000000000\",\"capabilityId\":\"clearance\",\"seq\":9,\"sampleTimeMs\":525,\"status\":\"ok\",\"value\":12.4}", + "ignored": true + }, + { + "name": "a reading naming no capability is ignored", + "line": "{\"type\":\"reading\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"\",\"seq\":10,\"sampleTimeMs\":575,\"status\":\"ok\",\"value\":12.4}", + "ignored": true + }, + { + "name": "a reading whose sequence number is not a whole number is ignored", + "line": "{\"type\":\"reading\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\",\"seq\":1.5,\"sampleTimeMs\":625,\"status\":\"ok\",\"value\":12.4}", + "ignored": true + }, + { + "name": "a reading with no sample time is ignored", + "line": "{\"type\":\"reading\",\"sessionId\":\"b06b9d76-6c73-4d70-a763-d933b294c45b\",\"capabilityId\":\"clearance\",\"seq\":11,\"status\":\"ok\",\"value\":12.4}", + "ignored": true + } + ], + "rangeCheck": [ + { + "name": "a value inside the declared window is a measurement", + "valueCm": 12.4, + "resolvedStatus": "ok", + "resolvedValueCm": 12.4 + }, + { + "name": "a value on the declared minimum is a measurement", + "valueCm": 3, + "resolvedStatus": "ok", + "resolvedValueCm": 3 + }, + { + "name": "a value on the declared maximum is a measurement", + "valueCm": 100, + "resolvedStatus": "ok", + "resolvedValueCm": 100 + }, + { + "name": "a value below the declared window is out of range, not a near reading", + "valueCm": 1, + "resolvedStatus": "out_of_range", + "resolvedValueCm": null + }, + { + "name": "a value above the declared window is out of range, not the maximum", + "valueCm": 140, + "resolvedStatus": "out_of_range", + "resolvedValueCm": null + } + ], + "acceptance": [ + { + "name": "the first sample of a session is accepted", + "previous": null, + "seq": 1, + "sampleTimeMs": 125, + "accepted": true + }, + { + "name": "the next sample is accepted", + "previous": { + "seq": 4, + "sampleTimeMs": 300 + }, + "seq": 5, + "sampleTimeMs": 350, + "accepted": true + }, + { + "name": "a repeated sequence number is a duplicate and is dropped", + "previous": { + "seq": 4, + "sampleTimeMs": 300 + }, + "seq": 4, + "sampleTimeMs": 350, + "accepted": false + }, + { + "name": "an older sequence number is dropped", + "previous": { + "seq": 4, + "sampleTimeMs": 300 + }, + "seq": 3, + "sampleTimeMs": 400, + "accepted": false + }, + { + "name": "a sequence number that advanced across a measurement pause is accepted", + "previous": { + "seq": 4, + "sampleTimeMs": 300 + }, + "seq": 40, + "sampleTimeMs": 9000, + "accepted": true + }, + { + "name": "a regressed sample time is dropped even when the sequence advanced", + "previous": { + "seq": 4, + "sampleTimeMs": 300 + }, + "seq": 5, + "sampleTimeMs": 299, + "accepted": false + }, + { + "name": "a repeated sample time with a new sequence number is accepted", + "previous": { + "seq": 4, + "sampleTimeMs": 300 + }, + "seq": 5, + "sampleTimeMs": 300, + "accepted": true + } + ], + "staleAfterMs": [ + { + "name": "30 Hz uses the floor", + "rateHz": 30, + "staleAfterMs": 300 + }, + { + "name": "20 Hz uses the floor", + "rateHz": 20, + "staleAfterMs": 300 + }, + { + "name": "10 Hz uses the floor", + "rateHz": 10, + "staleAfterMs": 300 + }, + { + "name": "5 Hz uses three sample periods", + "rateHz": 5, + "staleAfterMs": 600 + }, + { + "name": "2 Hz uses three sample periods", + "rateHz": 2, + "staleAfterMs": 1500 + } + ] + }, + "groundClearance": { + "$comment": "Saved ground-clearance calibration, and what it turns a reading into. Validity is checked against the capability's own declared window, so a firmware that narrowed its range invalidates a calibration that no longer fits instead of driving the board to numbers the hardware refuses. `problem` is the named reason a calibration is not one — a boolean would leave the rider's screen with nothing to say, and the rules live natively so a screen cannot grow a second definition of valid. `tiltInput` is signed: positive lifts the nose. Less ground clearance always means more correction; the mounting direction only decides which way.", + "declaredRange": { + "min": 3, + "max": 100 + }, + "validity": [ + { + "name": "near below far is a complete calibration", + "calibration": { + "nearCm": 5, + "farCm": 20, + "direction": "nose", + "strengthPercent": 60 + }, + "valid": true, + "problem": null + }, + { + "name": "near equal to far leaves no window to map", + "calibration": { + "nearCm": 20, + "farCm": 20, + "direction": "nose", + "strengthPercent": 60 + }, + "valid": false, + "problem": "near-not-below-far" + }, + { + "name": "near above far is not a calibration", + "calibration": { + "nearCm": 30, + "farCm": 20, + "direction": "nose", + "strengthPercent": 60 + }, + "valid": false, + "problem": "near-not-below-far" + }, + { + "name": "a near below the declared minimum no longer fits the hardware", + "calibration": { + "nearCm": 1, + "farCm": 20, + "direction": "nose", + "strengthPercent": 60 + }, + "valid": false, + "problem": "outside-declared-range" + }, + { + "name": "a far above the declared maximum no longer fits the hardware", + "calibration": { + "nearCm": 5, + "farCm": 140, + "direction": "nose", + "strengthPercent": 60 + }, + "valid": false, + "problem": "outside-declared-range" + }, + { + "name": "a tail mounting is a complete calibration", + "calibration": { + "nearCm": 5, + "farCm": 20, + "direction": "tail", + "strengthPercent": 100 + }, + "valid": true, + "problem": null + }, + { + "name": "a direction this app does not know drives nothing", + "calibration": { + "nearCm": 5, + "farCm": 20, + "direction": "sideways", + "strengthPercent": 60 + }, + "valid": false, + "problem": "unknown-direction" + }, + { + "name": "zero strength commands nothing and is not a calibration", + "calibration": { + "nearCm": 5, + "farCm": 20, + "direction": "nose", + "strengthPercent": 0 + }, + "valid": false, + "problem": "strength-out-of-bounds" + }, + { + "name": "strength past full is not a calibration", + "calibration": { + "nearCm": 5, + "farCm": 20, + "direction": "nose", + "strengthPercent": 140 + }, + "valid": false, + "problem": "strength-out-of-bounds" + }, + { + "name": "a non-finite distance is not a calibration", + "calibration": { + "nearCm": 5, + "farCm": null, + "direction": "nose", + "strengthPercent": 60 + }, + "valid": false, + "problem": "not-a-number" + } + ], + "tilt": [ + { + "name": "at the far distance nothing is commanded", + "calibration": { + "nearCm": 5, + "farCm": 20, + "direction": "nose", + "strengthPercent": 100 + }, + "valueCm": 20, + "tiltInput": 0 + }, + { + "name": "past the far distance nothing is commanded", + "calibration": { + "nearCm": 5, + "farCm": 20, + "direction": "nose", + "strengthPercent": 100 + }, + "valueCm": 60, + "tiltInput": 0 + }, + { + "name": "at the near distance the full strength is commanded", + "calibration": { + "nearCm": 5, + "farCm": 20, + "direction": "nose", + "strengthPercent": 100 + }, + "valueCm": 5, + "tiltInput": 1 + }, + { + "name": "below the near distance it stays at full strength", + "calibration": { + "nearCm": 5, + "farCm": 20, + "direction": "nose", + "strengthPercent": 100 + }, + "valueCm": 3, + "tiltInput": 1 + }, + { + "name": "halfway through the window is half the correction", + "calibration": { + "nearCm": 5, + "farCm": 20, + "direction": "nose", + "strengthPercent": 100 + }, + "valueCm": 12.5, + "tiltInput": 0.5 + }, + { + "name": "strength scales the whole command", + "calibration": { + "nearCm": 5, + "farCm": 20, + "direction": "nose", + "strengthPercent": 50 + }, + "valueCm": 5, + "tiltInput": 0.5 + }, + { + "name": "a tail sensor corrects the other way", + "calibration": { + "nearCm": 5, + "farCm": 20, + "direction": "tail", + "strengthPercent": 100 + }, + "valueCm": 5, + "tiltInput": -1 + }, + { + "name": "a tail sensor is also released at the far distance", + "calibration": { + "nearCm": 5, + "farCm": 20, + "direction": "tail", + "strengthPercent": 100 + }, + "valueCm": 20, + "tiltInput": 0 + } + ] + } +} diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 59f11629..f58c4013 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]) @@ -228,6 +231,16 @@ 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..c9167d73 --- /dev/null +++ b/src/app/accessories/[accessoryId].tsx @@ -0,0 +1,23 @@ +import { router, useLocalSearchParams } from 'expo-router' + +import { AccessoryDetailScreen } from '@/modules/accessories/screens/AccessoryDetailScreen' +import { routes } from '@/navigation/routes' + +export default function AccessoryRoute() { + const { accessoryId } = useLocalSearchParams<{ accessoryId: string }>() + return ( + { + if (router.canGoBack()) router.back() + }} + onConfigureCapability={(capabilityId, type) => + router.push({ + pathname: + type === 'brake_light' ? routes.accessoryBrakeLight : routes.accessoryGroundClearance, + params: { accessoryId, capabilityId }, + }) + } + /> + ) +} diff --git a/src/app/accessories/brake-light.tsx b/src/app/accessories/brake-light.tsx new file mode 100644 index 00000000..b9d861d8 --- /dev/null +++ b/src/app/accessories/brake-light.tsx @@ -0,0 +1,11 @@ +import { useLocalSearchParams } from 'expo-router' + +import { BrakeLightScreen } from '@/modules/accessories/screens/BrakeLightScreen' + +export default function BrakeLightRoute() { + const { accessoryId, capabilityId } = useLocalSearchParams<{ + accessoryId: string + capabilityId: string + }>() + return +} diff --git a/src/app/accessories/ground-clearance.tsx b/src/app/accessories/ground-clearance.tsx new file mode 100644 index 00000000..8d1e9dfa --- /dev/null +++ b/src/app/accessories/ground-clearance.tsx @@ -0,0 +1,11 @@ +import { useLocalSearchParams } from 'expo-router' + +import { GroundClearanceScreen } from '@/modules/accessories/screens/GroundClearanceScreen' + +export default function GroundClearanceRoute() { + const { accessoryId, capabilityId } = useLocalSearchParams<{ + accessoryId: string + capabilityId: 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..c15b5dee --- /dev/null +++ b/src/app/settings/components/accessories.tsx @@ -0,0 +1,38 @@ +import { ScrollView, StyleSheet } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' + +import { AccessoryIcon } from '@/modules/accessories/constants/accessoryIcon' +import { IconHero } from '@/components/settings/IconHero' +import { AccessorySelectorSectionShowcase } from '@/screens/showcase/accessories/AccessorySelectorSectionShowcase' +import { + AccessoryCapabilityRowShowcase, + AccessoryCompatibilityNoticeShowcase, + GroundClearanceReadoutShowcase, +} from '@/screens/showcase/accessories/AccessoryManifestShowcase' +import { BrakeLightStatesShowcase } from '@/screens/showcase/accessories/BrakeLightStatesShowcase' +import { SensorReadoutShowcase } from '@/screens/showcase/accessories/SensorReadoutShowcase' +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/base.tsx b/src/app/settings/components/base.tsx index 3e1dc637..eb839e6a 100644 --- a/src/app/settings/components/base.tsx +++ b/src/app/settings/components/base.tsx @@ -266,6 +266,7 @@ function ButtonShowcase() { function PlaceholderShowcase() { const [showTitle, setShowTitle] = useState(true) const [showAction, setShowAction] = useState(true) + const [compact, setCompact] = useState(false) const [colorKey, setColorKey] = useState<'muted' | 'sky' | 'error'>('muted') const color = { muted: theme.palette.slate.textMuted, @@ -280,6 +281,7 @@ function PlaceholderShowcase() { <> + {}} />} /> + ) } diff --git a/src/app/settings/components/index.tsx b/src/app/settings/components/index.tsx index 86a03e56..364be7ff 100644 --- a/src/app/settings/components/index.tsx +++ b/src/app/settings/components/index.tsx @@ -19,6 +19,7 @@ import { TextAaIcon, } from 'phosphor-react-native' +import { AccessoryIcon } from '@/modules/accessories/constants/accessoryIcon' import { SettingsCard } from '@/components/settings/SettingsCard' import { SettingsRow } from '@/components/settings/SettingsRow' import { SettingsSectionTitle } from '@/components/settings/SettingsSectionTitle' @@ -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: AccessoryIcon, + color: theme.palette.teal.color, + }, { label: 'Widgets', hint: 'Dashboard tiles for showing and editing live board data', diff --git a/src/app/settings/components/settings.tsx b/src/app/settings/components/settings.tsx index b2c6d7aa..02427c68 100644 --- a/src/app/settings/components/settings.tsx +++ b/src/app/settings/components/settings.tsx @@ -1,4 +1,4 @@ -import { ScrollView, StyleSheet, Switch } from 'react-native' +import { ScrollView, StyleSheet } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useState } from 'react' import { @@ -12,6 +12,7 @@ import { import { SettingsCard } from '@/components/settings/SettingsCard' import { SettingsRow } from '@/components/settings/SettingsRow' +import { SettingsSwitch } from '@/components/settings/SettingsSwitch' import { SettingsSectionTitle } from '@/components/settings/SettingsSectionTitle' import { Stepper } from '@/components/forms/Stepper' import { ShowcaseCard } from '@/components/dev/ShowcaseCard' @@ -91,14 +92,7 @@ export default function SettingsPage() { iconWeight="fill" label="Dark mode" hint="Use dark theme throughout the app" - right={ - - } + right={} /> @@ -126,14 +120,7 @@ export default function SettingsPage() { icon={BellIcon} label="Push notifications" hint="Receive alerts about your board" - right={ - - } + right={} /> void set('autoConnect', v)} - trackColor={{ - false: theme.palette.slate.border, - true: companionPresenceEnabled - ? theme.palette.slate.border - : theme.palette.sky.border, - }} - thumbColor={ - companionPresenceEnabled - ? theme.palette.slate.textMuted - : autoConnect - ? theme.palette.sky.color - : theme.palette.slate.textMuted - } /> } /> @@ -214,11 +202,9 @@ export default function ConnectionSettingsScreen() { label="Auto recording" hint="Start recording when board connects" right={ - void set('autoRecording', v)} - trackColor={{ false: theme.palette.slate.border, true: theme.palette.sky.border }} - thumbColor={autoRecording ? theme.palette.sky.color : theme.palette.slate.textMuted} /> } /> @@ -228,13 +214,9 @@ export default function ConnectionSettingsScreen() { label="Connection sounds" hint="Play on/off sounds on connect and dropout" right={ - void set('connectionSoundsEnabled', v)} - trackColor={{ false: theme.palette.slate.border, true: theme.palette.sky.border }} - thumbColor={ - connectionSoundsEnabled ? theme.palette.sky.color : theme.palette.slate.textMuted - } /> } /> @@ -250,16 +232,9 @@ export default function ConnectionSettingsScreen() { label="Auto close app" hint="Close the app when the board stays disconnected" right={ - void set('autoCloseEnabled', v)} - trackColor={{ - false: theme.palette.slate.border, - true: theme.palette.sky.border, - }} - thumbColor={ - autoCloseEnabled ? theme.palette.sky.color : theme.palette.slate.textMuted - } /> } /> diff --git a/src/app/settings/diagnostics.tsx b/src/app/settings/diagnostics.tsx index cba8d5ca..c6536bc3 100644 --- a/src/app/settings/diagnostics.tsx +++ b/src/app/settings/diagnostics.tsx @@ -1,4 +1,4 @@ -import { ScrollView, StyleSheet, Switch } from 'react-native' +import { ScrollView, StyleSheet } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { router } from 'expo-router' import { @@ -12,6 +12,7 @@ import { routes } from '@/navigation/routes' import { theme } from '@/constants/theme' import { SettingsCard } from '@/components/settings/SettingsCard' import { SettingsRow } from '@/components/settings/SettingsRow' +import { SettingsSwitch } from '@/components/settings/SettingsSwitch' import { IconHero } from '@/components/settings/IconHero' import { useSettingsStore } from '@/modules/settings/store/settingsStore' @@ -35,13 +36,9 @@ export default function DiagnosticsSettingsScreen() { label="Board warnings" hint="Master switch — off stops all detection and hides warnings" right={ - void set('boardWarningsEnabled', v)} - trackColor={{ false: theme.neutral.border, true: theme.palette.sky.border }} - thumbColor={ - boardWarningsEnabled ? theme.palette.sky.color : theme.neutral.textMuted - } /> } /> @@ -51,13 +48,9 @@ export default function DiagnosticsSettingsScreen() { label="VESC fault collection" hint="Record live Refloat faults. Controller log loads when the fault drawer opens" right={ - void set('vescFaultCollectionEnabled', v)} - trackColor={{ false: theme.neutral.border, true: theme.palette.sky.border }} - thumbColor={ - vescFaultCollectionEnabled ? theme.palette.sky.color : theme.neutral.textMuted - } /> } /> diff --git a/src/app/settings/graphs.tsx b/src/app/settings/graphs.tsx index fd96e8b8..6d376878 100644 --- a/src/app/settings/graphs.tsx +++ b/src/app/settings/graphs.tsx @@ -1,4 +1,4 @@ -import { View, Switch, StyleSheet, ScrollView } from 'react-native' +import { View, StyleSheet, ScrollView } from 'react-native' import { Text } from '@/components/base/Text' import { SafeAreaView } from 'react-native-safe-area-context' import { GaugeIcon, ChartLineUpIcon } from 'phosphor-react-native' @@ -13,6 +13,7 @@ import { } from '@/modules/history/lib/metricColorScale' import { SettingsCard } from '@/components/settings/SettingsCard' import { SettingsRow } from '@/components/settings/SettingsRow' +import { SettingsSwitch } from '@/components/settings/SettingsSwitch' import { Stepper } from '@/components/forms/Stepper' import { IconHero } from '@/components/settings/IconHero' @@ -67,18 +68,10 @@ export default function GraphsSettingsScreen() { label="Graph hot gradients" hint="Color live, history, and map graphs by metric value" right={ - void set('historyMetricGradientsEnabled', v)} - trackColor={{ - false: theme.neutral.border, - true: theme.status.warning.border, - }} - thumbColor={ - historyMetricGradientsEnabled - ? theme.status.warning.color - : theme.neutral.textMuted - } + accent={theme.status.warning} /> } /> diff --git a/src/app/settings/map.tsx b/src/app/settings/map.tsx index 7a72e258..3b4eae0e 100644 --- a/src/app/settings/map.tsx +++ b/src/app/settings/map.tsx @@ -1,4 +1,4 @@ -import { ScrollView, StyleSheet, Switch } from 'react-native' +import { ScrollView, StyleSheet } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { ImageSquareIcon, @@ -12,6 +12,7 @@ import { useShallow } from 'zustand/react/shallow' import { IconHero } from '@/components/settings/IconHero' import { SettingsCard } from '@/components/settings/SettingsCard' import { SettingsRow } from '@/components/settings/SettingsRow' +import { SettingsSwitch } from '@/components/settings/SettingsSwitch' import { SettingsSectionTitle } from '@/components/settings/SettingsSectionTitle' import { Stepper } from '@/components/forms/Stepper' import { theme } from '@/constants/theme' @@ -55,16 +56,9 @@ export default function MapSettingsScreen() { label="Hide telemetry map details" hint="Hide POI names and icons on the home map; Explore still shows full detail" right={ - void set('hideTelemetryMapDetails', enabled)} - trackColor={{ - false: theme.neutral.border, - true: theme.palette.sky.border, - }} - thumbColor={ - hideTelemetryMapDetails ? theme.palette.sky.color : theme.neutral.textMuted - } /> } /> @@ -78,16 +72,9 @@ export default function MapSettingsScreen() { label="Satellite overlay" hint="Use the toned satellite image with One Dark labels" right={ - void set('satelliteOverlayEnabled', enabled)} - trackColor={{ - false: theme.neutral.border, - true: theme.palette.sky.border, - }} - thumbColor={ - satelliteOverlayEnabled ? theme.palette.sky.color : theme.neutral.textMuted - } /> } /> diff --git a/src/app/settings/watch.tsx b/src/app/settings/watch.tsx index 191cf6a0..982d93da 100644 --- a/src/app/settings/watch.tsx +++ b/src/app/settings/watch.tsx @@ -1,4 +1,4 @@ -import { StyleSheet, ScrollView, Switch } from 'react-native' +import { StyleSheet, ScrollView } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { ClockCountdownIcon, NavigationArrowIcon, WatchIcon } from 'phosphor-react-native' import { useShallow } from 'zustand/react/shallow' @@ -6,6 +6,7 @@ import { useShallow } from 'zustand/react/shallow' import { theme } from '@/constants/theme' import { SettingsCard } from '@/components/settings/SettingsCard' import { SettingsRow } from '@/components/settings/SettingsRow' +import { SettingsSwitch } from '@/components/settings/SettingsSwitch' import { Stepper } from '@/components/forms/Stepper' import { IconHero } from '@/components/settings/IconHero' import { useSettingsStore } from '@/modules/settings/store/settingsStore' @@ -34,13 +35,9 @@ export default function WatchSettingsScreen() { label="Open on connect" hint="Bring the watch app to the front when the board connects" right={ - void set('wearAutoLaunchOnConnect', v)} - trackColor={{ false: theme.neutral.border, true: theme.palette.sky.border }} - thumbColor={ - wearAutoLaunchOnConnect ? theme.palette.sky.color : theme.neutral.textMuted - } /> } /> @@ -71,13 +68,9 @@ export default function WatchSettingsScreen() { label="Navigation arrow" hint="Draw the direction chevron over the route. Route and distance show either way" right={ - void set('wearNavArrowEnabled', v)} - trackColor={{ false: theme.palette.slate.border, true: theme.palette.sky.border }} - thumbColor={ - wearNavArrowEnabled ? theme.palette.sky.color : theme.palette.slate.textMuted - } /> } /> diff --git a/src/components/base/Placeholder.tsx b/src/components/base/Placeholder.tsx index b332425d..7a8d003f 100644 --- a/src/components/base/Placeholder.tsx +++ b/src/components/base/Placeholder.tsx @@ -10,6 +10,8 @@ interface PlaceholderProps { description: string iconColor?: string action?: ReactNode + /** Sized for an empty section inside a list or drawer rather than a whole empty screen. */ + compact?: boolean style?: ViewStyle } @@ -19,11 +21,12 @@ export function Placeholder({ description, iconColor = theme.neutral.textMuted, action, + compact = false, style, }: PlaceholderProps) { return ( - - + + {title ? {title} : null} {description} @@ -41,6 +44,11 @@ const styles = StyleSheet.create({ paddingHorizontal: 36, gap: 18, }, + containerCompact: { + paddingHorizontal: 24, + paddingVertical: 12, + gap: 12, + }, textBlock: { alignItems: 'center', gap: 6, diff --git a/src/components/base/SectionHeader.tsx b/src/components/base/SectionHeader.tsx index a125f35c..6e153a62 100644 --- a/src/components/base/SectionHeader.tsx +++ b/src/components/base/SectionHeader.tsx @@ -14,6 +14,8 @@ interface SectionHeaderProps { description?: string /** Action belonging to the section, pinned to the right of the title row. */ right?: ReactNode + /** Centred headings name a section that owns the full width, such as one inside a drawer. */ + align?: 'left' | 'center' } /** @@ -27,15 +29,23 @@ export function SectionHeader({ color = theme.neutral.textSecondary, description, right, + align = 'left', }: SectionHeaderProps) { + const centered = align === 'center' return ( - - + + {title} - {right} + {/* Rendered only when there is an action: an empty auto-margin spacer still claims the + free space, which shoved a centred heading back to the left. */} + {right ? {right} : null} - {description ? {description} : null} + {description ? ( + + {description} + + ) : null} ) } @@ -45,11 +55,17 @@ const styles = StyleSheet.create({ // The description is part of the heading, but it is not the title's second line. gap: 4, }, + containerCentered: { + alignSelf: 'stretch', + }, row: { flexDirection: 'row', alignItems: 'center', gap: 8, }, + rowCentered: { + justifyContent: 'center', + }, title: { color: theme.neutral.textPrimary, fontSize: 18, @@ -64,4 +80,7 @@ const styles = StyleSheet.create({ fontSize: 11, letterSpacing: 0.3, }, + descriptionCentered: { + textAlign: 'center', + }, }) diff --git a/src/components/charts/line/LineChart.tsx b/src/components/charts/line/LineChart.tsx index 9583a016..14dbdc18 100644 --- a/src/components/charts/line/LineChart.tsx +++ b/src/components/charts/line/LineChart.tsx @@ -163,6 +163,8 @@ export function LineChart({ chart, width, index }: LineChartProps) { {chart.series.map((series) => ( dataKey: string + domainStartMs: number + domainEndMs: number } /** @@ -64,6 +66,8 @@ export function SeriesLayer({ plot, camera, dataKey, + domainStartMs, + domainEndMs, }: SeriesLayerProps) { // React Compiler memoises hook results by its own rules, which do not know that a derived // value must be rebuilt when its declared dependencies change. @@ -106,7 +110,7 @@ export function SeriesLayer({ const linePath = useDerivedValue(() => { // Reading the counter is what subscribes this mapper to the nudge; it never goes negative. if (repaint.value < 0 || paths.isEmpty || plot.width <= 0) return Skia.Path.Make() - const viewport = viewportFor(camera.value, dataKey, paths.domainStartMs, paths.domainEndMs) + const viewport = viewportFor(camera.value, dataKey, domainStartMs, domainEndMs) const level = pickLevel(paths.bucketMs, msPerPixel(viewport, plot.width)) const source = level < 0 ? paths.raw : paths.levels[level] const matrix = viewportMatrix(viewport, paths.domainStartMs, yRange, plot.width, plot.height) @@ -114,30 +118,30 @@ export function SeriesLayer({ const fromSec = (viewport.startMs - paths.domainStartMs) / 1000 const toSec = (viewport.endMs - paths.domainStartMs) / 1000 return composeVisibleTiles(source, fromSec, toSec, matrix) - }, [dataKey, paths, plot.height, plot.width, yRange]) + }, [dataKey, domainStartMs, domainEndMs, paths, plot.height, plot.width, yRange]) // Marking samples reuses the line that is already projected, so nothing extra is stored per // dataset and only the points actually on screen are read. const dotPath = useDerivedValue(() => { if (paths.isEmpty || plot.width <= 0) return Skia.Path.Make() - const viewport = viewportFor(camera.value, dataKey, paths.domainStartMs, paths.domainEndMs) + const viewport = viewportFor(camera.value, dataKey, domainStartMs, domainEndMs) if (!shouldMarkSamples(paths.sampleMs, msPerPixel(viewport, plot.width))) { return Skia.Path.Make() } return visiblePointDots(linePath.value, plot.width) - }, [dataKey, paths, plot.height, plot.width, yRange]) + }, [dataKey, domainStartMs, domainEndMs, paths, plot.height, plot.width, yRange]) // The head only moves when the camera or the data does, so it is one mapper that sleeps through // a scrub — and it is parked off-canvas rather than hidden when there is nothing to mark. const head = paths.head const headTransform = useDerivedValue(() => { if (head == null || plot.width <= 0) return [{ translateX: OFFSCREEN }, { translateY: 0 }] - const viewport = viewportFor(camera.value, dataKey, paths.domainStartMs, paths.domainEndMs) + const viewport = viewportFor(camera.value, dataKey, domainStartMs, domainEndMs) return [ { translateX: projectX(paths.domainStartMs + head.sec * 1000, viewport, plot.width) }, { translateY: projectY(head.value, yRange, plot.height) }, ] - }, [camera, dataKey, head, paths, plot.height, plot.width, yRange]) + }, [camera, dataKey, domainStartMs, domainEndMs, head, paths, plot.height, plot.width, yRange]) const shader = gradient ? ( void + /** Tint of the on state, so a row can read in its own colour. */ + accent?: SettingsSwitchAccent + disabled?: boolean + accessibilityLabel?: string + testID?: string +} + +/** + * The switch a `SettingsRow` puts on its trailing edge. One tint rule for every settings screen. + * + * A disabled switch drops its accent entirely rather than dimming it: a switch that still shows + * its on colour while refusing taps reads as broken, not as locked. + */ +export function SettingsSwitch({ + value, + onValueChange, + accent = theme.palette.sky, + disabled, + accessibilityLabel, + testID, +}: SettingsSwitchProps) { + const tint = disabled ? { color: theme.neutral.textMuted, border: theme.neutral.border } : accent + + return ( + + ) +} diff --git a/src/modules/accessories/components/AccessoryCapabilityRow.tsx b/src/modules/accessories/components/AccessoryCapabilityRow.tsx new file mode 100644 index 00000000..e1e693dc --- /dev/null +++ b/src/modules/accessories/components/AccessoryCapabilityRow.tsx @@ -0,0 +1,147 @@ +import { Pressable, StyleSheet, View } from 'react-native' +import { CaretRightIcon } from 'phosphor-react-native' +import type { AccessoryCapability, AccessoryLinkPhase } from 'vescape-core' + +import { Text } from '@/components/base/Text' +import { capabilityPresentation } from '@/modules/accessories/constants/accessoryCapabilities' +import { capabilityStateCopy } from '@/modules/accessories/lib/capabilityStateCopy' +import { interaction, 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. + * + * A capability the rider switched off says so here too. Otherwise the list reads identically + * whether or not the thing is actually doing anything, and the only way to find out is to open + * every row. + * + * A row is only a way in when this build has a configuration screen for that capability type. An + * unsupported capability, or a recognized one whose slice has not shipped, stays a flat row rather + * than a tap that leads somewhere empty — [onPress] is simply absent. + */ +export function AccessoryCapabilityRow({ + capability, + phase, + onPress, +}: { + capability: AccessoryCapability + /** The link this capability lives on. Given, the row also says what it is doing right now. */ + phase?: AccessoryLinkPhase + /** Omit when this capability has nothing to open. */ + onPress?: () => void +}) { + const { title, description, icon: CapabilityIcon } = capabilityPresentation(capability) + const off = capability.supported && capability.enabled === false + const tint = !capability.supported + ? theme.neutral.textDim + : off + ? theme.neutral.textMuted + : theme.palette.sky.color + const badge = !capability.supported + ? { label: 'Unsupported', tint } + : off + ? { label: 'Off', tint: theme.status.caution.color } + : null + // "Off" is already the badge's job, and the link phase is the screen header's — the state line is + // for the half-second answer the list otherwise makes the rider open a row to get. + const state = + phase === 'connected' && capability.supported && !off + ? capabilityStateCopy(capability, phase) + : null + + const body = ( + <> + + + + + + + {title} + + {badge ? ( + + {badge.label} + + ) : null} + + {description} + {state ? {state} : null} + + {onPress ? : null} + + ) + + if (!onPress) return {body} + + return ( + [styles.row, pressed && styles.rowPressed]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={`Configure ${title}`} + testID={`accessory-capability-${capability.id}`} + > + {body} + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingVertical: 12, + paddingHorizontal: 14, + }, + rowPressed: { backgroundColor: interaction.pressedBg }, + 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, + }, + description: { + color: theme.neutral.textSecondary, + fontSize: 12, + lineHeight: 16, + }, + state: { + color: theme.palette.sky.color, + fontSize: 11, + fontWeight: '600', + }, + badgeText: { + fontSize: 9, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.4, + }, +}) 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..dbc4f61a --- /dev/null +++ b/src/modules/accessories/components/AccessoryRow.tsx @@ -0,0 +1,137 @@ +import { Pressable, StyleSheet, View } from 'react-native' + +import { Text } from '@/components/base/Text' +import { AccessoryIcon } from '@/modules/accessories/constants/accessoryIcon' +import { accessoryStatusCopy } from '@/modules/accessories/lib/accessoryStatus' +import type { AccessoryLinkPhase } from 'vescape-core' +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 + /** 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 +} + +/** + * 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, 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' + // Only a link that is actually answering lights the row up. Connecting keeps the quiet tile: + // the tint is a claim about the hardware, not about the app's intent. + const live = phase === 'connected' && !warn + + return ( + [styles.row, pressed && styles.rowPressed]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={`${name}, ${label}`} + testID={`accessory-row-${name}`} + > + {/* One glyph in every state — an Accessory does not become a different thing because its + link dropped or its setup went stale. The tile's outline carries the state instead. */} + + + + + + {name} + + + + {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..dd218207 --- /dev/null +++ b/src/modules/accessories/components/AccessorySelectorSection.tsx @@ -0,0 +1,111 @@ +import { Pressable, StyleSheet, View } from 'react-native' +import { PlusIcon } from 'phosphor-react-native' + +import { Text } from '@/components/base/Text' +import { Placeholder } from '@/components/base/Placeholder' +import { SectionHeader } from '@/components/base/SectionHeader' +import { AccessoryIcon } from '@/modules/accessories/constants/accessoryIcon' +import { AccessoryRow } from '@/modules/accessories/components/AccessoryRow' +import type { AccessoryLinkPhase } from 'vescape-core' +import { interaction, theme } from '@/constants/theme' + +export interface AccessorySelectorItem { + accessoryId: string + name: string + detail?: string | undefined + phase: AccessoryLinkPhase + needsSetup?: 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 ? ( + + ) : ( + 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%', + gap: 6, + }, + rowPressed: { + backgroundColor: interaction.pressedBg, + }, + 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/components/BrakeLightStates.tsx b/src/modules/accessories/components/BrakeLightStates.tsx new file mode 100644 index 00000000..0856fb8e --- /dev/null +++ b/src/modules/accessories/components/BrakeLightStates.tsx @@ -0,0 +1,226 @@ +import { Pressable, StyleSheet, View } from 'react-native' +import type { BrakeLightMode } from 'vescape-core' + +import { Text } from '@/components/base/Text' +import { theme, type AlphaLevel } from '@/constants/theme' + +interface LightState { + mode: BrakeLightMode + label: string + /** What the rider should see on the light itself, not what the app asked for. */ + appearance: string + /** The strip that stands for this state: how bright, how thick, and whether it is broken up. */ + beam: Beam +} + +interface Beam { + /** One unbroken bar for a steady light; several for one that blinks. */ + dashes: number + thickness: number + alpha: AlphaLevel + /** Unlit: drawn in grey, because nothing red is coming out of the lamp. */ + dark?: boolean +} + +/** + * Every state the light can be in, in the order the board walks through them: standing still, + * rolling, slowing, stopping hard. + */ +function lightStates(parked: 'off' | 'glow'): LightState[] { + return [ + { + mode: 'not_riding', + label: 'Parked', + appearance: parked === 'glow' ? 'Red glow' : 'Off', + beam: + parked === 'glow' + ? { dashes: 1, thickness: 2, alpha: 0.3 } + : { dashes: 1, thickness: 2, alpha: 0.4, dark: true }, + }, + { + mode: 'riding', + label: 'Riding', + appearance: 'Dim red', + beam: { dashes: 1, thickness: 2, alpha: 0.6 }, + }, + { + mode: 'braking', + label: 'Braking', + appearance: 'Bright red', + beam: { dashes: 1, thickness: 3, alpha: 1 }, + }, + { + mode: 'hard_braking', + label: 'Hard braking', + appearance: 'Blinking', + beam: { dashes: 4, thickness: 3, alpha: 1 }, + }, + ] +} + +export interface BrakeLightStatesProps { + /** What the light is showing right now — the preview if one is held, else the Board's own state. */ + activeMode: BrakeLightMode | null + /** Set while the rider is holding the light on one state instead of the Board driving it. */ + previewMode: BrakeLightMode | null + parked: 'off' | 'glow' + /** Seconds before a held state hands the light back on its own. Null when nothing is held. */ + previewSecondsLeft?: number | null + /** Inert while the link is down, the light is switched off, or the Board is moving. */ + disabled?: boolean + /** Hold that state on the light, or hand it back when the same one is tapped again. */ + onPreview: (mode: BrakeLightMode | null) => void +} + +/** + * The light's states as a thing to look at and a thing to press, rather than a line of text. + * + * One tile per state, each drawn the way the lamp actually looks in it, with the live one lit. A + * rider comparing "what is it doing" against "what can it do" reads both off the same four tiles — + * and tapping one holds the light there long enough to walk behind the board and check, which is + * the only way to verify a light the protocol never reports back. + */ +export function BrakeLightStates({ + activeMode, + previewMode, + parked, + previewSecondsLeft, + disabled, + onPreview, +}: BrakeLightStatesProps) { + return ( + + {lightStates(parked).map((state) => { + const live = state.mode === activeMode + const held = state.mode === previewMode + return ( + [ + styles.tile, + live && styles.tileLive, + held && styles.tileHeld, + disabled && styles.tileDisabled, + pressed && !disabled && styles.tilePressed, + ]} + disabled={disabled} + onPress={() => onPreview(held ? null : state.mode)} + accessibilityRole="button" + accessibilityState={{ selected: live, disabled }} + accessibilityLabel={`${state.label}, ${state.appearance}${live ? ', showing now' : ''}`} + testID={`brake-light-state-${state.mode}`} + > + + + {state.label} + + + {state.appearance} + + {held ? ( + + {previewSecondsLeft == null ? 'HELD' : `HELD ${previewSecondsLeft}s`} + + ) : live ? ( + NOW + ) : ( + Tap to test + )} + + ) + })} + + ) +} + +/** + * The light itself, drawn as what it puts out: one bar for a steady lamp, broken dashes for one + * that blinks, thicker and brighter the harder it burns. + * + * Deliberately still. An animated lamp says "something is happening now", which is a lie on three + * of these four tiles — they are the states the light *can* be in, and only one of them is live. + */ +function BeamStrip({ beam }: { beam: Beam }) { + const color = beam.dark + ? theme.alpha(theme.neutral.textDim, beam.alpha) + : theme.alpha(theme.palette.red.light, beam.alpha) + + return ( + + {Array.from({ length: beam.dashes }, (_, index) => ( + + ))} + + ) +} + +const styles = StyleSheet.create({ + grid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + tile: { + flexGrow: 1, + flexBasis: '46%', + alignItems: 'center', + gap: 4, + paddingVertical: 14, + paddingHorizontal: 10, + borderRadius: 14, + borderWidth: 1, + borderColor: theme.neutral.border, + backgroundColor: theme.neutral.surface, + }, + tileLive: { + borderColor: theme.alpha(theme.palette.red.color, 0.6), + backgroundColor: theme.alpha(theme.palette.red.color, 0.1), + }, + tileHeld: { borderColor: theme.palette.red.color }, + tileDisabled: { opacity: 0.45 }, + tilePressed: { opacity: 0.7 }, + // A short rule, not a bar across the tile: the design language keeps accent colour to thin + // lines and icons, and a full-width red plane is exactly the fill it rules out. + beam: { + width: 44, + flexDirection: 'row', + alignItems: 'center', + gap: 5, + height: 10, + marginBottom: 4, + }, + tileLabel: { + color: theme.neutral.textPrimary, + fontSize: 13, + fontWeight: '700', + }, + tileAppearance: { + color: theme.neutral.textSecondary, + fontSize: 11, + }, + tileTag: { + color: theme.palette.red.light, + fontSize: 9, + fontWeight: '700', + letterSpacing: 0.4, + }, + tileTagLive: { + color: theme.neutral.textSecondary, + fontSize: 9, + fontWeight: '700', + letterSpacing: 0.4, + }, + tileTagIdle: { + color: theme.neutral.textDim, + fontSize: 9, + fontWeight: '600', + }, +}) diff --git a/src/modules/accessories/components/CapabilityEnabledControl.tsx b/src/modules/accessories/components/CapabilityEnabledControl.tsx new file mode 100644 index 00000000..b8ee6516 --- /dev/null +++ b/src/modules/accessories/components/CapabilityEnabledControl.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react' +import type { Icon } from 'phosphor-react-native' +import { setAccessoryCapabilityEnabled, type AccessoryCapability } from 'vescape-core' + +import { Text } from '@/components/base/Text' +import { SettingsCard } from '@/components/settings/SettingsCard' +import { SettingsRow } from '@/components/settings/SettingsRow' +import { SettingsSwitch, type SettingsSwitchAccent } from '@/components/settings/SettingsSwitch' +import { capabilityPresentation } from '../constants/accessoryCapabilities' +import { theme } from '@/constants/theme' + +/** The one switch that decides whether a capability runs at all — always the top of its screen. */ +export function CapabilityEnabledControl({ + accessoryId, + capability, + accent, +}: { + accessoryId: string + capability: AccessoryCapability + /** Tint of the row, so a light's screen reads in its own colour. */ + accent?: SettingsSwitchAccent +}) { + const [saving, setSaving] = useState(false) + const [failed, setFailed] = useState(false) + const change = async (enabled: boolean) => { + setSaving(true) + setFailed(false) + try { + setFailed(!(await setAccessoryCapabilityEnabled(accessoryId, capability.id, enabled))) + } catch { + setFailed(true) + } finally { + setSaving(false) + } + } + const { title, icon } = capabilityPresentation(capability) + return ( + { + void change(enabled) + }} + /> + ) +} + +export function CapabilityEnabledSetting({ + icon, + accent, + label, + enabled, + disabled, + failed, + onChange, +}: { + icon: Icon + accent?: SettingsSwitchAccent + label: string + enabled: boolean + disabled?: boolean + failed?: boolean + onChange: (enabled: boolean) => void +}) { + return ( + <> + + + } + /> + + {failed ? ( + Could not save. Try again. + ) : null} + + ) +} diff --git a/src/modules/accessories/components/GroundClearanceReadout.tsx b/src/modules/accessories/components/GroundClearanceReadout.tsx new file mode 100644 index 00000000..5f9f9b79 --- /dev/null +++ b/src/modules/accessories/components/GroundClearanceReadout.tsx @@ -0,0 +1,104 @@ +import { StyleSheet, View } from 'react-native' +import type { AccessoryReadingStatus } from 'vescape-core' + +import { Text } from '@/components/base/Text' +import { readingCopy } from '@/modules/accessories/lib/groundClearanceCopy' +import { theme } from '@/constants/theme' + +interface GroundClearanceReadoutProps { + /** Null while native has accepted no sample yet, and again once the stream goes quiet. */ + status: AccessoryReadingStatus | null + /** Set only when `status` is `ok`. Never borrowed from an earlier sample. */ + valueCm: number | null + /** Whether native currently has the sensor measuring at all. */ + measuring: boolean + /** A sample arrived and then the stream stopped. Distinct from never having had one. */ + stalled?: boolean +} + +/** + * The live distance, or an honest account of why there is not one. + * + * Five states, and only one of them is a number. A sensor that cannot see the ground, one that + * failed outright, one that stopped answering, and one that is not running at all read as four + * different sentences — because they are four different problems, and a single blank would leave + * the rider guessing which. + * + * There is deliberately no "last known" fallback, and that is what the stalled state is for. + * Holding the previous value on screen while the sensor has gone silent is exactly the illusion + * this feature exists to avoid: the rider would read a clearance the board no longer has. + */ +export function GroundClearanceReadout({ + status, + valueCm, + measuring, + stalled = false, +}: GroundClearanceReadoutProps) { + if (!measuring) { + return ( + + + + Not measuring. The sensor runs while this screen is open, and while you are riding a board + this accessory is calibrated for. + + + ) + } + + if (status == null) { + return ( + + + — + + + {stalled + ? 'The sensor stopped sending measurements. It may have lost power or moved out of range.' + : 'Waiting for the first measurement…'} + + + ) + } + + const copy = readingCopy(status, valueCm) + const tint = copy.tone === 'success' ? theme.palette.sky.color : theme.status.caution.text + + return ( + + + {copy.value} + + {copy.detail ? {copy.detail} : null} + + ) +} + +const styles = StyleSheet.create({ + frame: { + alignItems: 'center', + gap: 6, + paddingVertical: 22, + paddingHorizontal: 16, + borderRadius: 12, + borderWidth: 1, + borderColor: theme.neutral.border, + backgroundColor: theme.neutral.surface, + }, + value: { + fontFamily: theme.mono('700'), + fontSize: 40, + lineHeight: 46, + }, + detail: { + color: theme.neutral.textSecondary, + fontSize: 12, + lineHeight: 17, + textAlign: 'center', + }, +}) diff --git a/src/modules/accessories/components/GroundClearanceTelemetry.tsx b/src/modules/accessories/components/GroundClearanceTelemetry.tsx new file mode 100644 index 00000000..e9eb2fd0 --- /dev/null +++ b/src/modules/accessories/components/GroundClearanceTelemetry.tsx @@ -0,0 +1,110 @@ +import { useMemo } from 'react' +import { StyleSheet, View } from 'react-native' + +import { Text } from '@/components/base/Text' +import { SettingsSectionTitle } from '@/components/settings/SettingsSectionTitle' +import { ChartStack } from '@/components/charts/line/ChartStack' +import type { ChartSpec } from '@/components/charts/line/types' +import { useResolvedAccentColors } from '@/hooks/useTheme' +import { theme } from '@/constants/theme' +import { LiveNumber } from './LiveNumber' +import { GroundClearanceTiltPreview } from './GroundClearanceTiltPreview' +import { SensorBar } from './SensorBar' +import { useGroundClearancePreview } from '../hooks/useGroundClearancePreview' +import { readingCopy } from '../lib/groundClearanceCopy' + +/** Preview-only display. Native owns history and statistics; calibration never rerenders at sample rate. */ +export function GroundClearanceTelemetry({ + accessoryId, + capabilityId, + range, +}: { + accessoryId: string + capabilityId: string + range: { min: number; max: number } +}) { + const { liveValue, tiltPreviewPercent, diagnostics, reading, stalled } = + useGroundClearancePreview(accessoryId, capabilityId) + const colors = useResolvedAccentColors().sky + const charts = useMemo( + () => [ + { + key: 'clearance', + label: 'Ground clearance (cm)', + height: 140, + left: { range }, + series: (diagnostics?.segments ?? []).map((points, index) => { + const ts: number[] = [], + vs: number[] = [] + for (let i = 0; i + 1 < points.length; i += 2) { + ts.push(points[i]!) + vs.push(points[i + 1]!) + } + return { + key: `clearance-${index}`, + label: 'Ground clearance', + unit: 'cm', + decimals: 1, + color: colors.color, + data: { ts, vs }, + } + }), + }, + ], + [diagnostics, range, colors.color], + ) + return ( + <> + + Readings + + + Ground clearance + + + + + {stalled + ? 'No fresh measurements.' + : reading + ? readingCopy(reading.status, reading.valueCm).detail || 'Live distance' + : 'Waiting for measurements…'} + + + Link · last 20 seconds + + Delivered + {diagnostics?.deliveredHz.toFixed(1) ?? '—'} Hz + + + Missing samples + {diagnostics?.dropped ?? 0} + + + Invalid readings + + {diagnostics?.invalid ?? 0} / {diagnostics?.samples ?? 0} + + + History + {charts[0]!.series.some((series) => series.data.ts.length > 1) ? ( + + ) : ( + + Waiting for valid distances. Invalid readings leave gaps in the chart. + + )} + + ) +} +const styles = StyleSheet.create({ + reading: { padding: 12, gap: 10 }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 6, + }, + detail: { color: theme.neutral.textSecondary, fontSize: 12, paddingHorizontal: 12 }, +}) diff --git a/src/modules/accessories/components/GroundClearanceTiltPreview.tsx b/src/modules/accessories/components/GroundClearanceTiltPreview.tsx new file mode 100644 index 00000000..ef8139d4 --- /dev/null +++ b/src/modules/accessories/components/GroundClearanceTiltPreview.tsx @@ -0,0 +1,25 @@ +import { StyleSheet, View } from 'react-native' +import type { SharedValue } from 'react-native-reanimated' + +import { Text } from '@/components/base/Text' +import { LiveNumber } from './LiveNumber' + +/** Hypothetical sensor output, calculated natively from calibration and a fresh reading. */ +export function GroundClearanceTiltPreview({ value }: { value: SharedValue }) { + return ( + + Tilt preview + + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 12, + paddingVertical: 6, + }, +}) diff --git a/src/modules/accessories/components/LiveNumber.tsx b/src/modules/accessories/components/LiveNumber.tsx new file mode 100644 index 00000000..170c6475 --- /dev/null +++ b/src/modules/accessories/components/LiveNumber.tsx @@ -0,0 +1,41 @@ +import { useDerivedValue, type SharedValue } from 'react-native-reanimated' + +import { MonoValue } from '@/components/base/MonoValue' +import { theme } from '@/constants/theme' + +/** Fits the widest readout on this screen without the row shifting as digits change. */ +const WIDTH = 110 + +const SIZE = 15 + +interface LiveNumberProps { + value: SharedValue + decimals: number + unit?: string +} + +/** + * A number that changes as fast as the board sends it, without a React render. + * + * The board can push fifty frames a second, which is more than the reconciler should ever see for + * five digits. `MonoValue` draws on Skia from a shared value, so a new reading is a repaint and + * nothing else. `NaN` reads as `-`: a sensor that answered nothing has no number, and a stale one + * would be a lie. + */ +export function LiveNumber({ value, decimals, unit }: LiveNumberProps) { + const text = useDerivedValue(() => { + const current = value.value + if (Number.isNaN(current)) return '-' + return unit ? `${current.toFixed(decimals)} ${unit}` : current.toFixed(decimals) + }) + return ( + + ) +} diff --git a/src/modules/accessories/components/SensorBar.tsx b/src/modules/accessories/components/SensorBar.tsx new file mode 100644 index 00000000..ef9c0e56 --- /dev/null +++ b/src/modules/accessories/components/SensorBar.tsx @@ -0,0 +1,54 @@ +import { StyleSheet, View } from 'react-native' +import Animated, { useAnimatedStyle, type SharedValue } from 'react-native-reanimated' + +import { theme } from '@/constants/theme' +interface ReadingRange { + min: number + max: number +} + +interface SensorBarProps { + value: SharedValue + range: ReadingRange + color: string +} + +const HEIGHT = 4 + +/** + * A reading drawn as a proportion of its range, for sensors whose numbers move faster than they + * can be read. Scaled rather than resized: a width animation lays the row out again on every + * frame, while a transform is the UI thread moving one already-measured view — the same reason + * the numbers beside it are shared values and not React state. + */ +export function SensorBar({ value, range, color }: SensorBarProps) { + const span = range.max - range.min + const style = useAnimatedStyle(() => { + const current = value.value + if (Number.isNaN(current) || span <= 0) return { transform: [{ scaleX: 0 }] } + return { + transform: [{ scaleX: Math.min(Math.max((current - range.min) / span, 0), 1) }], + } + }) + + return ( + + + + ) +} + +const styles = StyleSheet.create({ + track: { + height: HEIGHT, + borderRadius: HEIGHT / 2, + backgroundColor: theme.neutral.border, + overflow: 'hidden', + }, + fill: { + height: HEIGHT, + // Anchored left so the bar grows out of its origin instead of from its middle. + transformOrigin: 'left', + width: '100%', + }, +}) diff --git a/src/modules/accessories/constants/accessoryCapabilities.ts b/src/modules/accessories/constants/accessoryCapabilities.ts new file mode 100644 index 00000000..1fbbb650 --- /dev/null +++ b/src/modules/accessories/constants/accessoryCapabilities.ts @@ -0,0 +1,50 @@ +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 +} + +/** + * 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.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 + // one is still usable for everything else it offers. + title: capability.type, + description: 'This app does not know this capability type yet.', + icon: QuestionIcon, + } +} diff --git a/src/modules/accessories/constants/accessoryIcon.ts b/src/modules/accessories/constants/accessoryIcon.ts new file mode 100644 index 00000000..97a5f8ab --- /dev/null +++ b/src/modules/accessories/constants/accessoryIcon.ts @@ -0,0 +1,11 @@ +import { CircuitryIcon } from 'phosphor-react-native' +import type { Icon } from 'phosphor-react-native' + +/** + * The single mark for an Accessory, wherever one is shown. + * + * Kept here rather than picked per screen so the Board selector's row, the detail screen, the scan + * screen and the top bar's live badge are recognisably the same thing. A plug was the wrong mark: + * the Board's own link timeline already wears it, so "connected" and "accessory" read alike. + */ +export const AccessoryIcon: Icon = CircuitryIcon diff --git a/src/modules/accessories/hooks/useGroundClearancePreview.ts b/src/modules/accessories/hooks/useGroundClearancePreview.ts new file mode 100644 index 00000000..0db853ec --- /dev/null +++ b/src/modules/accessories/hooks/useGroundClearancePreview.ts @@ -0,0 +1,117 @@ +import { useEffect, useRef, useState } from 'react' +import { useSharedValue, type SharedValue } from 'react-native-reanimated' +import { AppState } from 'react-native' +import { + addAccessoryReadingListener, + setAccessoryPreview, + type AccessoryReadingEvent, + type ClearancePreviewDiagnostics, +} from 'vescape-core' + +/** The newest sample native accepted, or null when there is not one to show. */ +export type LiveReading = Pick | null + +export interface GroundClearancePreview { + /** Null before the first sample, and again once the stream goes quiet. */ + reading: LiveReading + liveValue: SharedValue + tiltPreviewPercent: SharedValue + diagnostics: ClearancePreviewDiagnostics | null + /** + * A sample arrived and then the stream stopped. + * + * Distinct from "no sample yet": the accessory answered once and has gone silent, which is a + * different sentence for the rider and a different thing to check. + */ + stalled: boolean +} + +/** + * Holds a measurement preview open for one capability, and reports what arrives. + * + * Preview demand is a *request to measure*, not a subscription: native takes the union of this and + * the rider actually riding a calibrated board, and stops the sensor's continuous measurement when + * neither holds. Which is why this releases on three separate exits and not just one — + * + * - the screen unmounting, the obvious one; + * - the app leaving the foreground, because a screen the rider cannot see is not a screen they are + * reading numbers off, and leaving the sensor running would burn accessory battery in a pocket; + * - an accessory id or capability changing under the hook, which releases the previous pair before + * asking for the new one. + * + * A displayed reading also expires on its own. `staleAfterMs` rides along with every sample — it is + * native's window, from the rate the accessory confirmed — and when it elapses with nothing new the + * number is dropped rather than left on screen. A frozen distance presented as a live one is the + * same lie as an invalid reading shown as the maximum range, just slower. + */ +export function useGroundClearancePreview( + accessoryId: string, + capabilityId: string | undefined, +): GroundClearancePreview { + const liveValue = useSharedValue(Number.NaN) + const tiltPreviewPercent = useSharedValue(Number.NaN) + const [diagnostics, setDiagnostics] = useState(null) + const [reading, setReading] = useState(null) + const [stalled, setStalled] = useState(false) + const expiry = useRef | null>(null) + + useEffect(() => { + if (!capabilityId) return + let open = false + + const clearExpiry = () => { + if (expiry.current) clearTimeout(expiry.current) + expiry.current = null + } + + const demand = (next: boolean) => { + if (next === open) return + open = next + setAccessoryPreview(accessoryId, capabilityId, next) + if (!next) { + // Released: the last sample described the ground under a board at a moment that has passed, + // and it is not stale either — nothing is being measured at all. + clearExpiry() + liveValue.value = Number.NaN + tiltPreviewPercent.value = Number.NaN + setDiagnostics(null) + setReading(null) + setStalled(false) + } + } + + const subscription = addAccessoryReadingListener((event) => { + if (event.accessoryId !== accessoryId || event.capabilityId !== capabilityId) return + liveValue.value = event.status === 'ok' ? (event.valueCm ?? Number.NaN) : Number.NaN + tiltPreviewPercent.value = + event.status === 'ok' ? (event.tiltPreviewPercent ?? Number.NaN) : Number.NaN + if (event.diagnostics) { + setDiagnostics(event.diagnostics) + setReading({ status: event.status, valueCm: event.valueCm, seq: event.seq }) + } + setStalled(false) + clearExpiry() + expiry.current = setTimeout(() => { + expiry.current = null + liveValue.value = Number.NaN + tiltPreviewPercent.value = Number.NaN + setReading(null) + setStalled(true) + }, event.staleAfterMs) + }) + const appState = AppState.addEventListener('change', (status) => { + demand(status === 'active') + }) + + demand(AppState.currentState === 'active') + + return () => { + subscription.remove() + appState.remove() + demand(false) + clearExpiry() + } + }, [accessoryId, capabilityId, liveValue, tiltPreviewPercent]) + + return { reading, stalled, liveValue, tiltPreviewPercent, diagnostics } +} diff --git a/src/modules/accessories/lib/accessoryStatus.ts b/src/modules/accessories/lib/accessoryStatus.ts new file mode 100644 index 00000000..d33d0dc1 --- /dev/null +++ b/src/modules/accessories/lib/accessoryStatus.ts @@ -0,0 +1,132 @@ +import type { + AccessoryCompatibility, + AccessoryInspectionError, + AccessoryLinkPhase, +} from 'vescape-core' + +export interface AccessoryStatusCopy { + label: string + /** A `theme.status` / `theme.palette` key resolved by the caller, never a color literal. */ + tone: 'success' | 'neutral' | 'caution' +} + +/** + * 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: 'Saved', 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. + * + * 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': + 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.' + 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/lib/capabilityStateCopy.ts b/src/modules/accessories/lib/capabilityStateCopy.ts new file mode 100644 index 00000000..52a06fac --- /dev/null +++ b/src/modules/accessories/lib/capabilityStateCopy.ts @@ -0,0 +1,32 @@ +import type { AccessoryCapability, AccessoryLinkPhase } from 'vescape-core' + +/** Native requested state; the protocol does not report physical light output. */ +export function capabilityStateCopy( + capability: AccessoryCapability, + phase: AccessoryLinkPhase, +): string { + if (phase !== 'connected') return 'Disconnected · output unknown' + if (capability.enabled === false) return 'Disabled' + if (capability.type === 'ground_clearance') { + if (!capability.calibration || capability.calibration.problem) return 'Calibration needed' + if (!capability.measuring) return 'Standby · waiting to ride' + return capability.samplingRateHz + ? `Measuring · ${capability.samplingRateHz} Hz` + : 'Starting measurement' + } + const mode = capability.lightPreview ?? capability.lightMode + const preview = capability.lightPreview ? 'Preview · ' : '' + switch (mode) { + case 'riding': + return `${preview}Riding · dim red` + case 'braking': + return `${preview}Braking · bright red` + case 'hard_braking': + return `${preview}Hard braking · blinking red` + case 'not_riding': + return `${preview}Parked · ${capability.brakeLight?.parked === 'glow' ? 'red glow' : 'off'}` + case null: + case undefined: + return 'No telemetry · device default' + } +} diff --git a/src/modules/accessories/lib/groundClearanceCopy.ts b/src/modules/accessories/lib/groundClearanceCopy.ts new file mode 100644 index 00000000..517469dc --- /dev/null +++ b/src/modules/accessories/lib/groundClearanceCopy.ts @@ -0,0 +1,79 @@ +import type { + AccessoryReadingStatus, + GroundClearanceDirection, + GroundClearanceProblem, +} from 'vescape-core' + +/** + * Rider-facing phrasing for the ground-clearance capability. Native decides; this only phrases it. + * + * Nothing here re-derives a verdict. Validity, sample status and measurement demand are all decided + * natively — a second definition of "valid" in JS could disagree with the one the binding actually + * uses, and the rider would be told the calibration is fine while the board refuses to act on it. + */ + +/** How a sample reads to the rider, with whether it is a number at all. */ +export interface ReadingCopy { + /** The measured distance, or a short phrase when there is none. */ + value: string + /** Present only when the reading is not a measurement. */ + detail?: string + tone: 'success' | 'caution' +} + +/** + * One sample as a line of text. + * + * `out_of_range` and `error` never borrow a number — not the last good one, not the top of the + * declared range. The whole safety property of this feature is that a missing measurement is not a + * distance, and a screen that filled the gap with the previous reading would be the first place + * that stops being true. + */ +export function readingCopy(status: AccessoryReadingStatus, valueCm: number | null): ReadingCopy { + if (status === 'ok' && valueCm != null) { + return { value: `${valueCm.toFixed(1)} cm`, tone: 'success' } + } + if (status === 'out_of_range') { + return { + value: '—', + detail: 'Nothing in range. Point the sensor at the ground from a mounted position.', + tone: 'caution', + } + } + return { + value: '—', + detail: 'The sensor could not measure. Check that it is connected and unobstructed.', + tone: 'caution', + } +} + +/** Why native did not accept a calibration, in rider language. */ +export function calibrationProblemCopy( + problem: GroundClearanceProblem | 'unknown-capability' | 'storage-unavailable' | (string & {}), +): string { + switch (problem) { + case 'near-not-below-far': + return 'The near distance has to be smaller than the far distance — less clearance means more correction.' + case 'outside-declared-range': + return 'These distances are outside what the sensor says it can measure. Move them inside its range.' + case 'strength-out-of-bounds': + return 'Strength has to be between 1% and 100%. At 0% the binding would command nothing.' + case 'unknown-direction': + return 'This mounting position was saved by a newer version of Vescape and cannot be used here.' + case 'not-a-number': + return 'One of these distances is not a number. Set them again.' + case 'unknown-capability': + return 'This accessory is no longer saved on this phone.' + case 'storage-unavailable': + return 'Vescape could not save this. Nothing was changed; try again.' + default: + return problem + } +} + +/** What each mounting position means for the rider, beside its label. */ +export function directionCopy(direction: GroundClearanceDirection): string { + return direction === 'nose' + ? 'The sensor is mounted at the nose. Losing clearance there lifts the nose.' + : 'The sensor is mounted at the tail. Losing clearance there lifts the tail.' +} diff --git a/src/modules/accessories/screens/AccessoryDetailScreen.tsx b/src/modules/accessories/screens/AccessoryDetailScreen.tsx new file mode 100644 index 00000000..f50cf212 --- /dev/null +++ b/src/modules/accessories/screens/AccessoryDetailScreen.tsx @@ -0,0 +1,226 @@ +import { useCallback, useState } from 'react' +import { ScrollView, StyleSheet, View } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' + +import { TrashIcon } from 'phosphor-react-native' + +import { Text } from '@/components/base/Text' +import { Button } from '@/components/base/Button' +import { ConfirmModal } from '@/components/modals/ConfirmModal' +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 { AccessoryIcon } from '@/modules/accessories/constants/accessoryIcon' +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, 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. A + * capability row is a way in only when this build has a screen for that type: ground clearance + * opens calibration, brake light opens controls and parked preview. + * + * 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, + onForgotten, + onConfigureCapability, +}: { + accessoryId: string + /** Called once the Accessory is gone, so the route that opened this can leave. */ + onForgotten?: () => void + /** Open one capability's own configuration. Only offered for types this build can configure. */ + onConfigureCapability?: (capabilityId: string, type: string) => void +}) { + const accessory = useSavedAccessory(accessoryId) + const forget = useAccessoryStore((s) => s.forget) + const [forgetting, setForgetting] = useState(false) + const [forgetFailed, setForgetFailed] = useState(false) + const [confirming, setConfirming] = useState(false) + + const onForget = useCallback(async () => { + setForgetting(true) + setForgetFailed(false) + try { + // Native answers false when the saved identity is still there — a storage failure means the + // Accessory is still enrolled and still connecting, so leaving the screen would claim + // something that did not happen. + if (await forget(accessoryId)) { + onForgotten?.() + return + } + setForgetFailed(true) + } finally { + setForgetting(false) + setConfirming(false) + } + }, [accessoryId, forget, onForgotten]) + + if (!accessory) { + return ( + + + + ) + } + + 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 + + {accessory.capabilities.length === 0 ? ( + This accessory declared no capabilities. + ) : ( + accessory.capabilities.map((capability) => ( + onConfigureCapability?.(capability.id, capability.type) } + : {})} + /> + )) + )} + + + +