BLE Cycling Power meter support (issue #50) #113

Merged
robert merged 1 commit from area/ble-power-meter into main 2026-09-05 17:25:15 +02:00
Owner

Closes #50.

Adds a BLE GATT client for the Cycling Power Service (0x1818), decodes the Power Measurement characteristic (0x2A63), and wires real instantaneous power into NormalizedPowerCalculator/a new Power3sAverage.

Cycling Power Measurement (0x2A63) format -- verified live, not recalled

Checked 2026-09-05 against two independent sources mirroring the Bluetooth SIG's own GATT Specification Supplement field table for this characteristic (cited in CyclingPowerMeasurement.kt's KDoc):

Three ways this genuinely differs from CSC's layout (D16's own "materially more involved" warning, now concretely true):

  1. Flags is 2 bytes (uint16, 13 meaningful bits), not CSC's 1 byte.
  2. Instantaneous Power is signed (sint16) -- a meter can report a small negative value (calibration drift / regen-capable trainers).
  3. Wheel Revolution Data's Last Wheel Event Time is 1/2048 s resolution here, not CSC's 1/1024 s. Crank Revolution Data keeps CSC's 1/1024 s unchanged.

What's decoded vs. left out, and why

  • Decoded: instantaneous power (mandatory), pedal power balance + reference (flag bits 0/1 -- cleanly decodable, but this codebase has no consumer for it yet, so it's carried through CyclingPowerSensorState.Connected undecoded-further, same "decode it, note there's no consumer" honesty applied elsewhere tonight rather than skipping a clean field), crank revolution data (flag bit 5, reusing CscMeasurement.kt's existing CrankRevolutionData type since the resolution is unchanged from CSC -- feeds a real CrankCadenceTracker instance).
  • Walked past but not decoded: accumulated torque (bit 2) and wheel revolution data (bit 4) -- their bytes are correctly consumed so later fields' offsets stay right (exactly the "offsets shift" complexity D16 flags), but neither is turned into a value. Wheel revolution data in particular is a deliberate non-decode: because of gotcha #3 above, feeding it through the existing WheelSpeedTracker (hard-coded to CSC's 1024 ticks/s) would silently compute wheel speed at half its real value -- building a separate CPS-specific wheel tracker on spec with no real consumer (the CSC client already owns wheel speed) is exactly the premature abstraction SensorHub.kt's own KDoc already argues against.
  • Not parsed at all: extreme force/torque magnitudes, extreme angles, top/bottom dead spot angle, accumulated energy, offset compensation indicator (flag bits 6-12). Decoding stops once the crank field is consumed -- nothing needs these, and claiming to parse several uint12-packed fields with no real device or test to check them against would be unverified code.

SensorPermissions: reused, not duplicated

Checked first, as asked: SensorPermissions (issue #18) is already fully generic over BLUETOOTH_SCAN/BLUETOOTH_CONNECT with nothing CSC-specific in its name or shape. AndroidCyclingPowerClient calls it directly, unchanged. The sensors module's manifest already declares both permissions with a 0x1818-aware comment from #18, so no manifest change was needed either.

Dropout/reconnect honesty

CyclingPowerClient/CyclingPowerSensorState/AndroidCyclingPowerClient mirror CscClient/CscSensorState/AndroidCscClient structurally: NotConnected/PermissionRequired/Scanning/Connecting/Reconnecting/Connected states, the same API-37 connectGatt overload split, the same "reset trackers and move to Reconnecting before attempting reconnect" ordering, the same autoConnect=true OS-level reconnect on an unrequested drop. No stale reading of any kind survives a drop.

How NormalizedPowerCalculator is now actually fed

AndroidCyclingPowerClient owns one NormalizedPowerCalculator and one new Power3sAverage instance per connection, and calls onPowerSample on both, on every notification -- the same "sensor client owns the tracker instance directly" shape AndroidCscClient already uses for WheelSpeedTracker/CrankCadenceTracker. This is legitimate (not a hack) specifically because neither calculator needs a live MovementState to decide what counts -- both classes' KDoc already say so explicitly. Instantaneous power is sint16 and can be negative; both calculators reject negative input by contract, so a raw negative reading is clamped to 0 at this boundary (documented in AndroidCyclingPowerClient's KDoc), the same kind of display clamp SpeedPipelineSample already applies for a stopped rider.

AvgPowerAccumulator (new, mirrors AvgHrAccumulator) has no live feed -- same "no ride orchestrator (RideSession) exists yet to supply a live MovementState" gap AvgCadenceAccumulator/AvgHrAccumulator already have, not a new gap invented for this issue.

