Add WP sensor status system with BLE scanning

Implemented three-tier sensor status (online/warning/offline) with visual indicators and BLE scanning for nearby devices.

Features:
- WPSensor type with status field (online/warning/offline)
- Automatic status calculation based on lastSeen time:
  • Online: < 5 minutes (fresh data)
  • Warning: 5 min - 1 hour (potential issue)
  • Offline: > 1 hour (definitely problem)
- Dual sensor display: Connected (API) + Available Nearby (BLE)
- BLE scanning button for discovering nearby WP sensors
- Action Sheet for offline sensors with Reconnect/Remove options
- Updated summary card: Total/Online/Warning/Offline counts
- Visual status indicators: colored dots and labels
- Graceful error handling for API unavailability

Files changed:
- types/index.ts: Added WPSensor interface with status and source fields
- services/api.ts: Updated getDevicesForBeneficiary with status calculation
- equipment.tsx: Complete UI overhaul with BLE scanning and two-tier sensor list

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-14 19:03:06 -08:00
parent 1d0bf73222
commit 3aee73a731
3 changed files with 637 additions and 150 deletions

View File

@ -8,12 +8,17 @@ import {
Alert, Alert,
ActivityIndicator, ActivityIndicator,
RefreshControl, RefreshControl,
Platform,
ActionSheetIOS,
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
import * as Device from 'expo-device';
import { useBeneficiary } from '@/contexts/BeneficiaryContext'; import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { useBLE } from '@/contexts/BLEContext';
import { api } from '@/services/api'; import { api } from '@/services/api';
import type { WPSensor } from '@/types';
import { import {
AppColors, AppColors,
BorderRadius, BorderRadius,
@ -24,111 +29,54 @@ import {
} from '@/constants/theme'; } from '@/constants/theme';
import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu'; import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu';
interface Device { const sensorConfig = {
id: string; icon: 'water' as const,
name: string; label: 'WP Sensor',
type: 'motion' | 'door' | 'temperature' | 'hub';
status: 'online' | 'offline';
lastSeen?: string;
room?: string;
}
const deviceTypeConfig = {
motion: {
icon: 'body-outline' as const,
label: 'Motion Sensor',
color: AppColors.primary, color: AppColors.primary,
bgColor: AppColors.primaryLighter, bgColor: AppColors.primaryLighter,
},
door: {
icon: 'enter-outline' as const,
label: 'Door Sensor',
color: AppColors.info,
bgColor: AppColors.infoLight,
},
temperature: {
icon: 'thermometer-outline' as const,
label: 'Temperature',
color: AppColors.warning,
bgColor: AppColors.warningLight,
},
hub: {
icon: 'git-network-outline' as const,
label: 'Hub',
color: AppColors.accent,
bgColor: AppColors.accentLight,
},
}; };
export default function EquipmentScreen() { export default function EquipmentScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const { currentBeneficiary } = useBeneficiary(); const { currentBeneficiary } = useBeneficiary();
const { isBLEAvailable, scanForDevices, stopScan } = useBLE();
const [devices, setDevices] = useState<Device[]>([]); // Separate state for API sensors (attached) and BLE sensors (nearby)
const [apiSensors, setApiSensors] = useState<WPSensor[]>([]);
const [bleSensors, setBleSensors] = useState<WPSensor[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [isScanning, setIsScanning] = useState(false);
const [isDetaching, setIsDetaching] = useState<string | null>(null); const [isDetaching, setIsDetaching] = useState<string | null>(null);
const beneficiaryName = currentBeneficiary?.name || 'this person'; const beneficiaryName = currentBeneficiary?.name || 'this person';
useEffect(() => { useEffect(() => {
loadDevices(); loadSensors();
}, [id]); }, [id]);
const loadDevices = async () => { const loadSensors = async () => {
if (!id) return; if (!id) return;
try { try {
// For now, mock data - replace with actual API call setIsLoading(true);
// const response = await api.getDevices(id);
// Mock devices for demonstration // Get WP sensors from API (attached to beneficiary)
const mockDevices: Device[] = [ const response = await api.getDevicesForBeneficiary(id);
{
id: '1',
name: 'Living Room Motion',
type: 'motion',
status: 'online',
lastSeen: '2 min ago',
room: 'Living Room',
},
{
id: '2',
name: 'Front Door',
type: 'door',
status: 'online',
lastSeen: '5 min ago',
room: 'Entrance',
},
{
id: '3',
name: 'Bedroom Motion',
type: 'motion',
status: 'offline',
lastSeen: '2 hours ago',
room: 'Bedroom',
},
{
id: '4',
name: 'Temperature Monitor',
type: 'temperature',
status: 'online',
lastSeen: '1 min ago',
room: 'Kitchen',
},
{
id: '5',
name: 'WellNuo Hub',
type: 'hub',
status: 'online',
lastSeen: 'Just now',
},
];
setDevices(mockDevices); if (!response.ok) {
// If error is "Not authenticated with Legacy API" or network error,
// just show empty state without Alert
console.warn('[Equipment] Could not load sensors:', response.error);
setApiSensors([]);
return;
}
setApiSensors(response.data);
} catch (error) { } catch (error) {
console.error('Failed to load devices:', error); console.error('[Equipment] Failed to load sensors:', error);
Alert.alert('Error', 'Failed to load devices'); // Show empty state instead of Alert
setApiSensors([]);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
setIsRefreshing(false); setIsRefreshing(false);
@ -137,33 +85,137 @@ export default function EquipmentScreen() {
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
setIsRefreshing(true); setIsRefreshing(true);
loadDevices(); loadSensors();
}, [id]); }, [id]);
const handleDetachDevice = (device: Device) => { // BLE Scan for nearby sensors
const handleScanNearby = async () => {
if (isScanning) {
// Stop scan
stopScan();
setIsScanning(false);
return;
}
setIsScanning(true);
setBleSensors([]); // Clear previous results
try {
const devices = await scanForDevices(10000); // 10 second scan
// Convert BLE devices to WPSensor format
const nearbyWPSensors: WPSensor[] = devices
.filter(d => d.name?.startsWith('WP_')) // Only WP sensors
.map(d => {
// Parse WP_<wellId>_<mac> format
const parts = d.name!.split('_');
const wellId = parseInt(parts[1], 10) || 0;
const mac = parts[2] || d.id.slice(-6);
return {
deviceId: d.id,
wellId: wellId,
mac: mac,
name: d.name!,
status: 'offline' as const, // Nearby but not attached
lastSeen: new Date(),
beneficiaryId: id!,
deploymentId: 0, // Not attached yet
source: 'ble' as const, // From BLE scan
};
});
// Filter out sensors that are already in API list
const apiDeviceIds = new Set(apiSensors.map(s => s.mac));
const uniqueBleSensors = nearbyWPSensors.filter(s => !apiDeviceIds.has(s.mac));
setBleSensors(uniqueBleSensors);
} catch (error) {
console.error('[Equipment] BLE scan failed:', error);
Alert.alert('Scan Failed', 'Could not scan for nearby sensors. Make sure Bluetooth is enabled.');
} finally {
setIsScanning(false);
}
};
// Handle sensor click - show action sheet for offline, navigate to settings for online
const handleSensorPress = (sensor: WPSensor) => {
// For offline API sensors - show reconnect options
if (sensor.source === 'api' && sensor.status === 'offline') {
if (Platform.OS === 'ios') {
ActionSheetIOS.showActionSheetWithOptions(
{
title: `${sensor.name} is Offline`,
message: `Last seen: ${formatLastSeen(sensor.lastSeen)}`,
options: ['Cancel', 'Reconnect via Bluetooth', 'Remove from this home'],
destructiveButtonIndex: 2,
cancelButtonIndex: 0,
},
buttonIndex => {
if (buttonIndex === 1) {
// Reconnect - go to setup-wifi flow
router.push(`/(tabs)/beneficiaries/${id}/setup-wifi?deviceId=${sensor.deviceId}&deviceName=${sensor.name}&wellId=${sensor.wellId}` as any);
} else if (buttonIndex === 2) {
// Remove
handleDetachDevice(sensor);
}
}
);
} else {
// Android fallback
Alert.alert( Alert.alert(
'Detach Device', `${sensor.name} is Offline`,
`Are you sure you want to detach "${device.name}" from ${beneficiaryName}?\n\nThe device will become available for use with another person.`, `Last seen: ${formatLastSeen(sensor.lastSeen)}\n\nWhat would you like to do?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Reconnect',
onPress: () => router.push(`/(tabs)/beneficiaries/${id}/setup-wifi?deviceId=${sensor.deviceId}&deviceName=${sensor.name}&wellId=${sensor.wellId}` as any)
},
{
text: 'Remove',
style: 'destructive',
onPress: () => handleDetachDevice(sensor)
},
]
);
}
}
// For BLE nearby sensors - go directly to setup
else if (sensor.source === 'ble') {
router.push(`/(tabs)/beneficiaries/${id}/setup-wifi?deviceId=${sensor.deviceId}&deviceName=${sensor.name}&wellId=${sensor.wellId}` as any);
}
// For online API sensors - navigate to settings
else {
handleDeviceSettings(sensor);
}
};
const handleDetachDevice = (sensor: WPSensor) => {
Alert.alert(
'Detach Sensor',
`Are you sure you want to detach "${sensor.name}" from ${beneficiaryName}?\n\nThe sensor will become available for use with another person.`,
[ [
{ text: 'Cancel', style: 'cancel' }, { text: 'Cancel', style: 'cancel' },
{ {
text: 'Detach', text: 'Detach',
style: 'destructive', style: 'destructive',
onPress: async () => { onPress: async () => {
setIsDetaching(device.id); setIsDetaching(sensor.deviceId);
try { try {
// API call to detach device const response = await api.detachDeviceFromBeneficiary(id!, sensor.deviceId);
// await api.detachDevice(id, device.id);
// Simulate API delay if (!response.ok) {
await new Promise(resolve => setTimeout(resolve, 1000)); throw new Error('Failed to detach sensor');
}
// Remove from local state // Remove from local state
setDevices(prev => prev.filter(d => d.id !== device.id)); setApiSensors(prev => prev.filter(s => s.deviceId !== sensor.deviceId));
Alert.alert('Success', `${device.name} has been detached.`); Alert.alert('Success', `${sensor.name} has been detached.`);
} catch (error) { } catch (error) {
Alert.alert('Error', 'Failed to detach device. Please try again.'); console.error('[Equipment] Failed to detach sensor:', error);
Alert.alert('Error', 'Failed to detach sensor. Please try again.');
} finally { } finally {
setIsDetaching(null); setIsDetaching(null);
} }
@ -174,14 +226,14 @@ export default function EquipmentScreen() {
}; };
const handleDetachAll = () => { const handleDetachAll = () => {
if (devices.length === 0) { if (apiSensors.length === 0) {
Alert.alert('No Devices', 'There are no devices to detach.'); Alert.alert('No Sensors', 'There are no sensors to detach.');
return; return;
} }
Alert.alert( Alert.alert(
'Detach All Devices', 'Detach All Sensors',
`Are you sure you want to detach all ${devices.length} devices from ${beneficiaryName}?\n\nThis action cannot be undone.`, `Are you sure you want to detach all ${apiSensors.length} sensors from ${beneficiaryName}?\n\nThis action cannot be undone.`,
[ [
{ text: 'Cancel', style: 'cancel' }, { text: 'Cancel', style: 'cancel' },
{ {
@ -190,16 +242,16 @@ export default function EquipmentScreen() {
onPress: async () => { onPress: async () => {
setIsLoading(true); setIsLoading(true);
try { try {
// API call to detach all devices // Detach all sensors sequentially
// await api.detachAllDevices(id); for (const sensor of apiSensors) {
await api.detachDeviceFromBeneficiary(id!, sensor.deviceId);
}
// Simulate API delay setApiSensors([]);
await new Promise(resolve => setTimeout(resolve, 1500)); Alert.alert('Success', 'All sensors have been detached.');
setDevices([]);
Alert.alert('Success', 'All devices have been detached.');
} catch (error) { } catch (error) {
Alert.alert('Error', 'Failed to detach devices. Please try again.'); console.error('[Equipment] Failed to detach all sensors:', error);
Alert.alert('Error', 'Failed to detach sensors. Please try again.');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -209,11 +261,56 @@ export default function EquipmentScreen() {
); );
}; };
const handleAddDevice = () => { const handleAddSensor = () => {
router.push({ // Navigate to Add Sensor screen
pathname: '/(auth)/activate', router.push(`/(tabs)/beneficiaries/${id}/add-sensor` as any);
params: { lovedOneName: beneficiaryName, beneficiaryId: id }, };
});
const handleDeviceSettings = (sensor: WPSensor) => {
// Navigate to Device Settings screen
router.push(`/(tabs)/beneficiaries/${id}/device-settings/${sensor.deviceId}` as any);
};
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 getStatusColor = (status: 'online' | 'warning' | 'offline') => {
switch (status) {
case 'online':
return AppColors.success;
case 'warning':
return AppColors.warning;
case 'offline':
return AppColors.error;
}
};
const getStatusLabel = (status: 'online' | 'warning' | 'offline') => {
switch (status) {
case 'online':
return 'Online';
case 'warning':
return 'Warning';
case 'offline':
return 'Offline';
}
};
const getSignalStrength = (rssi: number): string => {
if (rssi >= -50) return 'Excellent';
if (rssi >= -60) return 'Good';
if (rssi >= -70) return 'Fair';
return 'Weak';
}; };
if (isLoading) { if (isLoading) {
@ -243,7 +340,7 @@ export default function EquipmentScreen() {
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.headerTitle}>Sensors</Text> <Text style={styles.headerTitle}>Sensors</Text>
<View style={styles.headerRight}> <View style={styles.headerRight}>
<TouchableOpacity style={styles.addButton} onPress={handleAddDevice}> <TouchableOpacity style={styles.addButton} onPress={handleAddSensor}>
<Ionicons name="add" size={24} color={AppColors.primary} /> <Ionicons name="add" size={24} color={AppColors.primary} />
</TouchableOpacity> </TouchableOpacity>
<BeneficiaryMenu <BeneficiaryMenu
@ -254,6 +351,16 @@ export default function EquipmentScreen() {
</View> </View>
</View> </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 <ScrollView
style={styles.content} style={styles.content}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
@ -266,78 +373,96 @@ export default function EquipmentScreen() {
<View style={styles.summaryCard}> <View style={styles.summaryCard}>
<View style={styles.summaryRow}> <View style={styles.summaryRow}>
<View style={styles.summaryItem}> <View style={styles.summaryItem}>
<Text style={styles.summaryValue}>{devices.length}</Text> <Text style={styles.summaryValue}>{apiSensors.length}</Text>
<Text style={styles.summaryLabel}>Total Devices</Text> <Text style={styles.summaryLabel}>Total</Text>
</View> </View>
<View style={styles.summaryDivider} /> <View style={styles.summaryDivider} />
<View style={styles.summaryItem}> <View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: AppColors.success }]}> <Text style={[styles.summaryValue, { color: AppColors.success }]}>
{devices.filter(d => d.status === 'online').length} {apiSensors.filter(s => s.status === 'online').length}
</Text> </Text>
<Text style={styles.summaryLabel}>Online</Text> <Text style={styles.summaryLabel}>Online</Text>
</View> </View>
<View style={styles.summaryDivider} /> <View style={styles.summaryDivider} />
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: AppColors.warning }]}>
{apiSensors.filter(s => s.status === 'warning').length}
</Text>
<Text style={styles.summaryLabel}>Warning</Text>
</View>
<View style={styles.summaryDivider} />
<View style={styles.summaryItem}> <View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: AppColors.error }]}> <Text style={[styles.summaryValue, { color: AppColors.error }]}>
{devices.filter(d => d.status === 'offline').length} {apiSensors.filter(s => s.status === 'offline').length}
</Text> </Text>
<Text style={styles.summaryLabel}>Offline</Text> <Text style={styles.summaryLabel}>Offline</Text>
</View> </View>
</View> </View>
</View> </View>
{/* Devices List */} {/* Connected Sensors Section */}
{devices.length === 0 ? ( {apiSensors.length === 0 ? (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<View style={styles.emptyIconContainer}> <View style={styles.emptyIconContainer}>
<Ionicons name="hardware-chip-outline" size={48} color={AppColors.textMuted} /> <Ionicons name="water-outline" size={48} color={AppColors.textMuted} />
</View> </View>
<Text style={styles.emptyTitle}>No Devices Connected</Text> <Text style={styles.emptyTitle}>No Sensors Connected</Text>
<Text style={styles.emptyText}> <Text style={styles.emptyText}>
Add sensors to start monitoring {beneficiaryName}'s wellness. Add WP sensors to start monitoring {beneficiaryName}'s wellness.
</Text> </Text>
<TouchableOpacity style={styles.addDeviceButton} onPress={handleAddDevice}> <TouchableOpacity style={styles.addDeviceButton} onPress={handleAddSensor}>
<Ionicons name="add" size={20} color={AppColors.white} /> <Ionicons name="add" size={20} color={AppColors.white} />
<Text style={styles.addDeviceButtonText}>Add Device</Text> <Text style={styles.addDeviceButtonText}>Add Sensor</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : ( ) : (
<> <>
<Text style={styles.sectionTitle}>Connected Devices</Text> <Text style={styles.sectionTitle}>Connected Sensors ({apiSensors.length})</Text>
<View style={styles.devicesList}> <View style={styles.devicesList}>
{devices.map((device) => { {apiSensors.map((sensor) => {
const config = deviceTypeConfig[device.type]; const isDetachingThis = isDetaching === sensor.deviceId;
const isDetachingThis = isDetaching === device.id; const sensorConfig = {
icon: 'water' as const,
color: AppColors.primary,
bgColor: AppColors.primaryLighter,
};
return ( return (
<View key={device.id} style={styles.deviceCard}> <TouchableOpacity
key={sensor.deviceId}
style={styles.deviceCard}
onPress={() => handleSensorPress(sensor)}
activeOpacity={0.7}
>
<View style={styles.deviceInfo}> <View style={styles.deviceInfo}>
<View style={[styles.deviceIcon, { backgroundColor: config.bgColor }]}> <View style={[styles.deviceIcon, { backgroundColor: sensorConfig.bgColor }]}>
<Ionicons name={config.icon} size={22} color={config.color} /> <Ionicons name={sensorConfig.icon} size={22} color={sensorConfig.color} />
</View> </View>
<View style={styles.deviceDetails}> <View style={styles.deviceDetails}>
<Text style={styles.deviceName}>{device.name}</Text> <Text style={styles.deviceName}>{sensor.name}</Text>
<View style={styles.deviceMeta}> <View style={styles.deviceMeta}>
<View style={[ <View style={[
styles.statusDot, styles.statusDot,
{ backgroundColor: device.status === 'online' ? AppColors.success : AppColors.error } { backgroundColor: getStatusColor(sensor.status) }
]} /> ]} />
<Text style={styles.deviceStatus}> <Text style={[styles.deviceStatus, { color: getStatusColor(sensor.status) }]}>
{device.status === 'online' ? 'Online' : 'Offline'} {getStatusLabel(sensor.status)}
</Text> </Text>
{device.room && (
<>
<Text style={styles.deviceMetaSeparator}></Text> <Text style={styles.deviceMetaSeparator}></Text>
<Text style={styles.deviceRoom}>{device.room}</Text> <Text style={styles.deviceRoom}>{formatLastSeen(sensor.lastSeen)}</Text>
</>
)}
</View> </View>
{sensor.location && (
<Text style={styles.deviceLocation}>{sensor.location}</Text>
)}
</View> </View>
</View> </View>
<TouchableOpacity <TouchableOpacity
style={styles.detachButton} style={styles.detachButton}
onPress={() => handleDetachDevice(device)} onPress={(e) => {
e.stopPropagation();
handleDetachDevice(sensor);
}}
disabled={isDetachingThis} disabled={isDetachingThis}
> >
{isDetachingThis ? ( {isDetachingThis ? (
@ -346,30 +471,93 @@ export default function EquipmentScreen() {
<Ionicons name="unlink-outline" size={20} color={AppColors.error} /> <Ionicons name="unlink-outline" size={20} color={AppColors.error} />
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </TouchableOpacity>
); );
})} })}
</View> </View>
{/* Detach All Button */} {/* Detach All Button */}
{devices.length > 1 && ( {apiSensors.length > 1 && (
<TouchableOpacity style={styles.detachAllButton} onPress={handleDetachAll}> <TouchableOpacity style={styles.detachAllButton} onPress={handleDetachAll}>
<Ionicons name="trash-outline" size={20} color={AppColors.error} /> <Ionicons name="trash-outline" size={20} color={AppColors.error} />
<Text style={styles.detachAllText}>Detach All Devices</Text> <Text style={styles.detachAllText}>Detach All Sensors</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
</> </>
)} )}
{/* Scan Nearby Button */}
<TouchableOpacity
style={[styles.scanButton, isScanning && styles.scanButtonActive]}
onPress={handleScanNearby}
disabled={!isBLEAvailable}
>
{isScanning ? (
<>
<ActivityIndicator size="small" color={AppColors.white} />
<Text style={styles.scanButtonText}>Scanning... ({bleSensors.length} found)</Text>
</>
) : (
<>
<Ionicons name="bluetooth" size={20} color={AppColors.white} />
<Text style={styles.scanButtonText}>
{bleSensors.length > 0 ? 'Scan Again' : 'Scan for Nearby Sensors'}
</Text>
</>
)}
</TouchableOpacity>
{/* Available Nearby Section */}
{bleSensors.length > 0 && (
<>
<Text style={styles.sectionTitle}>Available Nearby ({bleSensors.length})</Text>
<View style={styles.devicesList}>
{bleSensors.map((sensor) => {
const sensorConfig = {
icon: 'water-outline' as const,
color: AppColors.textMuted,
bgColor: AppColors.surface,
};
return (
<TouchableOpacity
key={sensor.deviceId}
style={[styles.deviceCard, styles.nearbyDeviceCard]}
onPress={() => handleSensorPress(sensor)}
activeOpacity={0.7}
>
<View style={styles.deviceInfo}>
<View style={[styles.deviceIcon, { backgroundColor: sensorConfig.bgColor }]}>
<Ionicons name={sensorConfig.icon} size={22} color={sensorConfig.color} />
</View>
<View style={styles.deviceDetails}>
<Text style={styles.deviceName}>{sensor.name}</Text>
<Text style={styles.deviceMeta}>
<Text style={styles.deviceStatus}>Not Connected</Text>
<Text style={styles.deviceMetaSeparator}> </Text>
<Text style={styles.deviceRoom}>Tap to connect</Text>
</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
</TouchableOpacity>
);
})}
</View>
</>
)}
{/* Info Section */} {/* Info Section */}
<View style={styles.infoCard}> <View style={styles.infoCard}>
<View style={styles.infoHeader}> <View style={styles.infoHeader}>
<Ionicons name="information-circle" size={20} color={AppColors.info} /> <Ionicons name="information-circle" size={20} color={AppColors.info} />
<Text style={styles.infoTitle}>About Equipment</Text> <Text style={styles.infoTitle}>About Sensors</Text>
</View> </View>
<Text style={styles.infoText}> <Text style={styles.infoText}>
Detaching a device will remove it from {beneficiaryName}'s monitoring setup. WP sensors monitor wellness metrics via WiFi. Tap a sensor to configure WiFi settings, view detailed status, or troubleshoot connectivity issues.
You can then attach it to another person or re-attach it later using the activation code. {'\n\n'}
Detaching a sensor removes it from {beneficiaryName}'s monitoring setup. You can then attach it to another person or re-attach it later.
</Text> </Text>
</View> </View>
</ScrollView> </ScrollView>
@ -619,4 +807,64 @@ const styles = StyleSheet.create({
color: AppColors.info, color: AppColors.info,
lineHeight: 20, lineHeight: 20,
}, },
// WiFi Info
wifiInfo: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: 4,
},
wifiText: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
},
// Simulator Warning
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,
},
// Scan Button
scanButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
marginTop: Spacing.lg,
marginBottom: Spacing.lg,
gap: Spacing.sm,
...Shadows.md,
},
scanButtonActive: {
backgroundColor: AppColors.secondary,
},
scanButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
// Nearby Device Card
nearbyDeviceCard: {
borderWidth: 1,
borderColor: AppColors.border,
borderStyle: 'dashed',
},
deviceLocation: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
marginTop: 2,
},
}); });

View File

@ -1,4 +1,4 @@
import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, NotificationSettings } from '@/types'; import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, NotificationSettings, WPSensor } from '@/types';
import { File } from 'expo-file-system'; import { File } from 'expo-file-system';
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
@ -1534,6 +1534,230 @@ class ApiService {
getDemoDeploymentId(): number { getDemoDeploymentId(): number {
return this.DEMO_DEPLOYMENT_ID; return this.DEMO_DEPLOYMENT_ID;
} }
// ============================================================================
// WP SENSORS / DEVICES MANAGEMENT
// ============================================================================
/**
* Get all devices for a beneficiary
* Returns WP sensors with online/offline status
*/
async getDevicesForBeneficiary(beneficiaryId: string) {
try {
// Get beneficiary's deployment_id from PostgreSQL
const response = await fetch(`${this.baseUrl}/me/beneficiaries/${beneficiaryId}`);
if (!response.ok) throw new Error('Failed to get beneficiary');
const beneficiary = await response.json();
const deploymentId = beneficiary.deploymentId;
if (!deploymentId) {
return { ok: true, data: [] }; // No deployment = no devices
}
// Get Legacy API credentials
const creds = await this.getLegacyCredentials();
if (!creds) return { ok: false, error: 'Not authenticated with Legacy API' };
// Get devices from Legacy API
const formData = new URLSearchParams({
function: 'device_list_by_deployment',
user_name: creds.userName,
token: creds.token,
deployment_id: deploymentId.toString(),
first: '0',
last: '100',
});
const devicesResponse = await fetch(this.legacyApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
});
if (!devicesResponse.ok) {
throw new Error('Failed to fetch devices from Legacy API');
}
const devicesData = await devicesResponse.json();
if (!devicesData.result_list || devicesData.result_list.length === 0) {
return { ok: true, data: [] };
}
// Get online status
const onlineDevices = await this.getOnlineDevices(deploymentId);
// Transform to WPSensor format with status calculation
const sensors: WPSensor[] = devicesData.result_list.map((device: any) => {
const [deviceId, wellId, mac, lastSeenTimestamp, location, description] = device;
const lastSeen = new Date(lastSeenTimestamp * 1000);
// Calculate status based on lastSeen time
const now = new Date();
const diffMinutes = (now.getTime() - lastSeen.getTime()) / (1000 * 60);
let status: 'online' | 'warning' | 'offline';
if (diffMinutes < 5) {
status = 'online'; // 🟢 Fresh data
} else if (diffMinutes < 60) {
status = 'warning'; // 🟡 Might be issue
} else {
status = 'offline'; // 🔴 Definitely problem
}
return {
deviceId: deviceId.toString(),
wellId: parseInt(wellId, 10),
mac: mac,
name: `WP_${wellId}_${mac.slice(-6).toLowerCase()}`,
status: status,
lastSeen: lastSeen,
location: location || '',
description: description || '',
beneficiaryId: beneficiaryId,
deploymentId: deploymentId,
source: 'api', // From API = attached to beneficiary
};
});
return { ok: true, data: sensors };
} catch (error: any) {
console.error('[API] getDevicesForBeneficiary error:', error);
return { ok: false, error: error.message };
}
}
/**
* Get online devices for a deployment (using fresh=true)
* Returns Set of device_ids that are online
*/
private async getOnlineDevices(deploymentId: number): Promise<Set<number>> {
try {
const creds = await this.getLegacyCredentials();
if (!creds) return new Set();
const formData = new URLSearchParams({
function: 'request_devices',
user_name: creds.userName,
token: creds.token,
deployment_id: deploymentId.toString(),
group_id: 'All',
location: 'All',
fresh: 'true', // Only online devices
});
const response = await fetch(this.legacyApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
});
if (!response.ok) return new Set();
const data = await response.json();
if (!data.result_list) return new Set();
// Extract device_ids from result
const deviceIds = data.result_list.map((device: any) => device[0]);
return new Set(deviceIds);
} catch (error) {
console.error('[API] getOnlineDevices error:', error);
return new Set();
}
}
/**
* Attach device to beneficiary's deployment
*/
async attachDeviceToBeneficiary(
beneficiaryId: string,
wellId: number,
ssid: string,
password: string
) {
try {
// Get beneficiary details
const response = await fetch(`${this.baseUrl}/me/beneficiaries/${beneficiaryId}`);
if (!response.ok) throw new Error('Failed to get beneficiary');
const beneficiary = await response.json();
const deploymentId = beneficiary.deploymentId;
if (!deploymentId) {
throw new Error('Beneficiary has no deployment');
}
const creds = await this.getLegacyCredentials();
if (!creds) throw new Error('Not authenticated with Legacy API');
// Call set_deployment to attach device
const formData = new URLSearchParams({
function: 'set_deployment',
user_name: creds.userName,
token: creds.token,
deployment: deploymentId.toString(),
devices: JSON.stringify([wellId]),
wifis: JSON.stringify([`${ssid}|${password}`]),
reuse_existing_devices: '1',
});
const attachResponse = await fetch(this.legacyApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
});
if (!attachResponse.ok) {
throw new Error('Failed to attach device');
}
const data = await attachResponse.json();
if (data.status !== '200 OK') {
throw new Error(data.message || 'Failed to attach device');
}
return { ok: true };
} catch (error: any) {
console.error('[API] attachDeviceToBeneficiary error:', error);
return { ok: false, error: error.message };
}
}
/**
* Detach device from beneficiary
*/
async detachDeviceFromBeneficiary(beneficiaryId: string, deviceId: string) {
try {
const creds = await this.getLegacyCredentials();
if (!creds) throw new Error('Not authenticated with Legacy API');
// Set device's deployment to 0 (unassigned)
const formData = new URLSearchParams({
function: 'device_form',
user_name: creds.userName,
token: creds.token,
device_id: deviceId,
deployment_id: '0', // Unassign
});
const response = await fetch(this.legacyApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
});
if (!response.ok) throw new Error('Failed to detach device');
return { ok: true };
} catch (error: any) {
console.error('[API] detachDeviceFromBeneficiary error:', error);
return { ok: false, error: error.message };
}
}
} }
export const api = new ApiService(); export const api = new ApiService();

View File

@ -43,6 +43,21 @@ export interface BeneficiaryDevice {
lastSeen?: string; lastSeen?: string;
} }
// WP Sensor (Water Pressure sensor) from Legacy API
export interface WPSensor {
deviceId: string; // Device ID from Legacy API
wellId: number; // Well ID (physical device identifier)
mac: string; // MAC address
name: string; // Display name (e.g., "WP_12_a1b2c3")
status: 'online' | 'warning' | 'offline'; // Connection status
lastSeen: Date; // Last data transmission time
location?: string; // Physical location
description?: string; // User description
beneficiaryId: string; // Associated beneficiary
deploymentId: number; // Legacy API deployment ID
source: 'api' | 'ble'; // Data source (API = attached, BLE = nearby)
}
// Equipment/Kit delivery status // Equipment/Kit delivery status
export type EquipmentStatus = export type EquipmentStatus =
| 'none' // No equipment ordered | 'none' // No equipment ordered