diff options
| author | hc <haocheng.xie@respiree.com> | 2026-04-10 17:39:12 +0800 |
|---|---|---|
| committer | hc <haocheng.xie@respiree.com> | 2026-04-10 17:39:22 +0800 |
| commit | e4fb9966e762852bf17f21c8406501d42fae0b61 (patch) | |
| tree | 658bbdba977ff7846a17ee94b8ed6b676f6ce9dd /App.tsx | |
Initial commit: iHealth BLE scanner app with patched SDK v1.5.0
Diffstat (limited to 'App.tsx')
| -rw-r--r-- | App.tsx | 351 |
1 files changed, 351 insertions, 0 deletions
| @@ -0,0 +1,351 @@ | |||
| 1 | import React, {useState, useEffect, useRef} from 'react'; | ||
| 2 | import { | ||
| 3 | StyleSheet, | ||
| 4 | View, | ||
| 5 | Text, | ||
| 6 | TouchableOpacity, | ||
| 7 | FlatList, | ||
| 8 | Platform, | ||
| 9 | PermissionsAndroid, | ||
| 10 | DeviceEventEmitter, | ||
| 11 | NativeModules, | ||
| 12 | } from 'react-native'; | ||
| 13 | import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context'; | ||
| 14 | |||
| 15 | // Device type string names accepted by startDiscovery on both platforms | ||
| 16 | const DEVICE_TYPE_KEYS = [ | ||
| 17 | 'AM3S', 'AM4', 'PO3', 'BP5', 'BP5S', 'BP3L', 'BP7', 'BP7S', | ||
| 18 | 'KN550', 'HS2', 'HS2S', 'HS4S', 'BG1', 'BG1S', 'BG5', 'BG5S', | ||
| 19 | 'ECG3', 'BTM', 'TS28B', 'NT13B', | ||
| 20 | ] as const; | ||
| 21 | |||
| 22 | type DeviceTypeName = (typeof DEVICE_TYPE_KEYS)[number]; | ||
| 23 | |||
| 24 | // The native module exports string constants on iOS, number constants on Android. | ||
| 25 | type DiscoveryConstant = string | number; | ||
| 26 | |||
| 27 | interface IHealthDeviceManager { | ||
| 28 | // Device type constants (string on iOS, number on Android) | ||
| 29 | AM3S: DiscoveryConstant; | ||
| 30 | AM4: DiscoveryConstant; | ||
| 31 | PO3: DiscoveryConstant; | ||
| 32 | BP5: DiscoveryConstant; | ||
| 33 | BP5S: DiscoveryConstant; | ||
| 34 | BP3L: DiscoveryConstant; | ||
| 35 | BP7: DiscoveryConstant; | ||
| 36 | BP7S: DiscoveryConstant; | ||
| 37 | KN550: DiscoveryConstant; | ||
| 38 | HS2: DiscoveryConstant; | ||
| 39 | HS2S: DiscoveryConstant; | ||
| 40 | HS4S: DiscoveryConstant; | ||
| 41 | BG1: DiscoveryConstant; | ||
| 42 | BG1S: DiscoveryConstant; | ||
| 43 | BG5: DiscoveryConstant; | ||
| 44 | BG5S: DiscoveryConstant; | ||
| 45 | ECG3: DiscoveryConstant; | ||
| 46 | BTM: DiscoveryConstant; | ||
| 47 | TS28B: DiscoveryConstant; | ||
| 48 | NT13B: DiscoveryConstant; | ||
| 49 | // Event name constants | ||
| 50 | Event_Scan_Device: string; | ||
| 51 | Event_Scan_Finish: string; | ||
| 52 | Event_Device_Connected: string; | ||
| 53 | Event_Device_Connect_Failed: string; | ||
| 54 | Event_Device_Disconnect: string; | ||
| 55 | Event_Authenticate_Result: string; | ||
| 56 | // Methods — startDiscovery takes a device type name string per official docs | ||
| 57 | startDiscovery(type: DeviceTypeName | string): void; | ||
| 58 | stopDiscovery(): void; | ||
| 59 | connectDevice(mac: string, type: string): void; | ||
| 60 | disconnectDevice(mac: string, type: string): void; | ||
| 61 | sdkAuthWithLicense(license: string): void; | ||
| 62 | authenConfigureInfo(userName: string, clientID: string, clientSecret: string): void; | ||
| 63 | getDevicesIDPS(mac: string, callback: (idps: Record<string, string>) => void): void; | ||
| 64 | } | ||
| 65 | |||
| 66 | const iHealthDeviceManagerModule = | ||
| 67 | NativeModules.iHealthDeviceManagerModule as IHealthDeviceManager; | ||
| 68 | |||
| 69 | type Device = { | ||
| 70 | mac: string; | ||
| 71 | type: string; | ||
| 72 | rssi?: number; | ||
| 73 | timestamp: number; | ||
| 74 | }; | ||
| 75 | |||
| 76 | async function requestAndroidPermissions(): Promise<boolean> { | ||
| 77 | if (Platform.OS !== 'android') return true; | ||
| 78 | |||
| 79 | const apiLevel = Platform.Version; | ||
| 80 | const permissions: string[] = []; | ||
| 81 | |||
| 82 | if (apiLevel >= 31) { | ||
| 83 | permissions.push( | ||
| 84 | PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, | ||
| 85 | PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, | ||
| 86 | ); | ||
| 87 | } | ||
| 88 | permissions.push(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION); | ||
| 89 | |||
| 90 | const results = await PermissionsAndroid.requestMultiple(permissions as any); | ||
| 91 | return Object.values(results).every( | ||
| 92 | r => r === PermissionsAndroid.RESULTS.GRANTED, | ||
| 93 | ); | ||
| 94 | } | ||
| 95 | |||
| 96 | function DeviceIcon({type}: {type: string}) { | ||
| 97 | const icons: Record<string, string> = { | ||
| 98 | BP: 'BP', | ||
| 99 | AM: 'AM', | ||
| 100 | PO: 'PO', | ||
| 101 | BG: 'BG', | ||
| 102 | HS: 'HS', | ||
| 103 | ECG: 'ECG', | ||
| 104 | BTM: 'BTM', | ||
| 105 | }; | ||
| 106 | const prefix = Object.keys(icons).find(k => type.startsWith(k)) || '?'; | ||
| 107 | return ( | ||
| 108 | <View style={styles.deviceIcon}> | ||
| 109 | <Text style={styles.deviceIconText}>{icons[prefix] || '?'}</Text> | ||
| 110 | </View> | ||
| 111 | ); | ||
| 112 | } | ||
| 113 | |||
| 114 | function App() { | ||
| 115 | const [devices, setDevices] = useState<Device[]>([]); | ||
| 116 | const [scanning, setScanning] = useState(false); | ||
| 117 | const [error, setError] = useState<string | null>(null); | ||
| 118 | const devicesRef = useRef<Device[]>([]); | ||
| 119 | |||
| 120 | useEffect(() => { | ||
| 121 | if (!iHealthDeviceManagerModule) { | ||
| 122 | setError('iHealth native module not found. Check linking.'); | ||
| 123 | return; | ||
| 124 | } | ||
| 125 | |||
| 126 | // The iHealth SDK uses the global DeviceEventEmitter (not NativeEventEmitter) | ||
| 127 | // because the iOS native module doesn't subclass RCTEventEmitter. | ||
| 128 | const scanSub = DeviceEventEmitter.addListener( | ||
| 129 | iHealthDeviceManagerModule.Event_Scan_Device ?? 'event_scan_device', | ||
| 130 | (event: {mac: string; type: string; rssi?: number}) => { | ||
| 131 | const {mac = '', type = 'Unknown', rssi} = event; | ||
| 132 | |||
| 133 | // Filter out AM3 — likely false positives from non-iHealth heart rate devices | ||
| 134 | if (type === 'AM3') return; | ||
| 135 | |||
| 136 | const existing = devicesRef.current.findIndex(d => d.mac === mac); | ||
| 137 | let updated: Device[]; | ||
| 138 | if (existing >= 0) { | ||
| 139 | updated = [...devicesRef.current]; | ||
| 140 | updated[existing] = {mac, type, rssi, timestamp: Date.now()}; | ||
| 141 | } else { | ||
| 142 | updated = [ | ||
| 143 | ...devicesRef.current, | ||
| 144 | {mac, type, rssi, timestamp: Date.now()}, | ||
| 145 | ]; | ||
| 146 | } | ||
| 147 | devicesRef.current = updated; | ||
| 148 | setDevices(updated); | ||
| 149 | }, | ||
| 150 | ); | ||
| 151 | |||
| 152 | const finishSub = DeviceEventEmitter.addListener( | ||
| 153 | iHealthDeviceManagerModule.Event_Scan_Finish ?? 'event_scan_finish', | ||
| 154 | () => { | ||
| 155 | setScanning(false); | ||
| 156 | }, | ||
| 157 | ); | ||
| 158 | |||
| 159 | return () => { | ||
| 160 | scanSub.remove(); | ||
| 161 | finishSub.remove(); | ||
| 162 | }; | ||
| 163 | }, []); | ||
| 164 | |||
| 165 | const startScan = async () => { | ||
| 166 | setError(null); | ||
| 167 | |||
| 168 | const granted = await requestAndroidPermissions(); | ||
| 169 | if (!granted) { | ||
| 170 | setError('Bluetooth permissions denied'); | ||
| 171 | return; | ||
| 172 | } | ||
| 173 | |||
| 174 | devicesRef.current = []; | ||
| 175 | setDevices([]); | ||
| 176 | setScanning(true); | ||
| 177 | |||
| 178 | // Discover all device types. 'ALL' hits the default case in getDiscoveryType() | ||
| 179 | // which maps to DiscoveryTypeEnum.All (MIX = BLE + BT Classic + WiFi). | ||
| 180 | // Requires ACCESS_NETWORK_STATE permission for WiFi scan. | ||
| 181 | try { | ||
| 182 | iHealthDeviceManagerModule.startDiscovery('ALL'); | ||
| 183 | } catch (e) { | ||
| 184 | console.log('Failed to start discovery:', e); | ||
| 185 | } | ||
| 186 | }; | ||
| 187 | |||
| 188 | const stopScan = () => { | ||
| 189 | try { | ||
| 190 | iHealthDeviceManagerModule.stopDiscovery(); | ||
| 191 | } catch (e) { | ||
| 192 | console.log('Failed to stop discovery:', e); | ||
| 193 | } | ||
| 194 | setScanning(false); | ||
| 195 | }; | ||
| 196 | |||
| 197 | const renderDevice = ({item}: {item: Device}) => ( | ||
| 198 | <View style={styles.deviceRow}> | ||
| 199 | <DeviceIcon type={item.type} /> | ||
| 200 | <View style={styles.deviceInfo}> | ||
| 201 | <Text style={styles.deviceType}>{item.type}</Text> | ||
| 202 | <Text style={styles.deviceMac}>{item.mac}</Text> | ||
| 203 | </View> | ||
| 204 | {item.rssi != null && ( | ||
| 205 | <Text style={styles.deviceRssi}>{item.rssi} dBm</Text> | ||
| 206 | )} | ||
| 207 | </View> | ||
| 208 | ); | ||
| 209 | |||
| 210 | return ( | ||
| 211 | <SafeAreaProvider> | ||
| 212 | <SafeAreaView style={styles.container}> | ||
| 213 | <Text style={styles.title}>iHealth Scanner</Text> | ||
| 214 | <Text style={styles.subtitle}> | ||
| 215 | {scanning | ||
| 216 | ? `Scanning... (${devices.length} found)` | ||
| 217 | : `${devices.length} device(s) found`} | ||
| 218 | </Text> | ||
| 219 | |||
| 220 | {error && <Text style={styles.error}>{error}</Text>} | ||
| 221 | |||
| 222 | <TouchableOpacity | ||
| 223 | style={[styles.button, scanning && styles.buttonStop]} | ||
| 224 | onPress={scanning ? stopScan : startScan}> | ||
| 225 | <Text style={styles.buttonText}> | ||
| 226 | {scanning ? 'Stop Scan' : 'Start Scan'} | ||
| 227 | </Text> | ||
| 228 | </TouchableOpacity> | ||
| 229 | |||
| 230 | <FlatList | ||
| 231 | data={devices} | ||
| 232 | keyExtractor={item => item.mac} | ||
| 233 | renderItem={renderDevice} | ||
| 234 | style={styles.list} | ||
| 235 | contentContainerStyle={devices.length === 0 && styles.emptyList} | ||
| 236 | ListEmptyComponent={ | ||
| 237 | <Text style={styles.emptyText}> | ||
| 238 | {scanning | ||
| 239 | ? 'Looking for iHealth devices...' | ||
| 240 | : 'Tap "Start Scan" to find nearby iHealth devices'} | ||
| 241 | </Text> | ||
| 242 | } | ||
| 243 | /> | ||
| 244 | </SafeAreaView> | ||
| 245 | </SafeAreaProvider> | ||
| 246 | ); | ||
| 247 | } | ||
| 248 | |||
| 249 | const styles = StyleSheet.create({ | ||
| 250 | container: { | ||
| 251 | flex: 1, | ||
| 252 | backgroundColor: '#f5f5f5', | ||
| 253 | paddingHorizontal: 16, | ||
| 254 | }, | ||
| 255 | title: { | ||
| 256 | fontSize: 28, | ||
| 257 | fontWeight: '700', | ||
| 258 | color: '#1a1a1a', | ||
| 259 | marginTop: 16, | ||
| 260 | }, | ||
| 261 | subtitle: { | ||
| 262 | fontSize: 14, | ||
| 263 | color: '#666', | ||
| 264 | marginTop: 4, | ||
| 265 | marginBottom: 16, | ||
| 266 | }, | ||
| 267 | error: { | ||
| 268 | color: '#d32f2f', | ||
| 269 | fontSize: 13, | ||
| 270 | marginBottom: 8, | ||
| 271 | backgroundColor: '#ffebee', | ||
| 272 | padding: 8, | ||
| 273 | borderRadius: 6, | ||
| 274 | }, | ||
| 275 | button: { | ||
| 276 | backgroundColor: '#2196F3', | ||
| 277 | paddingVertical: 14, | ||
| 278 | borderRadius: 10, | ||
| 279 | alignItems: 'center', | ||
| 280 | marginBottom: 16, | ||
| 281 | }, | ||
| 282 | buttonStop: { | ||
| 283 | backgroundColor: '#f44336', | ||
| 284 | }, | ||
| 285 | buttonText: { | ||
| 286 | color: '#fff', | ||
| 287 | fontSize: 16, | ||
| 288 | fontWeight: '600', | ||
| 289 | }, | ||
| 290 | list: { | ||
| 291 | flex: 1, | ||
| 292 | }, | ||
| 293 | emptyList: { | ||
| 294 | flex: 1, | ||
| 295 | justifyContent: 'center', | ||
| 296 | alignItems: 'center', | ||
| 297 | }, | ||
| 298 | emptyText: { | ||
| 299 | color: '#999', | ||
| 300 | fontSize: 15, | ||
| 301 | textAlign: 'center', | ||
| 302 | }, | ||
| 303 | deviceRow: { | ||
| 304 | flexDirection: 'row', | ||
| 305 | alignItems: 'center', | ||
| 306 | backgroundColor: '#fff', | ||
| 307 | padding: 14, | ||
| 308 | borderRadius: 10, | ||
| 309 | marginBottom: 8, | ||
| 310 | shadowColor: '#000', | ||
| 311 | shadowOffset: {width: 0, height: 1}, | ||
| 312 | shadowOpacity: 0.05, | ||
| 313 | shadowRadius: 2, | ||
| 314 | elevation: 1, | ||
| 315 | }, | ||
| 316 | deviceIcon: { | ||
| 317 | width: 44, | ||
| 318 | height: 44, | ||
| 319 | borderRadius: 22, | ||
| 320 | backgroundColor: '#e3f2fd', | ||
| 321 | justifyContent: 'center', | ||
| 322 | alignItems: 'center', | ||
| 323 | marginRight: 12, | ||
| 324 | }, | ||
| 325 | deviceIconText: { | ||
| 326 | fontSize: 13, | ||
| 327 | fontWeight: '700', | ||
| 328 | color: '#1565c0', | ||
| 329 | }, | ||
| 330 | deviceInfo: { | ||
| 331 | flex: 1, | ||
| 332 | }, | ||
| 333 | deviceType: { | ||
| 334 | fontSize: 16, | ||
| 335 | fontWeight: '600', | ||
| 336 | color: '#1a1a1a', | ||
| 337 | }, | ||
| 338 | deviceMac: { | ||
| 339 | fontSize: 12, | ||
| 340 | color: '#888', | ||
| 341 | fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', | ||
| 342 | marginTop: 2, | ||
| 343 | }, | ||
| 344 | deviceRssi: { | ||
| 345 | fontSize: 12, | ||
| 346 | color: '#999', | ||
| 347 | marginLeft: 8, | ||
| 348 | }, | ||
| 349 | }); | ||
| 350 | |||
| 351 | export default App; | ||