DerivedMetricsWireEncoding.kt (:companion:pebble) now encodes POWER_W/POWER_3S_W/AVG_POWER_W alongside the existing NORM_POWER_W -- all four wire keys already existed in shared/message_keys.json/docs/PROTOCOL.md §2.2, generated against real Proto.Key constants.

Explicitly out of scope for this PR (documented, not silently dropped)

  • "Crank cadence from the power meter in preference to a separate CSC sensor" -- decoding+tracking crank cadence from the power meter itself is built (its own CrankCadenceTracker instance); the preference/arbitration between two independently connected sensors is a source-arbitration decision one level up (the same category of gap as #19's GPS-vs-wheel-sensor speed arbitration), documented on CyclingPowerSensorState.Connected.cadence's KDoc, not built here.
  • Pairing/calibration UI (incl. zero-offset), power written to GPX, and watchapp/src/c/view_ride.c display wiring -- all depend on a ride orchestrator (RideSession) that doesn't exist yet for any sensor-derived metric in this codebase today (HR, cadence, and now power all sit in this same documented state -- see DerivedMetrics.kt's own KDoc). view_ride.c is also watch-side, outside this agent's ownership boundary (phone half of docs/PROTOCOL.md).

Test runs (real, not hand-reviewed)

  • ./gradlew :companion:core:test -- passes. New: CyclingPowerMeasurementTest (14 tests, decode/offset/sign/resolution coverage), Power3sAverageTest (6 tests), AvgPowerAccumulatorTest (4 tests). Existing NormalizedPowerCalculatorTest/full suite still green.
  • ./gradlew :companion:pebble:testDebugUnitTest -- passes, including the extended DerivedMetricsWireEncodingTest (9-key encode()).
  • ./gradlew :companion:sensors:assembleDebug -- compiles cleanly (real Android SDK, compileSdk 37).
  • ./gradlew :companion:assembleDebug -- compiles/packages cleanly end to end.

