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] **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)
|
||||
|
||||
|
||||
@@ -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 }) => (
|
||||
<View style={styles.deviceRow}>
|
||||
<View>
|
||||
<Text style={styles.deviceName}>{item.name}</Text>
|
||||
<Text style={styles.deviceType}>{TYPE_LABEL[item.type]}</Text>
|
||||
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.deviceInfo}>
|
||||
<Text style={styles.deviceName}>{entry.device.name}</Text>
|
||||
<Text style={styles.deviceType}>{TYPE_LABEL[entry.device.type]}</Text>
|
||||
</View>
|
||||
<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>
|
||||
<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>
|
||||
</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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user