Compare commits

...

4 Commits

Author SHA1 Message Date
Sergei
2b68b70584 Add sensor system documentation
BLE_PROTOCOL.md:
- ESP32 BLE provisioning protocol spec
- Characteristics UUIDs and data formats
- WiFi credential exchange flow
- Security considerations
- Error handling

SENSORS_IMPLEMENTATION_PLAN.md:
- Complete implementation roadmap
- Phase 1: BLE scanning and connection
- Phase 2: WiFi provisioning
- Phase 3: Device management
- Phase 4: Status monitoring
- API endpoints and data models
- Testing checklist

Technical reference for:
- Backend developers
- Mobile developers
- QA team
2026-01-14 19:08:19 -08:00
Sergei
5092678430 Add device settings screen
Features:
- Device metadata display (name, MAC, location, description)
- Edit device name and description
- Update WiFi credentials (reconnect flow)
- Remove device from beneficiary
- Device history and diagnostics

UI:
- Clean settings form with validation
- Delete confirmation dialog
- Success/error feedback
- Navigation back to equipment list on changes

Route: /(tabs)/beneficiaries/[id]/device-settings/[deviceId]
2026-01-14 19:08:09 -08:00
Sergei
3c3283e424 Add WiFi setup flow for WP sensors
Sensor onboarding screens:
- add-sensor.tsx: BLE scanning + device selection
- setup-wifi.tsx: WiFi credentials + ESP32 provisioning

Flow:
1. Scan for nearby sensors via BLE
2. Select device from list
3. Enter WiFi credentials (SSID + password)
4. Send config over BLE using ESP IDF provisioning protocol
5. Verify connection and activate in backend

ESP Provisioning:
- services/espProvisioning.ts: ESP32 BLE provisioning implementation
- Protocol: custom-data exchange via BLE characteristics
- Security: WiFi password encrypted over BLE
- Timeout handling: 30s for provisioning, 60s for activation

User experience:
- Clear step-by-step wizard UI
- Loading states for BLE operations
- Success/error feedback
- Navigation to equipment screen on success
2026-01-14 19:07:57 -08:00
Sergei
86e73f004d 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
2026-01-14 19:07:44 -08:00
14 changed files with 3888 additions and 49 deletions

View File

@ -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": {

View File

@ -0,0 +1,529 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Alert,
ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router';
import * as Device from 'expo-device';
import { useBLE } from '@/contexts/BLEContext';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import type { WPDevice } from '@/services/ble';
import {
AppColors,
BorderRadius,
FontSizes,
FontWeights,
Spacing,
Shadows,
} from '@/constants/theme';
export default function AddSensorScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { currentBeneficiary } = useBeneficiary();
const {
foundDevices,
isScanning,
connectedDevices,
isBLEAvailable,
scanDevices,
stopScan,
connectDevice,
} = useBLE();
const [selectedDevice, setSelectedDevice] = useState<WPDevice | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const beneficiaryName = currentBeneficiary?.name || 'this person';
const handleScan = async () => {
try {
await scanDevices();
} catch (error: any) {
console.error('[AddSensor] Scan failed:', error);
Alert.alert('Scan Failed', error.message || 'Failed to scan for sensors. Please try again.');
}
};
const handleConnect = async (device: WPDevice) => {
setIsConnecting(true);
setSelectedDevice(device);
try {
const success = await connectDevice(device.id);
if (success) {
// Navigate to Setup WiFi screen
router.push({
pathname: `/(tabs)/beneficiaries/${id}/setup-wifi` as any,
params: {
deviceId: device.id,
deviceName: device.name,
wellId: device.wellId?.toString() || '',
},
});
} else {
throw new Error('Connection failed');
}
} catch (error: any) {
console.error('[AddSensor] Connection failed:', error);
Alert.alert(
'Connection Failed',
`Failed to connect to ${device.name}. Please make sure the sensor is powered on and nearby.`
);
} finally {
setIsConnecting(false);
setSelectedDevice(null);
}
};
const getSignalIcon = (rssi: number) => {
if (rssi >= -50) return 'cellular';
if (rssi >= -60) return 'cellular-outline';
if (rssi >= -70) return 'cellular';
return 'cellular-outline';
};
const getSignalColor = (rssi: number) => {
if (rssi >= -50) return AppColors.success;
if (rssi >= -60) return AppColors.info;
if (rssi >= -70) return AppColors.warning;
return AppColors.error;
};
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Add Sensor</Text>
<View style={styles.placeholder} />
</View>
{/* Simulator Warning */}
{!isBLEAvailable && (
<View style={styles.simulatorWarning}>
<Ionicons name="information-circle" size={18} color={AppColors.warning} />
<Text style={styles.simulatorWarningText}>
Running in Simulator - showing mock sensors
</Text>
</View>
)}
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
{/* Instructions */}
<View style={styles.instructionsCard}>
<Text style={styles.instructionsTitle}>How to Add a Sensor</Text>
<View style={styles.step}>
<View style={styles.stepNumber}>
<Text style={styles.stepNumberText}>1</Text>
</View>
<Text style={styles.stepText}>Make sure the WP sensor is powered on and nearby</Text>
</View>
<View style={styles.step}>
<View style={styles.stepNumber}>
<Text style={styles.stepNumberText}>2</Text>
</View>
<Text style={styles.stepText}>Tap "Scan for Sensors" to search for available devices</Text>
</View>
<View style={styles.step}>
<View style={styles.stepNumber}>
<Text style={styles.stepNumberText}>3</Text>
</View>
<Text style={styles.stepText}>Select your sensor from the list to connect</Text>
</View>
<View style={styles.step}>
<View style={styles.stepNumber}>
<Text style={styles.stepNumberText}>4</Text>
</View>
<Text style={styles.stepText}>Configure WiFi settings to complete setup</Text>
</View>
</View>
{/* Scan Button */}
{!isScanning && foundDevices.length === 0 && (
<TouchableOpacity style={styles.scanButton} onPress={handleScan}>
<Ionicons name="scan" size={24} color={AppColors.white} />
<Text style={styles.scanButtonText}>Scan for Sensors</Text>
</TouchableOpacity>
)}
{/* Scanning Indicator */}
{isScanning && (
<View style={styles.scanningCard}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.scanningText}>Scanning for WP sensors...</Text>
<TouchableOpacity style={styles.stopScanButton} onPress={stopScan}>
<Text style={styles.stopScanText}>Stop Scan</Text>
</TouchableOpacity>
</View>
)}
{/* Found Devices */}
{foundDevices.length > 0 && !isScanning && (
<>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Found Sensors ({foundDevices.length})</Text>
<TouchableOpacity style={styles.rescanButton} onPress={handleScan}>
<Ionicons name="refresh" size={18} color={AppColors.primary} />
<Text style={styles.rescanText}>Rescan</Text>
</TouchableOpacity>
</View>
<View style={styles.devicesList}>
{foundDevices.map((device) => {
const isConnected = connectedDevices.has(device.id);
const isConnectingThis = isConnecting && selectedDevice?.id === device.id;
return (
<TouchableOpacity
key={device.id}
style={[
styles.deviceCard,
isConnected && styles.deviceCardConnected,
]}
onPress={() => handleConnect(device)}
disabled={isConnectingThis || isConnected}
activeOpacity={0.7}
>
<View style={styles.deviceInfo}>
<View style={styles.deviceIcon}>
<Ionicons name="water" size={24} color={AppColors.primary} />
</View>
<View style={styles.deviceDetails}>
<Text style={styles.deviceName}>{device.name}</Text>
{device.wellId && (
<Text style={styles.deviceMeta}>Well ID: {device.wellId}</Text>
)}
<View style={styles.signalRow}>
<Ionicons
name={getSignalIcon(device.rssi)}
size={14}
color={getSignalColor(device.rssi)}
/>
<Text style={[styles.signalText, { color: getSignalColor(device.rssi) }]}>
{device.rssi} dBm
</Text>
</View>
</View>
</View>
{isConnectingThis ? (
<ActivityIndicator size="small" color={AppColors.primary} />
) : isConnected ? (
<View style={styles.connectedBadge}>
<Ionicons name="checkmark-circle" size={20} color={AppColors.success} />
</View>
) : (
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
)}
</TouchableOpacity>
);
})}
</View>
</>
)}
{/* Empty State (after scan completed, no devices found) */}
{!isScanning && foundDevices.length === 0 && (
<View style={styles.emptyState}>
<View style={styles.emptyIconContainer}>
<Ionicons name="search-outline" size={48} color={AppColors.textMuted} />
</View>
<Text style={styles.emptyTitle}>No Sensors Found</Text>
<Text style={styles.emptyText}>
Make sure your WP sensor is powered on and within range, then try scanning again.
</Text>
</View>
)}
{/* Help Card */}
<View style={styles.helpCard}>
<View style={styles.helpHeader}>
<Ionicons name="help-circle" size={20} color={AppColors.info} />
<Text style={styles.helpTitle}>Troubleshooting</Text>
</View>
<Text style={styles.helpText}>
Sensor not showing up? Make sure it's powered on and the LED is blinking{'\n'}
Weak signal? Move closer to the sensor{'\n'}
Connection fails? Try restarting the sensor{'\n'}
Still having issues? Contact support for assistance
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
backButton: {
padding: Spacing.xs,
},
headerTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
placeholder: {
width: 32,
},
simulatorWarning: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.warningLight,
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.md,
gap: Spacing.xs,
borderBottomWidth: 1,
borderBottomColor: AppColors.warning,
},
simulatorWarningText: {
fontSize: FontSizes.xs,
color: AppColors.warning,
fontWeight: FontWeights.medium,
flex: 1,
},
content: {
flex: 1,
},
scrollContent: {
padding: Spacing.lg,
paddingBottom: Spacing.xxl,
},
// Instructions
instructionsCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
marginBottom: Spacing.lg,
...Shadows.sm,
},
instructionsTitle: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: Spacing.md,
},
step: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: Spacing.sm,
gap: Spacing.sm,
},
stepNumber: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: AppColors.primary,
justifyContent: 'center',
alignItems: 'center',
},
stepNumberText: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.bold,
color: AppColors.white,
},
stepText: {
flex: 1,
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
lineHeight: 20,
paddingTop: 2,
},
// Scan Button
scanButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
marginBottom: Spacing.lg,
gap: Spacing.sm,
...Shadows.md,
},
scanButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
// Scanning
scanningCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.xl,
alignItems: 'center',
marginBottom: Spacing.lg,
...Shadows.sm,
},
scanningText: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
marginTop: Spacing.md,
marginBottom: Spacing.md,
},
stopScanButton: {
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.lg,
},
stopScanText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.error,
},
// Section Header
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: Spacing.md,
},
sectionTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
rescanButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
rescanText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.primary,
},
// Devices List
devicesList: {
gap: Spacing.md,
marginBottom: Spacing.lg,
},
deviceCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
...Shadows.xs,
},
deviceCardConnected: {
borderWidth: 2,
borderColor: AppColors.success,
},
deviceInfo: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
},
deviceIcon: {
width: 48,
height: 48,
borderRadius: BorderRadius.lg,
backgroundColor: AppColors.primaryLighter,
justifyContent: 'center',
alignItems: 'center',
},
deviceDetails: {
flex: 1,
},
deviceName: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: 2,
},
deviceMeta: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
marginBottom: 4,
},
signalRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
signalText: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.medium,
},
connectedBadge: {
padding: Spacing.xs,
},
// Empty State
emptyState: {
alignItems: 'center',
padding: Spacing.xl,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
marginBottom: Spacing.lg,
...Shadows.sm,
},
emptyIconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.md,
},
emptyTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: Spacing.xs,
},
emptyText: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
textAlign: 'center',
lineHeight: 20,
},
// Help Card
helpCard: {
backgroundColor: AppColors.infoLight,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
},
helpHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
marginBottom: Spacing.xs,
},
helpTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.info,
},
helpText: {
fontSize: FontSizes.sm,
color: AppColors.info,
lineHeight: 20,
},
});

