BLE Cycling Power meter support (issue #50) #113
No reviewers
Labels
No labels
area:companion
area:docs
area:shared
area:tooling
area:watchapp
blocker
kind:chore
kind:feature
kind:spike
kind:test
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/PedalPebble!113
Loading…
Reference in a new issue
No description provided.
Delete branch "area/ble-power-meter"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #50.
Adds a BLE GATT client for the Cycling Power Service (
0x1818), decodes the Power Measurement characteristic (0x2A63), and wires real instantaneous power intoNormalizedPowerCalculator/a newPower3sAverage.Cycling Power Measurement (
0x2A63) format -- verified live, not recalledChecked 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):
uint16, 13 meaningful bits), not CSC's 1 byte.sint16) -- a meter can report a small negative value (calibration drift / regen-capable trainers).Last Wheel Event Timeis 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
CyclingPowerSensorState.Connectedundecoded-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, reusingCscMeasurement.kt's existingCrankRevolutionDatatype since the resolution is unchanged from CSC -- feeds a realCrankCadenceTrackerinstance).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 abstractionSensorHub.kt's own KDoc already argues against.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 overBLUETOOTH_SCAN/BLUETOOTH_CONNECTwith nothing CSC-specific in its name or shape.AndroidCyclingPowerClientcalls it directly, unchanged. The sensors module's manifest already declares both permissions with a0x1818-aware comment from #18, so no manifest change was needed either.Dropout/reconnect honesty
CyclingPowerClient/CyclingPowerSensorState/AndroidCyclingPowerClientmirrorCscClient/CscSensorState/AndroidCscClientstructurally:NotConnected/PermissionRequired/Scanning/Connecting/Reconnecting/Connectedstates, the same API-37connectGattoverload split, the same "reset trackers and move toReconnectingbefore attempting reconnect" ordering, the sameautoConnect=trueOS-level reconnect on an unrequested drop. No stale reading of any kind survives a drop.How NormalizedPowerCalculator is now actually fed
AndroidCyclingPowerClientowns oneNormalizedPowerCalculatorand one newPower3sAverageinstance per connection, and callsonPowerSampleon both, on every notification -- the same "sensor client owns the tracker instance directly" shapeAndroidCscClientalready uses forWheelSpeedTracker/CrankCadenceTracker. This is legitimate (not a hack) specifically because neither calculator needs a liveMovementStateto decide what counts -- both classes' KDoc already say so explicitly. Instantaneous power issint16and can be negative; both calculators reject negative input by contract, so a raw negative reading is clamped to0at this boundary (documented inAndroidCyclingPowerClient's KDoc), the same kind of display clampSpeedPipelineSamplealready applies for a stopped rider.AvgPowerAccumulator(new, mirrorsAvgHrAccumulator) has no live feed -- same "no ride orchestrator (RideSession) exists yet to supply a liveMovementState" gapAvgCadenceAccumulator/AvgHrAccumulatoralready have, not a new gap invented for this issue.DerivedMetricsWireEncoding.kt(:companion:pebble) now encodesPOWER_W/POWER_3S_W/AVG_POWER_Walongside the existingNORM_POWER_W-- all four wire keys already existed inshared/message_keys.json/docs/PROTOCOL.md§2.2, generated against realProto.Keyconstants.Explicitly out of scope for this PR (documented, not silently dropped)
CrankCadenceTrackerinstance); 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 onCyclingPowerSensorState.Connected.cadence's KDoc, not built here.watchapp/src/c/view_ride.cdisplay 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 -- seeDerivedMetrics.kt's own KDoc).view_ride.cis also watch-side, outside this agent's ownership boundary (phone half ofdocs/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). ExistingNormalizedPowerCalculatorTest/full suite still green../gradlew :companion:pebble:testDebugUnitTest-- passes, including the extendedDerivedMetricsWireEncodingTest(9-keyencode())../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.
AndroidCyclingPowerClienthas 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 byCyclingPowerMeasurementTeston the JVM;autoConnectreconnect behaviour is documented Android platform behaviour this class relies on, same disclosureAndroidCscClient(#18/PR #99) already makes.https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt