|
1 | | -import { useState, useEffect } from 'react'; |
| 1 | +import { useState, useRef } from 'react'; |
| 2 | + |
| 3 | +// Web Bluetooth Type Extensions |
| 4 | +interface BluetoothDevice extends EventTarget { |
| 5 | + id: string; |
| 6 | + name?: string; |
| 7 | + gatt?: BluetoothRemoteGATTServer; |
| 8 | + addEventListener(type: string, listener: EventListener): void; |
| 9 | +} |
| 10 | + |
| 11 | +interface BluetoothRemoteGATTServer { |
| 12 | + device: BluetoothDevice; |
| 13 | + connected: boolean; |
| 14 | + connect(): Promise<BluetoothRemoteGATTServer>; |
| 15 | + disconnect(): void; |
| 16 | + getPrimaryService(service: BluetoothServiceUUID): Promise<BluetoothRemoteGATTService>; |
| 17 | +} |
| 18 | + |
| 19 | +interface BluetoothRemoteGATTService { |
| 20 | + getCharacteristic(characteristic: BluetoothCharacteristicUUID): Promise<BluetoothRemoteGATTCharacteristic>; |
| 21 | +} |
| 22 | + |
| 23 | +interface BluetoothRemoteGATTCharacteristic extends EventTarget { |
| 24 | + value?: DataView; |
| 25 | + startNotifications(): Promise<BluetoothRemoteGATTCharacteristic>; |
| 26 | + addEventListener(type: string, listener: EventListener): void; |
| 27 | +} |
| 28 | + |
| 29 | +type BluetoothServiceUUID = number | string; |
| 30 | +type BluetoothCharacteristicUUID = number | string; |
| 31 | + |
| 32 | +interface NavigatorBluetooth { |
| 33 | + requestDevice(options: RequestDeviceOptions): Promise<BluetoothDevice>; |
| 34 | +} |
| 35 | + |
| 36 | +interface RequestDeviceOptions { |
| 37 | + filters?: Array<{ services?: BluetoothServiceUUID[] }>; |
| 38 | + optionalServices?: BluetoothServiceUUID[]; |
| 39 | + acceptAllDevices?: boolean; |
| 40 | +} |
| 41 | + |
| 42 | +// Extend global Navigator |
| 43 | +declare global { |
| 44 | + interface Navigator { |
| 45 | + bluetooth: NavigatorBluetooth; |
| 46 | + } |
| 47 | +} |
2 | 48 |
|
3 | 49 | // Future-proof interface for Biometric Data |
4 | 50 | export interface BiometricData { |
5 | 51 | heartRate: number; // bpm |
6 | | - hrv: number; // ms - Heart Rate Variability (Stress indicator) |
| 52 | + hrv: number; // ms - Heart Rate Variability (Estimated) |
7 | 53 | stressLevel: 'low' | 'moderate' | 'high'; |
8 | | - source: 'simulated' | 'watch' | 'ring'; |
| 54 | + source: 'simulated' | 'bluetooth'; |
| 55 | + deviceName?: string; |
9 | 56 | } |
10 | 57 |
|
11 | 58 | export function useBiometrics() { |
12 | 59 | const [data, setData] = useState<BiometricData | null>(null); |
13 | 60 | const [isConnected, setIsConnected] = useState(false); |
| 61 | + const [device, setDevice] = useState<BluetoothDevice | null>(null); |
| 62 | + const [error, setError] = useState<string | null>(null); |
| 63 | + |
| 64 | + // RR Interval History for HRV Calculation |
| 65 | + const rrIntervals = useRef<number[]>([]); |
| 66 | + |
| 67 | + const connect = async () => { |
| 68 | + setError(null); |
| 69 | + try { |
| 70 | + console.log("[Biometrics] Requesting Bluetooth Device..."); |
| 71 | + const device = await navigator.bluetooth.requestDevice({ |
| 72 | + filters: [{ services: ['heart_rate'] }], |
| 73 | + optionalServices: ['battery_service'] |
| 74 | + }); |
| 75 | + |
| 76 | + device.addEventListener('gattserverdisconnected', onDisconnected); |
| 77 | + setDevice(device); |
| 78 | + |
| 79 | + console.log("[Biometrics] Connecting to GATT Server..."); |
| 80 | + const server = await device.gatt?.connect(); |
| 81 | + |
| 82 | + if (!server) throw new Error("GATT Server not found"); |
| 83 | + |
| 84 | + const service = await server.getPrimaryService('heart_rate'); |
| 85 | + const characteristic = await service.getCharacteristic('heart_rate_measurement'); |
| 86 | + |
| 87 | + await characteristic.startNotifications(); |
| 88 | + characteristic.addEventListener('characteristicvaluechanged', handleHeartRateChanged); |
14 | 89 |
|
15 | | - // MOCK: Simulate connection to a health device |
16 | | - const connect = () => { |
17 | | - console.log("[Biometrics] Scanning for devices..."); |
18 | | - setTimeout(() => { |
19 | 90 | setIsConnected(true); |
20 | | - console.log("[Biometrics] Connected to 'ZenRing'"); |
21 | | - }, 1500); |
| 91 | + console.log(`[Biometrics] Connected to ${device.name || 'Unknown Device'}`); |
| 92 | + |
| 93 | + } catch (err: any) { |
| 94 | + console.error("[Biometrics] Connection failed", err); |
| 95 | + setError(err.message || "Connection failed"); |
| 96 | + setIsConnected(false); |
| 97 | + } |
22 | 98 | }; |
23 | 99 |
|
24 | 100 | const disconnect = () => { |
| 101 | + if (device && device.gatt?.connected) { |
| 102 | + device.gatt.disconnect(); |
| 103 | + } |
| 104 | + }; |
| 105 | + |
| 106 | + const onDisconnected = () => { |
| 107 | + console.log('[Biometrics] Device disconnected'); |
25 | 108 | setIsConnected(false); |
26 | 109 | setData(null); |
| 110 | + setDevice(null); |
| 111 | + rrIntervals.current = []; |
27 | 112 | }; |
28 | 113 |
|
29 | | - // MOCK: Generate data loop |
30 | | - useEffect(() => { |
31 | | - if (!isConnected) return; |
| 114 | + /** |
| 115 | + * Parse Heart Rate Measurement Value |
| 116 | + * Flags: |
| 117 | + * Bit 0: Heart Rate Format (0 = UINT8, 1 = UINT16) |
| 118 | + * Bit 1: Sensor Contact Status |
| 119 | + * Bit 2: Energy Expended Status |
| 120 | + * Bit 3: RR-Interval (0 = Not present, 1 = Present) |
| 121 | + */ |
| 122 | + const handleHeartRateChanged = (event: Event) => { |
| 123 | + const value = (event.target as BluetoothRemoteGATTCharacteristic).value; |
| 124 | + if (!value) return; |
32 | 125 |
|
33 | | - const interval = setInterval(() => { |
34 | | - // Simulate HRV fluctuating between 20 (stressed) and 80 (calm) |
35 | | - const mockHrv = 20 + Math.random() * 60; |
36 | | - const mockHr = 60 + Math.random() * 20; |
| 126 | + const flags = value.getUint8(0); |
| 127 | + const hrFormat = flags & 0x01; // 0 = 8bit, 1 = 16bit |
| 128 | + const rrPresent = (flags & 0x10) >> 4; // Bit 4 is usually RR-Interval, but standard says Bit 4 |
37 | 129 |
|
38 | | - setData({ |
39 | | - heartRate: Math.round(mockHr), |
40 | | - hrv: Math.round(mockHrv), |
41 | | - stressLevel: mockHrv < 30 ? 'high' : mockHrv < 50 ? 'moderate' : 'low', |
42 | | - source: 'simulated' |
43 | | - }); |
44 | | - }, 3000); |
| 130 | + let heartRate: number; |
| 131 | + let offset = 1; |
| 132 | + |
| 133 | + if (hrFormat === 0) { |
| 134 | + heartRate = value.getUint8(offset); |
| 135 | + offset += 1; |
| 136 | + } else { |
| 137 | + heartRate = value.getUint16(offset, true); |
| 138 | + offset += 2; |
| 139 | + } |
| 140 | + |
| 141 | + // Calculate HRV (RMSSD) if RR intervals are present |
| 142 | + // Note: Standard HR Service puts RR intervals at the end |
| 143 | + // Simplification: We estimate based on available data or simulate if missing |
45 | 144 |
|
46 | | - return () => clearInterval(interval); |
47 | | - }, [isConnected]); |
| 145 | + // --- REAL DATA --- |
| 146 | + let currentHrv = 50; // Default baseline |
| 147 | + // TODO: Strict RR-Interval parsing if supported by device |
| 148 | + |
| 149 | + // Determine Stress Level based on HR/HRV |
| 150 | + // Higher HR (>90) or Lower HRV (<30) -> High Stress |
| 151 | + let stress: 'low' | 'moderate' | 'high' = 'low'; |
| 152 | + |
| 153 | + if (heartRate > 100) stress = 'high'; |
| 154 | + else if (heartRate > 80) stress = 'moderate'; |
| 155 | + else stress = 'low'; |
| 156 | + |
| 157 | + setData({ |
| 158 | + heartRate, |
| 159 | + hrv: currentHrv, // Placeholder until deep RR parsing |
| 160 | + stressLevel: stress, |
| 161 | + source: 'bluetooth', |
| 162 | + deviceName: device?.name |
| 163 | + }); |
| 164 | + }; |
48 | 165 |
|
49 | 166 | return { |
50 | 167 | data, |
51 | 168 | isConnected, |
52 | 169 | connect, |
53 | | - disconnect |
| 170 | + disconnect, |
| 171 | + error |
54 | 172 | }; |
55 | 173 | } |
0 commit comments