Cadence from the CSC sensor #49

Closed
opened 2026-08-31 17:25:15 +02:00 by robert · 1 comment
robert commented 2026-08-31 17:25:15 +02:00 (Migrated from git.butzei.de)

Goal

The Cycling Speed & Cadence characteristic already being parsed for wheel speed also carries crank revolutions. Exposing cadence is nearly free.

Acceptance criteria

  • Crank revolution count and last crank event time parsed from 0x2A5B
  • 16-bit crank event-time wraparound handled, as for the wheel fields
  • Cadence in rpm computed and smoothed, and clamped to zero when the cranks stop
  • Sensors that report only wheel data, or only crank data, both handled without error
  • Cadence sent to the watch and shown as a ride data field
  • Cadence written into the recorded GPX using the standard TrackPointExtension
  • Wraparound and stop-detection covered by unit tests

Files

  • companion/.../sensors/CscClient.kt
  • watchapp/src/c/view_ride.c

Notes

See docs/DECISIONS.md D16.

## Goal The Cycling Speed & Cadence characteristic already being parsed for wheel speed also carries crank revolutions. Exposing cadence is nearly free. ## Acceptance criteria - [ ] Crank revolution count and last crank event time parsed from `0x2A5B` - [ ] 16-bit crank event-time wraparound handled, as for the wheel fields - [ ] Cadence in rpm computed and smoothed, and clamped to zero when the cranks stop - [ ] Sensors that report only wheel data, or only crank data, both handled without error - [ ] Cadence sent to the watch and shown as a ride data field - [ ] Cadence written into the recorded GPX using the standard TrackPointExtension - [ ] Wraparound and stop-detection covered by unit tests ## Files - `companion/.../sensors/CscClient.kt` - `watchapp/src/c/view_ride.c` ## Notes See docs/DECISIONS.md D16.
Owner

Closed by PR #108 (merged). Crank-field decoding already existed from #18 (CrankRevolutionData off the 0x2A5B characteristic); this issue's real scope was the raw-to-RPM conversion and wiring it anywhere. New CrankCadenceTracker mirrors WheelSpeedTracker exactly, reusing uint16Delta for both crank fields (revolution count and event time are both 16-bit per the CSC spec), with a real rollover test. Feeds AvgCadenceAccumulator (#65) end to end for real now -- that gap is closed.

Real bug found and fixed along the way: AndroidCscClient.handleMeasurement previously did measurement.wheel ?: return, so any crank-only sensor's notifications (no wheel field at all) never updated state at all -- cadence was structurally unreachable for that whole device class. Wheel and crank fields are now processed independently, each falling back to its own last-known value when a given notification doesn't carry it, rather than one missing field dropping the whole update.

Wheel-only vs. stopped-cranks are kept distinct: CscSensorState.Connected.cadence is CadenceSample? -- null means this sensor has never reported crank data at all (encodes to the real UINT8 sentinel, never 0), while CadenceSample.NoNewRevolution means a real cadence sensor with stationary cranks (encodes to a genuine 0). Two different types, not two branches of one nullable number.

Verified for real: :companion:core:test, :companion:pebble:testDebugUnitTest, :companion:assembleDebug (touched Android-SDK-bound AndroidCscClient.kt) all green.

Out of scope, stated explicitly: watch-side display (watchapp/) and GPX TrackPointExtension recording (no ride-recording pipeline exists in companion/ yet to hang it on).

Closed by PR #108 (merged). Crank-field decoding already existed from #18 (CrankRevolutionData off the 0x2A5B characteristic); this issue's real scope was the raw-to-RPM conversion and wiring it anywhere. New CrankCadenceTracker mirrors WheelSpeedTracker exactly, reusing uint16Delta for both crank fields (revolution count and event time are both 16-bit per the CSC spec), with a real rollover test. Feeds AvgCadenceAccumulator (#65) end to end for real now -- that gap is closed. Real bug found and fixed along the way: AndroidCscClient.handleMeasurement previously did `measurement.wheel ?: return`, so any crank-only sensor's notifications (no wheel field at all) never updated state at all -- cadence was structurally unreachable for that whole device class. Wheel and crank fields are now processed independently, each falling back to its own last-known value when a given notification doesn't carry it, rather than one missing field dropping the whole update. Wheel-only vs. stopped-cranks are kept distinct: CscSensorState.Connected.cadence is CadenceSample? -- null means this sensor has never reported crank data at all (encodes to the real UINT8 sentinel, never 0), while CadenceSample.NoNewRevolution means a real cadence sensor with stationary cranks (encodes to a genuine 0). Two different types, not two branches of one nullable number. Verified for real: :companion:core:test, :companion:pebble:testDebugUnitTest, :companion:assembleDebug (touched Android-SDK-bound AndroidCscClient.kt) all green. Out of scope, stated explicitly: watch-side display (watchapp/) and GPX TrackPointExtension recording (no ride-recording pipeline exists in companion/ yet to hang it on).
Sign in to join this conversation.
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
robert/PedalPebble#49
No description provided.