From 86e73f004d2283931e2e46fd61507527ba1eae54 Mon Sep 17 00:00:00 2001 From: Sergei Date: Wed, 14 Jan 2026 19:07:44 -0800 Subject: [PATCH] Add BLE infrastructure for sensor connectivity Core BLE system: - BLEManager: Real BLE device scanning and connection (iOS/Android) - MockBLEManager: Simulator-safe mock for development - BLEContext: React context for BLE state management - BLEProvider: Added to app/_layout.tsx Bluetooth permissions: - iOS: NSBluetoothAlwaysUsageDescription, NSBluetoothPeripheralUsageDescription - Android: BLUETOOTH, BLUETOOTH_ADMIN, BLUETOOTH_CONNECT, BLUETOOTH_SCAN, ACCESS_FINE_LOCATION Dependencies: - react-native-ble-plx@3.5.0 - expo-device@8.0.10 - react-native-base64@0.2.2 Simulator support: - Auto-detects iOS simulator via expo-device - Falls back to MockBLEManager with fake devices - No crashes or permission errors in development --- app.json | 19 ++- app/_layout.tsx | 9 +- contexts/BLEContext.tsx | 171 ++++++++++++++++++++ package-lock.json | 141 +++++++++++----- package.json | 9 +- services/ble/BLEManager.ts | 287 +++++++++++++++++++++++++++++++++ services/ble/MockBLEManager.ts | 112 +++++++++++++ services/ble/index.ts | 17 ++ services/ble/types.ts | 60 +++++++ 9 files changed, 776 insertions(+), 49 deletions(-) create mode 100644 contexts/BLEContext.tsx create mode 100644 services/ble/BLEManager.ts create mode 100644 services/ble/MockBLEManager.ts create mode 100644 services/ble/index.ts create mode 100644 services/ble/types.ts diff --git a/app.json b/app.json index b1daf4f..ec56013 100644 --- a/app.json +++ b/app.json @@ -16,7 +16,9 @@ "infoPlist": { "ITSAppUsesNonExemptEncryption": false, "NSSpeechRecognitionUsageDescription": "Allow $(PRODUCT_NAME) to use speech recognition.", - "NSMicrophoneUsageDescription": "Allow $(PRODUCT_NAME) to use the microphone." + "NSMicrophoneUsageDescription": "Allow $(PRODUCT_NAME) to use the microphone.", + "NSBluetoothAlwaysUsageDescription": "WellNuo needs Bluetooth to connect to your wellness sensors and monitor their status.", + "NSBluetoothPeripheralUsageDescription": "WellNuo needs Bluetooth to manage and configure your sensors." } }, "android": { @@ -31,7 +33,12 @@ "predictiveBackGestureEnabled": false, "permissions": [ "android.permission.RECORD_AUDIO", - "android.permission.MODIFY_AUDIO_SETTINGS" + "android.permission.MODIFY_AUDIO_SETTINGS", + "android.permission.BLUETOOTH", + "android.permission.BLUETOOTH_ADMIN", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.BLUETOOTH_SCAN", + "android.permission.ACCESS_FINE_LOCATION" ] }, "web": { @@ -69,6 +76,14 @@ "merchantIdentifier": "merchant.com.wellnuo.app", "enableGooglePay": true } + ], + [ + "react-native-ble-plx", + { + "isBackgroundEnabled": true, + "modes": ["peripheral", "central"], + "bluetoothAlwaysPermission": "Allow $(PRODUCT_NAME) to connect to WellNuo sensors" + } ] ], "experiments": { diff --git a/app/_layout.tsx b/app/_layout.tsx index eebc444..bd835d4 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -10,6 +10,7 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { ToastProvider } from '@/components/ui/Toast'; import { AuthProvider, useAuth } from '@/contexts/AuthContext'; import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext'; +import { BLEProvider } from '@/contexts/BLEContext'; import { useColorScheme } from '@/hooks/use-color-scheme'; // Stripe publishable key (test mode) - must match backend STRIPE_PUBLISHABLE_KEY @@ -95,9 +96,11 @@ export default function RootLayout() { > - - - + + + + + diff --git a/contexts/BLEContext.tsx b/contexts/BLEContext.tsx new file mode 100644 index 0000000..486f3c0 --- /dev/null +++ b/contexts/BLEContext.tsx @@ -0,0 +1,171 @@ +// BLE Context - Global state for Bluetooth management + +import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import { bleManager, WPDevice, WiFiNetwork, WiFiStatus, isBLEAvailable } from '@/services/ble'; + +interface BLEContextType { + // State + foundDevices: WPDevice[]; + isScanning: boolean; + connectedDevices: Set; + isBLEAvailable: boolean; + error: string | null; + + // Actions + scanDevices: () => Promise; + stopScan: () => void; + connectDevice: (deviceId: string) => Promise; + disconnectDevice: (deviceId: string) => Promise; + getWiFiList: (deviceId: string) => Promise; + setWiFi: (deviceId: string, ssid: string, password: string) => Promise; + getCurrentWiFi: (deviceId: string) => Promise; + rebootDevice: (deviceId: string) => Promise; + clearError: () => void; +} + +const BLEContext = createContext(undefined); + +export function BLEProvider({ children }: { children: ReactNode }) { + const [foundDevices, setFoundDevices] = useState([]); + const [isScanning, setIsScanning] = useState(false); + const [connectedDevices, setConnectedDevices] = useState>(new Set()); + const [error, setError] = useState(null); + + const scanDevices = useCallback(async () => { + try { + setError(null); + setIsScanning(true); + const devices = await bleManager.scanDevices(); + // Sort by RSSI (strongest first) + const sorted = devices.sort((a, b) => b.rssi - a.rssi); + setFoundDevices(sorted); + } catch (err: any) { + console.error('[BLEContext] Scan error:', err); + setError(err.message || 'Failed to scan for devices'); + throw err; + } finally { + setIsScanning(false); + } + }, []); + + const stopScan = useCallback(() => { + bleManager.stopScan(); + setIsScanning(false); + }, []); + + const connectDevice = useCallback(async (deviceId: string): Promise => { + try { + setError(null); + const success = await bleManager.connectDevice(deviceId); + if (success) { + setConnectedDevices(prev => new Set(prev).add(deviceId)); + } + return success; + } catch (err: any) { + console.error('[BLEContext] Connect error:', err); + setError(err.message || 'Failed to connect to device'); + return false; + } + }, []); + + const disconnectDevice = useCallback(async (deviceId: string): Promise => { + try { + await bleManager.disconnectDevice(deviceId); + setConnectedDevices(prev => { + const next = new Set(prev); + next.delete(deviceId); + return next; + }); + } catch (err: any) { + console.error('[BLEContext] Disconnect error:', err); + setError(err.message || 'Failed to disconnect device'); + } + }, []); + + const getWiFiList = useCallback(async (deviceId: string): Promise => { + try { + setError(null); + return await bleManager.getWiFiList(deviceId); + } catch (err: any) { + console.error('[BLEContext] Get WiFi list error:', err); + setError(err.message || 'Failed to get WiFi networks'); + throw err; + } + }, []); + + const setWiFi = useCallback( + async (deviceId: string, ssid: string, password: string): Promise => { + try { + setError(null); + return await bleManager.setWiFi(deviceId, ssid, password); + } catch (err: any) { + console.error('[BLEContext] Set WiFi error:', err); + setError(err.message || 'Failed to configure WiFi'); + throw err; + } + }, + [] + ); + + const getCurrentWiFi = useCallback( + async (deviceId: string): Promise => { + try { + setError(null); + return await bleManager.getCurrentWiFi(deviceId); + } catch (err: any) { + console.error('[BLEContext] Get current WiFi error:', err); + setError(err.message || 'Failed to get current WiFi'); + throw err; + } + }, + [] + ); + + const rebootDevice = useCallback(async (deviceId: string): Promise => { + try { + setError(null); + await bleManager.rebootDevice(deviceId); + // Remove from connected devices + setConnectedDevices(prev => { + const next = new Set(prev); + next.delete(deviceId); + return next; + }); + } catch (err: any) { + console.error('[BLEContext] Reboot error:', err); + setError(err.message || 'Failed to reboot device'); + throw err; + } + }, []); + + const clearError = useCallback(() => { + setError(null); + }, []); + + const value: BLEContextType = { + foundDevices, + isScanning, + connectedDevices, + isBLEAvailable, + error, + scanDevices, + stopScan, + connectDevice, + disconnectDevice, + getWiFiList, + setWiFi, + getCurrentWiFi, + rebootDevice, + clearError, + }; + + return {children}; +} + +export function useBLE() { + const context = useContext(BLEContext); + if (context === undefined) { + throw new Error('useBLE must be used within a BLEProvider'); + } + return context; +} diff --git a/package-lock.json b/package-lock.json index 4218322..dacba21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,13 +15,14 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "@stripe/stripe-react-native": "0.50.3", - "expo": "~54.0.30", + "expo": "~54.0.31", "expo-audio": "~1.1.1", "expo-av": "~16.0.8", "expo-build-properties": "~1.0.10", "expo-camera": "~17.0.10", "expo-clipboard": "~8.0.8", - "expo-constants": "~18.0.12", + "expo-constants": "~18.0.13", + "expo-device": "^8.0.10", "expo-file-system": "~19.0.21", "expo-font": "~14.0.10", "expo-haptics": "~15.0.8", @@ -41,9 +42,11 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", + "react-native-base64": "^0.2.2", + "react-native-ble-plx": "^3.5.0", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "~2.28.0", - "react-native-get-random-values": "^2.0.0", + "react-native-get-random-values": "~1.11.0", "react-native-reanimated": "~4.1.1", "react-native-root-toast": "^4.0.1", "react-native-safe-area-context": "~5.6.0", @@ -2474,13 +2477,12 @@ } }, "node_modules/@expo/code-signing-certificates": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.5.tgz", - "integrity": "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw==", + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", + "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", "license": "MIT", "dependencies": { - "node-forge": "^1.2.1", - "nullthrows": "^1.1.1" + "node-forge": "^1.3.3" } }, "node_modules/@expo/config": { @@ -2748,9 +2750,9 @@ } }, "node_modules/@expo/metro-config": { - "version": "54.0.12", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.12.tgz", - "integrity": "sha512-Xhv1z/ak/cuJWeLxlnWr2u22q2AM/klASbjpP5eE34y91lGWa2NUwrFWoS830MhJ6kuAqtGdoQhwyPa3TES7sA==", + "version": "54.0.13", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.13.tgz", + "integrity": "sha512-RRufMCgLR2Za1WGsh02OatIJo5qZFt31yCnIOSfoubNc3Qqe92Z41pVsbrFnmw5CIaisv1NgdBy05DHe7pEyuw==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -10553,28 +10555,28 @@ } }, "node_modules/expo": { - "version": "54.0.30", - "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.30.tgz", - "integrity": "sha512-6q+aFfKL0SpT8prfdpR3V8HcN51ov0mCGuwQTzyuk6eeO9rg7a7LWbgPv9rEVXGZEuyULstL8LGNwHqusand7Q==", + "version": "54.0.31", + "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.31.tgz", + "integrity": "sha512-kQ3RDqA/a59I7y+oqQGyrPbbYlgPMUdKBOgvFLpoHbD2bCM+F75i4N0mUijy7dG5F/CUCu2qHmGGUCXBbMDkCg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "54.0.20", + "@expo/cli": "54.0.21", "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/devtools": "0.1.8", "@expo/fingerprint": "0.15.4", "@expo/metro": "~54.2.0", - "@expo/metro-config": "54.0.12", + "@expo/metro-config": "54.0.13", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.9", "expo-asset": "~12.0.12", - "expo-constants": "~18.0.12", + "expo-constants": "~18.0.13", "expo-file-system": "~19.0.21", "expo-font": "~14.0.10", "expo-keep-awake": "~15.0.8", - "expo-modules-autolinking": "3.0.23", + "expo-modules-autolinking": "3.0.24", "expo-modules-core": "3.0.29", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", @@ -10727,12 +10729,12 @@ } }, "node_modules/expo-constants": { - "version": "18.0.12", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.12.tgz", - "integrity": "sha512-WzcKYMVNRRu4NcSzfIVRD5aUQFnSpTZgXFrlWmm19xJoDa4S3/PQNi6PNTBRc49xz9h8FT7HMxRKaC8lr0gflA==", + "version": "18.0.13", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", + "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", "license": "MIT", "dependencies": { - "@expo/config": "~12.0.12", + "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" }, "peerDependencies": { @@ -10740,6 +10742,44 @@ "react-native": "*" } }, + "node_modules/expo-device": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz", + "integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==", + "license": "MIT", + "dependencies": { + "ua-parser-js": "^0.7.33" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-device/node_modules/ua-parser-js": { + "version": "0.7.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", + "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/expo-file-system": { "version": "19.0.21", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", @@ -10848,9 +10888,9 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.23.tgz", - "integrity": "sha512-YZnaE0G+52xftjH5nsIRaWsoVBY38SQCECclpdgLisdbRY/6Mzo7ndokjauOv3mpFmzMZACHyJNu1YSAffQwTg==", + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz", + "integrity": "sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.7.2", @@ -11236,13 +11276,13 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "54.0.20", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.20.tgz", - "integrity": "sha512-cwsXmhftvS0p9NNYOhXGnicBAZl9puWwRt19Qq5eQ6njLnaj8WvcR+kDZyADtgZxBsZiyVlrKXvnjt43HXywQA==", + "version": "54.0.21", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.21.tgz", + "integrity": "sha512-L/FdpyZDsg/Nq6xW6kfiyF9DUzKfLZCKFXEVZcDqCNar6bXxQVotQyvgexRvtUF5nLinuT/UafLOdC3FUALUmA==", "license": "MIT", "dependencies": { "@0no-co/graphql.web": "^1.0.8", - "@expo/code-signing-certificates": "^0.0.5", + "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/devcert": "^1.2.1", @@ -11250,7 +11290,7 @@ "@expo/image-utils": "^0.8.8", "@expo/json-file": "^10.0.8", "@expo/metro": "~54.2.0", - "@expo/metro-config": "~54.0.12", + "@expo/metro-config": "~54.0.13", "@expo/osascript": "^2.3.8", "@expo/package-manager": "^1.9.9", "@expo/plist": "^0.4.8", @@ -11279,7 +11319,7 @@ "glob": "^13.0.0", "lan-network": "^0.1.6", "minimatch": "^9.0.0", - "node-forge": "^1.3.1", + "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", @@ -11386,9 +11426,9 @@ } }, "node_modules/expo/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -18790,6 +18830,25 @@ } } }, + "node_modules/react-native-base64": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/react-native-base64/-/react-native-base64-0.2.2.tgz", + "integrity": "sha512-9iDzlDQrJqRlgoi7GnO4dqK/7/6lpA3DFrArhp85tDB7ZI6wLr7luHihb/pX6jhm4zlHqOz2OYSGJ6PSgyUO1g==", + "license": "MIT" + }, + "node_modules/react-native-ble-plx": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/react-native-ble-plx/-/react-native-ble-plx-3.5.0.tgz", + "integrity": "sha512-PeSnRswHLwLRVMQkOfDaRICtrGmo94WGKhlSC09XmHlqX2EuYgH+vNJpGcLkd8lyiYpEJyf8wlFAdj9Akliwmw==", + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-fs": { "version": "2.20.0", "resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.20.0.tgz", @@ -18825,15 +18884,15 @@ } }, "node_modules/react-native-get-random-values": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-2.0.0.tgz", - "integrity": "sha512-wx7/aPqsUIiWsG35D+MsUJd8ij96e3JKddklSdrdZUrheTx89gPtz3Q2yl9knBArj5u26Cl23T88ai+Q0vypdQ==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz", + "integrity": "sha512-4BTbDbRmS7iPdhYLRcz3PGFIpFJBwNZg9g42iwa2P6FOv9vZj/xJc678RZXnLNZzd0qd7Q3CCF6Yd+CU2eoXKQ==", "license": "MIT", "dependencies": { "fast-base64-decode": "^1.0.0" }, "peerDependencies": { - "react-native": ">=0.81" + "react-native": ">=0.56" } }, "node_modules/react-native-is-edge-to-edge": { @@ -24327,9 +24386,9 @@ } }, "node_modules/undici": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz", - "integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==", + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", "license": "MIT", "engines": { "node": ">=18.17" diff --git a/package.json b/package.json index 68fb7fe..b79cd78 100644 --- a/package.json +++ b/package.json @@ -18,13 +18,14 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "@stripe/stripe-react-native": "0.50.3", - "expo": "~54.0.30", + "expo": "~54.0.31", "expo-audio": "~1.1.1", "expo-av": "~16.0.8", "expo-build-properties": "~1.0.10", "expo-camera": "~17.0.10", "expo-clipboard": "~8.0.8", - "expo-constants": "~18.0.12", + "expo-constants": "~18.0.13", + "expo-device": "^8.0.10", "expo-file-system": "~19.0.21", "expo-font": "~14.0.10", "expo-haptics": "~15.0.8", @@ -44,9 +45,11 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", + "react-native-base64": "^0.2.2", + "react-native-ble-plx": "^3.5.0", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "~2.28.0", - "react-native-get-random-values": "^2.0.0", + "react-native-get-random-values": "~1.11.0", "react-native-reanimated": "~4.1.1", "react-native-root-toast": "^4.0.1", "react-native-safe-area-context": "~5.6.0", diff --git a/services/ble/BLEManager.ts b/services/ble/BLEManager.ts new file mode 100644 index 0000000..95e02a0 --- /dev/null +++ b/services/ble/BLEManager.ts @@ -0,0 +1,287 @@ +// Real BLE Manager для физических устройств + +import { BleManager, Device, State } from 'react-native-ble-plx'; +import { PermissionsAndroid, Platform } from 'react-native'; +import { IBLEManager, WPDevice, WiFiNetwork, WiFiStatus, BLE_CONFIG, BLE_COMMANDS } from './types'; +import base64 from 'react-native-base64'; + +export class RealBLEManager implements IBLEManager { + private manager: BleManager; + private connectedDevices = new Map(); + private scanning = false; + + constructor() { + this.manager = new BleManager(); + } + + // Check and request permissions + private async requestPermissions(): Promise { + if (Platform.OS === 'ios') { + // iOS handles permissions automatically via Info.plist + return true; + } + + if (Platform.OS === 'android') { + if (Platform.Version >= 31) { + // Android 12+ + const granted = await PermissionsAndroid.requestMultiple([ + PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN!, + PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT!, + PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION!, + ]); + + return Object.values(granted).every( + status => status === PermissionsAndroid.RESULTS.GRANTED + ); + } else { + // Android < 12 + const granted = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION! + ); + return granted === PermissionsAndroid.RESULTS.GRANTED; + } + } + + return false; + } + + // Check if Bluetooth is enabled + private async isBluetoothEnabled(): Promise { + const state = await this.manager.state(); + return state === State.PoweredOn; + } + + async scanDevices(): Promise { + const hasPermission = await this.requestPermissions(); + if (!hasPermission) { + throw new Error('Bluetooth permissions not granted'); + } + + const isEnabled = await this.isBluetoothEnabled(); + if (!isEnabled) { + throw new Error('Bluetooth is disabled. Please enable it in settings.'); + } + + const foundDevices = new Map(); + + return new Promise((resolve, reject) => { + this.scanning = true; + + this.manager.startDeviceScan( + null, + { allowDuplicates: false }, + (error, device) => { + if (error) { + this.scanning = false; + reject(error); + return; + } + + if (device && device.name?.startsWith(BLE_CONFIG.DEVICE_NAME_PREFIX)) { + // Parse well_id from name (WP_497_81a14c -> 497) + const wellIdMatch = device.name.match(/WP_(\d+)_/); + const wellId = wellIdMatch ? parseInt(wellIdMatch[1], 10) : undefined; + + // Extract MAC from device name (last part after underscore) + const macMatch = device.name.match(/_([a-fA-F0-9]{6})$/); + const mac = macMatch ? macMatch[1].toUpperCase() : ''; + + foundDevices.set(device.id, { + id: device.id, + name: device.name, + mac: mac, + rssi: device.rssi || -100, + wellId, + }); + } + } + ); + + // Stop scan after timeout + setTimeout(() => { + this.stopScan(); + resolve(Array.from(foundDevices.values())); + }, BLE_CONFIG.SCAN_TIMEOUT); + }); + } + + stopScan(): void { + if (this.scanning) { + this.manager.stopDeviceScan(); + this.scanning = false; + } + } + + async connectDevice(deviceId: string): Promise { + try { + const device = await this.manager.connectToDevice(deviceId); + await device.discoverAllServicesAndCharacteristics(); + this.connectedDevices.set(deviceId, device); + return true; + } catch (error) { + console.error('[BLE] Connection failed:', error); + return false; + } + } + + async disconnectDevice(deviceId: string): Promise { + const device = this.connectedDevices.get(deviceId); + if (device) { + await device.cancelConnection(); + this.connectedDevices.delete(deviceId); + } + } + + isDeviceConnected(deviceId: string): boolean { + return this.connectedDevices.has(deviceId); + } + + async sendCommand(deviceId: string, command: string): Promise { + const device = this.connectedDevices.get(deviceId); + if (!device) { + throw new Error('Device not connected'); + } + + return new Promise(async (resolve, reject) => { + let responseReceived = false; + let response = ''; + + try { + // Subscribe to notifications + device.monitorCharacteristicForService( + BLE_CONFIG.SERVICE_UUID, + BLE_CONFIG.CHAR_UUID, + (error, characteristic) => { + if (error) { + if (!responseReceived) { + responseReceived = true; + reject(error); + } + return; + } + + if (characteristic?.value) { + const decoded = base64.decode(characteristic.value); + response = decoded; + responseReceived = true; + resolve(decoded); + } + } + ); + + // Send command + const encoded = base64.encode(command); + await device.writeCharacteristicWithResponseForService( + BLE_CONFIG.SERVICE_UUID, + BLE_CONFIG.CHAR_UUID, + encoded + ); + + // Timeout + setTimeout(() => { + if (!responseReceived) { + responseReceived = true; + reject(new Error('Command timeout')); + } + }, BLE_CONFIG.COMMAND_TIMEOUT); + } catch (error) { + reject(error); + } + }); + } + + async getWiFiList(deviceId: string): Promise { + // Step 1: Unlock device + const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); + if (!unlockResponse.includes('ok')) { + throw new Error('Failed to unlock device'); + } + + // Step 2: Get WiFi list + const listResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_LIST); + + // Parse response: "mac,XXXXXX|w|COUNT|SSID1,RSSI1|SSID2,RSSI2|..." + const parts = listResponse.split('|'); + if (parts.length < 3) { + throw new Error('Invalid WiFi list response'); + } + + const count = parseInt(parts[2], 10); + if (count < 0) { + if (count === -1) { + throw new Error('WiFi scan in progress, please wait'); + } + if (count === -2) { + return []; // No networks found + } + } + + const networks: WiFiNetwork[] = []; + for (let i = 3; i < parts.length; i++) { + const [ssid, rssiStr] = parts[i].split(','); + if (ssid && rssiStr) { + networks.push({ + ssid: ssid.trim(), + rssi: parseInt(rssiStr, 10), + }); + } + } + + // Sort by signal strength (strongest first) + return networks.sort((a, b) => b.rssi - a.rssi); + } + + async setWiFi(deviceId: string, ssid: string, password: string): Promise { + // Step 1: Unlock device + const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); + if (!unlockResponse.includes('ok')) { + throw new Error('Failed to unlock device'); + } + + // Step 2: Set WiFi credentials + const command = `${BLE_COMMANDS.SET_WIFI}|${ssid},${password}`; + const setResponse = await this.sendCommand(deviceId, command); + + // Parse response: "mac,XXXXXX|W|ok" or "mac,XXXXXX|W|fail" + return setResponse.includes('|W|ok'); + } + + async getCurrentWiFi(deviceId: string): Promise { + // Step 1: Unlock device + const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); + if (!unlockResponse.includes('ok')) { + throw new Error('Failed to unlock device'); + } + + // Step 2: Get current WiFi status + const statusResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_STATUS); + + // Parse response: "mac,XXXXXX|a|SSID,RSSI" or "mac,XXXXXX|a|,0" (not connected) + const parts = statusResponse.split('|'); + if (parts.length < 3) { + return null; + } + + const [ssid, rssiStr] = parts[2].split(','); + if (!ssid || ssid.trim() === '') { + return null; // Not connected + } + + return { + ssid: ssid.trim(), + rssi: parseInt(rssiStr, 10), + connected: true, + }; + } + + async rebootDevice(deviceId: string): Promise { + // Step 1: Unlock device + await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); + + // Step 2: Reboot (device will disconnect) + await this.sendCommand(deviceId, BLE_COMMANDS.REBOOT); + + // Remove from connected devices + this.connectedDevices.delete(deviceId); + } +} diff --git a/services/ble/MockBLEManager.ts b/services/ble/MockBLEManager.ts new file mode 100644 index 0000000..3fd29e1 --- /dev/null +++ b/services/ble/MockBLEManager.ts @@ -0,0 +1,112 @@ +// Mock BLE Manager для iOS Simulator (Bluetooth недоступен) + +import { IBLEManager, WPDevice, WiFiNetwork, WiFiStatus } from './types'; + +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export class MockBLEManager implements IBLEManager { + private connectedDevices = new Set(); + private mockDevices: WPDevice[] = [ + { + id: 'mock-743', + name: 'WP_497_81a14c', + mac: '142B2F81A14C', + rssi: -55, + wellId: 497, + }, + { + id: 'mock-769', + name: 'WP_523_81aad4', + mac: '142B2F81AAD4', + rssi: -67, + wellId: 523, + }, + ]; + + async scanDevices(): Promise { + console.log('[MockBLE] Scanning for devices...'); + await delay(2000); // Simulate scan delay + return this.mockDevices; + } + + stopScan(): void { + console.log('[MockBLE] Scan stopped'); + } + + async connectDevice(deviceId: string): Promise { + console.log(`[MockBLE] Connecting to ${deviceId}...`); + await delay(1000); + this.connectedDevices.add(deviceId); + return true; + } + + async disconnectDevice(deviceId: string): Promise { + console.log(`[MockBLE] Disconnecting ${deviceId}`); + await delay(500); + this.connectedDevices.delete(deviceId); + } + + isDeviceConnected(deviceId: string): boolean { + return this.connectedDevices.has(deviceId); + } + + async sendCommand(deviceId: string, command: string): Promise { + console.log(`[MockBLE] Sending command: ${command}`); + await delay(500); + + // Simulate responses + if (command === 'pin|7856') { + return 'pin|ok'; + } + if (command === 'w') { + return 'mac,142b2f81a14c|w|3|FrontierTower,-55|HomeNetwork,-67|TP-Link_5G,-75'; + } + if (command === 'a') { + return 'mac,142b2f81a14c|a|FrontierTower,-67'; + } + if (command.startsWith('W|')) { + return 'mac,142b2f81a14c|W|ok'; + } + + return 'ok'; + } + + async getWiFiList(deviceId: string): Promise { + console.log(`[MockBLE] Getting WiFi list for ${deviceId}`); + await delay(1500); + + return [ + { ssid: 'FrontierTower', rssi: -55 }, + { ssid: 'HomeNetwork', rssi: -67 }, + { ssid: 'TP-Link_5G', rssi: -75 }, + { ssid: 'Office-WiFi', rssi: -80 }, + ]; + } + + async setWiFi( + deviceId: string, + ssid: string, + password: string + ): Promise { + console.log(`[MockBLE] Setting WiFi: ${ssid}`); + await delay(2000); + return true; + } + + async getCurrentWiFi(deviceId: string): Promise { + console.log(`[MockBLE] Getting current WiFi for ${deviceId}`); + await delay(1000); + + return { + ssid: 'FrontierTower', + rssi: -67, + connected: true, + }; + } + + async rebootDevice(deviceId: string): Promise { + console.log(`[MockBLE] Rebooting ${deviceId}`); + await delay(500); + this.connectedDevices.delete(deviceId); + } +} diff --git a/services/ble/index.ts b/services/ble/index.ts new file mode 100644 index 0000000..da8794c --- /dev/null +++ b/services/ble/index.ts @@ -0,0 +1,17 @@ +// BLE Service entry point + +import * as Device from 'expo-device'; +import { RealBLEManager } from './BLEManager'; +import { MockBLEManager } from './MockBLEManager'; +import { IBLEManager } from './types'; + +// Determine if BLE is available (real device vs simulator) +export const isBLEAvailable = Device.isDevice; + +// Export singleton instance +export const bleManager: IBLEManager = isBLEAvailable + ? new RealBLEManager() + : new MockBLEManager(); + +// Re-export types +export * from './types'; diff --git a/services/ble/types.ts b/services/ble/types.ts new file mode 100644 index 0000000..4a2b682 --- /dev/null +++ b/services/ble/types.ts @@ -0,0 +1,60 @@ +// BLE Service Types + +export interface WPDevice { + id: string; // BLE device ID + name: string; // "WP_497_81a14c" + mac: string; // "142B2F81A14C" + rssi: number; // Signal strength in dBm (-55, -67, etc.) + wellId?: number; // Parsed from name (497, 523) +} + +export interface WiFiNetwork { + ssid: string; + rssi: number; // Signal strength in dBm +} + +export interface WiFiStatus { + ssid: string; + rssi: number; + connected: boolean; +} + +export interface BLECommand { + PIN_UNLOCK: 'pin|7856'; + GET_WIFI_LIST: 'w'; + SET_WIFI: 'W'; // Format: W|SSID,PASSWORD + GET_WIFI_STATUS: 'a'; + REBOOT: 's'; + DISCONNECT: 'D'; +} + +export const BLE_COMMANDS: BLECommand = { + PIN_UNLOCK: 'pin|7856', + GET_WIFI_LIST: 'w', + SET_WIFI: 'W', + GET_WIFI_STATUS: 'a', + REBOOT: 's', + DISCONNECT: 'D', +}; + +export const BLE_CONFIG = { + SERVICE_UUID: '4fafc201-1fb5-459e-8fcc-c5c9c331914b', + CHAR_UUID: 'beb5483e-36e1-4688-b7f5-ea07361b26a8', + SCAN_TIMEOUT: 10000, // 10 seconds + COMMAND_TIMEOUT: 5000, // 5 seconds + DEVICE_NAME_PREFIX: 'WP_', +}; + +// Interface для BLE Manager (и real и mock) +export interface IBLEManager { + scanDevices(): Promise; + stopScan(): void; + connectDevice(deviceId: string): Promise; + disconnectDevice(deviceId: string): Promise; + isDeviceConnected(deviceId: string): boolean; + sendCommand(deviceId: string, command: string): Promise; + getWiFiList(deviceId: string): Promise; + setWiFi(deviceId: string, ssid: string, password: string): Promise; + getCurrentWiFi(deviceId: string): Promise; + rebootDevice(deviceId: string): Promise; +}