BLE CSC wheel sensor client and wheel-circumference calibration (#18) #99

Merged
robert merged 1 commit from area/ble-csc-sensor into main 2026-09-04 19:39:44 +02:00
Owner

Closes #18.

Pure logic (:companion:core, de.butzei.pedalpebble.core.sensors)

  • CscMeasurement.kt — decodeCscMeasurement() parses the CSC Measurement characteristic (0x2A5B) byte layout: flags byte, then a little-endian uint32 cumulative-wheel-revolutions + uint16 last-wheel-event-time if flag bit 0 is set, then a little-endian uint16 cumulative-crank-revolutions + uint16 last-crank-event-time if flag bit 1 is set. Crank fields are decoded (the characteristic is shared with #49's cadence half) but not otherwise consumed by this issue.
  • CscWraparound.kt — uint32Delta/uint16Delta compute (current - previous) mod 2^n via Math.floorMod, handling the uint32 revolution-counter and uint16 event-time rollovers explicitly.
  • WheelSpeedTracker.kt — turns successive raw wheel samples into distance/speed deltas against RideSettings.WheelCircumference (#44, the real settings type — no parallel type invented). WheelSpeedSample is Measured | NoNewRevolution; "connected but not turning" is never conflated with "no sensor at all" (that split happens one level up, in CscSensorState, so #19's arbitration can consume it correctly later).

UUIDs (service 0x1816, CSC Measurement 0x2A5B, CSC Feature 0x2A5C) were verified 2026-09-04 against the Bluetooth SIG's own CSC Service GATT specification, not carried over unchecked from this project's earlier planning notes.

Android BLE client (:companion:sensors, de.butzei.pedalpebble.sensors)

  • CscClient.kt (interface) / AndroidCscClient.kt (impl) — scans filtered on the CSC service UUID, connects via BluetoothGatt, subscribes to CSC Measurement notifications, and reads Battery Service (0x180F/0x2A19) where the peripheral exposes it. Implements both the pre-API-33 and API-33+ onCharacteristicChanged/onCharacteristicRead overload pairs, and (a genuine surprise found by actually building against this repo's real compileSdk 37 SDK) the newer connectGatt(BluetoothGattConnectionSettings, Executor, BluetoothGattCallback) overload that deprecates the Context/boolean/int one starting at API 37 — confirmed directly against this SDK's api-versions.xml (since="37.0"/deprecated="37.0"). minSdk is 31, so the old overload is still what actually runs on real devices; AndroidCscClient branches on SDK_INT and documents the exact versions in its KDoc.
  • Auto-reconnect: built on BluetoothDevice.connectGatt's own autoConnect flag — first connect after a scan uses autoConnect=false; any unrequested disconnect re-connects with autoConnect=true so the OS reconnects once the sensor is back in range ("wakes"), no polling loop of this client's own.
  • Dropout honesty: an unrequested disconnect resets WheelSpeedTracker and moves state to Reconnecting before any reconnect attempt — it never holds a stale Connected value while no sensor is actually live. See CscSensorState's KDoc.
  • SensorPermissions.kt — BLUETOOTH_SCAN/BLUETOOTH_CONNECT checks, mirroring :companion:location's RidePermissions. connect() reports CscSensorState.PermissionRequired rather than requesting permissions itself (NFR-S6, same pattern as RideSetupActivity) — there's no pairing UI yet to host a rationale, which is out of scope for this issue (its own "Files" note names only CscClient.kt).
  • Manifest declares BLUETOOTH_SCAN with android:usesPermissionFlags="neverForLocation" (the scan filter only ever matches the CSC service UUID, never derives location) and BLUETOOTH_CONNECT.

Wraparound handling and its test coverage

Math.floorMod-based modular delta, not naive subtraction. 23 new Kotest cases in :companion:core, including:

  • fixture sequences that cross a real uint16 rollover (65530 → 10) and a real uint32 rollover (4294967290 → 5), each asserted against the exact expected positive delta
  • both counters rolling over on the same sample pair
  • a stationary-wheel case (unchanged counters → NoNewRevolution, not a fabricated 0.0)
  • an event-time-delta-of-zero guard (nonzero revolution delta but no elapsed time → NoNewRevolution, not a divide-by-zero)
  • reset() behaviour after a simulated disconnect
  • property tests asserting uint32Delta/uint16Delta always land in [0, 2^n) for arbitrary inputs

Real builds

  • ./gradlew :companion:core:test — 23/23 passing (CscMeasurementTest: 8, WheelSpeedTrackerTest: 15).
  • ./gradlew :companion:sensors:assembleDebug and ./gradlew :companion:assembleDebug — both build clean, zero warnings (after fixing the connectGatt deprecation and two OVERRIDE_DEPRECATION warnings the first real build surfaced).

What's honestly unverified

This sandbox has no Bluetooth hardware. The scan filter, GATT callback wiring, notification subscription/parsing end-to-end, and the autoConnect reconnect behaviour have never run against a physical CSC sensor — only compiled. autoConnect reconnect in particular is documented Android platform behaviour this class relies on, not something it reimplements.

https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt

Closes #18. ## Pure logic (`:companion:core`, `de.butzei.pedalpebble.core.sensors`) - `CscMeasurement.kt` — `decodeCscMeasurement()` parses the CSC Measurement characteristic (`0x2A5B`) byte layout: flags byte, then a little-endian `uint32` cumulative-wheel-revolutions + `uint16` last-wheel-event-time if flag bit 0 is set, then a little-endian `uint16` cumulative-crank-revolutions + `uint16` last-crank-event-time if flag bit 1 is set. Crank fields are decoded (the characteristic is shared with #49's cadence half) but not otherwise consumed by this issue. - `CscWraparound.kt` — `uint32Delta`/`uint16Delta` compute `(current - previous) mod 2^n` via `Math.floorMod`, handling the `uint32` revolution-counter and `uint16` event-time rollovers explicitly. - `WheelSpeedTracker.kt` — turns successive raw wheel samples into distance/speed deltas against `RideSettings.WheelCircumference` (#44, the real settings type — no parallel type invented). `WheelSpeedSample` is `Measured | NoNewRevolution`; "connected but not turning" is never conflated with "no sensor at all" (that split happens one level up, in `CscSensorState`, so #19's arbitration can consume it correctly later). UUIDs (service `0x1816`, CSC Measurement `0x2A5B`, CSC Feature `0x2A5C`) were verified 2026-09-04 against the Bluetooth SIG's own CSC Service GATT specification, not carried over unchecked from this project's earlier planning notes. ## Android BLE client (`:companion:sensors`, `de.butzei.pedalpebble.sensors`) - `CscClient.kt` (interface) / `AndroidCscClient.kt` (impl) — scans filtered on the CSC service UUID, connects via `BluetoothGatt`, subscribes to CSC Measurement notifications, and reads Battery Service (`0x180F`/`0x2A19`) where the peripheral exposes it. Implements both the pre-API-33 and API-33+ `onCharacteristicChanged`/`onCharacteristicRead` overload pairs, and (a genuine surprise found by actually building against this repo's real `compileSdk 37` SDK) the newer `connectGatt(BluetoothGattConnectionSettings, Executor, BluetoothGattCallback)` overload that deprecates the `Context`/`boolean`/`int` one starting at API 37 — confirmed directly against this SDK's `api-versions.xml` (`since="37.0"`/`deprecated="37.0"`). `minSdk` is 31, so the old overload is still what actually runs on real devices; `AndroidCscClient` branches on `SDK_INT` and documents the exact versions in its KDoc. - Auto-reconnect: built on `BluetoothDevice.connectGatt`'s own `autoConnect` flag — first connect after a scan uses `autoConnect=false`; any *unrequested* disconnect re-connects with `autoConnect=true` so the OS reconnects once the sensor is back in range ("wakes"), no polling loop of this client's own. - Dropout honesty: an unrequested disconnect resets `WheelSpeedTracker` and moves `state` to `Reconnecting` *before* any reconnect attempt — it never holds a stale `Connected` value while no sensor is actually live. See `CscSensorState`'s KDoc. - `SensorPermissions.kt` — `BLUETOOTH_SCAN`/`BLUETOOTH_CONNECT` checks, mirroring `:companion:location`'s `RidePermissions`. `connect()` reports `CscSensorState.PermissionRequired` rather than requesting permissions itself (NFR-S6, same pattern as `RideSetupActivity`) — there's no pairing UI yet to host a rationale, which is out of scope for this issue (its own "Files" note names only `CscClient.kt`). - Manifest declares `BLUETOOTH_SCAN` with `android:usesPermissionFlags="neverForLocation"` (the scan filter only ever matches the CSC service UUID, never derives location) and `BLUETOOTH_CONNECT`. ## Wraparound handling and its test coverage `Math.floorMod`-based modular delta, not naive subtraction. 23 new Kotest cases in `:companion:core`, including: - fixture sequences that cross a real `uint16` rollover (`65530 → 10`) and a real `uint32` rollover (`4294967290 → 5`), each asserted against the exact expected positive delta - both counters rolling over on the same sample pair - a stationary-wheel case (unchanged counters → `NoNewRevolution`, not a fabricated `0.0`) - an event-time-delta-of-zero guard (nonzero revolution delta but no elapsed time → `NoNewRevolution`, not a divide-by-zero) - `reset()` behaviour after a simulated disconnect - property tests asserting `uint32Delta`/`uint16Delta` always land in `[0, 2^n)` for arbitrary inputs ## Real builds - `./gradlew :companion:core:test` — **23/23 passing** (`CscMeasurementTest`: 8, `WheelSpeedTrackerTest`: 15). - `./gradlew :companion:sensors:assembleDebug` and `./gradlew :companion:assembleDebug` — both build clean, **zero warnings** (after fixing the `connectGatt` deprecation and two `OVERRIDE_DEPRECATION` warnings the first real build surfaced). ## What's honestly unverified This sandbox has no Bluetooth hardware. The scan filter, GATT callback wiring, notification subscription/parsing end-to-end, and the `autoConnect` reconnect behaviour have never run against a physical CSC sensor — only compiled. `autoConnect` reconnect in particular is documented Android platform behaviour this class relies on, not something it reimplements. https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Implement BLE CSC wheel sensor client and wheel-circumference calibration (#18)
Some checks failed
dev-artifact / build-pbw (push) Failing after 0s
dev-artifact / build-apk (push) Failing after 0s
dev-artifact / publish (push) Has been skipped
fast-lane / host-c-tests (push) Failing after 0s
fast-lane / jvm-tests (push) Failing after 0s
fast-lane / pebble-build (push) Failing after 0s
fast-lane / lint-and-secrets (push) Failing after 0s
fast-lane / meta-declares-required-jobs (push) Failing after 0s
fast-lane / host-c-tests (pull_request) Failing after 0s
fast-lane / jvm-tests (pull_request) Failing after 0s
fast-lane / pebble-build (pull_request) Failing after 0s
fast-lane / lint-and-secrets (pull_request) Failing after 0s
fast-lane / meta-declares-required-jobs (pull_request) Failing after 0s
6c9a688306
Pure logic (:companion:core, de.butzei.pedalpebble.core.sensors):
- CscMeasurement.kt: decodeCscMeasurement() parses the CSC Measurement
  characteristic (0x2A5B) byte layout — flags byte, uint32 cumulative wheel
  revolutions + uint16 last-wheel-event-time when flag bit 0 is set, uint16
  cumulative crank revolutions + uint16 last-crank-event-time when flag bit 1
  is set. Crank fields are decoded (the characteristic is shared with #49's
  cadence half) but not otherwise consumed here.
- CscWraparound.kt: uint32Delta/uint16Delta compute (current - previous) mod
  2^n via Math.floorMod, handling the uint32 revolution-counter and uint16
  event-time rollovers explicitly rather than relying on Kotlin's unsigned
  types wrapping silently.
- WheelSpeedTracker.kt: turns successive raw wheel samples into distance/speed
  deltas against RideSettings' WheelCircumference (#44). WheelSpeedSample is
  Measured | NoNewRevolution — "sensor connected but not turning" is never
  conflated with "no sensor connected at all" (that distinction is carried up
  a level by :companion:sensors' CscSensorState, for #19 to consume later).

UUIDs (service 0x1816, CSC Measurement 0x2A5B, CSC Feature 0x2A5C) verified
2026-09-04 against the Bluetooth SIG's own CSC Service GATT specification
rather than carried over unchecked from earlier planning notes.

Android BLE client (:companion:sensors, de.butzei.pedalpebble.sensors):
- CscClient.kt / AndroidCscClient.kt: scans for the CSC service UUID, connects
  via BluetoothGatt, subscribes to CSC Measurement notifications, and reads
  Battery Service (0x180F/0x2A19) where the peripheral exposes it. Handles
  both the pre-API-33 and API-33+ onCharacteristicChanged/onCharacteristicRead
  overload pairs, and the compileSdk-37 connectGatt(BluetoothGattConnectionSettings, ...)
  overload that deprecates the Context/boolean/int one at API 37+ (still used
  below that, since minSdk is 31) — see AndroidCscClient's KDoc for exact
  since/deprecated versions, checked against this SDK's api-versions.xml.
- Auto-reconnect: BluetoothDevice.connectGatt's own autoConnect flag — first
  connect after a scan uses autoConnect=false, any unrequested disconnect
  re-connects with autoConnect=true so the OS reconnects once the sensor is
  back in range, no polling loop of this client's own.
- Dropout honesty: an unrequested disconnect resets WheelSpeedTracker and
  moves state to Reconnecting before any reconnect attempt — never holds a
  stale Connected value while no sensor is actually live.
- SensorPermissions.kt: BLUETOOTH_SCAN/BLUETOOTH_CONNECT checks, mirroring
  :companion:location's RidePermissions. connect() reports
  CscSensorState.PermissionRequired rather than requesting permissions itself
  (NFR-S6) — no pairing UI exists yet to host a rationale (out of scope for
  this issue, which names CscClient.kt as its only file).
- AndroidManifest.xml declares BLUETOOTH_SCAN with
  usesPermissionFlags="neverForLocation" (the scan filter only ever matches
  the CSC service UUID) and BLUETOOTH_CONNECT.

Tests: 23 new Kotest cases in :companion:core, including fixture sequences
that cross real uint16 (65530->10) and uint32 (4294967290->5) rollover
boundaries, both together, a stationary-wheel case, an event-time-delta-of-zero
guard, and property tests asserting uint32Delta/uint16Delta stay within their
modulus for arbitrary inputs. ./gradlew :companion:core:test passes (23/23).
:companion:sensors:assembleDebug and :companion:assembleDebug both build
cleanly with zero warnings. Not exercised: this sandbox has no Bluetooth
hardware, so the actual scan/connect/notify path against a physical sensor is
unverified — only compiled.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit cf4f89a1cf into main 2026-09-04 19:39:44 +02:00
Sign in to join this conversation.
No description provided.