Power Meter¶
BLE-based power meter connectivity for real-time wattage and cadence during cycling sessions.
Overview¶
The power meter feature allows cyclists to connect BLE cycling power meters (Bluetooth Cycling Power Service, UUID 0x1818) and stream real-time power and cadence data. Power readings integrate into active sessions, providing average/max power, cadence RPM, and energy expenditure. A standalone test mode lets users verify device connectivity before riding.
Architecture¶
graph TD
BLE[shared:ble] --> SP[shared:sensor-protocol]
SP --> FSP[feature:sensor:power]
FSP --> FC[feature:connections]
FC --> Bridge[bridge:connection-session]
Bridge --> FS[feature:session]
BLE -.- BleGattManager
BLE -.- BleScanner
SP -.- CyclingPowerParser
SP -.- CadenceCalculator
FSP -.- ObservePowerDataUseCase
FC -.- PairedDevice / DeviceListScreen
Bridge -.- SessionPowerMeterUseCase
FS -.- SessionTracker / UpdateSessionPowerUseCase
BLE Infrastructure (shared/ble)¶
Low-level BLE primitives reused by any feature needing Bluetooth connectivity.
| Component | Purpose | DI Scope |
|---|---|---|
BleGattManager |
Manages a single GATT connection — connect, disconnect, enable notifications | factory |
BleScanner |
Scans for devices matching service UUIDs with timeout and deduplication | single |
BluetoothStateMonitor |
Observes system Bluetooth on/off state via BroadcastReceiver |
single |
BlePermissionChecker |
Returns required permissions per API level, checks grant status | single |
GATT Event Flow¶
BleGattManager.connect(address) returns a cold Flow<BleGattEvent>:
- Opens a GATT connection to the MAC address
- Emits
ConnectionStateChanged(CONNECTED)on successful connect - Auto-discovers services, emits
ServicesDiscovered - After notification subscription, emits
CharacteristicChanged(serviceUuid, characteristicUuid, data)for each notification - Emits
ConnectionStateChanged(DISCONNECTED)and completes on disconnect
Implementation wraps BluetoothGattCallback in a callbackFlow, handling both Tiramisu+ and legacy Android APIs.
Permissions¶
| API Level | Required Permissions |
|---|---|
| 31+ (S) | BLUETOOTH_SCAN, BLUETOOTH_CONNECT |
| < 31 | ACCESS_FINE_LOCATION |
Sensor Protocol (shared/sensor-protocol)¶
Pure Kotlin parsers for BLE cycling power data — no Android dependencies.
CyclingPowerParser¶
Parses raw byte arrays from the Cycling Power Measurement characteristic (0x2A63):
- 2-byte flags (little-endian)
- 2-byte instantaneous power (watts)
- If
FLAG_CRANK_REVOLUTION_DATA_PRESENT(0x0020) is set: 2-byte cumulative crank revolutions + 2-byte last crank event time
Returns a CyclingPowerMeasurement(instantaneousPowerWatts, crankRevolutions?, lastCrankEventTime?).
CadenceCalculator¶
Stateful calculator that derives RPM from consecutive crank revolution/event-time pairs:
- Formula:
RPM = (deltaRevs / deltaTime) * 60 * 1024(time in 1/1024s units) - Handles 16-bit counter wraparound via
and 0xFFFF - Returns
nullfor the first reading, stationary crank, or zero time delta - DI scope:
factory(one per connection, since it tracks previous readings)
Power Observation (feature/sensor/power)¶
ObservePowerDataUseCase¶
Combines BleGattManager + CyclingPowerParser + CadenceCalculator into a single observable pipeline:
- Connects to device via
BleGattManager.connect(macAddress) - Waits for
ServicesDiscovered, then enables notifications on the power measurement characteristic - Filters for
CharacteristicChangedevents, parses withCyclingPowerParser - Calculates cadence via
CadenceCalculator - Emits
PowerReading(timestampMs, powerWatts, cadenceRpm?)
DI scope: factory (both BleGattManager and CadenceCalculator are stateful per connection).
Wraps BLE errors in PowerMeterConnectionException.
Power Test Mode¶
Standalone screen for verifying power meter connectivity before a ride:
- Route:
power_test_mode/{mac_address} - Shows live power gauge, cadence RPM, rolling chart (last 60 readings), and accumulated stats (avg/max power, calories)
- Checks Bluetooth state before connecting — shows
BluetoothDisabledoverlay if off - Reconnect/disconnect controls
Device Management (feature/connections)¶
Domain¶
| Model | Fields |
|---|---|
PairedDevice |
id, macAddress, name, deviceType, isSessionUsageEnabled |
DeviceType |
POWER_METER (extensible for future sensor types) |
The isSessionUsageEnabled flag controls whether a device is automatically used during cycling sessions.
Use Cases:
| Use Case | Purpose |
|---|---|
SavePairedDeviceUseCase |
Save new device (duplicate MAC check) |
ObservePairedDevicesUseCase |
Observe all paired devices |
DeletePairedDeviceUseCase |
Delete a paired device |
UpdateDeviceSessionUsageUseCase |
Toggle session usage flag |
Data¶
- Room entity:
PairedDeviceEntity(table:paired_devices) —id(PK),macAddress,name,deviceType,isSessionUsageEnabled - DAO:
PairedDeviceDao— observable query ordered by name, standard CRUD operations - Data access via
ConcurrentFactory<PairedDeviceDao>withConnectionsQualifier.DaoFactoryqualifier
Presentation¶
Device List Screen:
LazyColumnwithSwipeToDismissBoxfor swipe-to-delete- Session usage toggle per device
- FAB for adding new devices
- Delete confirmation dialog
- Navigates to test mode or scanning
BLE Scanning Sheet:
ModalBottomSheetscanning for Cycling Power Service devices (30s timeout)- Marks already-saved devices in scan results
BlePermissionHandlerfor runtime permission requests (Accompanist)- On selection: saves device as
DeviceType.POWER_METER
Navigation: Nested graph (connections_graph) containing device list, scanning, and power test mode screens.
Session Integration¶
Bridge (connection-session)¶
SessionPowerMeterUseCase (API) exposes three operations:
| Method | Purpose |
|---|---|
getSessionPowerDevice() |
Find first session-enabled power meter device |
observePowerReadings(macAddress) |
Stream PowerReadingData from device |
disconnect() |
Disconnect from power meter |
The impl combines ObservePairedDevicesUseCase (connections) with ObservePowerDataUseCase (sensor/power).
UpdateSessionPowerUseCase¶
Processes incoming power readings within an active session:
- Acquires the shared session
Mutex(same one used by location and status use cases) - Verifies session is
RUNNING - Calculates energy delta:
powerWatts * (currentTimestamp - lastTimestamp) / 1000.0joules - Updates session fields and saves
Session power fields:
| Field | Type | Description |
|---|---|---|
totalPowerReadings |
Int? |
Count of readings received |
sumPowerWatts |
Long? |
Sum of all power values |
maxPowerWatts |
Int? |
Peak power recorded |
totalEnergyJoules |
Double? |
Accumulated energy |
averagePowerWatts |
Int? |
Computed: sumPowerWatts / totalPowerReadings |
All fields are nullable — sessions without a power meter have no power data.
Retry Strategy¶
SessionTrackerImpl uses exponential backoff for power meter reconnection:
| Parameter | Value |
|---|---|
| Initial delay | 2 seconds |
| Max delay | 5 minutes |
| Backoff factor | 2x |
| Reset on success | Yes |
Power collection starts when session status is RUNNING and stops (with disconnect) on PAUSED, COMPLETED, or service stop. Only PowerConnectionException triggers retry — cancellation propagates normally.
See also: Session Tracking for broader session architecture, Performance for retry pattern details.