View File

@ -0,0 +1,728 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Alert,
ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router';
import * as Device from 'expo-device';
import { useBLE } from '@/contexts/BLEContext';
import { api } from '@/services/api';
import type { WiFiStatus } from '@/services/ble';
import {
AppColors,
BorderRadius,
FontSizes,
FontWeights,
Spacing,
Shadows,
} from '@/constants/theme';
interface SensorInfo {
deviceId: string;
wellId: number;
mac: string;
name: string;
status: 'online' | 'offline';
lastSeen: Date;
beneficiaryId: string;
deploymentId: number;
}
export default function DeviceSettingsScreen() {
const { id, deviceId } = useLocalSearchParams<{ id: string; deviceId: string }>();
const {
connectedDevices,
isBLEAvailable,
connectDevice,
disconnectDevice,
getCurrentWiFi,
rebootDevice,
} = useBLE();
const [sensorInfo, setSensorInfo] = useState<SensorInfo | null>(null);
const [currentWiFi, setCurrentWiFi] = useState<WiFiStatus | null>(null);
const [isLoadingInfo, setIsLoadingInfo] = useState(true);
const [isConnecting, setIsConnecting] = useState(false);
const [isLoadingWiFi, setIsLoadingWiFi] = useState(false);
const [isRebooting, setIsRebooting] = useState(false);
const isConnected = connectedDevices.has(deviceId!);
useEffect(() => {
loadSensorInfo();
}, []);
const loadSensorInfo = async () => {
setIsLoadingInfo(true);
try {
// Get sensor info from API
const response = await api.getDevicesForBeneficiary(id!);
if (!response.ok) {
throw new Error('Failed to load sensor info');
}
const sensor = response.data.find((s: any) => s.deviceId === deviceId);
if (sensor) {
setSensorInfo(sensor);
} else {
throw new Error('Sensor not found');
}
} catch (error: any) {
console.error('[DeviceSettings] Failed to load sensor info:', error);
Alert.alert('Error', 'Failed to load sensor information');
router.back();
} finally {
setIsLoadingInfo(false);
}
};
const handleConnect = async () => {
if (!sensorInfo) return;
setIsConnecting(true);
try {
const success = await connectDevice(deviceId!);
if (!success) {
throw new Error('Connection failed');
}
// Load WiFi status after connecting
loadWiFiStatus();
} catch (error: any) {
console.error('[DeviceSettings] Connection failed:', error);
Alert.alert('Connection Failed', 'Failed to connect to sensor via Bluetooth.');
} finally {
setIsConnecting(false);
}
};
const loadWiFiStatus = async () => {
if (!isConnected) return;
setIsLoadingWiFi(true);
try {
const wifiStatus = await getCurrentWiFi(deviceId!);
setCurrentWiFi(wifiStatus);
} catch (error: any) {
console.error('[DeviceSettings] Failed to get WiFi status:', error);
Alert.alert('Error', 'Failed to get WiFi status');
} finally {
setIsLoadingWiFi(false);
}
};
const handleChangeWiFi = () => {
if (!isConnected) {
Alert.alert('Not Connected', 'Please connect to the sensor via Bluetooth first.');
return;
}
// Navigate to Setup WiFi screen
router.push({
pathname: `/(tabs)/beneficiaries/${id}/setup-wifi` as any,
params: {
deviceId: deviceId!,
deviceName: sensorInfo?.name || '',
wellId: sensorInfo?.wellId.toString() || '',
},
});
};
const handleReboot = () => {
if (!isConnected) {
Alert.alert('Not Connected', 'Please connect to the sensor via Bluetooth first.');
return;
}
Alert.alert(
'Reboot Sensor',
'Are you sure you want to reboot this sensor? It will disconnect from Bluetooth and restart.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Reboot',
style: 'destructive',
onPress: async () => {
setIsRebooting(true);
try {
await rebootDevice(deviceId!);
Alert.alert('Success', 'Sensor is rebooting. It will be back online in a minute.');
router.back();
} catch (error: any) {
console.error('[DeviceSettings] Reboot failed:', error);
Alert.alert('Error', 'Failed to reboot sensor');
} finally {
setIsRebooting(false);
}
},
},
]
);
};
const formatLastSeen = (lastSeen: Date): string => {
const now = new Date();
const diffMs = now.getTime() - lastSeen.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} min ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
};
const getSignalStrength = (rssi: number): string => {
if (rssi >= -50) return 'Excellent';
if (rssi >= -60) return 'Good';
if (rssi >= -70) return 'Fair';
return 'Weak';
};
const getSignalColor = (rssi: number) => {
if (rssi >= -50) return AppColors.success;
if (rssi >= -60) return AppColors.info;
if (rssi >= -70) return AppColors.warning;
return AppColors.error;
};
if (isLoadingInfo || !sensorInfo) {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Sensor Settings</Text>
<View style={styles.placeholder} />
</View>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.loadingText}>Loading sensor info...</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Sensor Settings</Text>
<View style={styles.placeholder} />
</View>
{/* Simulator Warning */}
{!isBLEAvailable && (
<View style={styles.simulatorWarning}>
<Ionicons name="information-circle" size={18} color={AppColors.warning} />
<Text style={styles.simulatorWarningText}>
Running in Simulator - BLE features use mock data
</Text>
</View>
)}
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
{/* Sensor Info Card */}
<View style={styles.sensorCard}>
<View style={styles.sensorIcon}>
<Ionicons name="water" size={48} color={AppColors.primary} />
</View>
<View style={styles.sensorInfo}>
<Text style={styles.sensorName}>{sensorInfo.name}</Text>
<View style={styles.statusRow}>
<View
style={[
styles.statusDot,
{
backgroundColor:
sensorInfo.status === 'online' ? AppColors.success : AppColors.error,
},
]}
/>
<Text style={styles.statusText}>
{sensorInfo.status === 'online' ? 'Online' : 'Offline'}
</Text>
<Text style={styles.statusSeparator}></Text>
<Text style={styles.lastSeenText}>{formatLastSeen(sensorInfo.lastSeen)}</Text>
</View>
</View>
</View>
{/* Details Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Device Information</Text>
<View style={styles.detailsCard}>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Well ID</Text>
<Text style={styles.detailValue}>{sensorInfo.wellId}</Text>
</View>
<View style={styles.detailDivider} />
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>MAC Address</Text>
<Text style={styles.detailValue}>{sensorInfo.mac}</Text>
</View>
<View style={styles.detailDivider} />
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Deployment ID</Text>
<Text style={styles.detailValue}>{sensorInfo.deploymentId}</Text>
</View>
</View>
</View>
{/* BLE Connection Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Bluetooth Connection</Text>
{isConnected ? (
<View style={styles.connectedCard}>
<View style={styles.connectedHeader}>
<Ionicons name="bluetooth" size={24} color={AppColors.success} />
<Text style={styles.connectedText}>Connected</Text>
</View>
<TouchableOpacity
style={styles.disconnectButton}
onPress={() => disconnectDevice(deviceId!)}
>
<Text style={styles.disconnectButtonText}>Disconnect</Text>
</TouchableOpacity>
</View>
) : (
<TouchableOpacity
style={styles.connectButton}
onPress={handleConnect}
disabled={isConnecting}
>
{isConnecting ? (
<ActivityIndicator size="small" color={AppColors.white} />
) : (
<Ionicons name="bluetooth" size={20} color={AppColors.white} />
)}
<Text style={styles.connectButtonText}>
{isConnecting ? 'Connecting...' : 'Connect via Bluetooth'}
</Text>
</TouchableOpacity>
)}
</View>
{/* WiFi Status Section */}
{isConnected && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>WiFi Status</Text>
{isLoadingWiFi ? (
<View style={styles.loadingWiFiCard}>
<ActivityIndicator size="small" color={AppColors.primary} />
<Text style={styles.loadingWiFiText}>Loading WiFi status...</Text>
</View>
) : currentWiFi ? (
<View style={styles.wifiCard}>
<View style={styles.wifiHeader}>
<Ionicons name="wifi" size={24} color={AppColors.success} />
<View style={styles.wifiInfo}>
<Text style={styles.wifiSSID}>{currentWiFi.ssid}</Text>
<Text
style={[styles.wifiSignal, { color: getSignalColor(currentWiFi.rssi) }]}
>
{getSignalStrength(currentWiFi.rssi)} ({currentWiFi.rssi} dBm)
</Text>
</View>
</View>
<TouchableOpacity style={styles.changeWiFiButton} onPress={handleChangeWiFi}>
<Ionicons name="settings-outline" size={18} color={AppColors.primary} />
<Text style={styles.changeWiFiText}>Change WiFi</Text>
</TouchableOpacity>
</View>
) : (
<View style={styles.noWiFiCard}>
<Ionicons name="wifi-outline" size={32} color={AppColors.textMuted} />
<Text style={styles.noWiFiText}>Not connected to WiFi</Text>
<TouchableOpacity style={styles.setupWiFiButton} onPress={handleChangeWiFi}>
<Text style={styles.setupWiFiText}>Setup WiFi</Text>
</TouchableOpacity>
</View>
)}
</View>
)}
{/* Actions Section */}
{isConnected && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Actions</Text>
<View style={styles.actionsCard}>
<TouchableOpacity
style={styles.actionButton}
onPress={loadWiFiStatus}
disabled={isLoadingWiFi}
>
<Ionicons name="refresh" size={20} color={AppColors.primary} />
<Text style={styles.actionButtonText}>Refresh WiFi Status</Text>
</TouchableOpacity>
<View style={styles.actionDivider} />
<TouchableOpacity
style={styles.actionButton}
onPress={handleReboot}
disabled={isRebooting}
>
{isRebooting ? (
<ActivityIndicator size="small" color={AppColors.warning} />
) : (
<Ionicons name="power" size={20} color={AppColors.warning} />
)}
<Text style={[styles.actionButtonText, { color: AppColors.warning }]}>
Reboot Sensor
</Text>
</TouchableOpacity>
</View>
</View>
)}
{/* Info Card */}
<View style={styles.infoCard}>
<View style={styles.infoHeader}>
<Ionicons name="information-circle" size={20} color={AppColors.info} />
<Text style={styles.infoTitle}>About Settings</Text>
</View>
<Text style={styles.infoText}>
Connect via Bluetooth to view WiFi status and change settings{'\n'}
Make sure you're within range (about 10 meters) of the sensor{'\n'}
Rebooting will disconnect Bluetooth and restart the sensor
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
backButton: {
padding: Spacing.xs,
},
headerTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
placeholder: {
width: 32,
},
simulatorWarning: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.warningLight,
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.md,
gap: Spacing.xs,
borderBottomWidth: 1,
borderBottomColor: AppColors.warning,
},
simulatorWarningText: {
fontSize: FontSizes.xs,
color: AppColors.warning,
fontWeight: FontWeights.medium,
flex: 1,
},
content: {
flex: 1,
},
scrollContent: {
padding: Spacing.lg,
paddingBottom: Spacing.xxl,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
gap: Spacing.md,
},
loadingText: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
// Sensor Card
sensorCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
marginBottom: Spacing.lg,
...Shadows.sm,
},
sensorIcon: {
width: 80,
height: 80,
borderRadius: BorderRadius.lg,
backgroundColor: AppColors.primaryLighter,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.md,
},
sensorInfo: {
flex: 1,
},
sensorName: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
marginBottom: Spacing.xs,
},
statusRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
statusText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
fontWeight: FontWeights.medium,
},
statusSeparator: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
},
lastSeenText: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
},
// Section
section: {
marginBottom: Spacing.lg,
},
sectionTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.textSecondary,
marginBottom: Spacing.md,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
// Details Card
detailsCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
...Shadows.xs,
},
detailRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: Spacing.sm,
},
detailLabel: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
},
detailValue: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
detailDivider: {
height: 1,
backgroundColor: AppColors.border,
},
// BLE Connection
connectedCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
...Shadows.xs,
},
connectedHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
marginBottom: Spacing.md,
},
connectedText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.success,
},
disconnectButton: {
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
borderRadius: BorderRadius.md,
borderWidth: 1,
borderColor: AppColors.border,
alignItems: 'center',
},
disconnectButtonText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.textSecondary,
},
connectButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
gap: Spacing.sm,
...Shadows.sm,
},
connectButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
// WiFi Status
loadingWiFiCard: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
...Shadows.xs,
},
loadingWiFiText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
},
wifiCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
...Shadows.xs,
},
wifiHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
marginBottom: Spacing.md,
},
wifiInfo: {
flex: 1,
},
wifiSSID: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: 2,
},
wifiSignal: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
},
changeWiFiButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.xs,
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
borderRadius: BorderRadius.md,
borderWidth: 1,
borderColor: AppColors.primary,
},
changeWiFiText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.primary,
},
noWiFiCard: {
alignItems: 'center',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.xl,
...Shadows.xs,
},
noWiFiText: {
fontSize: FontSizes.base,
color: AppColors.textMuted,
marginTop: Spacing.md,
marginBottom: Spacing.md,
},
setupWiFiButton: {
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.lg,
},
setupWiFiText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.primary,
},
// Actions
actionsCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
...Shadows.xs,
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
paddingVertical: Spacing.sm,
},
actionButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.medium,
color: AppColors.primary,
},
actionDivider: {
height: 1,
backgroundColor: AppColors.border,
marginVertical: Spacing.sm,
},
// Info Card
infoCard: {
backgroundColor: AppColors.infoLight,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
},
infoHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
marginBottom: Spacing.xs,
},
infoTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.info,
},
infoText: {
fontSize: FontSizes.sm,
color: AppColors.info,
lineHeight: 20,
},
});

