Compare commits
4 Commits
3aee73a731
...
2b68b70584
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b68b70584 | ||
|
|
5092678430 | ||
|
|
3c3283e424 | ||
|
|
86e73f004d |
19
app.json
19
app.json
@ -16,7 +16,9 @@
|
|||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"ITSAppUsesNonExemptEncryption": false,
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
"NSSpeechRecognitionUsageDescription": "Allow $(PRODUCT_NAME) to use speech recognition.",
|
"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": {
|
"android": {
|
||||||
@ -31,7 +33,12 @@
|
|||||||
"predictiveBackGestureEnabled": false,
|
"predictiveBackGestureEnabled": false,
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"android.permission.RECORD_AUDIO",
|
"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": {
|
"web": {
|
||||||
@ -69,6 +76,14 @@
|
|||||||
"merchantIdentifier": "merchant.com.wellnuo.app",
|
"merchantIdentifier": "merchant.com.wellnuo.app",
|
||||||
"enableGooglePay": true
|
"enableGooglePay": true
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"react-native-ble-plx",
|
||||||
|
{
|
||||||
|
"isBackgroundEnabled": true,
|
||||||
|
"modes": ["peripheral", "central"],
|
||||||
|
"bluetoothAlwaysPermission": "Allow $(PRODUCT_NAME) to connect to WellNuo sensors"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
|
|||||||
529
app/(tabs)/beneficiaries/[id]/add-sensor.tsx
Normal file
529
app/(tabs)/beneficiaries/[id]/add-sensor.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
728
app/(tabs)/beneficiaries/[id]/device-settings/[deviceId].tsx
Normal file
728
app/(tabs)/beneficiaries/[id]/device-settings/[deviceId].tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
563
app/(tabs)/beneficiaries/[id]/setup-wifi.tsx
Normal file
563
app/(tabs)/beneficiaries/[id]/setup-wifi.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -10,6 +10,7 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
|||||||
import { ToastProvider } from '@/components/ui/Toast';
|
import { ToastProvider } from '@/components/ui/Toast';
|
||||||
import { AuthProvider, useAuth } from '@/contexts/AuthContext';
|
import { AuthProvider, useAuth } from '@/contexts/AuthContext';
|
||||||
import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext';
|
import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext';
|
||||||
|
import { BLEProvider } from '@/contexts/BLEContext';
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||||
|
|
||||||
// Stripe publishable key (test mode) - must match backend STRIPE_PUBLISHABLE_KEY
|
// Stripe publishable key (test mode) - must match backend STRIPE_PUBLISHABLE_KEY
|
||||||
@ -95,9 +96,11 @@ export default function RootLayout() {
|
|||||||
>
|
>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BeneficiaryProvider>
|
<BeneficiaryProvider>
|
||||||
<ToastProvider>
|
<BLEProvider>
|
||||||
<RootLayoutNav />
|
<ToastProvider>
|
||||||
</ToastProvider>
|
<RootLayoutNav />
|
||||||
|
</ToastProvider>
|
||||||
|
</BLEProvider>
|
||||||
</BeneficiaryProvider>
|
</BeneficiaryProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</StripeProvider>
|
</StripeProvider>
|
||||||
|
|||||||
171
contexts/BLEContext.tsx
Normal file
171
contexts/BLEContext.tsx
Normal 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
599
docs/BLE_PROTOCOL.md
Normal 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
|
||||||
693
docs/SENSORS_IMPLEMENTATION_PLAN.md
Normal file
693
docs/SENSORS_IMPLEMENTATION_PLAN.md
Normal 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
141
package-lock.json
generated
@ -15,13 +15,14 @@
|
|||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@stripe/stripe-react-native": "0.50.3",
|
"@stripe/stripe-react-native": "0.50.3",
|
||||||
"expo": "~54.0.30",
|
"expo": "~54.0.31",
|
||||||
"expo-audio": "~1.1.1",
|
"expo-audio": "~1.1.1",
|
||||||
"expo-av": "~16.0.8",
|
"expo-av": "~16.0.8",
|
||||||
"expo-build-properties": "~1.0.10",
|
"expo-build-properties": "~1.0.10",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-clipboard": "~8.0.8",
|
"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-file-system": "~19.0.21",
|
||||||
"expo-font": "~14.0.10",
|
"expo-font": "~14.0.10",
|
||||||
"expo-haptics": "~15.0.8",
|
"expo-haptics": "~15.0.8",
|
||||||
@ -41,9 +42,11 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"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-fs": "^2.20.0",
|
||||||
"react-native-gesture-handler": "~2.28.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-reanimated": "~4.1.1",
|
||||||
"react-native-root-toast": "^4.0.1",
|
"react-native-root-toast": "^4.0.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
@ -2474,13 +2477,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/code-signing-certificates": {
|
"node_modules/@expo/code-signing-certificates": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
|
||||||
"integrity": "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw==",
|
"integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"node-forge": "^1.2.1",
|
"node-forge": "^1.3.3"
|
||||||
"nullthrows": "^1.1.1"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/config": {
|
"node_modules/@expo/config": {
|
||||||
@ -2748,9 +2750,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/metro-config": {
|
"node_modules/@expo/metro-config": {
|
||||||
"version": "54.0.12",
|
"version": "54.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.13.tgz",
|
||||||
"integrity": "sha512-Xhv1z/ak/cuJWeLxlnWr2u22q2AM/klASbjpP5eE34y91lGWa2NUwrFWoS830MhJ6kuAqtGdoQhwyPa3TES7sA==",
|
"integrity": "sha512-RRufMCgLR2Za1WGsh02OatIJo5qZFt31yCnIOSfoubNc3Qqe92Z41pVsbrFnmw5CIaisv1NgdBy05DHe7pEyuw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.20.0",
|
"@babel/code-frame": "^7.20.0",
|
||||||
@ -10553,28 +10555,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo": {
|
"node_modules/expo": {
|
||||||
"version": "54.0.30",
|
"version": "54.0.31",
|
||||||
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.30.tgz",
|
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.31.tgz",
|
||||||
"integrity": "sha512-6q+aFfKL0SpT8prfdpR3V8HcN51ov0mCGuwQTzyuk6eeO9rg7a7LWbgPv9rEVXGZEuyULstL8LGNwHqusand7Q==",
|
"integrity": "sha512-kQ3RDqA/a59I7y+oqQGyrPbbYlgPMUdKBOgvFLpoHbD2bCM+F75i4N0mUijy7dG5F/CUCu2qHmGGUCXBbMDkCg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.20.0",
|
"@babel/runtime": "^7.20.0",
|
||||||
"@expo/cli": "54.0.20",
|
"@expo/cli": "54.0.21",
|
||||||
"@expo/config": "~12.0.13",
|
"@expo/config": "~12.0.13",
|
||||||
"@expo/config-plugins": "~54.0.4",
|
"@expo/config-plugins": "~54.0.4",
|
||||||
"@expo/devtools": "0.1.8",
|
"@expo/devtools": "0.1.8",
|
||||||
"@expo/fingerprint": "0.15.4",
|
"@expo/fingerprint": "0.15.4",
|
||||||
"@expo/metro": "~54.2.0",
|
"@expo/metro": "~54.2.0",
|
||||||
"@expo/metro-config": "54.0.12",
|
"@expo/metro-config": "54.0.13",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@ungap/structured-clone": "^1.3.0",
|
"@ungap/structured-clone": "^1.3.0",
|
||||||
"babel-preset-expo": "~54.0.9",
|
"babel-preset-expo": "~54.0.9",
|
||||||
"expo-asset": "~12.0.12",
|
"expo-asset": "~12.0.12",
|
||||||
"expo-constants": "~18.0.12",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~19.0.21",
|
||||||
"expo-font": "~14.0.10",
|
"expo-font": "~14.0.10",
|
||||||
"expo-keep-awake": "~15.0.8",
|
"expo-keep-awake": "~15.0.8",
|
||||||
"expo-modules-autolinking": "3.0.23",
|
"expo-modules-autolinking": "3.0.24",
|
||||||
"expo-modules-core": "3.0.29",
|
"expo-modules-core": "3.0.29",
|
||||||
"pretty-format": "^29.7.0",
|
"pretty-format": "^29.7.0",
|
||||||
"react-refresh": "^0.14.2",
|
"react-refresh": "^0.14.2",
|
||||||
@ -10727,12 +10729,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-constants": {
|
"node_modules/expo-constants": {
|
||||||
"version": "18.0.12",
|
"version": "18.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||||
"integrity": "sha512-WzcKYMVNRRu4NcSzfIVRD5aUQFnSpTZgXFrlWmm19xJoDa4S3/PQNi6PNTBRc49xz9h8FT7HMxRKaC8lr0gflA==",
|
"integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/config": "~12.0.12",
|
"@expo/config": "~12.0.13",
|
||||||
"@expo/env": "~2.0.8"
|
"@expo/env": "~2.0.8"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@ -10740,6 +10742,44 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-file-system": {
|
||||||
"version": "19.0.21",
|
"version": "19.0.21",
|
||||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
||||||
@ -10848,9 +10888,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-modules-autolinking": {
|
"node_modules/expo-modules-autolinking": {
|
||||||
"version": "3.0.23",
|
"version": "3.0.24",
|
||||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.23.tgz",
|
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
|
||||||
"integrity": "sha512-YZnaE0G+52xftjH5nsIRaWsoVBY38SQCECclpdgLisdbRY/6Mzo7ndokjauOv3mpFmzMZACHyJNu1YSAffQwTg==",
|
"integrity": "sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/spawn-async": "^1.7.2",
|
"@expo/spawn-async": "^1.7.2",
|
||||||
@ -11236,13 +11276,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo/node_modules/@expo/cli": {
|
"node_modules/expo/node_modules/@expo/cli": {
|
||||||
"version": "54.0.20",
|
"version": "54.0.21",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.20.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.21.tgz",
|
||||||
"integrity": "sha512-cwsXmhftvS0p9NNYOhXGnicBAZl9puWwRt19Qq5eQ6njLnaj8WvcR+kDZyADtgZxBsZiyVlrKXvnjt43HXywQA==",
|
"integrity": "sha512-L/FdpyZDsg/Nq6xW6kfiyF9DUzKfLZCKFXEVZcDqCNar6bXxQVotQyvgexRvtUF5nLinuT/UafLOdC3FUALUmA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@0no-co/graphql.web": "^1.0.8",
|
"@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": "~12.0.13",
|
||||||
"@expo/config-plugins": "~54.0.4",
|
"@expo/config-plugins": "~54.0.4",
|
||||||
"@expo/devcert": "^1.2.1",
|
"@expo/devcert": "^1.2.1",
|
||||||
@ -11250,7 +11290,7 @@
|
|||||||
"@expo/image-utils": "^0.8.8",
|
"@expo/image-utils": "^0.8.8",
|
||||||
"@expo/json-file": "^10.0.8",
|
"@expo/json-file": "^10.0.8",
|
||||||
"@expo/metro": "~54.2.0",
|
"@expo/metro": "~54.2.0",
|
||||||
"@expo/metro-config": "~54.0.12",
|
"@expo/metro-config": "~54.0.13",
|
||||||
"@expo/osascript": "^2.3.8",
|
"@expo/osascript": "^2.3.8",
|
||||||
"@expo/package-manager": "^1.9.9",
|
"@expo/package-manager": "^1.9.9",
|
||||||
"@expo/plist": "^0.4.8",
|
"@expo/plist": "^0.4.8",
|
||||||
@ -11279,7 +11319,7 @@
|
|||||||
"glob": "^13.0.0",
|
"glob": "^13.0.0",
|
||||||
"lan-network": "^0.1.6",
|
"lan-network": "^0.1.6",
|
||||||
"minimatch": "^9.0.0",
|
"minimatch": "^9.0.0",
|
||||||
"node-forge": "^1.3.1",
|
"node-forge": "^1.3.3",
|
||||||
"npm-package-arg": "^11.0.0",
|
"npm-package-arg": "^11.0.0",
|
||||||
"ora": "^3.4.0",
|
"ora": "^3.4.0",
|
||||||
"picomatch": "^3.0.1",
|
"picomatch": "^3.0.1",
|
||||||
@ -11386,9 +11426,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo/node_modules/ws": {
|
"node_modules/expo/node_modules/ws": {
|
||||||
"version": "8.18.3",
|
"version": "8.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"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": {
|
"node_modules/react-native-fs": {
|
||||||
"version": "2.20.0",
|
"version": "2.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.20.0.tgz",
|
"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": {
|
"node_modules/react-native-get-random-values": {
|
||||||
"version": "2.0.0",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz",
|
||||||
"integrity": "sha512-wx7/aPqsUIiWsG35D+MsUJd8ij96e3JKddklSdrdZUrheTx89gPtz3Q2yl9knBArj5u26Cl23T88ai+Q0vypdQ==",
|
"integrity": "sha512-4BTbDbRmS7iPdhYLRcz3PGFIpFJBwNZg9g42iwa2P6FOv9vZj/xJc678RZXnLNZzd0qd7Q3CCF6Yd+CU2eoXKQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-base64-decode": "^1.0.0"
|
"fast-base64-decode": "^1.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react-native": ">=0.81"
|
"react-native": ">=0.56"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-native-is-edge-to-edge": {
|
"node_modules/react-native-is-edge-to-edge": {
|
||||||
@ -24327,9 +24386,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.22.0",
|
"version": "6.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||||
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
|
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.17"
|
"node": ">=18.17"
|
||||||
|
|||||||
@ -18,13 +18,14 @@
|
|||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@stripe/stripe-react-native": "0.50.3",
|
"@stripe/stripe-react-native": "0.50.3",
|
||||||
"expo": "~54.0.30",
|
"expo": "~54.0.31",
|
||||||
"expo-audio": "~1.1.1",
|
"expo-audio": "~1.1.1",
|
||||||
"expo-av": "~16.0.8",
|
"expo-av": "~16.0.8",
|
||||||
"expo-build-properties": "~1.0.10",
|
"expo-build-properties": "~1.0.10",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-clipboard": "~8.0.8",
|
"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-file-system": "~19.0.21",
|
||||||
"expo-font": "~14.0.10",
|
"expo-font": "~14.0.10",
|
||||||
"expo-haptics": "~15.0.8",
|
"expo-haptics": "~15.0.8",
|
||||||
@ -44,9 +45,11 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"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-fs": "^2.20.0",
|
||||||
"react-native-gesture-handler": "~2.28.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-reanimated": "~4.1.1",
|
||||||
"react-native-root-toast": "^4.0.1",
|
"react-native-root-toast": "^4.0.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
|||||||
287
services/ble/BLEManager.ts
Normal file
287
services/ble/BLEManager.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
services/ble/MockBLEManager.ts
Normal file
112
services/ble/MockBLEManager.ts
Normal 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
17
services/ble/index.ts
Normal 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
60
services/ble/types.ts
Normal 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>;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user