diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index a26b4bb..0000000 --- a/AGENTS.md +++ /dev/null @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 1a94d6a..6c0ced5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/src/screens/SensorPairingScreen.tsx b/src/screens/SensorPairingScreen.tsx index ba0a119..21c74da 100644 --- a/src/screens/SensorPairingScreen.tsx +++ b/src/screens/SensorPairingScreen.tsx @@ -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 = { @@ -9,65 +15,153 @@ const TYPE_LABEL: Record = { 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([]); - const [paired, setPaired] = useState>({}); + const [found, setFound] = useState([]); + const [saved, setSaved] = useState([]); + const [connecting, setConnecting] = useState>({}); 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 ( - Sensor Pairing - Scan for nearby BLE sensors (HR, power, cadence). + Sensors + Pair your HR monitor, power meter, or cadence sensor. - - {scanning ? : Scan} + + {scanning ? : Scan for sensors} 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 }) => ( - - - {item.name} - {TYPE_LABEL[item.type]} + renderItem={({ item }) => { + if (item.kind === 'header') { + return {item.label}; + } + if (item.kind === 'saved') { + const { entry } = item; + return ( + + + {entry.device.name} + {TYPE_LABEL[entry.device.type]} + + + {entry.status === 'connecting' && } + {entry.status === 'connected' && Connected} + {(entry.status === 'saved' || entry.status === 'error') && ( + reconnect(entry.device, true)}> + {entry.status === 'error' ? 'Retry' : 'Reconnect'} + + )} + handleForget(entry.device)}> + Forget + + + + ); + } + const { device } = item; + const isConnecting = !!connecting[device.id]; + return ( + + + {device.name} + {TYPE_LABEL[device.type]} + + handleConnect(device)} + disabled={isConnecting} + > + {isConnecting ? : Connect} + - handlePair(item)} - disabled={!!paired[item.id]} - > - {paired[item.id] ? 'Connected' : 'Connect'} - - - )} + ); + }} ListEmptyComponent={ - {scanning ? 'Scanning…' : 'No devices found. Tap Scan.'} + !scanning ? No sensors found. Tap Scan to search. : null } /> @@ -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 }, }); diff --git a/src/services/ble.ts b/src/services/ble.ts index 6af336b..81207e7 100644 --- a/src/services/ble.ts +++ b/src/services/ble.ts @@ -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 { + 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 { 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 { + try { + const json = await AsyncStorage.getItem(PAIRED_DEVICES_KEY); + return json ? JSON.parse(json) : []; + } catch { + return []; + } +} + +export async function savePairedDevice(device: BleDevice): Promise { + 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 { + 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);