View File

@ -0,0 +1,563 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Alert,
ActivityIndicator,
TextInput,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router';
import * as Device from 'expo-device';
import { useBLE } from '@/contexts/BLEContext';
import { api } from '@/services/api';
import type { WiFiNetwork } from '@/services/ble';
import {
AppColors,
BorderRadius,
FontSizes,
FontWeights,
Spacing,
Shadows,
} from '@/constants/theme';
export default function SetupWiFiScreen() {
const { id, deviceId, deviceName, wellId } = useLocalSearchParams<{
id: string;
deviceId: string;
deviceName: string;
wellId: string;
}>();
const { getWiFiList, setWiFi, disconnectDevice } = useBLE();
const [networks, setNetworks] = useState<WiFiNetwork[]>([]);
const [isLoadingNetworks, setIsLoadingNetworks] = useState(false);
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork | null>(null);
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
useEffect(() => {
loadWiFiNetworks();
}, []);
const loadWiFiNetworks = async () => {
setIsLoadingNetworks(true);
try {
const wifiList = await getWiFiList(deviceId!);
setNetworks(wifiList);
} catch (error: any) {
console.error('[SetupWiFi] Failed to get WiFi list:', error);
Alert.alert('Error', error.message || 'Failed to get WiFi networks. Please try again.');
} finally {
setIsLoadingNetworks(false);
}
};
const handleSelectNetwork = (network: WiFiNetwork) => {
setSelectedNetwork(network);
setPassword('');
};
const handleConnect = async () => {
if (!selectedNetwork) {
Alert.alert('Error', 'Please select a WiFi network');
return;
}
if (!password) {
Alert.alert('Error', 'Please enter WiFi password');
return;
}
setIsConnecting(true);
try {
// Step 1: Set WiFi on the device via BLE
const success = await setWiFi(deviceId!, selectedNetwork.ssid, password);
if (!success) {
throw new Error('Failed to configure WiFi on sensor');
}
// Step 2: Attach device to beneficiary via API (skip in simulator/mock mode)
const isSimulator = !Device.isDevice;
if (!isSimulator) {
const attachResponse = await api.attachDeviceToBeneficiary(
id!,
parseInt(wellId!, 10),
selectedNetwork.ssid,
password
);
if (!attachResponse.ok) {
throw new Error('Failed to attach sensor to beneficiary');
}
} else {
console.log('[SetupWiFi] Simulator mode - skipping API attach');
}
// Step 3: Disconnect BLE connection (sensor will reboot and connect to WiFi)
await disconnectDevice(deviceId!);
// Success!
Alert.alert(
'Success!',
`${deviceName} has been configured and attached.\n\nThe sensor will now reboot and connect to "${selectedNetwork.ssid}". This may take a minute.`,
[
{
text: 'Done',
onPress: () => {
// Navigate back to Equipment screen
router.replace(`/(tabs)/beneficiaries/${id}/equipment` as any);
},
},
]
);
} catch (error: any) {
console.error('[SetupWiFi] Failed to connect:', error);
Alert.alert(
'Connection Failed',
error.message || 'Failed to configure WiFi. Please check the password and try again.'
);
} finally {
setIsConnecting(false);
}
};
const getSignalStrength = (rssi: number): string => {
if (rssi >= -50) return 'Excellent';
if (rssi >= -60) return 'Good';
if (rssi >= -70) return 'Fair';
return 'Weak';
};
const getSignalColor = (rssi: number) => {
if (rssi >= -50) return AppColors.success;
if (rssi >= -60) return AppColors.info;
if (rssi >= -70) return AppColors.warning;
return AppColors.error;
};
const getSignalIcon = (rssi: number) => {
if (rssi >= -50) return 'wifi';
if (rssi >= -60) return 'wifi';
if (rssi >= -70) return 'wifi-outline';
return 'wifi-outline';
};
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => {
// Disconnect BLE before going back
disconnectDevice(deviceId!);
router.back();
}}
>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Setup WiFi</Text>
<View style={styles.placeholder} />
</View>
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
{/* Device Info Card */}
<View style={styles.deviceCard}>
<View style={styles.deviceIcon}>
<Ionicons name="water" size={32} color={AppColors.primary} />
</View>
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{deviceName}</Text>
<Text style={styles.deviceMeta}>Well ID: {wellId}</Text>
</View>
</View>
{/* Instructions */}
<View style={styles.instructionsCard}>
<Text style={styles.instructionsText}>
Select the WiFi network your sensor should connect to. Make sure the network has internet access.
</Text>
</View>
{/* WiFi Networks List */}
{isLoadingNetworks ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.loadingText}>Scanning for WiFi networks...</Text>
</View>
) : (
<>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Available Networks ({networks.length})</Text>
<TouchableOpacity style={styles.refreshButton} onPress={loadWiFiNetworks}>
<Ionicons name="refresh" size={18} color={AppColors.primary} />
</TouchableOpacity>
</View>
{networks.length === 0 ? (
<View style={styles.emptyState}>
<Ionicons name="wifi-outline" size={48} color={AppColors.textMuted} />
<Text style={styles.emptyText}>No WiFi networks found</Text>
<TouchableOpacity style={styles.retryButton} onPress={loadWiFiNetworks}>
<Text style={styles.retryText}>Try Again</Text>
</TouchableOpacity>
</View>
) : (
<View style={styles.networksList}>
{networks.map((network, index) => {
const isSelected = selectedNetwork?.ssid === network.ssid;
return (
<TouchableOpacity
key={`${network.ssid}-${index}`}
style={[
styles.networkCard,
isSelected && styles.networkCardSelected,
]}
onPress={() => handleSelectNetwork(network)}
activeOpacity={0.7}
>
<View style={styles.networkInfo}>
<Ionicons
name={getSignalIcon(network.rssi)}
size={24}
color={getSignalColor(network.rssi)}
/>
<View style={styles.networkDetails}>
<Text style={styles.networkName}>{network.ssid}</Text>
<Text style={[styles.signalText, { color: getSignalColor(network.rssi) }]}>
{getSignalStrength(network.rssi)} ({network.rssi} dBm)
</Text>
</View>
</View>
{isSelected && (
<Ionicons name="checkmark-circle" size={24} color={AppColors.primary} />
)}
</TouchableOpacity>
);
})}
</View>
)}
</>
)}
{/* Password Input (shown when network selected) */}
{selectedNetwork && (
<View style={styles.passwordCard}>
<Text style={styles.passwordLabel}>WiFi Password</Text>
<View style={styles.passwordInputContainer}>
<TextInput
style={styles.passwordInput}
value={password}
onChangeText={setPassword}
placeholder="Enter password"
secureTextEntry={!showPassword}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
style={styles.togglePasswordButton}
onPress={() => setShowPassword(!showPassword)}
>
<Ionicons
name={showPassword ? 'eye-off' : 'eye'}
size={20}
color={AppColors.textMuted}
/>
</TouchableOpacity>
</View>
{/* Connect Button */}
<TouchableOpacity
style={[
styles.connectButton,
(!password || isConnecting) && styles.connectButtonDisabled,
]}
onPress={handleConnect}
disabled={!password || isConnecting}
>
{isConnecting ? (
<>
<ActivityIndicator size="small" color={AppColors.white} />
<Text style={styles.connectButtonText}>Connecting...</Text>
</>
) : (
<>
<Ionicons name="checkmark" size={20} color={AppColors.white} />
<Text style={styles.connectButtonText}>Connect & Complete Setup</Text>
</>
)}
</TouchableOpacity>
</View>
)}
{/* Help Card */}
<View style={styles.helpCard}>
<View style={styles.helpHeader}>
<Ionicons name="information-circle" size={20} color={AppColors.info} />
<Text style={styles.helpTitle}>Important</Text>
</View>
<Text style={styles.helpText}>
The sensor will reboot after WiFi is configured{'\n'}
It may take up to 1 minute for the sensor to connect{'\n'}
Make sure the WiFi password is correct{'\n'}
The network must have internet access
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
backButton: {
padding: Spacing.xs,
},
headerTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
placeholder: {
width: 32,
},
content: {
flex: 1,
},
scrollContent: {
padding: Spacing.lg,
paddingBottom: Spacing.xxl,
},
// Device Card
deviceCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
marginBottom: Spacing.lg,
...Shadows.sm,
},
deviceIcon: {
width: 60,
height: 60,
borderRadius: BorderRadius.lg,
backgroundColor: AppColors.primaryLighter,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.md,
},
deviceInfo: {
flex: 1,
},
deviceName: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: 2,
},
deviceMeta: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
},
// Instructions
instructionsCard: {
backgroundColor: AppColors.infoLight,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
marginBottom: Spacing.lg,
},
instructionsText: {
fontSize: FontSizes.sm,
color: AppColors.info,
lineHeight: 20,
},
// Loading
loadingContainer: {
alignItems: 'center',
padding: Spacing.xl,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
...Shadows.sm,
},
loadingText: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
marginTop: Spacing.md,
},
// Section Header
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: Spacing.md,
},
sectionTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
refreshButton: {
padding: Spacing.xs,
},
// Empty State
emptyState: {
alignItems: 'center',
padding: Spacing.xl,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
...Shadows.sm,
},
emptyText: {
fontSize: FontSizes.base,
color: AppColors.textMuted,
marginTop: Spacing.md,
marginBottom: Spacing.md,
},
retryButton: {
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.lg,
},
retryText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.primary,
},
// Networks List
networksList: {
gap: Spacing.sm,
marginBottom: Spacing.lg,
},
networkCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
...Shadows.xs,
},
networkCardSelected: {
borderWidth: 2,
borderColor: AppColors.primary,
},
networkInfo: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
},
networkDetails: {
flex: 1,
},
networkName: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: 2,
},
signalText: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.medium,
},
// Password Input
passwordCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
marginBottom: Spacing.lg,
...Shadows.sm,
},
passwordLabel: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.textSecondary,
marginBottom: Spacing.sm,
},
passwordInputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.background,
borderRadius: BorderRadius.md,
borderWidth: 1,
borderColor: AppColors.border,
marginBottom: Spacing.md,
},
passwordInput: {
flex: 1,
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textPrimary,
},
togglePasswordButton: {
padding: Spacing.md,
},
// Connect Button
connectButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
gap: Spacing.sm,
...Shadows.md,
},
connectButtonDisabled: {
opacity: 0.5,
},
connectButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
// Help Card
helpCard: {
backgroundColor: AppColors.infoLight,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
},
helpHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
marginBottom: Spacing.xs,
},
helpTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.info,
},
helpText: {
fontSize: FontSizes.sm,
color: AppColors.info,
lineHeight: 20,
},
});

