From d6d9a09d505d11148599a95a5be3e1351edbe0ac Mon Sep 17 00:00:00 2001 From: hc Date: Mon, 13 Apr 2026 15:17:52 +0800 Subject: Local iHealth SDK, device detail screen, iOS event fixes --- App.tsx | 561 +++++++++++++++++++++++++++++++++++++++++----------------------- 1 file changed, 364 insertions(+), 197 deletions(-) (limited to 'App.tsx') diff --git a/App.tsx b/App.tsx index 09e446f..9dca202 100644 --- a/App.tsx +++ b/App.tsx @@ -5,14 +5,18 @@ import { Text, TouchableOpacity, FlatList, + ScrollView, Platform, PermissionsAndroid, DeviceEventEmitter, + NativeEventEmitter, NativeModules, + Alert, } from 'react-native'; import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context'; -// Device type string names accepted by startDiscovery on both platforms +// ── Native module types ────────────────────────────────────────────── + const DEVICE_TYPE_KEYS = [ 'AM3S', 'AM4', 'PO3', 'BP5', 'BP5S', 'BP3L', 'BP7', 'BP7S', 'KN550', 'HS2', 'HS2S', 'HS4S', 'BG1', 'BG1S', 'BG5', 'BG5S', @@ -20,40 +24,22 @@ const DEVICE_TYPE_KEYS = [ ] as const; type DeviceTypeName = (typeof DEVICE_TYPE_KEYS)[number]; - -// The native module exports string constants on iOS, number constants on Android. type DiscoveryConstant = string | number; interface IHealthDeviceManager { - // Device type constants (string on iOS, number on Android) - AM3S: DiscoveryConstant; - AM4: DiscoveryConstant; - PO3: DiscoveryConstant; - BP5: DiscoveryConstant; - BP5S: DiscoveryConstant; - BP3L: DiscoveryConstant; - BP7: DiscoveryConstant; - BP7S: DiscoveryConstant; - KN550: DiscoveryConstant; - HS2: DiscoveryConstant; - HS2S: DiscoveryConstant; - HS4S: DiscoveryConstant; - BG1: DiscoveryConstant; - BG1S: DiscoveryConstant; - BG5: DiscoveryConstant; - BG5S: DiscoveryConstant; - ECG3: DiscoveryConstant; - BTM: DiscoveryConstant; - TS28B: DiscoveryConstant; - NT13B: DiscoveryConstant; - // Event name constants + AM3S: DiscoveryConstant; AM4: DiscoveryConstant; PO3: DiscoveryConstant; + BP5: DiscoveryConstant; BP5S: DiscoveryConstant; BP3L: DiscoveryConstant; + BP7: DiscoveryConstant; BP7S: DiscoveryConstant; KN550: DiscoveryConstant; + HS2: DiscoveryConstant; HS2S: DiscoveryConstant; HS4S: DiscoveryConstant; + BG1: DiscoveryConstant; BG1S: DiscoveryConstant; BG5: DiscoveryConstant; + BG5S: DiscoveryConstant; ECG3: DiscoveryConstant; BTM: DiscoveryConstant; + TS28B: DiscoveryConstant; NT13B: DiscoveryConstant; Event_Scan_Device: string; Event_Scan_Finish: string; Event_Device_Connected: string; Event_Device_Connect_Failed: string; Event_Device_Disconnect: string; Event_Authenticate_Result: string; - // Methods — startDiscovery takes a device type name string per official docs startDiscovery(type: DeviceTypeName | string): void; stopDiscovery(): void; connectDevice(mac: string, type: string): void; @@ -63,8 +49,20 @@ interface IHealthDeviceManager { getDevicesIDPS(mac: string, callback: (idps: Record) => void): void; } +interface IBP550BTModule { + Event_Notify: string; + getBattery(mac: string): void; + getOffLineNum(mac: string): void; + getOffLineData(mac: string): void; + getFunctionInfo(mac: string): void; + disconnect(mac: string): void; + getAllConnectedDevices(): void; +} + const iHealthDeviceManagerModule = NativeModules.iHealthDeviceManagerModule as IHealthDeviceManager; +const BP550BTModule = + NativeModules.BP550BTModule as IBP550BTModule; type Device = { mac: string; @@ -73,45 +71,227 @@ type Device = { timestamp: number; }; +// ── Helpers ────────────────────────────────────────────────────────── + async function requestAndroidPermissions(): Promise { if (Platform.OS !== 'android') return true; - - const apiLevel = Platform.Version; const permissions: string[] = []; - - if (apiLevel >= 31) { + if (Platform.Version >= 31) { permissions.push( PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, ); } permissions.push(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION); - const results = await PermissionsAndroid.requestMultiple(permissions as any); return Object.values(results).every( r => r === PermissionsAndroid.RESULTS.GRANTED, ); } -function DeviceIcon({type}: {type: string}) { - const icons: Record = { - BP: 'BP', - AM: 'AM', - PO: 'PO', - BG: 'BG', - HS: 'HS', - ECG: 'ECG', - BTM: 'BTM', +function getEmitter() { + return Platform.OS === 'ios' + ? new NativeEventEmitter(NativeModules.iHealthDeviceManagerModule) + : DeviceEventEmitter; +} + +function getBPEmitter() { + return Platform.OS === 'ios' + ? new NativeEventEmitter(NativeModules.BP550BTModule) + : DeviceEventEmitter; +} + +// ── Device Detail Screen ───────────────────────────────────────────── + +function DeviceScreen({ + device, + onBack, +}: { + device: Device; + onBack: () => void; +}) { + const [connected, setConnected] = useState(false); + const [connecting, setConnecting] = useState(false); + const [log, setLog] = useState([]); + + const addLog = (msg: string) => + setLog(prev => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev]); + + useEffect(() => { + const emitter = getEmitter(); + + const connSub = emitter.addListener( + iHealthDeviceManagerModule.Event_Device_Connected ?? 'event_device_connected', + (e: {mac: string; type: string}) => { + if (e.mac === device.mac) { + setConnected(true); + setConnecting(false); + addLog(`Connected to ${e.type}`); + } + }, + ); + + const failSub = emitter.addListener( + iHealthDeviceManagerModule.Event_Device_Connect_Failed ?? 'event_device_connect_failed', + (e: {mac: string; errorid?: number}) => { + if (e.mac === device.mac) { + setConnecting(false); + addLog(`Connection failed (error: ${e.errorid ?? 'unknown'})`); + } + }, + ); + + const dcSub = emitter.addListener( + iHealthDeviceManagerModule.Event_Device_Disconnect ?? 'event_device_disconnect', + (e: {mac: string}) => { + if (e.mac === device.mac) { + setConnected(false); + addLog('Disconnected'); + } + }, + ); + + // BP550BT event listener + const bpEmitter = getBPEmitter(); + const notifySub = bpEmitter.addListener( + BP550BTModule?.Event_Notify ?? 'event_notify', + (e: Record) => { + addLog(JSON.stringify(e, null, 2)); + }, + ); + + return () => { + connSub.remove(); + failSub.remove(); + dcSub.remove(); + notifySub.remove(); + }; + }, [device.mac]); + + const connect = () => { + setConnecting(true); + addLog(`Connecting to ${device.mac}...`); + iHealthDeviceManagerModule.connectDevice(device.mac, device.type); + }; + + const disconnect = () => { + addLog('Disconnecting...'); + if (BP550BTModule) { + BP550BTModule.disconnect(device.mac); + } else { + iHealthDeviceManagerModule.disconnectDevice(device.mac, device.type); + } }; - const prefix = Object.keys(icons).find(k => type.startsWith(k)) || '?'; + + const actions: {label: string; fn: () => void; needsConnect: boolean}[] = [ + { + label: 'Get Battery', + needsConnect: true, + fn: () => { + addLog('Requesting battery...'); + BP550BTModule.getBattery(device.mac); + }, + }, + { + label: 'Get Function Info', + needsConnect: true, + fn: () => { + addLog('Requesting function info...'); + BP550BTModule.getFunctionInfo(device.mac); + }, + }, + { + label: 'Get Offline Count', + needsConnect: true, + fn: () => { + addLog('Requesting offline data count...'); + BP550BTModule.getOffLineNum(device.mac); + }, + }, + { + label: 'Get Offline Data', + needsConnect: true, + fn: () => { + addLog('Requesting offline data...'); + BP550BTModule.getOffLineData(device.mac); + }, + }, + { + label: 'Get IDPS', + needsConnect: false, + fn: () => { + addLog('Requesting IDPS...'); + iHealthDeviceManagerModule.getDevicesIDPS(device.mac, (idps) => { + addLog(`IDPS: ${JSON.stringify(idps, null, 2)}`); + }); + }, + }, + ]; + return ( - - {icons[prefix] || '?'} - + + + Back + + + {device.type} + {device.mac} + + {connecting ? 'Connecting...' : connected ? 'Connected' : 'Disconnected'} + + + {!connected && !connecting && ( + + Connect + + )} + {connected && ( + + Disconnect + + )} + + Actions + + {actions.map(a => ( + + + {a.label} + + + ))} + + + Log + String(i)} + renderItem={({item}) => {item}} + style={styles.logList} + /> + ); } -function App() { +// ── Scanner Screen ─────────────────────────────────────────────────── + +function ScannerScreen({onSelectDevice}: {onSelectDevice: (d: Device) => void}) { const [devices, setDevices] = useState([]); const [scanning, setScanning] = useState(false); const [error, setError] = useState(null); @@ -119,18 +299,15 @@ function App() { useEffect(() => { if (!iHealthDeviceManagerModule) { - setError('iHealth native module not found. Check linking.'); + setError('iHealth native module not found.'); return; } + const emitter = getEmitter(); - // The iHealth SDK uses the global DeviceEventEmitter (not NativeEventEmitter) - // because the iOS native module doesn't subclass RCTEventEmitter. - const scanSub = DeviceEventEmitter.addListener( + const scanSub = emitter.addListener( iHealthDeviceManagerModule.Event_Scan_Device ?? 'event_scan_device', (event: {mac: string; type: string; rssi?: number}) => { const {mac = '', type = 'Unknown', rssi} = event; - - // Filter out AM3 — likely false positives from non-iHealth heart rate devices if (type === 'AM3') return; const existing = devicesRef.current.findIndex(d => d.mac === mac); @@ -149,11 +326,9 @@ function App() { }, ); - const finishSub = DeviceEventEmitter.addListener( + const finishSub = emitter.addListener( iHealthDeviceManagerModule.Event_Scan_Finish ?? 'event_scan_finish', - () => { - setScanning(false); - }, + () => setScanning(false), ); return () => { @@ -164,187 +339,179 @@ function App() { const startScan = async () => { setError(null); - const granted = await requestAndroidPermissions(); if (!granted) { setError('Bluetooth permissions denied'); return; } - devicesRef.current = []; setDevices([]); setScanning(true); - // Discover all device types. 'ALL' hits the default case in getDiscoveryType() - // which maps to DiscoveryTypeEnum.All (MIX = BLE + BT Classic + WiFi). - // Requires ACCESS_NETWORK_STATE permission for WiFi scan. - try { - iHealthDeviceManagerModule.startDiscovery('ALL'); - } catch (e) { - console.log('Failed to start discovery:', e); + if (Platform.OS === 'android') { + try { iHealthDeviceManagerModule.startDiscovery('ALL'); } + catch (e) { console.log('Discovery failed:', e); } + } else { + const iosTypes = ['KN550', 'BP3L', 'BP5S', 'BP7S', 'AM3S', 'AM4', + 'PO3', 'HS2', 'HS2S', 'HS4S', 'BG5S', 'BG1S', 'PO1', 'ECG3']; + let i = 0; + const scanNext = () => { + if (i < iosTypes.length) { + try { iHealthDeviceManagerModule.startDiscovery(iosTypes[i]); } + catch (e) { console.log(`Failed: ${iosTypes[i]}`, e); } + i++; + setTimeout(scanNext, 2000); + } + }; + setTimeout(scanNext, 2000); } }; const stopScan = () => { - try { - iHealthDeviceManagerModule.stopDiscovery(); - } catch (e) { - console.log('Failed to stop discovery:', e); - } + try { iHealthDeviceManagerModule.stopDiscovery(); } + catch (e) { console.log('Stop failed:', e); } setScanning(false); }; - const renderDevice = ({item}: {item: Device}) => ( - - - - {item.type} - {item.mac} - - {item.rssi != null && ( - {item.rssi} dBm - )} - - ); - return ( - - - iHealth Scanner - - {scanning - ? `Scanning... (${devices.length} found)` - : `${devices.length} device(s) found`} - + + iHealth Scanner + + {scanning + ? `Scanning... (${devices.length} found)` + : `${devices.length} device(s) found`} + - {error && {error}} + {error && {error}} - - - {scanning ? 'Stop Scan' : 'Start Scan'} + + + {scanning ? 'Stop Scan' : 'Start Scan'} + + + + item.mac} + renderItem={({item}) => ( + { + stopScan(); + onSelectDevice(item); + }}> + + + {item.type.substring(0, 3)} + + + + {item.type} + {item.mac} + + {item.rssi != null && ( + {item.rssi} dBm + )} + {'>'} + + )} + style={styles.list} + contentContainerStyle={devices.length === 0 && styles.emptyList} + ListEmptyComponent={ + + {scanning + ? 'Looking for iHealth devices...' + : 'Tap "Start Scan" to find nearby iHealth devices'} - + } + /> + + ); +} - item.mac} - renderItem={renderDevice} - style={styles.list} - contentContainerStyle={devices.length === 0 && styles.emptyList} - ListEmptyComponent={ - - {scanning - ? 'Looking for iHealth devices...' - : 'Tap "Start Scan" to find nearby iHealth devices'} - - } +// ── App Root ───────────────────────────────────────────────────────── + +function App() { + const [selectedDevice, setSelectedDevice] = useState(null); + + return ( + + {selectedDevice ? ( + setSelectedDevice(null)} /> - + ) : ( + + )} ); } +// ── Styles ─────────────────────────────────────────────────────────── + const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#f5f5f5', - paddingHorizontal: 16, - }, - title: { - fontSize: 28, - fontWeight: '700', - color: '#1a1a1a', - marginTop: 16, - }, - subtitle: { - fontSize: 14, - color: '#666', - marginTop: 4, - marginBottom: 16, - }, + container: {flex: 1, backgroundColor: '#f5f5f5', paddingHorizontal: 16}, + title: {fontSize: 28, fontWeight: '700', color: '#1a1a1a', marginTop: 16}, + subtitle: {fontSize: 14, color: '#666', marginTop: 4, marginBottom: 16}, error: { - color: '#d32f2f', - fontSize: 13, - marginBottom: 8, - backgroundColor: '#ffebee', - padding: 8, - borderRadius: 6, + color: '#d32f2f', fontSize: 13, marginBottom: 8, + backgroundColor: '#ffebee', padding: 8, borderRadius: 6, }, button: { - backgroundColor: '#2196F3', - paddingVertical: 14, - borderRadius: 10, - alignItems: 'center', - marginBottom: 16, - }, - buttonStop: { - backgroundColor: '#f44336', - }, - buttonText: { - color: '#fff', - fontSize: 16, - fontWeight: '600', - }, - list: { - flex: 1, - }, - emptyList: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - emptyText: { - color: '#999', - fontSize: 15, - textAlign: 'center', + backgroundColor: '#2196F3', paddingVertical: 14, + borderRadius: 10, alignItems: 'center', marginBottom: 16, }, + buttonStop: {backgroundColor: '#f44336'}, + buttonText: {color: '#fff', fontSize: 16, fontWeight: '600'}, + list: {flex: 1}, + emptyList: {flex: 1, justifyContent: 'center', alignItems: 'center'}, + emptyText: {color: '#999', fontSize: 15, textAlign: 'center'}, deviceRow: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#fff', - padding: 14, - borderRadius: 10, - marginBottom: 8, - shadowColor: '#000', - shadowOffset: {width: 0, height: 1}, - shadowOpacity: 0.05, - shadowRadius: 2, - elevation: 1, + flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff', + padding: 14, borderRadius: 10, marginBottom: 8, + shadowColor: '#000', shadowOffset: {width: 0, height: 1}, + shadowOpacity: 0.05, shadowRadius: 2, elevation: 1, }, deviceIcon: { - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: '#e3f2fd', - justifyContent: 'center', - alignItems: 'center', - marginRight: 12, - }, - deviceIconText: { - fontSize: 13, - fontWeight: '700', - color: '#1565c0', + width: 44, height: 44, borderRadius: 22, backgroundColor: '#e3f2fd', + justifyContent: 'center', alignItems: 'center', marginRight: 12, }, - deviceInfo: { - flex: 1, + deviceIconText: {fontSize: 12, fontWeight: '700', color: '#1565c0'}, + deviceInfo: {flex: 1}, + deviceType: {fontSize: 16, fontWeight: '600', color: '#1a1a1a'}, + deviceMac: { + fontSize: 12, color: '#888', marginTop: 2, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', }, - deviceType: { - fontSize: 16, + deviceRssi: {fontSize: 12, color: '#999', marginLeft: 8}, + chevron: {fontSize: 18, color: '#ccc', marginLeft: 8}, + // Device screen + backButton: {marginTop: 12, marginBottom: 4}, + backText: {color: '#2196F3', fontSize: 16}, + statusBadge: { + fontSize: 13, color: '#f44336', marginTop: 8, marginBottom: 16, fontWeight: '600', - color: '#1a1a1a', }, - deviceMac: { - fontSize: 12, - color: '#888', - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - marginTop: 2, + statusConnected: {color: '#4caf50'}, + sectionTitle: { + fontSize: 16, fontWeight: '600', color: '#333', + marginTop: 8, marginBottom: 8, + }, + actionsRow: {flexGrow: 0, marginBottom: 12}, + actionButton: { + backgroundColor: '#e3f2fd', paddingVertical: 10, paddingHorizontal: 16, + borderRadius: 8, marginRight: 8, }, - deviceRssi: { - fontSize: 12, - color: '#999', - marginLeft: 8, + actionDisabled: {backgroundColor: '#eee'}, + actionText: {color: '#1565c0', fontSize: 13, fontWeight: '600'}, + actionTextDisabled: {color: '#bbb'}, + logList: {flex: 1, marginTop: 4}, + logLine: { + fontSize: 11, color: '#555', paddingVertical: 3, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', }, }); -- cgit