feat: section 2 — BLE permissions, cadence parsing, sensor persistence

- requestBlePermissions() handles BLUETOOTH_SCAN + BLUETOOTH_CONNECT on Android 12+
- parseCscMeasurement() implements stateful CSC crank RPM with uint16 rollover
- savePairedDevice / loadPairedDevices / removePairedDevice via AsyncStorage
- SensorPairingScreen: saved sensors section, auto-reconnect on mount, Forget button
This commit is contained in:
Davide Scaini
2026-06-03 09:00:21 +02:00
parent 767c2d78aa
commit 2378d31f0b
4 changed files with 262 additions and 61 deletions
-3
View File
@@ -1,3 +0,0 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code.
+4 -4
View File
@@ -106,11 +106,11 @@ Items below are what remains before v1 is shippable.
- [x] **GPS pause/resume**`handlePause` calls `stopGpsRecording()`, `handleResume` calls `startGpsRecording()`
- [x] **Track view**`TrackView` component renders recorded lat/lon points as a scaled SVG polyline on a dark background (no tile server needed); replaces the grey placeholder
### 2 — BLE (half day)
### 2 — BLE
- [ ] **Android runtime permissions**`BLUETOOTH_SCAN` + `BLUETOOTH_CONNECT` require explicit permission requests on Android 12+; add a `requestBlePermissions()` call in `SensorPairingScreen` before scanning
- [ ] **Cadence CSC parsing**`subscribeCadence()` in `ble.ts` is stubbed; implement stateful CSC Measurement parsing (track previous crank revolution count + timestamp, compute RPM from delta)
- [ ] **BLE persistence** — save paired device IDs + types to AsyncStorage on connect; on app start, attempt to reconnect to previously paired devices automatically
- [x] **Android runtime permissions**`requestBlePermissions()` requests `BLUETOOTH_SCAN` + `BLUETOOTH_CONNECT` on Android 12+ (API 31+) before scanning
- [x] **Cadence CSC parsing**stateful parsing in `parseCscMeasurement()`: tracks previous crank revolution count + event time (uint16, 1/1024 s), computes RPM from delta with uint16 rollover handling
- [x] **BLE persistence**`savePairedDevice` / `loadPairedDevices` / `removePairedDevice` in `ble.ts` via AsyncStorage; SensorPairingScreen shows saved sensors at top, auto-attempts reconnect on mount, Forget button removes a device
### 3 — Km notifications (half day)
+138 -38
View File
@@ -1,6 +1,12 @@
import React, { useEffect, useState, useRef } from 'react';
import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator } from 'react-native';
import { scanForDevices, connectDevice, subscribeHr, subscribePower } from '../services/ble';
import {
View, Text, StyleSheet, FlatList, TouchableOpacity,
ActivityIndicator, Alert,
} from 'react-native';
import {
requestBlePermissions, scanForDevices, connectDevice,
subscribeForDevice, savePairedDevice, loadPairedDevices, removePairedDevice,
} from '../services/ble';
import { BleDevice } from '../types';
const TYPE_LABEL: Record<BleDevice['type'], string> = {
@@ -9,65 +15,153 @@ const TYPE_LABEL: Record<BleDevice['type'], string> = {
cadence: 'Cadence',
};
type ConnectionStatus = 'saved' | 'connecting' | 'connected' | 'error';
interface SavedEntry {
device: BleDevice;
status: ConnectionStatus;
}
export function SensorPairingScreen() {
const [scanning, setScanning] = useState(false);
const [devices, setDevices] = useState<BleDevice[]>([]);
const [paired, setPaired] = useState<Record<string, boolean>>({});
const [found, setFound] = useState<BleDevice[]>([]);
const [saved, setSaved] = useState<SavedEntry[]>([]);
const [connecting, setConnecting] = useState<Record<string, boolean>>({});
const stopScanRef = useRef<(() => void) | null>(null);
function startScan() {
setDevices([]);
// Load saved devices and attempt reconnect on mount
useEffect(() => {
(async () => {
const devices = await loadPairedDevices();
setSaved(devices.map((d) => ({ device: d, status: 'saved' })));
for (const d of devices) reconnect(d, false);
})();
return () => { stopScanRef.current?.(); };
}, []);
async function handleScan() {
const granted = await requestBlePermissions();
if (!granted) {
Alert.alert('Permission required', 'Bluetooth permission is required to scan for sensors.');
return;
}
setFound([]);
setScanning(true);
stopScanRef.current = scanForDevices(
(device) => setDevices((prev) => prev.find((d) => d.id === device.id) ? prev : [...prev, device]),
(device) => setFound((prev) => prev.find((d) => d.id === device.id) ? prev : [...prev, device]),
() => setScanning(false),
);
setTimeout(() => { stopScanRef.current?.(); setScanning(false); }, 15000);
}
useEffect(() => () => { stopScanRef.current?.(); }, []);
async function handlePair(device: BleDevice) {
async function reconnect(device: BleDevice, showError = true) {
setSaved((prev) => prev.map((e) => e.device.id === device.id ? { ...e, status: 'connecting' } : e));
try {
const connected = await connectDevice(device.id);
if (device.type === 'hr') subscribeHr(connected);
if (device.type === 'power') subscribePower(connected);
setPaired((prev) => ({ ...prev, [device.id]: true }));
subscribeForDevice(connected, device.type);
setSaved((prev) => prev.map((e) => e.device.id === device.id ? { ...e, status: 'connected' } : e));
} catch {
// error silenced — user can retry
setSaved((prev) => prev.map((e) => e.device.id === device.id ? { ...e, status: 'error' } : e));
if (showError) Alert.alert('Connection failed', `Could not connect to ${device.name}.`);
}
}
async function handleConnect(device: BleDevice) {
setConnecting((prev) => ({ ...prev, [device.id]: true }));
try {
const connected = await connectDevice(device.id);
subscribeForDevice(connected, device.type);
await savePairedDevice(device);
setSaved((prev) => {
const exists = prev.find((e) => e.device.id === device.id);
if (exists) return prev.map((e) => e.device.id === device.id ? { ...e, status: 'connected' } : e);
return [...prev, { device, status: 'connected' }];
});
} catch {
Alert.alert('Connection failed', `Could not connect to ${device.name}.`);
} finally {
setConnecting((prev) => ({ ...prev, [device.id]: false }));
}
}
async function handleForget(device: BleDevice) {
await removePairedDevice(device.id);
setSaved((prev) => prev.filter((e) => e.device.id !== device.id));
}
type ListItem =
| { kind: 'header'; label: string }
| { kind: 'saved'; entry: SavedEntry }
| { kind: 'found'; device: BleDevice };
const savedIds = new Set(saved.map((e) => e.device.id));
const newFound = found.filter((d) => !savedIds.has(d.id));
const listData: ListItem[] = [
...(saved.length > 0 ? [{ kind: 'header' as const, label: 'Saved sensors' }, ...saved.map((e) => ({ kind: 'saved' as const, entry: e }))] : []),
...(newFound.length > 0 ? [{ kind: 'header' as const, label: 'Found nearby' }, ...newFound.map((d) => ({ kind: 'found' as const, device: d }))] : []),
];
return (
<View style={styles.container}>
<Text style={styles.heading}>Sensor Pairing</Text>
<Text style={styles.sub}>Scan for nearby BLE sensors (HR, power, cadence).</Text>
<Text style={styles.heading}>Sensors</Text>
<Text style={styles.sub}>Pair your HR monitor, power meter, or cadence sensor.</Text>
<TouchableOpacity style={styles.scanBtn} onPress={startScan} disabled={scanning}>
{scanning ? <ActivityIndicator color="#fff" /> : <Text style={styles.scanBtnText}>Scan</Text>}
<TouchableOpacity style={styles.scanBtn} onPress={handleScan} disabled={scanning}>
{scanning ? <ActivityIndicator color="#fff" /> : <Text style={styles.scanBtnText}>Scan for sensors</Text>}
</TouchableOpacity>
<FlatList
data={devices}
keyExtractor={(d) => d.id}
data={listData}
keyExtractor={(item, i) => item.kind === 'header' ? `h-${i}` : item.kind === 'saved' ? item.entry.device.id : item.device.id}
style={styles.list}
renderItem={({ item }) => (
renderItem={({ item }) => {
if (item.kind === 'header') {
return <Text style={styles.sectionHeader}>{item.label}</Text>;
}
if (item.kind === 'saved') {
const { entry } = item;
return (
<View style={styles.deviceRow}>
<View>
<Text style={styles.deviceName}>{item.name}</Text>
<Text style={styles.deviceType}>{TYPE_LABEL[item.type]}</Text>
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{entry.device.name}</Text>
<Text style={styles.deviceType}>{TYPE_LABEL[entry.device.type]}</Text>
</View>
<TouchableOpacity
style={[styles.pairBtn, paired[item.id] && styles.pairBtnPaired]}
onPress={() => handlePair(item)}
disabled={!!paired[item.id]}
>
<Text style={styles.pairBtnText}>{paired[item.id] ? 'Connected' : 'Connect'}</Text>
<View style={styles.deviceActions}>
{entry.status === 'connecting' && <ActivityIndicator color="#3b82f6" />}
{entry.status === 'connected' && <Text style={styles.connectedLabel}>Connected</Text>}
{(entry.status === 'saved' || entry.status === 'error') && (
<TouchableOpacity style={styles.reconnectBtn} onPress={() => reconnect(entry.device, true)}>
<Text style={styles.reconnectBtnText}>{entry.status === 'error' ? 'Retry' : 'Reconnect'}</Text>
</TouchableOpacity>
)}
<TouchableOpacity onPress={() => handleForget(entry.device)}>
<Text style={styles.forgetLabel}>Forget</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}
const { device } = item;
const isConnecting = !!connecting[device.id];
return (
<View style={styles.deviceRow}>
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{device.name}</Text>
<Text style={styles.deviceType}>{TYPE_LABEL[device.type]}</Text>
</View>
<TouchableOpacity
style={styles.connectBtn}
onPress={() => handleConnect(device)}
disabled={isConnecting}
>
{isConnecting ? <ActivityIndicator color="#fff" /> : <Text style={styles.connectBtnText}>Connect</Text>}
</TouchableOpacity>
</View>
);
}}
ListEmptyComponent={
<Text style={styles.empty}>{scanning ? 'Scanning…' : 'No devices found. Tap Scan.'}</Text>
!scanning ? <Text style={styles.empty}>No sensors found. Tap Scan to search.</Text> : null
}
/>
</View>
@@ -78,14 +172,20 @@ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#111', padding: 24 },
heading: { color: '#fff', fontSize: 24, fontWeight: '700' },
sub: { color: '#888', marginTop: 4, marginBottom: 20 },
scanBtn: { backgroundColor: '#3b82f6', borderRadius: 12, padding: 14, alignItems: 'center', marginBottom: 16 },
scanBtn: { backgroundColor: '#3b82f6', borderRadius: 12, padding: 14, alignItems: 'center', marginBottom: 8 },
scanBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' },
list: { flex: 1 },
deviceRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#1e1e1e', borderRadius: 12, padding: 16, marginBottom: 10 },
list: { flex: 1, marginTop: 8 },
sectionHeader: { color: '#555', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.8, marginTop: 16, marginBottom: 8 },
deviceRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#1e1e1e', borderRadius: 12, padding: 16, marginBottom: 8 },
deviceInfo: { flex: 1, marginRight: 12 },
deviceName: { color: '#fff', fontSize: 16, fontWeight: '600' },
deviceType: { color: '#888', fontSize: 13, marginTop: 2 },
pairBtn: { backgroundColor: '#3b82f6', borderRadius: 8, paddingVertical: 8, paddingHorizontal: 16 },
pairBtnPaired: { backgroundColor: '#22c55e' },
pairBtnText: { color: '#fff', fontWeight: '600' },
deviceActions: { flexDirection: 'row', alignItems: 'center', gap: 12 },
connectedLabel: { color: '#22c55e', fontWeight: '600', fontSize: 14 },
reconnectBtn: { backgroundColor: '#1e3a5f', borderRadius: 8, paddingVertical: 7, paddingHorizontal: 12 },
reconnectBtnText: { color: '#3b82f6', fontWeight: '600', fontSize: 14 },
forgetLabel: { color: '#ef4444', fontSize: 14 },
connectBtn: { backgroundColor: '#3b82f6', borderRadius: 8, paddingVertical: 8, paddingHorizontal: 16, minWidth: 80, alignItems: 'center' },
connectBtnText: { color: '#fff', fontWeight: '600' },
empty: { color: '#555', textAlign: 'center', marginTop: 40 },
});
+117 -13
View File
@@ -1,4 +1,6 @@
import { BleManager, Device, Characteristic } from 'react-native-ble-plx';
import { Platform, PermissionsAndroid } from 'react-native';
import { BleManager, Device } from 'react-native-ble-plx';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useRecordingStore } from '../store/recording';
import { BleDevice } from '../types';
@@ -10,9 +12,36 @@ const CYCLING_POWER_MEASUREMENT = '00002a63-0000-1000-8000-00805f9b34fb';
const CYCLING_SPEED_CADENCE_SERVICE = '00001816-0000-1000-8000-00805f9b34fb';
const CSC_MEASUREMENT = '00002a5b-0000-1000-8000-00805f9b34fb';
const PAIRED_DEVICES_KEY = 'pairedDevices';
export const bleManager = new BleManager();
export function scanForDevices(onFound: (device: BleDevice) => void, onError: (error: Error) => void): () => void {
// ---------------------------------------------------------------------------
// Permissions
// ---------------------------------------------------------------------------
export async function requestBlePermissions(): Promise<boolean> {
if (Platform.OS !== 'android') return true;
if (Platform.Version < 31) return true; // pre-Android 12: only location needed, already granted
const results = await PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
]);
return (
results[PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN] === PermissionsAndroid.RESULTS.GRANTED &&
results[PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT] === PermissionsAndroid.RESULTS.GRANTED
);
}
// ---------------------------------------------------------------------------
// Scan
// ---------------------------------------------------------------------------
export function scanForDevices(
onFound: (device: BleDevice) => void,
onError: (error: Error) => void,
): () => void {
bleManager.startDeviceScan(
[HR_SERVICE, CYCLING_POWER_SERVICE, CYCLING_SPEED_CADENCE_SERVICE],
{ allowDuplicates: false },
@@ -32,6 +61,10 @@ export function scanForDevices(onFound: (device: BleDevice) => void, onError: (e
return () => bleManager.stopDeviceScan();
}
// ---------------------------------------------------------------------------
// Connect & subscribe
// ---------------------------------------------------------------------------
export async function connectDevice(deviceId: string): Promise<Device> {
const device = await bleManager.connectToDevice(deviceId);
await device.discoverAllServicesAndCharacteristics();
@@ -44,8 +77,7 @@ export function subscribeHr(device: Device): () => void {
HR_MEASUREMENT,
(error, char) => {
if (error || !char?.value) return;
const hr = parseHrMeasurement(char.value);
useRecordingStore.getState().updateBle({ hr });
useRecordingStore.getState().updateBle({ hr: parseHrMeasurement(char.value) });
},
);
return () => sub.remove();
@@ -57,39 +89,111 @@ export function subscribePower(device: Device): () => void {
CYCLING_POWER_MEASUREMENT,
(error, char) => {
if (error || !char?.value) return;
const power = parsePowerMeasurement(char.value);
useRecordingStore.getState().updateBle({ power });
useRecordingStore.getState().updateBle({ power: parsePowerMeasurement(char.value) });
},
);
return () => sub.remove();
}
// Stateful CSC crank tracking — module-level so it persists across notifications
let prevCrankRevs = 0;
let prevCrankTime = 0;
let cscInitialized = false;
export function subscribeCadence(device: Device): () => void {
cscInitialized = false;
const sub = device.monitorCharacteristicForService(
CYCLING_SPEED_CADENCE_SERVICE,
CSC_MEASUREMENT,
(error, char) => {
if (error || !char?.value) return;
// cadence parsing requires stateful wheel/crank event tracking — stub for now
const cadence = parseCscMeasurement(char.value);
if (cadence !== null) useRecordingStore.getState().updateBle({ cadence });
},
);
return () => sub.remove();
}
// HR Measurement characteristic: flags byte + uint8 (or uint16) BPM
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
export async function loadPairedDevices(): Promise<BleDevice[]> {
try {
const json = await AsyncStorage.getItem(PAIRED_DEVICES_KEY);
return json ? JSON.parse(json) : [];
} catch {
return [];
}
}
export async function savePairedDevice(device: BleDevice): Promise<void> {
const existing = await loadPairedDevices();
if (existing.find((d) => d.id === device.id)) return;
await AsyncStorage.setItem(PAIRED_DEVICES_KEY, JSON.stringify([...existing, device]));
}
export async function removePairedDevice(id: string): Promise<void> {
const existing = await loadPairedDevices();
await AsyncStorage.setItem(PAIRED_DEVICES_KEY, JSON.stringify(existing.filter((d) => d.id !== id)));
}
export function subscribeForDevice(device: Device, type: BleDevice['type']): () => void {
if (type === 'hr') return subscribeHr(device);
if (type === 'power') return subscribePower(device);
return subscribeCadence(device);
}
// ---------------------------------------------------------------------------
// Parsers
// ---------------------------------------------------------------------------
// HR Measurement: flags byte + uint8 (or uint16 if bit 0 set) BPM
function parseHrMeasurement(base64: string): number {
const bytes = fromBase64(base64);
const flags = bytes[0];
return flags & 0x01 ? (bytes[2] << 8) | bytes[1] : bytes[1];
const b = fromBase64(base64);
return b[0] & 0x01 ? (b[2] << 8) | b[1] : b[1];
}
// Cycling Power Measurement: flags (uint16) + instantaneous power (int16) at offset 2
function parsePowerMeasurement(base64: string): number {
const bytes = fromBase64(base64);
const raw = (bytes[3] << 8) | bytes[2];
const b = fromBase64(base64);
const raw = (b[3] << 8) | b[2];
return raw >= 0x8000 ? raw - 0x10000 : raw;
}
// CSC Measurement — bit 0: wheel data present, bit 1: crank data present
// Crank data: uint16 cumulative revolutions + uint16 last event time (1/1024 s)
function parseCscMeasurement(base64: string): number | null {
const b = fromBase64(base64);
const flags = b[0];
const hasWheel = (flags & 0x01) !== 0;
const hasCrank = (flags & 0x02) !== 0;
if (!hasCrank) return null;
const offset = 1 + (hasWheel ? 6 : 0);
if (b.length < offset + 4) return null;
const cranks = (b[offset + 1] << 8) | b[offset];
const time = (b[offset + 3] << 8) | b[offset + 2];
if (!cscInitialized) {
prevCrankRevs = cranks;
prevCrankTime = time;
cscInitialized = true;
return null;
}
// uint16 rollover at 65536
const deltaCranks = (cranks - prevCrankRevs + 65536) % 65536;
const deltaTime = ((time - prevCrankTime + 65536) % 65536) / 1024; // seconds
prevCrankRevs = cranks;
prevCrankTime = time;
if (deltaTime < 0.1 || deltaCranks === 0) return 0;
return Math.round((deltaCranks / deltaTime) * 60);
}
function fromBase64(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);