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:
@@ -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.
|
|
||||||
@@ -106,11 +106,11 @@ Items below are what remains before v1 is shippable.
|
|||||||
- [x] **GPS pause/resume** — `handlePause` calls `stopGpsRecording()`, `handleResume` calls `startGpsRecording()`
|
- [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
|
- [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
|
- [x] **Android runtime permissions** — `requestBlePermissions()` requests `BLUETOOTH_SCAN` + `BLUETOOTH_CONNECT` on Android 12+ (API 31+) 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)
|
- [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
|
||||||
- [ ] **BLE persistence** — save paired device IDs + types to AsyncStorage on connect; on app start, attempt to reconnect to previously paired devices automatically
|
- [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)
|
### 3 — Km notifications (half day)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator } from 'react-native';
|
import {
|
||||||
import { scanForDevices, connectDevice, subscribeHr, subscribePower } from '../services/ble';
|
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';
|
import { BleDevice } from '../types';
|
||||||
|
|
||||||
const TYPE_LABEL: Record<BleDevice['type'], string> = {
|
const TYPE_LABEL: Record<BleDevice['type'], string> = {
|
||||||
@@ -9,65 +15,153 @@ const TYPE_LABEL: Record<BleDevice['type'], string> = {
|
|||||||
cadence: 'Cadence',
|
cadence: 'Cadence',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ConnectionStatus = 'saved' | 'connecting' | 'connected' | 'error';
|
||||||
|
|
||||||
|
interface SavedEntry {
|
||||||
|
device: BleDevice;
|
||||||
|
status: ConnectionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export function SensorPairingScreen() {
|
export function SensorPairingScreen() {
|
||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
const [devices, setDevices] = useState<BleDevice[]>([]);
|
const [found, setFound] = useState<BleDevice[]>([]);
|
||||||
const [paired, setPaired] = useState<Record<string, boolean>>({});
|
const [saved, setSaved] = useState<SavedEntry[]>([]);
|
||||||
|
const [connecting, setConnecting] = useState<Record<string, boolean>>({});
|
||||||
const stopScanRef = useRef<(() => void) | null>(null);
|
const stopScanRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
function startScan() {
|
// Load saved devices and attempt reconnect on mount
|
||||||
setDevices([]);
|
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);
|
setScanning(true);
|
||||||
stopScanRef.current = scanForDevices(
|
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),
|
() => setScanning(false),
|
||||||
);
|
);
|
||||||
setTimeout(() => { stopScanRef.current?.(); setScanning(false); }, 15000);
|
setTimeout(() => { stopScanRef.current?.(); setScanning(false); }, 15000);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => () => { stopScanRef.current?.(); }, []);
|
async function reconnect(device: BleDevice, showError = true) {
|
||||||
|
setSaved((prev) => prev.map((e) => e.device.id === device.id ? { ...e, status: 'connecting' } : e));
|
||||||
async function handlePair(device: BleDevice) {
|
|
||||||
try {
|
try {
|
||||||
const connected = await connectDevice(device.id);
|
const connected = await connectDevice(device.id);
|
||||||
if (device.type === 'hr') subscribeHr(connected);
|
subscribeForDevice(connected, device.type);
|
||||||
if (device.type === 'power') subscribePower(connected);
|
setSaved((prev) => prev.map((e) => e.device.id === device.id ? { ...e, status: 'connected' } : e));
|
||||||
setPaired((prev) => ({ ...prev, [device.id]: true }));
|
|
||||||
} catch {
|
} 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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.heading}>Sensor Pairing</Text>
|
<Text style={styles.heading}>Sensors</Text>
|
||||||
<Text style={styles.sub}>Scan for nearby BLE sensors (HR, power, cadence).</Text>
|
<Text style={styles.sub}>Pair your HR monitor, power meter, or cadence sensor.</Text>
|
||||||
|
|
||||||
<TouchableOpacity style={styles.scanBtn} onPress={startScan} disabled={scanning}>
|
<TouchableOpacity style={styles.scanBtn} onPress={handleScan} disabled={scanning}>
|
||||||
{scanning ? <ActivityIndicator color="#fff" /> : <Text style={styles.scanBtnText}>Scan</Text>}
|
{scanning ? <ActivityIndicator color="#fff" /> : <Text style={styles.scanBtnText}>Scan for sensors</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={devices}
|
data={listData}
|
||||||
keyExtractor={(d) => d.id}
|
keyExtractor={(item, i) => item.kind === 'header' ? `h-${i}` : item.kind === 'saved' ? item.entry.device.id : item.device.id}
|
||||||
style={styles.list}
|
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 style={styles.deviceRow}>
|
||||||
<View>
|
<View style={styles.deviceInfo}>
|
||||||
<Text style={styles.deviceName}>{item.name}</Text>
|
<Text style={styles.deviceName}>{entry.device.name}</Text>
|
||||||
<Text style={styles.deviceType}>{TYPE_LABEL[item.type]}</Text>
|
<Text style={styles.deviceType}>{TYPE_LABEL[entry.device.type]}</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<View style={styles.deviceActions}>
|
||||||
style={[styles.pairBtn, paired[item.id] && styles.pairBtnPaired]}
|
{entry.status === 'connecting' && <ActivityIndicator color="#3b82f6" />}
|
||||||
onPress={() => handlePair(item)}
|
{entry.status === 'connected' && <Text style={styles.connectedLabel}>Connected</Text>}
|
||||||
disabled={!!paired[item.id]}
|
{(entry.status === 'saved' || entry.status === 'error') && (
|
||||||
>
|
<TouchableOpacity style={styles.reconnectBtn} onPress={() => reconnect(entry.device, true)}>
|
||||||
<Text style={styles.pairBtnText}>{paired[item.id] ? 'Connected' : 'Connect'}</Text>
|
<Text style={styles.reconnectBtnText}>{entry.status === 'error' ? 'Retry' : 'Reconnect'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity onPress={() => handleForget(entry.device)}>
|
||||||
|
<Text style={styles.forgetLabel}>Forget</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</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={
|
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>
|
</View>
|
||||||
@@ -78,14 +172,20 @@ const styles = StyleSheet.create({
|
|||||||
container: { flex: 1, backgroundColor: '#111', padding: 24 },
|
container: { flex: 1, backgroundColor: '#111', padding: 24 },
|
||||||
heading: { color: '#fff', fontSize: 24, fontWeight: '700' },
|
heading: { color: '#fff', fontSize: 24, fontWeight: '700' },
|
||||||
sub: { color: '#888', marginTop: 4, marginBottom: 20 },
|
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' },
|
scanBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' },
|
||||||
list: { flex: 1 },
|
list: { flex: 1, marginTop: 8 },
|
||||||
deviceRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#1e1e1e', borderRadius: 12, padding: 16, marginBottom: 10 },
|
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' },
|
deviceName: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||||
deviceType: { color: '#888', fontSize: 13, marginTop: 2 },
|
deviceType: { color: '#888', fontSize: 13, marginTop: 2 },
|
||||||
pairBtn: { backgroundColor: '#3b82f6', borderRadius: 8, paddingVertical: 8, paddingHorizontal: 16 },
|
deviceActions: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
||||||
pairBtnPaired: { backgroundColor: '#22c55e' },
|
connectedLabel: { color: '#22c55e', fontWeight: '600', fontSize: 14 },
|
||||||
pairBtnText: { color: '#fff', fontWeight: '600' },
|
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 },
|
empty: { color: '#555', textAlign: 'center', marginTop: 40 },
|
||||||
});
|
});
|
||||||
|
|||||||
+117
-13
@@ -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 { useRecordingStore } from '../store/recording';
|
||||||
import { BleDevice } from '../types';
|
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 CYCLING_SPEED_CADENCE_SERVICE = '00001816-0000-1000-8000-00805f9b34fb';
|
||||||
const CSC_MEASUREMENT = '00002a5b-0000-1000-8000-00805f9b34fb';
|
const CSC_MEASUREMENT = '00002a5b-0000-1000-8000-00805f9b34fb';
|
||||||
|
|
||||||
|
const PAIRED_DEVICES_KEY = 'pairedDevices';
|
||||||
|
|
||||||
export const bleManager = new BleManager();
|
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(
|
bleManager.startDeviceScan(
|
||||||
[HR_SERVICE, CYCLING_POWER_SERVICE, CYCLING_SPEED_CADENCE_SERVICE],
|
[HR_SERVICE, CYCLING_POWER_SERVICE, CYCLING_SPEED_CADENCE_SERVICE],
|
||||||
{ allowDuplicates: false },
|
{ allowDuplicates: false },
|
||||||
@@ -32,6 +61,10 @@ export function scanForDevices(onFound: (device: BleDevice) => void, onError: (e
|
|||||||
return () => bleManager.stopDeviceScan();
|
return () => bleManager.stopDeviceScan();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Connect & subscribe
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function connectDevice(deviceId: string): Promise<Device> {
|
export async function connectDevice(deviceId: string): Promise<Device> {
|
||||||
const device = await bleManager.connectToDevice(deviceId);
|
const device = await bleManager.connectToDevice(deviceId);
|
||||||
await device.discoverAllServicesAndCharacteristics();
|
await device.discoverAllServicesAndCharacteristics();
|
||||||
@@ -44,8 +77,7 @@ export function subscribeHr(device: Device): () => void {
|
|||||||
HR_MEASUREMENT,
|
HR_MEASUREMENT,
|
||||||
(error, char) => {
|
(error, char) => {
|
||||||
if (error || !char?.value) return;
|
if (error || !char?.value) return;
|
||||||
const hr = parseHrMeasurement(char.value);
|
useRecordingStore.getState().updateBle({ hr: parseHrMeasurement(char.value) });
|
||||||
useRecordingStore.getState().updateBle({ hr });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return () => sub.remove();
|
return () => sub.remove();
|
||||||
@@ -57,39 +89,111 @@ export function subscribePower(device: Device): () => void {
|
|||||||
CYCLING_POWER_MEASUREMENT,
|
CYCLING_POWER_MEASUREMENT,
|
||||||
(error, char) => {
|
(error, char) => {
|
||||||
if (error || !char?.value) return;
|
if (error || !char?.value) return;
|
||||||
const power = parsePowerMeasurement(char.value);
|
useRecordingStore.getState().updateBle({ power: parsePowerMeasurement(char.value) });
|
||||||
useRecordingStore.getState().updateBle({ power });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return () => sub.remove();
|
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 {
|
export function subscribeCadence(device: Device): () => void {
|
||||||
|
cscInitialized = false;
|
||||||
const sub = device.monitorCharacteristicForService(
|
const sub = device.monitorCharacteristicForService(
|
||||||
CYCLING_SPEED_CADENCE_SERVICE,
|
CYCLING_SPEED_CADENCE_SERVICE,
|
||||||
CSC_MEASUREMENT,
|
CSC_MEASUREMENT,
|
||||||
(error, char) => {
|
(error, char) => {
|
||||||
if (error || !char?.value) return;
|
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();
|
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 {
|
function parseHrMeasurement(base64: string): number {
|
||||||
const bytes = fromBase64(base64);
|
const b = fromBase64(base64);
|
||||||
const flags = bytes[0];
|
return b[0] & 0x01 ? (b[2] << 8) | b[1] : b[1];
|
||||||
return flags & 0x01 ? (bytes[2] << 8) | bytes[1] : bytes[1];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cycling Power Measurement: flags (uint16) + instantaneous power (int16) at offset 2
|
// Cycling Power Measurement: flags (uint16) + instantaneous power (int16) at offset 2
|
||||||
function parsePowerMeasurement(base64: string): number {
|
function parsePowerMeasurement(base64: string): number {
|
||||||
const bytes = fromBase64(base64);
|
const b = fromBase64(base64);
|
||||||
const raw = (bytes[3] << 8) | bytes[2];
|
const raw = (b[3] << 8) | b[2];
|
||||||
return raw >= 0x8000 ? raw - 0x10000 : raw;
|
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 {
|
function fromBase64(base64: string): Uint8Array {
|
||||||
const binary = atob(base64);
|
const binary = atob(base64);
|
||||||
const bytes = new Uint8Array(binary.length);
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|||||||
Reference in New Issue
Block a user