Honesty on hardware: no Bluetooth hardware in this sandbox. AndroidCyclingPowerClient has never run against a physical Cycling Power meter -- only compiled. The scan filter, GATT callback wiring, notification subscription and byte-layout decoding are exercised for real by CyclingPowerMeasurementTest on the JVM; autoConnect reconnect behaviour is documented Android platform behaviour this class relies on, same disclosure AndroidCscClient (#18/PR #99) already makes.

https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt

Closes #50. Adds a BLE GATT client for the Cycling Power Service (`0x1818`), decodes the Power Measurement characteristic (`0x2A63`), and wires real instantaneous power into `NormalizedPowerCalculator`/a new `Power3sAverage`. ## Cycling Power Measurement (`0x2A63`) format -- verified live, not recalled Checked 2026-09-05 against two independent sources mirroring the Bluetooth SIG's own GATT Specification Supplement field table for this characteristic (cited in `CyclingPowerMeasurement.kt`'s KDoc): - https://github.com/oesmith/gatt-xml/blob/master/org.bluetooth.characteristic.cycling_power_measurement.xml - https://raw.githubusercontent.com/sputnikdev/bluetooth-gatt-parser/master/src/main/resources/gatt/characteristic/org.bluetooth.characteristic.cycling_power_measurement.xml (field types/units/resolutions) - A third search result independently corroborated the one gotcha below. Three ways this genuinely differs from CSC's layout (D16's own "materially more involved" warning, now concretely true): 1. **Flags is 2 bytes** (`uint16`, 13 meaningful bits), not CSC's 1 byte. 2. **Instantaneous Power is signed** (`sint16`) -- a meter can report a small negative value (calibration drift / regen-capable trainers). 3. **Wheel Revolution Data's `Last Wheel Event Time` is 1/2048 s resolution here, not CSC's 1/1024 s.** Crank Revolution Data keeps CSC's 1/1024 s unchanged. ## What's decoded vs. left out, and why - **Decoded**: instantaneous power (mandatory), pedal power balance + reference (flag bits 0/1 -- cleanly decodable, but this codebase has no consumer for it yet, so it's carried through `CyclingPowerSensorState.Connected` undecoded-further, same "decode it, note there's no consumer" honesty applied elsewhere tonight rather than skipping a clean field), crank revolution data (flag bit 5, reusing `CscMeasurement.kt`'s existing `CrankRevolutionData` type since the resolution is unchanged from CSC -- feeds a real `CrankCadenceTracker` instance). - **Walked past but not decoded**: accumulated torque (bit 2) and wheel revolution data (bit 4) -- their bytes are correctly consumed so later fields' offsets stay right (exactly the "offsets shift" complexity D16 flags), but neither is turned into a value. Wheel revolution data in particular is a deliberate non-decode: because of gotcha #3 above, feeding it through the existing `WheelSpeedTracker` (hard-coded to CSC's 1024 ticks/s) would silently compute wheel speed at half its real value -- building a separate CPS-specific wheel tracker on spec with no real consumer (the CSC client already owns wheel speed) is exactly the premature abstraction `SensorHub.kt`'s own KDoc already argues against. - **Not parsed at all**: extreme force/torque magnitudes, extreme angles, top/bottom dead spot angle, accumulated energy, offset compensation indicator (flag bits 6-12). Decoding stops once the crank field is consumed -- nothing needs these, and claiming to parse several `uint12`-packed fields with no real device or test to check them against would be unverified code. ## SensorPermissions: reused, not duplicated Checked first, as asked: `SensorPermissions` (issue #18) is already fully generic over `BLUETOOTH_SCAN`/`BLUETOOTH_CONNECT` with nothing CSC-specific in its name or shape. `AndroidCyclingPowerClient` calls it directly, unchanged. The sensors module's manifest already declares both permissions with a `0x1818`-aware comment from #18, so no manifest change was needed either. ## Dropout/reconnect honesty `CyclingPowerClient`/`CyclingPowerSensorState`/`AndroidCyclingPowerClient` mirror `CscClient`/`CscSensorState`/`AndroidCscClient` structurally: `NotConnected`/`PermissionRequired`/`Scanning`/`Connecting`/`Reconnecting`/`Connected` states, the same API-37 `connectGatt` overload split, the same "reset trackers and move to `Reconnecting` *before* attempting reconnect" ordering, the same `autoConnect=true` OS-level reconnect on an unrequested drop. No stale reading of any kind survives a drop. ## How NormalizedPowerCalculator is now actually fed `AndroidCyclingPowerClient` owns one `NormalizedPowerCalculator` and one new `Power3sAverage` instance per connection, and calls `onPowerSample` on both, on every notification -- the same "sensor client owns the tracker instance directly" shape `AndroidCscClient` already uses for `WheelSpeedTracker`/`CrankCadenceTracker`. This is legitimate (not a hack) specifically because neither calculator needs a live `MovementState` to decide what counts -- both classes' KDoc already say so explicitly. Instantaneous power is `sint16` and can be negative; both calculators reject negative input by contract, so a raw negative reading is clamped to `0` at this boundary (documented in `AndroidCyclingPowerClient`'s KDoc), the same kind of display clamp `SpeedPipelineSample` already applies for a stopped rider. **`AvgPowerAccumulator` (new, mirrors `AvgHrAccumulator`) has no live feed** -- same "no ride orchestrator (`RideSession`) exists yet to supply a live `MovementState`" gap `AvgCadenceAccumulator`/`AvgHrAccumulator` already have, not a new gap invented for this issue. `DerivedMetricsWireEncoding.kt` (`:companion:pebble`) now encodes `POWER_W`/`POWER_3S_W`/`AVG_POWER_W` alongside the existing `NORM_POWER_W` -- all four wire keys already existed in `shared/message_keys.json`/`docs/PROTOCOL.md` §2.2, generated against real `Proto.Key` constants. ## Explicitly out of scope for this PR (documented, not silently dropped) - **"Crank cadence from the power meter in preference to a separate CSC sensor"** -- decoding+tracking crank cadence from the power meter itself is built (its own `CrankCadenceTracker` instance); the *preference/arbitration* between two independently connected sensors is a source-arbitration decision one level up (the same category of gap as #19's GPS-vs-wheel-sensor speed arbitration), documented on `CyclingPowerSensorState.Connected.cadence`'s KDoc, not built here. - **Pairing/calibration UI (incl. zero-offset), power written to GPX, and `watchapp/src/c/view_ride.c` display wiring** -- all depend on a ride orchestrator (`RideSession`) that doesn't exist yet for *any* sensor-derived metric in this codebase today (HR, cadence, and now power all sit in this same documented state -- see `DerivedMetrics.kt`'s own KDoc). `view_ride.c` is also watch-side, outside this agent's ownership boundary (phone half of `docs/PROTOCOL.md`). ## Test runs (real, not hand-reviewed) - `./gradlew :companion:core:test` -- **passes**. New: `CyclingPowerMeasurementTest` (14 tests, decode/offset/sign/resolution coverage), `Power3sAverageTest` (6 tests), `AvgPowerAccumulatorTest` (4 tests). Existing `NormalizedPowerCalculatorTest`/full suite still green. - `./gradlew :companion:pebble:testDebugUnitTest` -- **passes**, including the extended `DerivedMetricsWireEncodingTest` (9-key `encode()`). - `./gradlew :companion:sensors:assembleDebug` -- **compiles cleanly** (real Android SDK, compileSdk 37). - `./gradlew :companion:assembleDebug` -- **compiles/packages cleanly** end to end. **Honesty on hardware**: no Bluetooth hardware in this sandbox. `AndroidCyclingPowerClient` has never run against a physical Cycling Power meter -- only compiled. The scan filter, GATT callback wiring, notification subscription and byte-layout decoding are exercised for real by `CyclingPowerMeasurementTest` on the JVM; `autoConnect` reconnect behaviour is documented Android platform behaviour this class relies on, same disclosure `AndroidCscClient` (#18/PR #99) already makes. https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
BLE Cycling Power meter support (issue #50)
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
b1e434c798
Adds a BLE GATT client for the Cycling Power Service (0x1818), decoding the
Power Measurement characteristic (0x2A63) and feeding it into the existing
NormalizedPowerCalculator/Power3sAverage derived-metric producers.

companion/core (pure JVM, host-tested):
- sensors/CyclingPowerMeasurement.kt: decodes the variable-length,
  flags-driven Power Measurement layout -- verified live against the
  Bluetooth SIG GATT Specification Supplement field table for this
  characteristic, not assumed from CSC's shape. Flags is 2 bytes (not CSC's
  1), instantaneous power is signed (sint16), and Wheel Revolution Data's
  event-time field is 1/2048 s resolution here vs CSC's 1/1024 s -- all three
  differences are exercised by CyclingPowerMeasurementTest. Decodes
  instantaneous power and pedal power balance (+ reference); crank revolution
  data is decoded and reuses CscMeasurement.kt's existing CrankRevolutionData
  type since its resolution is unchanged from CSC. Accumulated torque and
  wheel revolution data are walked past (correct offset accounting) but not
  decoded into values -- no consumer needs them, and CPS's different wheel
  event-time resolution makes reusing WheelSpeedTracker on them an active
  trap rather than free reuse.
- ride/DerivedMetrics.kt: adds Power3sAverage (POWER_3S_W) and
  AvgPowerAccumulator (AVG_POWER_W), mirroring NormalizedPowerCalculator's
  and AvgHrAccumulator's existing shapes respectively.

companion/sensors (Android, compile-verified):
- CyclingPowerUuids.kt, CyclingPowerClient.kt, CyclingPowerSensorState.kt,
  AndroidCyclingPowerClient.kt -- mirror CscClient/AndroidCscClient/
  CscSensorState's structure and dropout/reconnect honesty exactly
  (Reconnecting state, trackers reset before reconnect, same connectGatt
  API-37 overload split). SensorPermissions is reused unchanged (already
  generic over BLUETOOTH_SCAN/CONNECT, nothing CSC-specific). Battery Service
  UUIDs are reused from CscUuids rather than redeclared. This client owns a
  NormalizedPowerCalculator and Power3sAverage directly per connection and
  feeds both on every notification (genuine wiring, since neither needs a
  live MovementState); AvgPowerAccumulator has no live feed yet, for the same
  "no ride orchestrator exists" reason AvgCadenceAccumulator/AvgHrAccumulator
  don't either.

companion/pebble:
- DerivedMetricsWireEncoding.kt: adds powerValue/power3sValue/avgPowerValue
  and extends encode() to all nine ride-metrics keys.

Out of scope for this PR (documented, not silently dropped): crank-cadence
preference over a separate CSC sensor is source arbitration nobody builds
yet (same category as #19's speed-source arbitration); pairing/calibration
UI, GPX embedding and watchapp view_ride.c display wiring all depend on a
ride orchestrator (RideSession) that doesn't exist yet, matching every other
sensor-derived metric in this codebase today.

Never run against real Cycling Power hardware in this sandbox (no Bluetooth
here) -- decode/math logic is host-tested for real
(./gradlew :companion:core:test), the Android-bound client is
compile-verified for real (./gradlew :companion:sensors:assembleDebug,
:companion:assembleDebug).

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit 1c07d63ba1 into main 2026-09-05 17:25:15 +02:00
Sign in to join this conversation.
No description provided.