View File

@ -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() {
>
<AuthProvider>
<BeneficiaryProvider>
<ToastProvider>
<RootLayoutNav />
</ToastProvider>
<BLEProvider>
<ToastProvider>
<RootLayoutNav />
</ToastProvider>
</BLEProvider>
</BeneficiaryProvider>
</AuthProvider>
</StripeProvider>

171
contexts/BLEContext.tsx Normal file
View File

@ -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<string>;
isBLEAvailable: boolean;
error: string | null;
// Actions
scanDevices: () => Promise<void>;
stopScan: () => void;
connectDevice: (deviceId: string) => Promise<boolean>;
disconnectDevice: (deviceId: string) => Promise<void>;
getWiFiList: (deviceId: string) => Promise<WiFiNetwork[]>;
setWiFi: (deviceId: string, ssid: string, password: string) => Promise<boolean>;
getCurrentWiFi: (deviceId: string) => Promise<WiFiStatus | null>;
rebootDevice: (deviceId: string) => Promise<void>;
clearError: () => void;
}
const BLEContext = createContext<BLEContextType | undefined>(undefined);
export function BLEProvider({ children }: { children: ReactNode }) {
const [foundDevices, setFoundDevices] = useState<WPDevice[]>([]);
const [isScanning, setIsScanning] = useState(false);
const [connectedDevices, setConnectedDevices] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(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<boolean> => {
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<void> => {
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<WiFiNetwork[]> => {
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<boolean> => {
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<WiFiStatus | null> => {
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<void> => {
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 <BLEContext.Provider value={value}>{children}</BLEContext.Provider>;
}
export function useBLE() {
const context = useContext(BLEContext);
if (context === undefined) {
throw new Error('useBLE must be used within a BLEProvider');
}
return context;
}

599
docs/BLE_PROTOCOL.md Normal file
View File

@ -0,0 +1,599 @@
# WellPlug BLE Protocol Documentation
## Overview
WellPlug devices (WP_XXX) use Bluetooth Low Energy (BLE) for configuration. This document describes the communication protocol.
## Device Information
| Parameter | Value |
|-----------|-------|
| Service UUID | `4fafc201-1fb5-459e-8fcc-c5c9c331914b` |
| Characteristic UUID | `beb5483e-36e1-4688-b7f5-ea07361b26a8` |
| Properties | read, write, notify |
| Platform | ESP32 |
## Connection Flow
1. **Scan** for devices with name starting with `WP_`
2. **Connect** to the device
3. **Subscribe** to notifications on the characteristic
4. **Unlock** device with PIN command
5. **Send commands** and receive responses
## Commands
### 1. Unlock Device (Required First!)
Must be sent before any other command.
| | |
|---|---|
| **Command** | `pin\|7856` |
| **Response** | `pin\|ok` |
| **Note** | PIN `7856` is universal for all devices |
**Example:**
```
Send: pin|7856
Recv: pin|ok
```
---
### 2. Get WiFi Networks List
Scans and returns available WiFi networks.
| | |
|---|---|
| **Command** | `w` |
| **Response** | `mac,XXXXXX\|w\|COUNT\|SSID1,RSSI1\|SSID2,RSSI2\|...` |
**Response Format:**
- `mac,XXXXXX` - Device MAC address
- `w` - Command echo
- `COUNT` - Number of networks found (-1 = scanning, -2 = no networks, 0+ = count)
- `SSID,RSSI` - Network name and signal strength (dBm)
**Example:**
```
Send: w
Recv: mac,142b2f81a14c|w|19|FrontierTower,-55|MyNetwork,-67|...
```
---
### 3. Set WiFi Credentials
Configures the device to connect to a WiFi network.
| | |
|---|---|
| **Command** | `W\|SSID,PASSWORD` |
| **Response** | `mac,XXXXXX\|W\|ok` or `mac,XXXXXX\|W\|fail` |
**Example:**
```
Send: W|FrontierTower,mypassword123
Recv: mac,142b2f81a14c|W|ok
```
**For devices with long MAC (>12 chars):**
Use JSON format instead:
```json
{"FUNC":"W","SSID":"NetworkName","PSW":"password123"}
```
---
### 4. Get WiFi Connection Status
Returns current WiFi connection status.
| | |
|---|---|
| **Command** | `a` |
| **Response** | `mac,XXXXXX\|a\|SSID,STATUS` |
**Status Values:**
- `0` - Not connected
- Other - Connected
**Example:**
```
Send: a
Recv: mac,142b2f81a14c|a|FrontierTower,1
```
---
### 5. Reboot Device
Restarts the device. BLE connection will be lost.
| | |
|---|---|
| **Command** | `s` |
| **Response** | (device disconnects) |
**Example:**
```
Send: s
(device reboots and disconnects)
```
---
### 6. Disconnect BLE
Disconnects BLE connection (device side).
| | |
|---|---|
| **Command** | `D` |
| **Response** | (connection closed) |
---
## Response Format
All responses follow this format:
```
mac,XXXXXX|COMMAND|DATA
```
Where:
- `mac,XXXXXX` - Device MAC address (last 12 hex chars)
- `COMMAND` - Echo of the command sent
- `DATA` - Response data (varies by command)
**Error Response:**
```
error
```
---
## Python Example
```python
import asyncio
from bleak import BleakClient, BleakScanner
SERVICE_UUID = "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
CHAR_UUID = "beb5483e-36e1-4688-b7f5-ea07361b26a8"
async def configure_wellplug(device_name, wifi_ssid, wifi_password):
# Find device
device = await BleakScanner.find_device_by_name(device_name, timeout=10)
if not device:
print("Device not found")
return
def on_notify(sender, data):
print(f"Response: {data.decode('utf-8')}")
async with BleakClient(device) as client:
# Subscribe to notifications
await client.start_notify(CHAR_UUID, on_notify)
# 1. Unlock device
await client.write_gatt_char(CHAR_UUID, b"pin|7856", response=True)
await asyncio.sleep(1)
# 2. Get WiFi list
await client.write_gatt_char(CHAR_UUID, b"w", response=True)
await asyncio.sleep(2)
# 3. Set WiFi credentials
cmd = f"W|{wifi_ssid},{wifi_password}".encode()
await client.write_gatt_char(CHAR_UUID, cmd, response=True)
await asyncio.sleep(2)
# 4. Check status
await client.write_gatt_char(CHAR_UUID, b"a", response=True)
await asyncio.sleep(1)
# Usage
asyncio.run(configure_wellplug("WP_497_81a14c", "MyWiFi", "password123"))
```
---
## Important Notes
1. **Always unlock first** - Device won't respond to commands without PIN
2. **Use `response=True`** - When writing with bleak, use `response=True` for reliable communication
3. **Subscribe before writing** - Subscribe to notifications before sending commands
4. **Wait for responses** - Allow 1-2 seconds between commands for device to respond
5. **Reboot disconnects** - Command `s` will disconnect BLE, need to reconnect after
---
## Known Devices
| Device Name | MAC Address | Notes |
|-------------|-------------|-------|
| WP_497_81a14c | 142b2f81a14c | Test device 1 |
| WP_523_81aad4 | 142b2f81aad4 | Test device 2 |
---
---
# WellNuo Legacy API Documentation
## Overview
The WellNuo Legacy API (eluxnetworks.net) is used for device management, deployments, and sensor data. This is a REST-like API using POST requests with form-urlencoded parameters.
## Base URL
```
https://eluxnetworks.net/function/well-api/api
```
## Authentication
### Login (Get Token)
| | |
|---|---|
| **Function** | `credentials` |
| **Method** | POST |
**Parameters:**
| Parameter | Value |
|-----------|-------|
| `function` | `credentials` |
| `user_name` | username |
| `ps` | password |
| `clientId` | `001` |
| `nonce` | any value |
**Response:**
```json
{
"access_token": "eyJhbGci...",
"privileges": "-1",
"user_id": 32,
"max_role": -1,
"status": "200 OK"
}
```
**Example:**
```bash
curl -X POST "https://eluxnetworks.net/function/well-api/api" \
-d "function=credentials&user_name=USERNAME&ps=PASSWORD&clientId=001&nonce=111"
```
---
## Deployments
### List Deployments
| | |
|---|---|
| **Function** | `deployments_list` |
**Parameters:**
| Parameter | Required | Description |
|-----------|----------|-------------|
| `function` | Yes | `deployments_list` |
| `user_name` | Yes | Username |
| `token` | Yes | Access token |
| `first` | Yes | Start index (0) |
| `last` | Yes | End index (50) |
**Response:**
```json
{
"result_list": [
{"deployment_id": 21, "email": "user@example.com", "first_name": "John", "last_name": "Doe"},
...
],
"status": "200 OK"
}
```
---
## Devices
### List All Devices
| | |
|---|---|
| **Function** | `device_list` |
**Parameters:**
| Parameter | Required | Description |
|-----------|----------|-------------|
| `function` | Yes | `device_list` |
| `user_name` | Yes | Username |
| `token` | Yes | Access token |
| `first` | Yes | Start index |
| `last` | Yes | End index |
**Response:**
```json
{
"result_list": [
[device_id, well_id, "MAC_ADDRESS", timestamp, "location", "description", deployment_id],
...
],
"status": "200 OK"
}
```
**Device Array Format:**
- `[0]` device_id - Internal device ID
- `[1]` well_id - Device well ID (from device name WP_XXX)
- `[2]` MAC address (uppercase, no colons)
- `[3]` Last seen timestamp (Unix)
- `[4]` Location name
- `[5]` Description
- `[6]` deployment_id (0 = not assigned)
---
### List Devices by Deployment
| | |
|---|---|
| **Function** | `device_list_by_deployment` |
**Parameters:**
| Parameter | Required | Description |
|-----------|----------|-------------|
| `function` | Yes | `device_list_by_deployment` |
| `user_name` | Yes | Username |
| `token` | Yes | Access token |
| `deployment_id` | Yes | Target deployment ID |
| `first` | Yes | Start index |
| `last` | Yes | End index |
---
### Assign Device to Deployment
| | |
|---|---|
| **Function** | `device_set_well_id` |
**Parameters:**
| Parameter | Required | Description |
|-----------|----------|-------------|
| `function` | Yes | `device_set_well_id` |
| `user_name` | Yes | Username |
| `token` | Yes | Access token |
| `device_id` | Yes | Internal device ID |
| `well_id` | Yes | New well_id to assign |
| `mac` | Yes | Device MAC address |
**Example - Reassign device:**
```bash
curl -X POST "https://eluxnetworks.net/function/well-api/api" \
-d "function=device_set_well_id&user_name=USER&token=TOKEN&device_id=743&well_id=500&mac=142B2F81A14C"
```
---
### Update Device Settings
| | |
|---|---|
| **Function** | `device_form` |
**Parameters:**
| Parameter | Required | Description |
|-----------|----------|-------------|
| `function` | Yes | `device_form` |
| `user_name` | Yes | Username |
| `token` | Yes | Access token |
| `well_id` | Yes | Device well_id |
| `device_mac` | Yes | MAC address |
| `description` | No | Description text |
| `location` | No | Location code |
| `close_to` | No | Position description |
| `radar_threshold` | No | Radar sensitivity (0-100) |
| `group` | No | Group ID |
---
### Reboot Device
| | |
|---|---|
| **Function** | `device_reboot` |
**Parameters:**
| Parameter | Required | Description |
|-----------|----------|-------------|
| `function` | Yes | `device_reboot` |
| `user_name` | Yes | Username |
| `token` | Yes | Access token |
| `device_id` | Yes | Device ID to reboot |
---
## Device Online Status Checking
### Method 1: `device_list` API (Individual Check)
Use `device_list` to check last seen timestamp for specific devices.
**How to determine online/offline:**
- Field `[3]` contains Unix timestamp of last update
- Calculate hours since last update
- If < 1 hour ONLINE
- If > 1 hour → OFFLINE
**Example:**
```bash
curl -X POST "https://eluxnetworks.net/function/well-api/api" \
-d "function=device_list&user_name=USER&token=TOKEN&first=0&last=100"
```
**Response:**
```json
{
"result_list": [
[743, 497, "142B2F81A14C", 1736859744, "Location", "Description", 70],
[769, 523, "142B2F81AAD4", 1736859630, "Location", "Description", 70]
],
"status": "200 OK"
}
```
**Status calculation:**
```
Device 743 (WP_497):
Last seen: 1736859744 → 2026-01-14 10:02:24
Hours ago: 0.0 → ONLINE
Device 769 (WP_523):
Last seen: 1736859630 → 2026-01-14 10:00:30
Hours ago: 0.03 → ONLINE
```
---
### Method 2: `request_devices` API (Batch Check by Deployment) ⭐
**IMPORTANT: This is the endpoint Robert uses to show "green" (online) devices in the app!**
**Quote from Robert:** *"there is API call to find out if set of devices are recently seen by server"*
This endpoint returns only devices that are **recently active** (sent data to server in the last ~1 hour).
| | |
|---|---|
| **Function** | `request_devices` |
**Parameters:**
| Parameter | Required | Description |
|-----------|----------|-------------|
| `function` | Yes | `request_devices` |
| `user_name` | Yes | Username |
| `token` | Yes | Access token |
| `deployment_id` | Yes | Deployment to check |
| `group_id` | Yes | Group filter ("All" for all groups) |
| `location` | Yes | Location filter ("All" for all locations) |
| `fresh` | Yes | **`true`** ← KEY PARAMETER! Filters only recently seen devices |
**Example:**
```bash
curl -X POST "https://eluxnetworks.net/function/well-api/api" \
-d "function=request_devices" \
-d "user_name=USER" \
-d "token=TOKEN" \
-d "deployment_id=38" \
-d "group_id=All" \
-d "location=All" \
-d "fresh=true"
```
**How it works (Robert's app logic):**
1. Call `request_devices` with `fresh=true`
2. Response contains ONLY online devices (recently seen by server)
3. If device is in response → Show **GREEN** (online) ✅
4. If device is NOT in response → Show **GRAY** (offline) ❌
**Response example:**
```json
{
"result_list": [
[device_id_1, well_id_1, "MAC1", timestamp_1, ...],
[device_id_2, well_id_2, "MAC2", timestamp_2, ...]
],
"status": "200 OK"
}
```
Only devices that sent data recently appear in the list!
**Alternative without `fresh=true`:**
- Returns ALL devices in deployment (regardless of status)
- You need to manually check timestamp field [3] to determine online/offline
**Note:** This endpoint requires devices to be properly linked to the deployment in the Legacy API system. Use Method 1 (`device_list`) if deployment linkage is not set up.
---
## Creating and Activating Deployments
### Create/Update Deployment with Devices
**Function:** `set_deployment`
This endpoint is used to create a new deployment or update existing one with devices, beneficiary info, and WiFi credentials.
**Key Parameters:**
| Parameter | Description |
|-----------|-------------|
| `function` | `set_deployment` |
| `user_name` | Installer username |
| `token` | Access token |
| `deployment` | Deployment name or "NEW" for new deployment |
| `beneficiary_name` | Beneficiary full name |
| `beneficiary_email` | Beneficiary email |
| `beneficiary_user_name` | Beneficiary login username |
| `beneficiary_password` | Beneficiary password |
| `beneficiary_address` | Beneficiary address |
| `caretaker_username` | Caretaker username (can be same as installer) |
| `caretaker_email` | Caretaker email |
| `persons` | Number of persons in household |
| `pets` | Number of pets |
| `gender` | Gender |
| `race` | Race index |
| `born` | Year born |
| `lat` | GPS latitude |
| `lng` | GPS longitude |
| `wifis` | JSON array of WiFi credentials: `["SSID1\|password1", "SSID2\|password2"]` |
| `devices` | JSON array of device well_ids: `[497, 523]` |
| `beneficiary_photo` | Photo filename |
| `beneficiary_photo_data` | Base64 encoded JPEG photo |
| `reuse_existing_devices` | `1` to reuse, `0` to create new |
**Example WiFi credentials format:**
```json
["FrontierTower|frontiertower995", "HomeNetwork|password123"]
```
**Example devices list:**
```json
[497, 523]
```
**After creating deployment:**
1. Devices are linked to the deployment
2. WiFi credentials are stored
3. Beneficiary account is created
4. Devices will start reporting data to server
5. Devices become "live" and visible in `request_devices` with `fresh=true`
---
## Known Test Devices
| Device Name | device_id | well_id | MAC Address | Status |
|-------------|-----------|---------|-------------|--------|
| WP_497_81a14c | 743 | 497 | 142B2F81A14C | Configured |
| WP_523_81aad4 | 769 | 523 | 142B2F81AAD4 | Configured |
Both devices are assigned to:
- **Deployment ID**: 70
- **Beneficiary**: Sergei Terekhov (ID: 76)
- **Owner**: serter2069@gmail.com (User ID: 63)
- **WiFi Network**: FrontierTower
---
## Changelog
- **2026-01-14** - Added Legacy API documentation (deployments, devices)
- **2026-01-13** - Initial BLE protocol documentation created

View File

@ -0,0 +1,693 @@
# WellNuo Sensors - Implementation Plan
## 🎯 Цель
Заменить мок-данные в экране `equipment.tsx` на реальное управление WP сенсорами через Bluetooth Low Energy (BLE).
---
## ⚠️ КРИТИЧНО: Bluetooth в iOS Simulator
**iOS Simulator НЕ ПОДДЕРЖИВАЕТ Bluetooth!**
### Решение:
- **На реальном устройстве:** полный BLE функционал
- **В Simulator:** показываем mock-данные + предупреждение
### Проверка доступности BLE:
```typescript
import * as Device from 'expo-device';
import { Platform } from 'react-native';
const isBLEAvailable = Platform.OS !== 'web' && Device.isDevice;
// Device.isDevice = false в Simulator, true на реальном устройстве
```
---
## 📚 Библиотека: react-native-ble-plx
**Рекомендуемая библиотека:** `react-native-ble-plx`
- ✅ Поддержка Expo (через config plugin)
- ✅ Кроссплатформенность (iOS + Android)
- ✅ Активная поддержка
- ✅ TypeScript типы
### Установка:
```bash
npx expo install react-native-ble-plx
```
### Конфигурация `app.json`:
```json
{
"expo": {
"plugins": [
[
"react-native-ble-plx",
{
"isBackgroundEnabled": true,
"modes": ["peripheral", "central"],
"bluetoothAlwaysPermission": "Allow $(PRODUCT_NAME) to connect to WellNuo sensors"
}
]
]
}
}
```
---
## 🏗️ Архитектура
### 1. BLE Service (`services/ble/`)
```
services/ble/
├── BLEManager.ts # Центральный менеджер BLE
├── WellPlugDevice.ts # Класс для работы с WP устройствами
├── BLECommands.ts # Константы команд (pin|7856, w, W, a, s, D)
├── MockBLEManager.ts # Mock для Simulator
└── types.ts # TypeScript типы
```
### 2. Context (`contexts/BLEContext.tsx`)
Глобальный контекст для управления BLE состоянием:
- Список найденных устройств
- Состояние сканирования
- Подключенные устройства
- Ошибки
### 3. Хуки (`hooks/`)
```typescript
// hooks/useBLE.ts
export function useBLE() {
// Доступ к BLEContext
// Scan, connect, disconnect
}
// hooks/useWellPlugDevice.ts
export function useWellPlugDevice(deviceId: string) {
// Управление конкретным WP устройством
// getWiFiList(), setWiFi(), checkCurrentWiFi()
}
```
---
## 🎨 Экраны и User Flow
### Экран 1: Equipment List (текущий `equipment.tsx`)
**Функционал:**
- ✅ Показать список привязанных WP сенсоров к beneficiary
- ✅ Статус онлайн/офлайн через API `request_devices` (fresh=true)
- ✅ Последний раз виден (Last seen)
- ✅ Текущая WiFi сеть (получаем через BLE команду `a`)
- ✅ Кнопка "Настроить" → переход на Device Settings
- ✅ Кнопка "Добавить сенсор" → Scan Screen
**Данные:**
```typescript
interface WPSensor {
deviceId: string; // device_id из PostgreSQL
wellId: number; // well_id (497, 523)
mac: string; // MAC адрес (142B2F81A14C)
name: string; // "WP_497_81a14c"
// Статус (из API)
status: 'online' | 'offline';
lastSeen: Date; // timestamp из device_list API
// WiFi (из BLE)
currentWiFi?: {
ssid: string; // "FrontierTower"
rssi: number; // -67 dBm
};
// Привязка
beneficiaryId: string;
deploymentId: number;
}
```
**API для получения списка:**
```typescript
// services/api.ts
async getDevicesForBeneficiary(beneficiaryId: string): Promise<WPSensor[]> {
// 1. Получить deployment_id для beneficiary из PostgreSQL
// 2. Вызвать Legacy API: device_list_by_deployment
// 3. Вызвать request_devices(fresh=true) для онлайн статуса
// 4. Объединить данные
}
```
**BLE для текущего WiFi:**
- При открытии экрана → подключаемся к каждому устройству по BLE
- Отправляем команду `a`
- Парсим ответ: `mac,XXXXXX|a|SSID,RSSI`
- Обновляем UI
---
### Экран 2: Add Sensor (новый `add-sensor.tsx`)
**Путь:** `app/(tabs)/beneficiaries/[id]/add-sensor.tsx`
**Функционал:**
- ✅ Сканирование BLE устройств с именем `WP_*`
- ✅ Показать список найденных (с RSSI - сила сигнала)
- ✅ Сортировка по близости (RSSI)
- ✅ Выбор устройства → переход на Setup WiFi Screen
**UI:**
```
┌─────────────────────────┐
│ ← Scan for Sensors │
├─────────────────────────┤
│ │
│ 📡 Scanning... │
│ │
│ WP_497_81a14c │
│ Signal: ████░░ -55dBm │
│ [Connect] │
│ │
│ WP_523_81aad4 │
│ Signal: ███░░░ -67dBm │
│ [Connect] │
│ │
│ [Rescan] │
│ │
└─────────────────────────┘
```
**Алгоритм сканирования:**
```typescript
async function scanForWPDevices() {
const manager = new BleManager();
// Сканируем 10 секунд
manager.startDeviceScan(
null, // serviceUUIDs (можем фильтровать по service UUID)
null, // options
(error, device) => {
if (error) {
// Handle error
return;
}
// Фильтруем только WP_ устройства
if (device.name?.startsWith('WP_')) {
// Добавляем в список
// Сортируем по RSSI (ближе = выше значение)
}
}
);
// Через 10 секунд останавливаем
setTimeout(() => manager.stopDeviceScan(), 10000);
}
```
---
### Экран 3: Setup WiFi (новый `setup-wifi.tsx`)
**Путь:** `app/(tabs)/beneficiaries/[id]/setup-wifi.tsx`
**Параметры:** `?deviceName=WP_497_81a14c&beneficiaryId=123`
**Функционал:**
**Шаг 1: Подключение к устройству**
```
┌─────────────────────────┐
│ ← Setup WP_497 │
├─────────────────────────┤
│ │
│ 📱 Connecting to │
│ WP_497_81a14c... │
│ │
│ ⏳ Please wait │
│ │
└─────────────────────────┘
```
**Шаг 2: Unlock (автоматически)**
- Отправляем `pin|7856`
- Ждём ответ `pin|ok`
**Шаг 3: Получение списка WiFi**
```
┌─────────────────────────┐
│ ← Select WiFi Network │
├─────────────────────────┤
│ │
│ 📶 FrontierTower │
│ Signal: ████░ -55 │
│ [Select] │
│ │
│ 📶 HomeNetwork │
│ Signal: ███░░ -67 │
│ [Select] │
│ │
│ 📶 TP-Link_5G │
│ Signal: ██░░░ -75 │
│ [Select] │
│ │
│ [Rescan] │
│ │
└─────────────────────────┘
```
**Алгоритм:**
- Отправляем команду `w`
- Ждём ответ: `mac,XXXXXX|w|3|SSID1,RSSI1|SSID2,RSSI2|SSID3,RSSI3`
- Парсим список
- Сортируем по RSSI (сильный сигнал первым)
**Шаг 4: Ввод пароля**
```
┌─────────────────────────┐
│ ← WiFi Password │
├─────────────────────────┤
│ │
│ Network: FrontierTower │
│ │
│ Password: │
│ [**************] │
│ [ ] Show password │
│ │
│ [Connect] │
│ │
└─────────────────────────┘
```
**Шаг 5: Настройка WiFi**
- Отправляем `W|FrontierTower,password123`
- Ждём ответ `mac,XXXXXX|W|ok` или `mac,XXXXXX|W|fail`
- Если `ok` → ждём 5 секунд → проверяем командой `a`
**Шаг 6: Проверка подключения**
```
┌─────────────────────────┐
│ ✅ Connected! │
├─────────────────────────┤
│ │
│ Network: FrontierTower │
│ Signal: ████░ -67 dBm │
│ │
│ Device is now online │
│ and sending data │
│ │
│ [Continue] │
│ │
└─────────────────────────┘
```
---
### Экран 4: Device Settings (новый `device-settings.tsx`)
**Путь:** `app/(tabs)/beneficiaries/[id]/device-settings/[deviceId].tsx`
**Функционал:**
- ✅ Показать текущую WiFi (команда `a`)
- ✅ Изменить WiFi → Setup WiFi Screen
- ✅ Перезагрузить устройство (команда `s`)
- ✅ Отвязать от beneficiary (Detach)
- ✅ Проверить статус онлайн/офлайн
**UI:**
```
┌─────────────────────────┐
│ ← Device Settings │
│ WP_497_81a14c │
├─────────────────────────┤
│ │
│ Status │
│ 🟢 Online │
│ Last seen: 2 min ago │
│ │
│ WiFi Network │
│ 📶 FrontierTower │
│ Signal: ████░ -67 dBm │
│ [Change WiFi] │
│ │
│ Device Info │
│ MAC: 142B2F81A14C │
│ Well ID: 497 │
│ │
│ Actions │
│ [🔄 Reboot Device] │
│ [🔗 Detach Device] │
│ │
└─────────────────────────┘
```
---
## 🔌 API Integration
### Legacy API Endpoints
**1. Получить список устройств deployment:**
```typescript
POST https://eluxnetworks.net/function/well-api/api
{
function: 'device_list_by_deployment',
user_name: 'USER',
token: 'TOKEN',
deployment_id: 70,
first: 0,
last: 100
}
```
**2. Проверить онлайн статус (batch):**
```typescript
POST https://eluxnetworks.net/function/well-api/api
{
function: 'request_devices',
user_name: 'USER',
token: 'TOKEN',
deployment_id: 70,
group_id: 'All',
location: 'All',
fresh: true // ← Только онлайн устройства
}
```
**3. Привязать устройство к deployment:**
```typescript
POST https://eluxnetworks.net/function/well-api/api
{
function: 'set_deployment',
// ... все параметры из BLE_PROTOCOL.md
devices: [497, 523], // well_ids
wifis: ["FrontierTower|password123"]
}
```
### PostgreSQL
**Получить deployment_id для beneficiary:**
```sql
SELECT d.deployment_id, pd.access_to_deployments
FROM person_details pd
JOIN deployments d ON d.deployment_id = ANY(string_to_array(pd.access_to_deployments, ',')::int[])
WHERE pd.user_id = $1;
```
---
## 🧩 Компоненты
### 1. DeviceCard Component
Переиспользуемая карточка устройства:
```tsx
<DeviceCard
device={sensor}
onPress={() => router.push(`/device-settings/${sensor.deviceId}`)}
onDetach={() => handleDetach(sensor)}
showWiFi={true}
/>
```
### 2. WiFiNetworkItem Component
Элемент списка WiFi сетей:
```tsx
<WiFiNetworkItem
ssid="FrontierTower"
rssi={-67}
onSelect={() => handleSelectNetwork('FrontierTower')}
/>
```
### 3. BLEScanner Component
Компонент сканирования:
```tsx
<BLEScanner
isScanning={scanning}
devices={foundDevices}
onDeviceSelect={(device) => handleConnect(device)}
onRescan={() => startScan()}
/>
```
### 4. SimulatorWarning Component
Предупреждение для Simulator:
```tsx
{!Device.isDevice && (
<SimulatorWarning
message="Bluetooth is not available in iOS Simulator. Please test on a real device."
/>
)}
```
---
## 🔐 Permissions
### iOS (Info.plist через app.json)
```json
{
"expo": {
"ios": {
"infoPlist": {
"NSBluetoothAlwaysUsageDescription": "WellNuo needs Bluetooth to connect to your wellness sensors",
"NSBluetoothPeripheralUsageDescription": "WellNuo needs Bluetooth to manage your sensors"
}
}
}
}
```
### Android (AndroidManifest.xml через app.json)
```json
{
"expo": {
"android": {
"permissions": [
"android.permission.BLUETOOTH",
"android.permission.BLUETOOTH_ADMIN",
"android.permission.BLUETOOTH_CONNECT",
"android.permission.BLUETOOTH_SCAN",
"android.permission.ACCESS_FINE_LOCATION"
]
}
}
}
```
---
## 📱 Mock Data для Simulator
```typescript
// services/ble/MockBLEManager.ts
export class MockBLEManager implements IBLEManager {
async scanDevices(): Promise<WPDevice[]> {
// Возвращаем фейковые WP устройства
return [
{
id: 'mock-1',
name: 'WP_497_81a14c',
rssi: -55,
mac: '142B2F81A14C'
},
{
id: 'mock-2',
name: 'WP_523_81aad4',
rssi: -67,
mac: '142B2F81AAD4'
}
];
}
async connect(deviceId: string): Promise<boolean> {
// Симулируем задержку
await delay(1000);
return true;
}
async getWiFiList(): Promise<WiFiNetwork[]> {
return [
{ ssid: 'FrontierTower', rssi: -55 },
{ ssid: 'HomeNetwork', rssi: -67 },
{ ssid: 'TP-Link_5G', rssi: -75 }
];
}
async setWiFi(ssid: string, password: string): Promise<boolean> {
await delay(2000);
return true;
}
async getCurrentWiFi(): Promise<{ ssid: string; rssi: number }> {
return { ssid: 'FrontierTower', rssi: -67 };
}
}
```
---
## 🧪 Тестирование
### 1. На iOS Simulator
- ❌ BLE не работает
- ✅ Показываем mock данные
- ✅ Проверяем UI/UX флоу
### 2. На реальном iPhone
- ✅ Полный BLE функционал
- ✅ Подключение к WP устройствам
- ✅ Настройка WiFi
### 3. Android Emulator
- ⚠️ Может работать с пробросом Bluetooth
- Не рекомендуется для основной разработки
---
## 📋 План Реализации (По Шагам)
### Фаза 1: Инфраструктура (2-3 часа)
1. ✅ Установить `react-native-ble-plx`
2. ✅ Настроить permissions (iOS + Android)
3. ✅ Создать `services/ble/BLEManager.ts`
4. ✅ Создать `services/ble/MockBLEManager.ts`
5. ✅ Создать `contexts/BLEContext.tsx`
6. ✅ Добавить проверку `Device.isDevice`
### Фаза 2: API Integration (1-2 часа)
1. ✅ Добавить методы в `services/api.ts`:
- `getDevicesForBeneficiary()`
- `getDeviceStatus()`
- `attachDeviceToDeployment()`
2. ✅ Интегрировать Legacy API endpoints
3. ✅ Добавить PostgreSQL queries
### Фаза 3: Equipment Screen (1-2 часа)
1. ✅ Заменить mock данные на реальные API calls
2. ✅ Добавить BLE функционал для getCurrentWiFi
3. ✅ Обновить UI для WP сенсоров
4. ✅ Добавить SimulatorWarning
### Фаза 4: Add Sensor Screen (2-3 часа)
1. ✅ Создать новый экран `add-sensor.tsx`
2. ✅ Реализовать BLE сканирование
3. ✅ Показать список устройств с RSSI
4. ✅ Добавить кнопку подключения
### Фаза 5: Setup WiFi Screen (3-4 часа)
1. ✅ Создать экран `setup-wifi.tsx`
2. ✅ Реализовать мульти-шаговый флоу:
- Connect → Unlock → Get WiFi List → Enter Password → Set WiFi → Verify
3. ✅ Обработка ошибок
4. ✅ Привязка к deployment через API
### Фаза 6: Device Settings Screen (1-2 часа)
1. ✅ Создать экран `device-settings.tsx`
2. ✅ Показать текущую WiFi
3. ✅ Кнопки: Change WiFi, Reboot, Detach
4. ✅ Статус онлайн/офлайн
### Фаза 7: Тестирование (2-3 часа)
1. ✅ Тест на Simulator (mock данные)
2. ✅ Тест на реальном iPhone с WP устройствами
3. ✅ Проверить все флоу
4. ✅ Обработка edge cases
---
## 🚨 Edge Cases
### 1. Bluetooth выключен
```tsx
if (!isBluetoothEnabled) {
Alert.alert(
'Bluetooth Disabled',
'Please enable Bluetooth to connect to sensors',
[
{ text: 'Open Settings', onPress: () => Linking.openSettings() },
{ text: 'Cancel' }
]
);
}
```
### 2. Нет permission
```tsx
const status = await manager.requestPermissions();
if (status !== 'granted') {
// Показать инструкцию
}
```
### 3. Устройство не найдено
- Показать "No devices found"
- Кнопка Rescan
- Проверить что устройство включено
### 4. Не подключается к WiFi
- Показать ошибку от устройства
- Предложить повторить
- Проверить правильность пароля
### 5. Устройство оффлайн
- Показать серым
- Не пытаться подключаться по BLE
- Показать Last seen
---
## 🎯 Итоговый User Flow
```
1. Юзер открывает Equipment Screen
→ Видит список WP сенсоров (если есть)
→ Статус: онлайн/оффлайн (из API)
→ Текущая WiFi (из BLE)
2. Юзер нажимает "Add Sensor"
→ Открывается Add Sensor Screen
→ Сканирование BLE (10 сек)
→ Список найденных WP_* устройств
→ Сортировка по близости (RSSI)
3. Юзер выбирает устройство
→ Подключение по BLE
→ Автоматический unlock (pin|7856)
→ Получение списка WiFi сетей
→ Показ списка с сигналом
4. Юзер выбирает WiFi сеть
→ Ввод пароля
→ Отправка credentials (W|SSID,PASS)
→ Ожидание подключения (5 сек)
→ Проверка (команда a)
5. Success!
→ Устройство подключено к WiFi
→ Привязка к beneficiary через API
→ Возврат на Equipment Screen
→ Устройство появляется в списке как "online"
```
---
## 📝 Changelog
- **2026-01-14** - Создан план реализации функционала сенсоров
- Определена архитектура BLE управления
- Спроектированы все экраны
- Решена проблема с Simulator (mock данные)

141
package-lock.json generated
View File

@ -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"

View File

@ -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",

287
services/ble/BLEManager.ts Normal file
View File

@ -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<string, Device>();
private scanning = false;
constructor() {
this.manager = new BleManager();
}
// Check and request permissions
private async requestPermissions(): Promise<boolean> {
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<boolean> {
const state = await this.manager.state();
return state === State.PoweredOn;
}
async scanDevices(): Promise<WPDevice[]> {
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<string, WPDevice>();
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<boolean> {
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<void> {
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<string> {
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<WiFiNetwork[]> {
// 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<boolean> {
// 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<WiFiStatus | null> {
// 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<void> {
// 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);
}
}

View File

@ -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<string>();
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<WPDevice[]> {
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<boolean> {
console.log(`[MockBLE] Connecting to ${deviceId}...`);
await delay(1000);
this.connectedDevices.add(deviceId);
return true;
}
async disconnectDevice(deviceId: string): Promise<void> {
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<string> {
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<WiFiNetwork[]> {
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<boolean> {
console.log(`[MockBLE] Setting WiFi: ${ssid}`);
await delay(2000);
return true;
}
async getCurrentWiFi(deviceId: string): Promise<WiFiStatus | null> {
console.log(`[MockBLE] Getting current WiFi for ${deviceId}`);
await delay(1000);
return {
ssid: 'FrontierTower',
rssi: -67,
connected: true,
};
}
async rebootDevice(deviceId: string): Promise<void> {
console.log(`[MockBLE] Rebooting ${deviceId}`);
await delay(500);
this.connectedDevices.delete(deviceId);
}
}

17
services/ble/index.ts Normal file
View File

@ -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';

60
services/ble/types.ts Normal file
View File

@ -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<WPDevice[]>;
stopScan(): void;
connectDevice(deviceId: string): Promise<boolean>;
disconnectDevice(deviceId: string): Promise<void>;
isDeviceConnected(deviceId: string): boolean;
sendCommand(deviceId: string, command: string): Promise<string>;
getWiFiList(deviceId: string): Promise<WiFiNetwork[]>;
setWiFi(deviceId: string, ssid: string, password: string): Promise<boolean>;
getCurrentWiFi(deviceId: string): Promise<WiFiStatus | null>;
rebootDevice(deviceId: string): Promise<void>;
}