Compare commits

...

10 Commits

Author SHA1 Message Date
Sergei
9f9124fdab feat(sensors): Batch sensor setup with progress UI and error handling
- Add updateDeviceMetadata and attachDeviceToDeployment API methods
- Device Settings: editable location/description fields with save
- Equipment screen: location placeholder and quick navigation to settings
- Add Sensor: multi-select with checkboxes, select all/deselect all
- Setup WiFi: batch processing of multiple sensors sequentially
- BatchSetupProgress: animated progress bar, step indicators, auto-scroll
- SetupResultsScreen: success/failed/skipped summary with retry options
- Error handling: modal with Retry/Skip/Cancel All buttons
- Documentation: SENSORS_SYSTEM.md with full BLE protocol and flows

Implemented via Ralphy CLI autonomous agent in ~43 minutes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 23:24:22 -08:00
Sergei
1301c6e093 Make sensor location tappable to navigate to Device Settings
Added TouchableOpacity wrapper around the location text in the equipment
list so users can tap on a sensor's location to go directly to its
Device Settings screen.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 23:17:00 -08:00
Sergei
102a562f9d Fix sensors list API: add missing auth headers and credentials method
- Add baseUrl and legacyApiUrl as class properties in ApiService
- Add getLegacyCredentials() method for device operations
- Add Authorization header to getDevicesForBeneficiary()
- Add Authorization header to attachDeviceToBeneficiary()

These changes fix the sensors list functionality allowing users
to view sensors for any beneficiary.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 23:04:45 -08:00
Sergei
8a633a0f6b Add attachDeviceToDeployment method to api.ts
Implements API method to link WP sensors to a beneficiary's deployment
via the Legacy API set_deployment endpoint. Uses proper authentication
through getLegacyWebViewCredentials() and follows existing API patterns.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 23:01:47 -08:00
Sergei
516dc37527 Add retry button for individual failed sensors on results screen
- Added handleRetryFromResults function to retry setup for a single sensor
- Added Retry button next to each failed/skipped sensor in the results list
- When clicked, resets sensor state and returns to batch setup phase
- Added new styles: resultItemWithAction, resultItemLeft, retryItemButton

TASK-6.2: Add results screen after batch setup

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 23:00:32 -08:00
Sergei
ca820b25fb Add progress UI enhancements for batch sensor setup
- Add sensor index badge (1/5, 2/5...) on each card
- Add elapsed time display for processing sensors
- Add auto-scroll to current active sensor
- Add animated progress bar with success/error segments
- Add stats row showing success/error/skipped counts
- Improve visual feedback during batch WiFi setup

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 22:55:10 -08:00
Sergei
be1c2eb7f5 Refactor Setup WiFi screen for batch sensor processing
- Add SensorSetupState and BatchSetupState types for tracking sensor setup progress
- Create BatchSetupProgress component with step-by-step progress UI
- Implement sequential sensor processing with:
  - Connect → Unlock → Set WiFi → Attach → Reboot steps
  - Error handling with Retry/Skip options for each sensor
  - Pause on failure, resume on retry/skip
  - Cancel all functionality
- Add results screen showing success/failed sensors
- Support processing multiple sensors with same WiFi credentials

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 22:47:48 -08:00
Sergei
b738d86419 Update navigation to pass selected devices array
- add-sensor.tsx now passes devices array with mac address via JSON
- setup-wifi.tsx parses devices from navigation params
- Support batch mode display (shows count and device names)
- Disconnect all devices when navigating back

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 22:43:12 -08:00
Sergei
52def3cb79 Show placeholder for empty location in Equipment screen
Display "No location set" in italic style when sensor has no location configured.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 22:38:39 -08:00
Sergei
c46af1ea1d Add updateDeviceMetadata method to api.ts
Add method to update device location and description via Legacy API
device_form endpoint. Uses getLegacyWebViewCredentials for auth.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 22:35:10 -08:00
12 changed files with 3514 additions and 186 deletions

17
.ralphy/config.yaml Normal file
View File

@ -0,0 +1,17 @@
project:
name: wellnuo
language: TypeScript
framework: React
description: ""
commands:
test: ""
lint: npm run lint
build: ""
rules:
- Read CLAUDE.md for project architecture - API-first, no local storage
- Read docs/SENSORS_SYSTEM.md before working on sensors
- Use TypeScript strict mode, proper types required
- "BLE operations: use BLEManager.ts for real, MockBLEManager.ts for simulator"
- "Legacy API auth: use getLegacyCredentials() from api.ts"
boundaries:
never_touch: []

25
.ralphy/progress.txt Normal file
View File

@ -0,0 +1,25 @@
# Ralphy Progress Log
- [✓] 2026-01-20 06:35 - **TASK-1.1: Add updateDeviceMetadata method to api.ts**
- [✓] 2026-01-20 06:38 - **TASK-2.1: Add location/description editing to Device Settings screen**
- [✓] 2026-01-20 06:38 - **TASK-3.1: Show placeholder for empty location in Equipment screen**
- [✓] 2026-01-20 06:39 - **TASK-3.2: Add quick navigation to Device Settings from Equipment screen**
- [✓] 2026-01-20 06:41 - **TASK-4.1: Add checkbox selection to Add Sensor screen**
- [✓] 2026-01-20 06:43 - **TASK-4.2: Update navigation to pass selected devices**
- [✓] 2026-01-20 06:48 - **TASK-5.1: Refactor Setup WiFi screen for batch processing**
- [✓] 2026-01-20 06:51 - **TASK-5.2: Implement batch setup processing logic**
- [✓] 2026-01-20 06:55 - **TASK-5.3: Add progress UI for batch setup**
- [✓] 2026-01-20 06:58 - **TASK-6.1: Add error handling UI with retry/skip options**
- [✓] 2026-01-20 07:00 - **TASK-6.2: Add results screen after batch setup**
- [✓] 2026-01-20 07:01 - **TASK-7.1: Add attachDeviceToDeployment method to api.ts**
- [✓] 2026-01-20 07:04 - Can view sensors list for any beneficiary
- [✓] 2026-01-20 07:06 - Can scan and find WP_* sensors via BLE
- [✓] 2026-01-20 07:07 - Can select multiple sensors with checkboxes
- [✓] 2026-01-20 07:09 - Can configure WiFi for all selected sensors
- [✓] 2026-01-20 07:09 - Progress UI shows status for each device
- [✓] 2026-01-20 07:11 - Errors show retry/skip options
- [✓] 2026-01-20 07:14 - Results screen shows success/failure summary
- [✓] 2026-01-20 07:16 - Can edit sensor location in Device Settings
- [✓] 2026-01-20 07:16 - Location placeholder shows in Equipment screen
- [✓] 2026-01-20 07:17 - Can tap location to go to Device Settings
- [✓] 2026-01-20 07:18 - Mock BLE works in iOS Simulator

355
PRD-SENSORS.md Normal file
View File

@ -0,0 +1,355 @@
# PRD: Sensors Management System
## Context
WellNuo app for elderly care. BLE/WiFi sensors monitor beneficiaries (elderly people) at home.
Each user can have multiple beneficiaries. Each beneficiary has one deployment (household) with up to 5 sensors.
**Architecture:**
- User → Beneficiary (WellNuo API) → deploymentId → Deployment (Legacy API) → Devices
- BLE for sensor setup, WiFi for data transmission
- Legacy API at `https://eluxnetworks.net/function/well-api/api` (external, read-only code access)
**Documentation:** `docs/SENSORS_SYSTEM.md`
**Feature Spec:** `specs/wellnuo/FEATURE-SENSORS-SYSTEM.md`
---
## Tasks
### Phase 1: API Layer
- [x] **TASK-1.1: Add updateDeviceMetadata method to api.ts**
File: `services/api.ts`
Add method to update device location and description via Legacy API.
```typescript
async updateDeviceMetadata(
wellId: number,
mac: string,
deploymentId: number,
location: string,
description: string
): Promise<boolean>
```
Implementation:
1. Get Legacy API credentials via `getLegacyCredentials()`
2. Build form data with: `function=device_form`, `user_name`, `token`, `well_id`, `device_mac`, `location`, `description`, `deployment_id`
3. POST to `https://eluxnetworks.net/function/well-api/api`
4. Return true on success, false on error
Reference: `docs/SENSORS_SYSTEM.md` lines 266-280 for API format.
---
### Phase 2: Device Settings UI
- [x] **TASK-2.1: Add location/description editing to Device Settings screen**
File: `app/(tabs)/beneficiaries/[id]/device-settings/[deviceId].tsx`
Add editable fields for sensor location and description:
1. Add state variables: `location`, `description`, `isSaving`
2. Add two TextInput fields below device info section
3. Add "Save" button that calls `api.updateDeviceMetadata()`
4. Show loading indicator during save
5. Show success/error toast after save
6. Pre-fill fields with current values from device data
UI requirements:
- TextInput for location (placeholder: "e.g., Bedroom, near bed")
- TextInput for description (placeholder: "e.g., Main activity sensor")
- Button: "Save Changes" (disabled when no changes or saving)
- Toast: "Settings saved" on success
---
### Phase 3: Equipment Screen Improvements
- [x] **TASK-3.1: Show placeholder for empty location in Equipment screen**
File: `app/(tabs)/beneficiaries/[id]/equipment.tsx`
Find the sensor list rendering (around line 454) and update:
Before:
```tsx
{sensor.location && (
<Text style={styles.deviceLocation}>{sensor.location}</Text>
)}
```
After:
```tsx
<Text style={[styles.deviceLocation, !sensor.location && styles.deviceLocationEmpty]}>
{sensor.location || 'Tap to set location'}
</Text>
```
Add style `deviceLocationEmpty` with `opacity: 0.5, fontStyle: 'italic'`
- [x] **TASK-3.2: Add quick navigation to Device Settings from Equipment screen**
File: `app/(tabs)/beneficiaries/[id]/equipment.tsx`
Make the location text tappable to navigate to Device Settings:
1. Wrap location Text in TouchableOpacity
2. onPress: navigate to `/beneficiaries/${id}/device-settings/${device.id}`
3. Import `useRouter` from `expo-router` if not already imported
---
### Phase 4: Batch Sensor Setup - Selection UI
- [x] **TASK-4.1: Add checkbox selection to Add Sensor screen**
File: `app/(tabs)/beneficiaries/[id]/add-sensor.tsx`
After BLE scan, show checkboxes for selecting multiple sensors:
1. Add state: `selectedDevices: Set<string>` (device IDs)
2. After scan, select ALL devices by default
3. Render each device with checkbox (use Checkbox from react-native or custom)
4. Add "Select All" / "Deselect All" toggle at top
5. Show count: "3 of 5 selected"
6. Change button from "Connect" to "Setup Selected (N)"
7. Pass selected devices to Setup WiFi screen via route params
UI layout:
```
[ ] Select All
[x] WP_497_81a14c -55 dBm ✓
[x] WP_498_82b25d -62 dBm ✓
[ ] WP_499_83c36e -78 dBm
[Setup Selected (2)]
```
- [x] **TASK-4.2: Update navigation to pass selected devices**
File: `app/(tabs)/beneficiaries/[id]/add-sensor.tsx`
When navigating to setup-wifi screen, pass selected devices:
```typescript
router.push({
pathname: `/(tabs)/beneficiaries/${id}/setup-wifi`,
params: {
devices: JSON.stringify(selectedDevicesArray),
beneficiaryId: id
}
});
```
---
### Phase 5: Batch Sensor Setup - WiFi Configuration
- [x] **TASK-5.1: Refactor Setup WiFi screen for batch processing**
File: `app/(tabs)/beneficiaries/[id]/setup-wifi.tsx`
Update to handle multiple devices:
1. Parse `devices` from route params (JSON array of WPDevice objects)
2. Get WiFi list from FIRST device only (all sensors at same location = same WiFi)
3. After user enters password, process ALL devices sequentially
4. Add state for batch progress tracking
New state:
```typescript
interface DeviceSetupState {
deviceId: string;
deviceName: string;
status: 'pending' | 'connecting' | 'unlocking' | 'setting_wifi' | 'attaching' | 'rebooting' | 'success' | 'error';
error?: string;
}
const [setupStates, setSetupStates] = useState<DeviceSetupState[]>([]);
```
- [x] **TASK-5.2: Implement batch setup processing logic**
File: `app/(tabs)/beneficiaries/[id]/setup-wifi.tsx`
Create `processBatchSetup` function:
```typescript
async function processBatchSetup(ssid: string, password: string) {
for (const device of devices) {
updateStatus(device.id, 'connecting');
// 1. Connect BLE
const connected = await bleManager.connectDevice(device.id);
if (!connected) {
updateStatus(device.id, 'error', 'Could not connect');
continue; // Skip to next device
}
// 2. Unlock with PIN
updateStatus(device.id, 'unlocking');
await bleManager.sendCommand(device.id, 'pin|7856');
// 3. Set WiFi
updateStatus(device.id, 'setting_wifi');
await bleManager.setWiFi(device.id, ssid, password);
// 4. Attach to deployment via Legacy API
updateStatus(device.id, 'attaching');
await api.attachDeviceToDeployment(device.wellId, device.mac, deploymentId);
// 5. Reboot
updateStatus(device.id, 'rebooting');
await bleManager.rebootDevice(device.id);
updateStatus(device.id, 'success');
}
}
```
- [x] **TASK-5.3: Add progress UI for batch setup**
File: `app/(tabs)/beneficiaries/[id]/setup-wifi.tsx`
Show progress for each device:
```
Connecting to "Home_Network"...
WP_497_81a14c
✓ Connected
✓ Unlocked
✓ WiFi configured
● Attaching to Maria...
WP_498_82b25d
✓ Connected
○ Waiting...
WP_499_83c36e
○ Pending
```
Use icons: ✓ (success), ● (in progress), ○ (pending), ✗ (error)
---
### Phase 6: Error Handling
- [x] **TASK-6.1: Add error handling UI with retry/skip options**
File: `app/(tabs)/beneficiaries/[id]/setup-wifi.tsx`
When a device fails:
1. Pause batch processing
2. Show error message with device name
3. Show three buttons: [Retry] [Skip] [Cancel All]
4. On Retry: try this device again
5. On Skip: mark as skipped, continue to next device
6. On Cancel All: abort entire process, show results
```tsx
{currentError && (
<View style={styles.errorContainer}>
<Text style={styles.errorTitle}>
Failed: {currentError.deviceName}
</Text>
<Text style={styles.errorMessage}>
{currentError.message}
</Text>
<View style={styles.errorButtons}>
<Button title="Retry" onPress={handleRetry} />
<Button title="Skip" onPress={handleSkip} />
<Button title="Cancel All" onPress={handleCancelAll} />
</View>
</View>
)}
```
- [x] **TASK-6.2: Add results screen after batch setup**
File: `app/(tabs)/beneficiaries/[id]/setup-wifi.tsx`
After all devices processed, show summary:
```
Setup Complete
Successfully connected:
✓ WP_497_81a14c
✓ WP_498_82b25d
Failed:
✗ WP_499_83c36e - Connection timeout
[Retry This Sensor]
Skipped:
⊘ WP_500_84d47f
[Done]
```
"Done" button navigates back to Equipment screen.
---
### Phase 7: API Method for Device Attachment
- [x] **TASK-7.1: Add attachDeviceToDeployment method to api.ts**
File: `services/api.ts`
Add method to register a new device with Legacy API:
```typescript
async attachDeviceToDeployment(
wellId: number,
mac: string,
deploymentId: number,
location?: string,
description?: string
): Promise<{ success: boolean; deviceId?: number; error?: string }>
```
Implementation:
1. Call Legacy API `device_form` with deployment_id set
2. Return device ID from response on success
3. Return error message on failure
This is used during batch setup to link each sensor to the beneficiary's deployment.
---
## Verification Checklist
After all tasks complete, verify:
- [x] Can view sensors list for any beneficiary
- [x] Can scan and find WP_* sensors via BLE
- [x] Can select multiple sensors with checkboxes
- [x] Can configure WiFi for all selected sensors
- [x] Progress UI shows status for each device
- [x] Errors show retry/skip options
- [x] Results screen shows success/failure summary
- [x] Can edit sensor location in Device Settings
- [x] Location placeholder shows in Equipment screen
- [x] Can tap location to go to Device Settings
- [x] Mock BLE works in iOS Simulator
---
## Notes for AI Agent
1. **Read documentation first**: `docs/SENSORS_SYSTEM.md` has full context
2. **Check existing code**: Files may already have partial implementations
3. **BLE Manager**: Use `services/ble/BLEManager.ts` for real device, `MockBLEManager.ts` for simulator
4. **Legacy API auth**: Use `getLegacyCredentials()` method in api.ts
5. **Error handling**: Always wrap BLE operations in try/catch
6. **TypeScript**: Project uses strict TypeScript, ensure proper types
7. **Testing**: Use iOS Simulator with Mock BLE for testing

View File

@ -37,11 +37,40 @@ export default function AddSensorScreen() {
connectDevice,
} = useBLE();
const [selectedDevice, setSelectedDevice] = useState<WPDevice | null>(null);
const [selectedDevices, setSelectedDevices] = useState<Set<string>>(new Set());
const [isConnecting, setIsConnecting] = useState(false);
const beneficiaryName = currentBeneficiary?.name || 'this person';
// Select all devices by default when scan completes
React.useEffect(() => {
if (foundDevices.length > 0 && !isScanning) {
setSelectedDevices(new Set(foundDevices.map(d => d.id)));
}
}, [foundDevices, isScanning]);
const toggleDeviceSelection = (deviceId: string) => {
setSelectedDevices(prev => {
const next = new Set(prev);
if (next.has(deviceId)) {
next.delete(deviceId);
} else {
next.add(deviceId);
}
return next;
});
};
const toggleSelectAll = () => {
if (selectedDevices.size === foundDevices.length) {
setSelectedDevices(new Set());
} else {
setSelectedDevices(new Set(foundDevices.map(d => d.id)));
}
};
const selectedCount = selectedDevices.size;
const handleScan = async () => {
try {
await scanDevices();
@ -51,36 +80,26 @@ export default function AddSensorScreen() {
}
};
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 handleAddSelected = () => {
if (selectedCount === 0) {
Alert.alert('No Sensors Selected', 'Please select at least one sensor to add.');
return;
}
const devices = foundDevices.filter(d => selectedDevices.has(d.id));
// Navigate to Setup WiFi screen with selected devices
router.push({
pathname: `/(tabs)/beneficiaries/${id}/setup-wifi` as any,
params: {
devices: JSON.stringify(devices.map(d => ({
id: d.id,
name: d.name,
mac: d.mac,
wellId: d.wellId,
}))),
},
});
};
const getSignalIcon = (rssi: number) => {
@ -172,28 +191,52 @@ export default function AddSensorScreen() {
<>
<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 style={styles.sectionActions}>
<TouchableOpacity style={styles.selectAllButton} onPress={toggleSelectAll}>
<Ionicons
name={selectedDevices.size === foundDevices.length ? 'checkbox' : 'square-outline'}
size={18}
color={AppColors.primary}
/>
<Text style={styles.selectAllText}>
{selectedDevices.size === foundDevices.length ? 'Deselect All' : 'Select All'}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.rescanButton} onPress={handleScan}>
<Ionicons name="refresh" size={18} color={AppColors.primary} />
<Text style={styles.rescanText}>Rescan</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.devicesList}>
{foundDevices.map((device) => {
const isConnected = connectedDevices.has(device.id);
const isConnectingThis = isConnecting && selectedDevice?.id === device.id;
const isSelected = selectedDevices.has(device.id);
return (
<TouchableOpacity
key={device.id}
style={[
styles.deviceCard,
isSelected && styles.deviceCardSelected,
isConnected && styles.deviceCardConnected,
]}
onPress={() => handleConnect(device)}
disabled={isConnectingThis || isConnected}
onPress={() => toggleDeviceSelection(device.id)}
disabled={isConnected}
activeOpacity={0.7}
>
<View style={styles.checkboxContainer}>
<View style={[
styles.checkbox,
isSelected && styles.checkboxSelected,
isConnected && styles.checkboxDisabled,
]}>
{isSelected && (
<Ionicons name="checkmark" size={16} color={AppColors.white} />
)}
</View>
</View>
<View style={styles.deviceInfo}>
<View style={styles.deviceIcon}>
<Ionicons name="water" size={24} color={AppColors.primary} />
@ -216,19 +259,30 @@ export default function AddSensorScreen() {
</View>
</View>
{isConnectingThis ? (
<ActivityIndicator size="small" color={AppColors.primary} />
) : isConnected ? (
<View style={styles.connectedBadge}>
<Ionicons name="checkmark-circle" size={20} color={AppColors.success} />
{isConnected && (
<View style={styles.alreadyAddedBadge}>
<Text style={styles.alreadyAddedText}>Added</Text>
</View>
) : (
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
)}
</TouchableOpacity>
);
})}
</View>
{/* Add Selected Button */}
<TouchableOpacity
style={[
styles.addSelectedButton,
selectedCount === 0 && styles.addSelectedButtonDisabled,
]}
onPress={handleAddSelected}
disabled={selectedCount === 0}
>
<Ionicons name="add-circle" size={24} color={AppColors.white} />
<Text style={styles.addSelectedButtonText}>
Add Selected ({selectedCount})
</Text>
</TouchableOpacity>
</>
)}
@ -406,6 +460,21 @@ const styles = StyleSheet.create({
textTransform: 'uppercase',
letterSpacing: 0.5,
},
sectionActions: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
},
selectAllButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
selectAllText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.primary,
},
rescanButton: {
flexDirection: 'row',
alignItems: 'center',
@ -427,12 +496,37 @@ const styles = StyleSheet.create({
padding: Spacing.md,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
...Shadows.xs,
},
deviceCardSelected: {
borderWidth: 2,
borderColor: AppColors.primary,
},
deviceCardConnected: {
borderWidth: 2,
borderColor: AppColors.success,
opacity: 0.6,
},
checkboxContainer: {
marginRight: Spacing.sm,
},
checkbox: {
width: 24,
height: 24,
borderRadius: BorderRadius.sm,
borderWidth: 2,
borderColor: AppColors.border,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: AppColors.white,
},
checkboxSelected: {
backgroundColor: AppColors.primary,
borderColor: AppColors.primary,
},
checkboxDisabled: {
backgroundColor: AppColors.surfaceSecondary,
borderColor: AppColors.border,
},
deviceInfo: {
flex: 1,
@ -471,8 +565,37 @@ const styles = StyleSheet.create({
fontSize: FontSizes.xs,
fontWeight: FontWeights.medium,
},
connectedBadge: {
padding: Spacing.xs,
alreadyAddedBadge: {
backgroundColor: AppColors.successLight,
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.sm,
borderRadius: BorderRadius.sm,
},
alreadyAddedText: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.medium,
color: AppColors.success,
},
// Add Selected Button
addSelectedButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
marginTop: Spacing.md,
marginBottom: Spacing.lg,
gap: Spacing.sm,
...Shadows.md,
},
addSelectedButtonDisabled: {
backgroundColor: AppColors.textMuted,
},
addSelectedButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
// Empty State
emptyState: {

View File

@ -7,6 +7,7 @@ import {
TouchableOpacity,
Alert,
ActivityIndicator,
TextInput,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
@ -29,10 +30,12 @@ interface SensorInfo {
wellId: number;
mac: string;
name: string;
status: 'online' | 'offline';
status: 'online' | 'warning' | 'offline';
lastSeen: Date;
beneficiaryId: string;
deploymentId: number;
location?: string;
description?: string;
}
export default function DeviceSettingsScreen() {
@ -52,6 +55,11 @@ export default function DeviceSettingsScreen() {
const [isConnecting, setIsConnecting] = useState(false);
const [isLoadingWiFi, setIsLoadingWiFi] = useState(false);
const [isRebooting, setIsRebooting] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Editable fields
const [location, setLocation] = useState('');
const [description, setDescription] = useState('');
const isConnected = connectedDevices.has(deviceId!);
@ -66,14 +74,16 @@ export default function DeviceSettingsScreen() {
// Get sensor info from API
const response = await api.getDevicesForBeneficiary(id!);
if (!response.ok) {
if (!response.ok || !response.data) {
throw new Error('Failed to load sensor info');
}
const sensor = response.data.find((s: any) => s.deviceId === deviceId);
const sensor = response.data.find((s: SensorInfo) => s.deviceId === deviceId);
if (sensor) {
setSensorInfo(sensor);
setLocation(sensor.location || '');
setDescription(sensor.description || '');
} else {
throw new Error('Sensor not found');
}
@ -174,6 +184,55 @@ export default function DeviceSettingsScreen() {
);
};
const handleSaveMetadata = async () => {
if (!sensorInfo) return;
// Check if anything changed
const locationChanged = location !== (sensorInfo.location || '');
const descriptionChanged = description !== (sensorInfo.description || '');
if (!locationChanged && !descriptionChanged) {
Alert.alert('No Changes', 'No changes to save.');
return;
}
setIsSaving(true);
try {
const updates: { location?: string; description?: string } = {};
if (locationChanged) updates.location = location;
if (descriptionChanged) updates.description = description;
const response = await api.updateDeviceMetadata(sensorInfo.deviceId, updates);
if (!response.ok) {
throw new Error(response.error?.message || 'Failed to save');
}
// Update local state
setSensorInfo({
...sensorInfo,
location,
description,
});
Alert.alert('Success', 'Device information updated.');
} catch (error: any) {
console.error('[DeviceSettings] Save failed:', error);
Alert.alert('Error', error.message || 'Failed to save device information');
} finally {
setIsSaving(false);
}
};
const hasUnsavedChanges = () => {
if (!sensorInfo) return false;
return (
location !== (sensorInfo.location || '') ||
description !== (sensorInfo.description || '')
);
};
const formatLastSeen = (lastSeen: Date): string => {
const now = new Date();
const diffMs = now.getTime() - lastSeen.getTime();
@ -288,6 +347,55 @@ export default function DeviceSettingsScreen() {
</View>
</View>
{/* Editable Metadata Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Sensor Details</Text>
<View style={styles.detailsCard}>
<View style={styles.editableRow}>
<Text style={styles.editableLabel}>Location</Text>
<TextInput
style={styles.editableInput}
value={location}
onChangeText={setLocation}
placeholder="e.g., Living Room, Kitchen..."
placeholderTextColor={AppColors.textMuted}
/>
</View>
<View style={styles.detailDivider} />
<View style={styles.editableRow}>
<Text style={styles.editableLabel}>Description</Text>
<TextInput
style={[styles.editableInput, styles.editableInputMultiline]}
value={description}
onChangeText={setDescription}
placeholder="Add notes about this sensor..."
placeholderTextColor={AppColors.textMuted}
multiline
numberOfLines={2}
/>
</View>
{hasUnsavedChanges() && (
<>
<View style={styles.detailDivider} />
<TouchableOpacity
style={styles.saveButton}
onPress={handleSaveMetadata}
disabled={isSaving}
>
{isSaving ? (
<ActivityIndicator size="small" color={AppColors.white} />
) : (
<Ionicons name="checkmark" size={20} color={AppColors.white} />
)}
<Text style={styles.saveButtonText}>
{isSaving ? 'Saving...' : 'Save Changes'}
</Text>
</TouchableOpacity>
</>
)}
</View>
</View>
{/* BLE Connection Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Bluetooth Connection</Text>
@ -557,6 +665,45 @@ const styles = StyleSheet.create({
height: 1,
backgroundColor: AppColors.border,
},
// Editable fields
editableRow: {
paddingVertical: Spacing.sm,
},
editableLabel: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
marginBottom: Spacing.xs,
},
editableInput: {
fontSize: FontSizes.base,
color: AppColors.textPrimary,
backgroundColor: AppColors.background,
borderRadius: BorderRadius.md,
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderWidth: 1,
borderColor: AppColors.border,
},
editableInputMultiline: {
minHeight: 60,
textAlignVertical: 'top',
},
saveButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
borderRadius: BorderRadius.md,
marginTop: Spacing.sm,
gap: Spacing.xs,
},
saveButtonText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
// BLE Connection
connectedCard: {
backgroundColor: AppColors.surface,

View File

@ -39,14 +39,13 @@ const sensorConfig = {
export default function EquipmentScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { currentBeneficiary } = useBeneficiary();
const { isBLEAvailable, scanForDevices, stopScan } = useBLE();
const { isBLEAvailable, scanDevices, stopScan, foundDevices, isScanning: isBLEScanning } = useBLE();
// 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 [isRefreshing, setIsRefreshing] = useState(false);
const [isScanning, setIsScanning] = useState(false);
const [isDetaching, setIsDetaching] = useState<string | null>(null);
const beneficiaryName = currentBeneficiary?.name || 'this person';
@ -72,7 +71,7 @@ export default function EquipmentScreen() {
return;
}
setApiSensors(response.data);
setApiSensors(response.data || []);
} catch (error) {
console.error('[Equipment] Failed to load sensors:', error);
// Show empty state instead of Alert
@ -90,54 +89,56 @@ export default function EquipmentScreen() {
// BLE Scan for nearby sensors
const handleScanNearby = async () => {
if (isScanning) {
if (isBLEScanning) {
// 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);
await scanDevices();
// foundDevices will be updated by BLEContext
} 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);
}
};
// Effect to convert BLE foundDevices to WPSensor format
useEffect(() => {
if (foundDevices.length === 0) return;
// Convert BLE devices to WPSensor format
const nearbyWPSensors: WPSensor[] = foundDevices
.filter((d: { name?: string }) => d.name?.startsWith('WP_')) // Only WP sensors
.map((d: { id: string; name?: string }) => {
// 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);
}, [foundDevices, apiSensors, id]);
// Handle sensor click - show action sheet for offline, navigate to settings for online
const handleSensorPress = (sensor: WPSensor) => {
// For offline API sensors - show reconnect options
@ -451,26 +452,48 @@ export default function EquipmentScreen() {
<Text style={styles.deviceMetaSeparator}></Text>
<Text style={styles.deviceRoom}>{formatLastSeen(sensor.lastSeen)}</Text>
</View>
{sensor.location && (
<Text style={styles.deviceLocation}>{sensor.location}</Text>
)}
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
handleDeviceSettings(sensor);
}}
activeOpacity={0.7}
>
<Text style={[
styles.deviceLocation,
!sensor.location && styles.deviceLocationPlaceholder
]}>
{sensor.location || 'No location set'}
</Text>
</TouchableOpacity>
</View>
</View>
<TouchableOpacity
style={styles.detachButton}
onPress={(e) => {
e.stopPropagation();
handleDetachDevice(sensor);
}}
disabled={isDetachingThis}
>
{isDetachingThis ? (
<ActivityIndicator size="small" color={AppColors.error} />
) : (
<Ionicons name="unlink-outline" size={20} color={AppColors.error} />
)}
</TouchableOpacity>
<View style={styles.deviceActions}>
<TouchableOpacity
style={styles.settingsButton}
onPress={(e) => {
e.stopPropagation();
handleDeviceSettings(sensor);
}}
>
<Ionicons name="settings-outline" size={20} color={AppColors.primary} />
</TouchableOpacity>
<TouchableOpacity
style={styles.detachButton}
onPress={(e) => {
e.stopPropagation();
handleDetachDevice(sensor);
}}
disabled={isDetachingThis}
>
{isDetachingThis ? (
<ActivityIndicator size="small" color={AppColors.error} />
) : (
<Ionicons name="unlink-outline" size={20} color={AppColors.error} />
)}
</TouchableOpacity>
</View>
</TouchableOpacity>
);
})}
@ -488,11 +511,10 @@ export default function EquipmentScreen() {
{/* Scan Nearby Button */}
<TouchableOpacity
style={[styles.scanButton, isScanning && styles.scanButtonActive]}
style={[styles.scanButton, isBLEScanning && styles.scanButtonActive]}
onPress={handleScanNearby}
disabled={!isBLEAvailable}
>
{isScanning ? (
{isBLEScanning ? (
<>
<ActivityIndicator size="small" color={AppColors.white} />
<Text style={styles.scanButtonText}>Scanning... ({bleSensors.length} found)</Text>
@ -717,6 +739,19 @@ const styles = StyleSheet.create({
fontSize: FontSizes.xs,
color: AppColors.textMuted,
},
deviceActions: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
},
settingsButton: {
width: 40,
height: 40,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.primaryLighter,
justifyContent: 'center',
alignItems: 'center',
},
detachButton: {
width: 40,
height: 40,
@ -849,7 +884,7 @@ const styles = StyleSheet.create({
...Shadows.md,
},
scanButtonActive: {
backgroundColor: AppColors.secondary,
backgroundColor: AppColors.primaryDark,
},
scanButtonText: {
fontSize: FontSizes.base,
@ -867,4 +902,8 @@ const styles = StyleSheet.create({
color: AppColors.textMuted,
marginTop: 2,
},
deviceLocationPlaceholder: {
fontStyle: 'italic',
opacity: 0.6,
},
});

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
View,
Text,
@ -16,6 +16,13 @@ import * as Device from 'expo-device';
import { useBLE } from '@/contexts/BLEContext';
import { api } from '@/services/api';
import type { WiFiNetwork } from '@/services/ble';
import type {
SensorSetupState,
SensorSetupStep,
SensorSetupStatus,
} from '@/types';
import BatchSetupProgress from '@/components/BatchSetupProgress';
import SetupResultsScreen from '@/components/SetupResultsScreen';
import {
AppColors,
BorderRadius,
@ -25,32 +32,95 @@ import {
Shadows,
} from '@/constants/theme';
// Type for device passed via navigation params
interface DeviceParam {
id: string;
name: string;
mac: string;
wellId?: number;
}
type SetupPhase = 'wifi_selection' | 'batch_setup' | 'results';
// Initialize steps for a sensor
function createInitialSteps(): SensorSetupStep[] {
return [
{ name: 'connect', status: 'pending' },
{ name: 'unlock', status: 'pending' },
{ name: 'wifi', status: 'pending' },
{ name: 'attach', status: 'pending' },
{ name: 'reboot', status: 'pending' },
];
}
// Initialize sensor state
function createSensorState(device: DeviceParam): SensorSetupState {
return {
deviceId: device.id,
deviceName: device.name,
wellId: device.wellId,
mac: device.mac,
status: 'pending',
steps: createInitialSteps(),
};
}
export default function SetupWiFiScreen() {
const { id, deviceId, deviceName, wellId } = useLocalSearchParams<{
const { id, devices: devicesParam } = useLocalSearchParams<{
id: string;
deviceId: string;
deviceName: string;
wellId: string;
devices: string; // JSON string of DeviceParam[]
}>();
const { getWiFiList, setWiFi, disconnectDevice } = useBLE();
const {
getWiFiList,
setWiFi,
connectDevice,
disconnectDevice,
rebootDevice,
} = useBLE();
// Parse devices from navigation params
const selectedDevices: DeviceParam[] = React.useMemo(() => {
if (!devicesParam) return [];
try {
return JSON.parse(devicesParam);
} catch (e) {
console.error('[SetupWiFi] Failed to parse devices param:', e);
return [];
}
}, [devicesParam]);
// Use first device for WiFi scanning
const firstDevice = selectedDevices[0];
const deviceId = firstDevice?.id;
// UI Phase
const [phase, setPhase] = useState<SetupPhase>('wifi_selection');
// WiFi selection state
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);
// Batch setup state
const [sensors, setSensors] = useState<SensorSetupState[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const setupInProgressRef = useRef(false);
const shouldCancelRef = useRef(false);
useEffect(() => {
loadWiFiNetworks();
}, []);
const loadWiFiNetworks = async () => {
if (!deviceId) return;
setIsLoadingNetworks(true);
try {
const wifiList = await getWiFiList(deviceId!);
const wifiList = await getWiFiList(deviceId);
setNetworks(wifiList);
} catch (error: any) {
console.error('[SetupWiFi] Failed to get WiFi list:', error);
@ -65,70 +135,319 @@ export default function SetupWiFiScreen() {
setPassword('');
};
const handleConnect = async () => {
// Update a specific step for a sensor
const updateSensorStep = useCallback((
deviceId: string,
stepName: SensorSetupStep['name'],
stepStatus: SensorSetupStep['status'],
error?: string
) => {
setSensors(prev => prev.map(sensor => {
if (sensor.deviceId !== deviceId) return sensor;
return {
...sensor,
steps: sensor.steps.map(step =>
step.name === stepName
? { ...step, status: stepStatus, error }
: step
),
};
}));
}, []);
// Update sensor status
const updateSensorStatus = useCallback((
deviceId: string,
status: SensorSetupStatus,
error?: string
) => {
setSensors(prev => prev.map(sensor =>
sensor.deviceId === deviceId
? { ...sensor, status, error, endTime: Date.now() }
: sensor
));
}, []);
// Process a single sensor
const processSensor = useCallback(async (
sensor: SensorSetupState,
ssid: string,
pwd: string
): Promise<boolean> => {
const { deviceId, wellId, deviceName } = sensor;
const isSimulator = !Device.isDevice;
console.log(`[SetupWiFi] [${deviceName}] Starting setup...`);
// Set start time
setSensors(prev => prev.map(s =>
s.deviceId === deviceId
? { ...s, startTime: Date.now() }
: s
));
try {
// Step 1: Connect
updateSensorStep(deviceId, 'connect', 'in_progress');
updateSensorStatus(deviceId, 'connecting');
const connected = await connectDevice(deviceId);
if (!connected) throw new Error('Could not connect to sensor');
updateSensorStep(deviceId, 'connect', 'completed');
if (shouldCancelRef.current) return false;
// Step 2: Unlock (PIN is handled by connectDevice in BLE manager)
updateSensorStep(deviceId, 'unlock', 'in_progress');
updateSensorStatus(deviceId, 'unlocking');
// PIN unlock is automatic in connectDevice, mark as completed
updateSensorStep(deviceId, 'unlock', 'completed');
if (shouldCancelRef.current) return false;
// Step 3: Set WiFi
updateSensorStep(deviceId, 'wifi', 'in_progress');
updateSensorStatus(deviceId, 'setting_wifi');
const wifiSuccess = await setWiFi(deviceId, ssid, pwd);
if (!wifiSuccess) throw new Error('Failed to configure WiFi');
updateSensorStep(deviceId, 'wifi', 'completed');
if (shouldCancelRef.current) return false;
// Step 4: Attach to deployment via API
updateSensorStep(deviceId, 'attach', 'in_progress');
updateSensorStatus(deviceId, 'attaching');
if (!isSimulator && wellId) {
const attachResponse = await api.attachDeviceToBeneficiary(
id!,
wellId,
ssid,
pwd
);
if (!attachResponse.ok) {
throw new Error('Failed to register sensor');
}
} else {
console.log(`[SetupWiFi] [${deviceName}] Simulator mode - skipping API attach`);
}
updateSensorStep(deviceId, 'attach', 'completed');
if (shouldCancelRef.current) return false;
// Step 5: Reboot
updateSensorStep(deviceId, 'reboot', 'in_progress');
updateSensorStatus(deviceId, 'rebooting');
await rebootDevice(deviceId);
updateSensorStep(deviceId, 'reboot', 'completed');
// Success!
updateSensorStatus(deviceId, 'success');
console.log(`[SetupWiFi] [${deviceName}] Setup completed successfully`);
return true;
} catch (error: any) {
console.error(`[SetupWiFi] [${deviceName}] Setup failed:`, error);
const errorMsg = error.message || 'Unknown error';
// Find current step and mark as failed
setSensors(prev => prev.map(s => {
if (s.deviceId !== deviceId) return s;
const currentStep = s.steps.find(step => step.status === 'in_progress');
return {
...s,
status: 'error' as SensorSetupStatus,
error: errorMsg,
steps: s.steps.map(step =>
step.status === 'in_progress'
? { ...step, status: 'failed' as const, error: errorMsg }
: step
),
};
}));
// Disconnect on error
try {
await disconnectDevice(deviceId);
} catch (e) {
// Ignore disconnect errors
}
return false;
}
}, [
id, connectDevice, disconnectDevice, setWiFi, rebootDevice,
updateSensorStep, updateSensorStatus
]);
// Run batch setup sequentially
const runBatchSetup = useCallback(async () => {
if (setupInProgressRef.current) return;
setupInProgressRef.current = true;
shouldCancelRef.current = false;
const ssid = selectedNetwork!.ssid;
const pwd = password;
for (let i = currentIndex; i < sensors.length; i++) {
if (shouldCancelRef.current) {
console.log('[SetupWiFi] Batch setup cancelled');
break;
}
setCurrentIndex(i);
const sensor = sensors[i];
// Skip already processed sensors
if (sensor.status === 'success' || sensor.status === 'skipped') {
continue;
}
// If sensor has error and we're not retrying, pause
if (sensor.status === 'error' && isPaused) {
break;
}
// Reset sensor state if retrying
if (sensor.status === 'error') {
setSensors(prev => prev.map(s =>
s.deviceId === sensor.deviceId
? { ...s, status: 'pending' as SensorSetupStatus, error: undefined, steps: createInitialSteps() }
: s
));
}
const success = await processSensor(
sensors[i],
ssid,
pwd
);
// Check for cancellation after each sensor
if (shouldCancelRef.current) break;
// If failed, pause for user input
if (!success) {
setIsPaused(true);
setupInProgressRef.current = false;
return;
}
}
// All done
setupInProgressRef.current = false;
// Check if we should show results
const finalSensors = sensors;
const allProcessed = finalSensors.every(
s => s.status === 'success' || s.status === 'error' || s.status === 'skipped'
);
if (allProcessed || shouldCancelRef.current) {
setPhase('results');
}
}, [sensors, currentIndex, selectedNetwork, password, isPaused, processSensor]);
// Start batch setup
const handleStartBatchSetup = () => {
if (!selectedNetwork) {
Alert.alert('Error', 'Please select a WiFi network');
return;
}
if (!password) {
Alert.alert('Error', 'Please enter WiFi password');
return;
}
setIsConnecting(true);
// Initialize sensor states
const initialStates = selectedDevices.map(createSensorState);
setSensors(initialStates);
setCurrentIndex(0);
setIsPaused(false);
setPhase('batch_setup');
};
try {
// Step 1: Set WiFi on the device via BLE
const success = await setWiFi(deviceId!, selectedNetwork.ssid, password);
// Start processing after phase change
useEffect(() => {
if (phase === 'batch_setup' && sensors.length > 0 && !setupInProgressRef.current) {
runBatchSetup();
}
}, [phase, sensors.length, runBatchSetup]);
if (!success) {
throw new Error('Failed to configure WiFi on sensor');
}
// Retry failed sensor
const handleRetry = (deviceId: string) => {
const index = sensors.findIndex(s => s.deviceId === deviceId);
if (index >= 0) {
setSensors(prev => prev.map(s =>
s.deviceId === deviceId
? { ...s, status: 'pending' as SensorSetupStatus, error: undefined, steps: createInitialSteps() }
: s
));
setCurrentIndex(index);
setIsPaused(false);
runBatchSetup();
}
};
// Step 2: Attach device to beneficiary via API (skip in simulator/mock mode)
const isSimulator = !Device.isDevice;
// Skip failed sensor
const handleSkip = (deviceId: string) => {
setSensors(prev => prev.map(s =>
s.deviceId === deviceId
? { ...s, status: 'skipped' as SensorSetupStatus }
: s
));
setIsPaused(false);
if (!isSimulator) {
const attachResponse = await api.attachDeviceToBeneficiary(
id!,
parseInt(wellId!, 10),
selectedNetwork.ssid,
password
);
// Move to next sensor
const nextIndex = currentIndex + 1;
if (nextIndex < sensors.length) {
setCurrentIndex(nextIndex);
runBatchSetup();
} else {
setPhase('results');
}
};
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);
},
// Cancel all
const handleCancelAll = () => {
Alert.alert(
'Cancel Setup',
'Are you sure you want to cancel? Progress will be lost.',
[
{ text: 'Continue Setup', style: 'cancel' },
{
text: 'Cancel',
style: 'destructive',
onPress: () => {
shouldCancelRef.current = true;
setupInProgressRef.current = false;
// Disconnect all devices
selectedDevices.forEach(d => disconnectDevice(d.id));
router.back();
},
]
);
} 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);
},
]
);
};
// Done - navigate back
const handleDone = () => {
router.replace(`/(tabs)/beneficiaries/${id}/equipment` as any);
};
// Retry a single sensor from results screen
const handleRetryFromResults = (deviceId: string) => {
const index = sensors.findIndex(s => s.deviceId === deviceId);
if (index >= 0) {
// Reset the sensor state
setSensors(prev => prev.map(s =>
s.deviceId === deviceId
? { ...s, status: 'pending' as SensorSetupStatus, error: undefined, steps: createInitialSteps() }
: s
));
setCurrentIndex(index);
setIsPaused(false);
// Go back to batch setup phase
setPhase('batch_setup');
}
};
@ -153,6 +472,43 @@ export default function SetupWiFiScreen() {
return 'wifi-outline';
};
// Results screen
if (phase === 'results') {
return (
<SetupResultsScreen
sensors={sensors}
onRetry={handleRetryFromResults}
onDone={handleDone}
/>
);
}
// Batch setup progress screen
if (phase === 'batch_setup') {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<View style={styles.placeholder} />
<Text style={styles.headerTitle}>Setting Up Sensors</Text>
<View style={styles.placeholder} />
</View>
<View style={styles.batchContent}>
<BatchSetupProgress
sensors={sensors}
currentIndex={currentIndex}
ssid={selectedNetwork?.ssid || ''}
isPaused={isPaused}
onRetry={handleRetry}
onSkip={handleSkip}
onCancelAll={handleCancelAll}
/>
</View>
</SafeAreaView>
);
}
// WiFi selection screen (default)
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */}
@ -160,8 +516,7 @@ export default function SetupWiFiScreen() {
<TouchableOpacity
style={styles.backButton}
onPress={() => {
// Disconnect BLE before going back
disconnectDevice(deviceId!);
selectedDevices.forEach(d => disconnectDevice(d.id));
router.back();
}}
>
@ -178,15 +533,28 @@ export default function SetupWiFiScreen() {
<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>
{selectedDevices.length === 1 ? (
<>
<Text style={styles.deviceName}>{firstDevice?.name}</Text>
<Text style={styles.deviceMeta}>Well ID: {firstDevice?.wellId}</Text>
</>
) : (
<>
<Text style={styles.deviceName}>{selectedDevices.length} Sensors Selected</Text>
<Text style={styles.deviceMeta}>
{selectedDevices.map(d => d.name).join(', ')}
</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.
{selectedDevices.length === 1
? 'Select the WiFi network your sensor should connect to. Make sure the network has internet access.'
: `Select the WiFi network for all ${selectedDevices.length} sensors. They will all be configured with the same WiFi credentials.`}
</Text>
</View>
@ -282,22 +650,17 @@ export default function SetupWiFiScreen() {
<TouchableOpacity
style={[
styles.connectButton,
(!password || isConnecting) && styles.connectButtonDisabled,
!password && styles.connectButtonDisabled,
]}
onPress={handleConnect}
disabled={!password || isConnecting}
onPress={handleStartBatchSetup}
disabled={!password}
>
{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>
</>
)}
<Ionicons name="checkmark" size={20} color={AppColors.white} />
<Text style={styles.connectButtonText}>
{selectedDevices.length === 1
? 'Connect & Complete Setup'
: `Connect All ${selectedDevices.length} Sensors`}
</Text>
</TouchableOpacity>
</View>
)}
@ -352,6 +715,10 @@ const styles = StyleSheet.create({
padding: Spacing.lg,
paddingBottom: Spacing.xxl,
},
batchContent: {
flex: 1,
padding: Spacing.lg,
},
// Device Card
deviceCard: {
flexDirection: 'row',

View File

@ -0,0 +1,946 @@
import React, { useRef, useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
ActivityIndicator,
Animated,
DimensionValue,
Modal,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import type { SensorSetupState, SensorSetupStep } from '@/types';
import {
AppColors,
BorderRadius,
FontSizes,
FontWeights,
Spacing,
Shadows,
} from '@/constants/theme';
// User-friendly error messages based on error type
function getErrorMessage(error: string | undefined): { title: string; description: string; hint: string } {
if (!error) {
return {
title: 'Unknown Error',
description: 'Something went wrong.',
hint: 'Try again or skip this sensor.',
};
}
const lowerError = error.toLowerCase();
if (lowerError.includes('connect') || lowerError.includes('connection')) {
return {
title: 'Connection Failed',
description: 'Could not connect to the sensor via Bluetooth.',
hint: 'Move closer to the sensor and ensure it\'s powered on.',
};
}
if (lowerError.includes('unlock') || lowerError.includes('pin')) {
return {
title: 'Unlock Failed',
description: 'Could not unlock the sensor for configuration.',
hint: 'The sensor may need to be reset. Try again.',
};
}
if (lowerError.includes('wifi') || lowerError.includes('network')) {
return {
title: 'WiFi Configuration Failed',
description: 'Could not set up the WiFi connection on the sensor.',
hint: 'Check that the WiFi password is correct.',
};
}
if (lowerError.includes('register') || lowerError.includes('attach') || lowerError.includes('api')) {
return {
title: 'Registration Failed',
description: 'Could not register the sensor with your account.',
hint: 'Check your internet connection and try again.',
};
}
if (lowerError.includes('timeout') || lowerError.includes('respond')) {
return {
title: 'Sensor Not Responding',
description: 'The sensor stopped responding during setup.',
hint: 'Move closer or check if the sensor is still powered on.',
};
}
if (lowerError.includes('reboot')) {
return {
title: 'Reboot Failed',
description: 'Could not restart the sensor.',
hint: 'The sensor may still work. Try checking it in Equipment.',
};
}
return {
title: 'Setup Failed',
description: error,
hint: 'Try again or skip this sensor to continue with others.',
};
}
interface BatchSetupProgressProps {
sensors: SensorSetupState[];
currentIndex: number;
ssid: string;
isPaused: boolean;
onRetry?: (deviceId: string) => void;
onSkip?: (deviceId: string) => void;
onCancelAll?: () => void;
}
// Error Action Modal Component
function ErrorActionModal({
visible,
sensor,
onRetry,
onSkip,
onCancelAll,
}: {
visible: boolean;
sensor: SensorSetupState | null;
onRetry: () => void;
onSkip: () => void;
onCancelAll: () => void;
}) {
if (!sensor) return null;
const errorInfo = getErrorMessage(sensor.error);
return (
<Modal
visible={visible}
transparent
animationType="fade"
statusBarTranslucent
>
<View style={modalStyles.overlay}>
<View style={modalStyles.container}>
{/* Error Icon */}
<View style={modalStyles.iconContainer}>
<Ionicons name="alert-circle" size={48} color={AppColors.error} />
</View>
{/* Error Title */}
<Text style={modalStyles.title}>{errorInfo.title}</Text>
{/* Sensor Name */}
<View style={modalStyles.sensorBadge}>
<Ionicons name="water" size={14} color={AppColors.primary} />
<Text style={modalStyles.sensorName}>{sensor.deviceName}</Text>
</View>
{/* Error Description */}
<Text style={modalStyles.description}>{errorInfo.description}</Text>
{/* Hint */}
<View style={modalStyles.hintContainer}>
<Ionicons name="bulb-outline" size={16} color={AppColors.info} />
<Text style={modalStyles.hintText}>{errorInfo.hint}</Text>
</View>
{/* Action Buttons */}
<View style={modalStyles.actions}>
<TouchableOpacity style={modalStyles.retryButton} onPress={onRetry}>
<Ionicons name="refresh" size={18} color={AppColors.white} />
<Text style={modalStyles.retryText}>Retry</Text>
</TouchableOpacity>
<TouchableOpacity style={modalStyles.skipButton} onPress={onSkip}>
<Ionicons name="arrow-forward" size={18} color={AppColors.textPrimary} />
<Text style={modalStyles.skipText}>Skip Sensor</Text>
</TouchableOpacity>
</View>
{/* Cancel All */}
<TouchableOpacity style={modalStyles.cancelAllButton} onPress={onCancelAll}>
<Text style={modalStyles.cancelAllText}>Cancel All Setup</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
const modalStyles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: Spacing.lg,
},
container: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.xl,
width: '100%',
maxWidth: 340,
alignItems: 'center',
...Shadows.lg,
},
iconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: AppColors.errorLight,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.md,
},
title: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
textAlign: 'center',
marginBottom: Spacing.sm,
},
sensorBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.primaryLighter,
paddingHorizontal: Spacing.sm,
paddingVertical: Spacing.xs,
borderRadius: BorderRadius.md,
gap: Spacing.xs,
marginBottom: Spacing.md,
},
sensorName: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.primary,
},
description: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
marginBottom: Spacing.md,
lineHeight: 22,
},
hintContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: AppColors.infoLight,
padding: Spacing.md,
borderRadius: BorderRadius.md,
marginBottom: Spacing.lg,
gap: Spacing.sm,
},
hintText: {
fontSize: FontSizes.sm,
color: AppColors.info,
flex: 1,
lineHeight: 20,
},
actions: {
flexDirection: 'row',
gap: Spacing.md,
marginBottom: Spacing.md,
},
retryButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
gap: Spacing.xs,
...Shadows.sm,
},
retryText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
skipButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.surfaceSecondary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
gap: Spacing.xs,
borderWidth: 1,
borderColor: AppColors.border,
},
skipText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
cancelAllButton: {
paddingVertical: Spacing.sm,
},
cancelAllText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.error,
},
});
// Format elapsed time as "Xs" or "Xm Xs"
function formatElapsedTime(startTime?: number, endTime?: number): string {
if (!startTime) return '';
const end = endTime || Date.now();
const elapsed = Math.floor((end - startTime) / 1000);
if (elapsed < 60) return `${elapsed}s`;
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
return `${minutes}m ${seconds}s`;
}
const STEP_LABELS: Record<SensorSetupStep['name'], string> = {
connect: 'Connecting',
unlock: 'Unlocking',
wifi: 'Setting WiFi',
attach: 'Registering',
reboot: 'Rebooting',
};
function StepIndicator({ step }: { step: SensorSetupStep }) {
const getIcon = () => {
switch (step.status) {
case 'completed':
return <Ionicons name="checkmark" size={12} color={AppColors.success} />;
case 'in_progress':
return <ActivityIndicator size={10} color={AppColors.primary} />;
case 'failed':
return <Ionicons name="close" size={12} color={AppColors.error} />;
default:
return <View style={styles.pendingDot} />;
}
};
const getTextColor = () => {
switch (step.status) {
case 'completed':
return AppColors.success;
case 'in_progress':
return AppColors.primary;
case 'failed':
return AppColors.error;
default:
return AppColors.textMuted;
}
};
return (
<View style={styles.stepRow}>
<View style={styles.stepIcon}>{getIcon()}</View>
<Text style={[styles.stepLabel, { color: getTextColor() }]}>
{STEP_LABELS[step.name]}
</Text>
{step.error && (
<Text style={styles.stepError}>{step.error}</Text>
)}
</View>
);
}
function SensorCard({
sensor,
isActive,
index,
total,
onRetry,
onSkip,
}: {
sensor: SensorSetupState;
isActive: boolean;
index: number;
total: number;
onRetry?: () => void;
onSkip?: () => void;
}) {
const [elapsedTime, setElapsedTime] = useState('');
// Update elapsed time for active sensors
useEffect(() => {
if (isActive && sensor.startTime && !sensor.endTime) {
const interval = setInterval(() => {
setElapsedTime(formatElapsedTime(sensor.startTime));
}, 1000);
return () => clearInterval(interval);
} else if (sensor.endTime && sensor.startTime) {
setElapsedTime(formatElapsedTime(sensor.startTime, sensor.endTime));
}
}, [isActive, sensor.startTime, sensor.endTime]);
const getStatusColor = () => {
switch (sensor.status) {
case 'success':
return AppColors.success;
case 'error':
return AppColors.error;
case 'skipped':
return AppColors.warning;
case 'pending':
return AppColors.textMuted;
default:
return AppColors.primary;
}
};
const getStatusIcon = () => {
switch (sensor.status) {
case 'success':
return <Ionicons name="checkmark-circle" size={24} color={AppColors.success} />;
case 'error':
return <Ionicons name="close-circle" size={24} color={AppColors.error} />;
case 'skipped':
return <Ionicons name="remove-circle" size={24} color={AppColors.warning} />;
case 'pending':
return <Ionicons name="ellipse-outline" size={24} color={AppColors.textMuted} />;
default:
return <ActivityIndicator size={20} color={AppColors.primary} />;
}
};
const showActions = sensor.status === 'error' && onRetry && onSkip;
const showProgress = sensor.status !== 'pending' && sensor.status !== 'skipped';
return (
<View style={[
styles.sensorCard,
isActive && styles.sensorCardActive,
sensor.status === 'success' && styles.sensorCardSuccess,
sensor.status === 'error' && styles.sensorCardError,
]}>
{/* Index Badge */}
<View style={[styles.indexBadge, { backgroundColor: getStatusColor() }]}>
<Text style={styles.indexBadgeText}>{index + 1}/{total}</Text>
</View>
<View style={styles.sensorHeader}>
<View style={[styles.sensorIcon, { backgroundColor: `${getStatusColor()}20` }]}>
<Ionicons name="water" size={20} color={getStatusColor()} />
</View>
<View style={styles.sensorInfo}>
<Text style={styles.sensorName}>{sensor.deviceName}</Text>
<View style={styles.sensorMetaRow}>
{sensor.wellId && (
<Text style={styles.sensorMeta}>Well ID: {sensor.wellId}</Text>
)}
{elapsedTime && showProgress && (
<Text style={styles.elapsedTime}>{elapsedTime}</Text>
)}
</View>
</View>
<View style={styles.statusIcon}>{getStatusIcon()}</View>
</View>
{/* Show steps for active or completed sensors */}
{(isActive || sensor.status === 'success' || sensor.status === 'error') && (
<View style={styles.stepsContainer}>
{sensor.steps.map((step, index) => (
<StepIndicator key={step.name} step={step} />
))}
</View>
)}
{/* Error message - enhanced display */}
{sensor.error && (
<View style={styles.errorContainer}>
<View style={styles.errorHeader}>
<Ionicons name="alert-circle" size={18} color={AppColors.error} />
<Text style={styles.errorTitle}>
{getErrorMessage(sensor.error).title}
</Text>
</View>
<Text style={styles.errorText}>
{getErrorMessage(sensor.error).description}
</Text>
</View>
)}
{/* Action buttons for failed sensors - improved styling */}
{showActions && (
<View style={styles.actionButtons}>
<TouchableOpacity style={styles.retryButton} onPress={onRetry}>
<Ionicons name="refresh" size={16} color={AppColors.white} />
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.skipButton} onPress={onSkip}>
<Ionicons name="arrow-forward" size={16} color={AppColors.textSecondary} />
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}
export default function BatchSetupProgress({
sensors,
currentIndex,
ssid,
isPaused,
onRetry,
onSkip,
onCancelAll,
}: BatchSetupProgressProps) {
const scrollViewRef = useRef<ScrollView>(null);
const sensorCardRefs = useRef<{ [key: string]: View | null }>({});
const progressAnim = useRef(new Animated.Value(0)).current;
const [showErrorModal, setShowErrorModal] = useState(false);
const completedCount = sensors.filter(s => s.status === 'success').length;
const failedCount = sensors.filter(s => s.status === 'error').length;
const skippedCount = sensors.filter(s => s.status === 'skipped').length;
const totalProcessed = completedCount + failedCount + skippedCount;
const progress = (totalProcessed / sensors.length) * 100;
// Find the current failed sensor for the modal
const failedSensor = sensors.find(s => s.status === 'error');
// Show error modal when paused due to error
useEffect(() => {
if (isPaused && failedSensor) {
// Small delay for better UX - let the card error state render first
const timer = setTimeout(() => setShowErrorModal(true), 300);
return () => clearTimeout(timer);
} else {
setShowErrorModal(false);
}
}, [isPaused, failedSensor]);
const handleRetryFromModal = () => {
setShowErrorModal(false);
if (failedSensor && onRetry) {
onRetry(failedSensor.deviceId);
}
};
const handleSkipFromModal = () => {
setShowErrorModal(false);
if (failedSensor && onSkip) {
onSkip(failedSensor.deviceId);
}
};
const handleCancelAllFromModal = () => {
setShowErrorModal(false);
if (onCancelAll) {
onCancelAll();
}
};
// Animate progress bar
useEffect(() => {
Animated.timing(progressAnim, {
toValue: progress,
duration: 300,
useNativeDriver: false,
}).start();
}, [progress, progressAnim]);
// Auto-scroll to current sensor
useEffect(() => {
if (currentIndex >= 0 && scrollViewRef.current) {
// Calculate approximate scroll position (each card ~120px + spacing)
const cardHeight = 120;
const spacing = 12;
const scrollTo = currentIndex * (cardHeight + spacing);
setTimeout(() => {
scrollViewRef.current?.scrollTo({
y: Math.max(0, scrollTo - 20), // Small offset for visibility
animated: true,
});
}, 100);
}
}, [currentIndex]);
const animatedWidth = progressAnim.interpolate({
inputRange: [0, 100],
outputRange: ['0%', '100%'],
});
return (
<View style={styles.container}>
{/* Progress Header */}
<View style={styles.progressHeader}>
<View style={styles.progressTitleRow}>
<Text style={styles.progressTitle}>
Connecting to "{ssid}"
</Text>
{!isPaused && currentIndex < sensors.length && (
<View style={styles.currentSensorBadge}>
<Text style={styles.currentSensorBadgeText}>
{currentIndex + 1}/{sensors.length}
</Text>
</View>
)}
</View>
<Text style={styles.progressSubtitle}>
{isPaused ? (
<Text style={styles.pausedText}>Paused - action required</Text>
) : totalProcessed === sensors.length ? (
'Setup complete!'
) : (
`Processing sensor ${currentIndex + 1} of ${sensors.length}...`
)}
</Text>
{/* Animated Progress bar */}
<View style={styles.progressBarContainer}>
<Animated.View style={[styles.progressBar, { width: animatedWidth }]} />
{/* Success/Error segments */}
<View style={styles.progressSegments}>
{sensors.map((sensor) => {
const segmentWidth: DimensionValue = `${100 / sensors.length}%`;
let backgroundColor = 'transparent';
if (sensor.status === 'success') backgroundColor = AppColors.success;
else if (sensor.status === 'error') backgroundColor = AppColors.error;
else if (sensor.status === 'skipped') backgroundColor = AppColors.warning;
return (
<View
key={sensor.deviceId}
style={[
styles.progressSegment,
{ width: segmentWidth, backgroundColor },
]}
/>
);
})}
</View>
</View>
{/* Stats Row */}
<View style={styles.statsRow}>
{completedCount > 0 && (
<View style={styles.statItem}>
<Ionicons name="checkmark-circle" size={14} color={AppColors.success} />
<Text style={[styles.statText, { color: AppColors.success }]}>{completedCount}</Text>
</View>
)}
{failedCount > 0 && (
<View style={styles.statItem}>
<Ionicons name="close-circle" size={14} color={AppColors.error} />
<Text style={[styles.statText, { color: AppColors.error }]}>{failedCount}</Text>
</View>
)}
{skippedCount > 0 && (
<View style={styles.statItem}>
<Ionicons name="remove-circle" size={14} color={AppColors.warning} />
<Text style={[styles.statText, { color: AppColors.warning }]}>{skippedCount}</Text>
</View>
)}
</View>
</View>
{/* Sensors List */}
<ScrollView
ref={scrollViewRef}
style={styles.sensorsList}
contentContainerStyle={styles.sensorsListContent}
showsVerticalScrollIndicator={false}
>
{sensors.map((sensor, index) => (
<SensorCard
key={sensor.deviceId}
sensor={sensor}
isActive={index === currentIndex && !isPaused}
index={index}
total={sensors.length}
onRetry={onRetry ? () => onRetry(sensor.deviceId) : undefined}
onSkip={onSkip ? () => onSkip(sensor.deviceId) : undefined}
/>
))}
</ScrollView>
{/* Cancel button */}
{onCancelAll && (
<TouchableOpacity style={styles.cancelAllButton} onPress={onCancelAll}>
<Text style={styles.cancelAllText}>Cancel Setup</Text>
</TouchableOpacity>
)}
{/* Error Action Modal */}
<ErrorActionModal
visible={showErrorModal}
sensor={failedSensor || null}
onRetry={handleRetryFromModal}
onSkip={handleSkipFromModal}
onCancelAll={handleCancelAllFromModal}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
progressHeader: {
marginBottom: Spacing.lg,
},
progressTitleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: Spacing.xs,
},
progressTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
flex: 1,
},
currentSensorBadge: {
backgroundColor: AppColors.primary,
paddingHorizontal: Spacing.sm,
paddingVertical: Spacing.xs,
borderRadius: BorderRadius.md,
},
currentSensorBadgeText: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.bold,
color: AppColors.white,
},
progressSubtitle: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
marginBottom: Spacing.md,
},
pausedText: {
color: AppColors.warning,
fontWeight: FontWeights.medium,
},
progressBarContainer: {
height: 6,
backgroundColor: AppColors.border,
borderRadius: 3,
overflow: 'hidden',
position: 'relative',
},
progressBar: {
position: 'absolute',
top: 0,
left: 0,
height: '100%',
backgroundColor: AppColors.primary,
borderRadius: 3,
opacity: 0.3,
},
progressSegments: {
flexDirection: 'row',
height: '100%',
position: 'absolute',
top: 0,
left: 0,
right: 0,
},
progressSegment: {
height: '100%',
},
statsRow: {
flexDirection: 'row',
marginTop: Spacing.sm,
gap: Spacing.md,
},
statItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
statText: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.medium,
},
sensorsList: {
flex: 1,
},
sensorsListContent: {
gap: Spacing.md,
paddingBottom: Spacing.lg,
},
sensorCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
position: 'relative',
overflow: 'hidden',
...Shadows.xs,
},
sensorCardActive: {
borderWidth: 2,
borderColor: AppColors.primary,
},
sensorCardSuccess: {
borderWidth: 1,
borderColor: AppColors.success,
},
sensorCardError: {
borderWidth: 1,
borderColor: AppColors.error,
},
indexBadge: {
position: 'absolute',
top: 0,
right: 0,
paddingHorizontal: Spacing.sm,
paddingVertical: 2,
borderBottomLeftRadius: BorderRadius.md,
},
indexBadgeText: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.bold,
color: AppColors.white,
},
sensorHeader: {
flexDirection: 'row',
alignItems: 'center',
},
sensorIcon: {
width: 40,
height: 40,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.primaryLighter,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.sm,
},
sensorInfo: {
flex: 1,
},
sensorName: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
sensorMetaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
sensorMeta: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
},
elapsedTime: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.medium,
color: AppColors.primary,
},
statusIcon: {
marginLeft: Spacing.sm,
},
stepsContainer: {
marginTop: Spacing.md,
paddingTop: Spacing.sm,
borderTopWidth: 1,
borderTopColor: AppColors.border,
gap: Spacing.xs,
},
stepRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
stepIcon: {
width: 16,
height: 16,
justifyContent: 'center',
alignItems: 'center',
},
pendingDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: AppColors.textMuted,
},
stepLabel: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
},
stepError: {
fontSize: FontSizes.xs,
color: AppColors.error,
flex: 1,
},
errorContainer: {
marginTop: Spacing.sm,
padding: Spacing.md,
backgroundColor: AppColors.errorLight,
borderRadius: BorderRadius.md,
borderLeftWidth: 3,
borderLeftColor: AppColors.error,
},
errorHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
marginBottom: Spacing.xs,
},
errorTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.error,
},
errorText: {
fontSize: FontSizes.xs,
color: AppColors.textSecondary,
lineHeight: 18,
},
actionButtons: {
flexDirection: 'row',
marginTop: Spacing.md,
gap: Spacing.sm,
},
retryButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
backgroundColor: AppColors.primary,
borderRadius: BorderRadius.md,
gap: Spacing.xs,
...Shadows.xs,
},
retryText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
skipButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
backgroundColor: AppColors.surfaceSecondary,
borderRadius: BorderRadius.md,
borderWidth: 1,
borderColor: AppColors.border,
gap: Spacing.xs,
},
skipText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.textSecondary,
},
cancelAllButton: {
alignItems: 'center',
paddingVertical: Spacing.sm,
marginTop: Spacing.md,
},
cancelAllText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.error,
},
});

View File

@ -0,0 +1,529 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { SensorSetupState } from '@/types';
import {
AppColors,
BorderRadius,
FontSizes,
FontWeights,
Spacing,
Shadows,
} from '@/constants/theme';
interface SetupResultsScreenProps {
sensors: SensorSetupState[];
onRetry: (deviceId: string) => void;
onDone: () => void;
}
// Format elapsed time as readable string
function formatElapsedTime(startTime?: number, endTime?: number): string {
if (!startTime || !endTime) return '';
const elapsed = Math.floor((endTime - startTime) / 1000);
if (elapsed < 60) return `${elapsed}s`;
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
return `${minutes}m ${seconds}s`;
}
// Get user-friendly error message
function getErrorMessage(error: string | undefined): string {
if (!error) return 'Unknown error';
const lowerError = error.toLowerCase();
if (lowerError.includes('connect') || lowerError.includes('connection')) {
return 'Could not connect via Bluetooth';
}
if (lowerError.includes('wifi') || lowerError.includes('network')) {
return 'WiFi configuration failed';
}
if (lowerError.includes('register') || lowerError.includes('attach') || lowerError.includes('api')) {
return 'Could not register sensor';
}
if (lowerError.includes('timeout') || lowerError.includes('respond')) {
return 'Sensor not responding';
}
if (lowerError.includes('unlock') || lowerError.includes('pin')) {
return 'Could not unlock sensor';
}
return error;
}
export default function SetupResultsScreen({
sensors,
onRetry,
onDone,
}: SetupResultsScreenProps) {
const successSensors = sensors.filter(s => s.status === 'success');
const failedSensors = sensors.filter(s => s.status === 'error');
const skippedSensors = sensors.filter(s => s.status === 'skipped');
const allSuccess = successSensors.length === sensors.length;
const allFailed = failedSensors.length + skippedSensors.length === sensors.length;
const partialSuccess = !allSuccess && !allFailed && successSensors.length > 0;
// Calculate total setup time
const totalTime = sensors.reduce((acc, sensor) => {
if (sensor.startTime && sensor.endTime) {
return acc + (sensor.endTime - sensor.startTime);
}
return acc;
}, 0);
const totalTimeStr = totalTime > 0 ? formatElapsedTime(0, totalTime) : null;
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */}
<View style={styles.header}>
<View style={styles.placeholder} />
<Text style={styles.headerTitle}>Setup Complete</Text>
<View style={styles.placeholder} />
</View>
<ScrollView
style={styles.content}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* Summary Card */}
<View style={styles.summaryCard}>
<View style={[
styles.summaryIcon,
{ backgroundColor: allSuccess ? AppColors.successLight :
allFailed ? AppColors.errorLight : AppColors.warningLight }
]}>
<Ionicons
name={allSuccess ? 'checkmark-circle' :
allFailed ? 'close-circle' : 'alert-circle'}
size={56}
color={allSuccess ? AppColors.success :
allFailed ? AppColors.error : AppColors.warning}
/>
</View>
<Text style={styles.summaryTitle}>
{allSuccess ? 'All Sensors Connected!' :
allFailed ? 'Setup Failed' :
'Partial Success'}
</Text>
<Text style={styles.summarySubtitle}>
{successSensors.length} of {sensors.length} sensors configured successfully
</Text>
{/* Stats Row */}
<View style={styles.statsRow}>
{successSensors.length > 0 && (
<View style={styles.statItem}>
<View style={[styles.statDot, { backgroundColor: AppColors.success }]} />
<Text style={styles.statText}>{successSensors.length} Success</Text>
</View>
)}
{failedSensors.length > 0 && (
<View style={styles.statItem}>
<View style={[styles.statDot, { backgroundColor: AppColors.error }]} />
<Text style={styles.statText}>{failedSensors.length} Failed</Text>
</View>
)}
{skippedSensors.length > 0 && (
<View style={styles.statItem}>
<View style={[styles.statDot, { backgroundColor: AppColors.warning }]} />
<Text style={styles.statText}>{skippedSensors.length} Skipped</Text>
</View>
)}
</View>
{totalTimeStr && (
<Text style={styles.totalTime}>Total time: {totalTimeStr}</Text>
)}
</View>
{/* Success List */}
{successSensors.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
<Text style={styles.sectionTitle}>Successfully Connected</Text>
</View>
{successSensors.map(sensor => (
<View key={sensor.deviceId} style={styles.sensorItem}>
<View style={styles.sensorInfo}>
<View style={[styles.sensorIcon, { backgroundColor: AppColors.successLight }]}>
<Ionicons name="water" size={18} color={AppColors.success} />
</View>
<View style={styles.sensorDetails}>
<Text style={styles.sensorName}>{sensor.deviceName}</Text>
<View style={styles.sensorMeta}>
{sensor.wellId && (
<Text style={styles.sensorMetaText}>Well ID: {sensor.wellId}</Text>
)}
{sensor.startTime && sensor.endTime && (
<Text style={styles.sensorMetaText}>
{formatElapsedTime(sensor.startTime, sensor.endTime)}
</Text>
)}
</View>
</View>
</View>
<Ionicons name="checkmark" size={20} color={AppColors.success} />
</View>
))}
</View>
)}
{/* Failed List */}
{failedSensors.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="close-circle" size={18} color={AppColors.error} />
<Text style={styles.sectionTitle}>Failed</Text>
</View>
{failedSensors.map(sensor => (
<View key={sensor.deviceId} style={styles.sensorItemWithAction}>
<View style={styles.sensorInfo}>
<View style={[styles.sensorIcon, { backgroundColor: AppColors.errorLight }]}>
<Ionicons name="water" size={18} color={AppColors.error} />
</View>
<View style={styles.sensorDetails}>
<Text style={styles.sensorName}>{sensor.deviceName}</Text>
<Text style={styles.errorText}>{getErrorMessage(sensor.error)}</Text>
</View>
</View>
<TouchableOpacity
style={styles.retryButton}
onPress={() => onRetry(sensor.deviceId)}
>
<Ionicons name="refresh" size={16} color={AppColors.primary} />
<Text style={styles.retryButtonText}>Retry</Text>
</TouchableOpacity>
</View>
))}
</View>
)}
{/* Skipped List */}
{skippedSensors.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="remove-circle" size={18} color={AppColors.warning} />
<Text style={styles.sectionTitle}>Skipped</Text>
</View>
{skippedSensors.map(sensor => (
<View key={sensor.deviceId} style={styles.sensorItemWithAction}>
<View style={styles.sensorInfo}>
<View style={[styles.sensorIcon, { backgroundColor: AppColors.warningLight }]}>
<Ionicons name="water" size={18} color={AppColors.warning} />
</View>
<View style={styles.sensorDetails}>
<Text style={styles.sensorName}>{sensor.deviceName}</Text>
<Text style={styles.skippedText}>Skipped by user</Text>
</View>
</View>
<TouchableOpacity
style={styles.retryButton}
onPress={() => onRetry(sensor.deviceId)}
>
<Ionicons name="refresh" size={16} color={AppColors.primary} />
<Text style={styles.retryButtonText}>Retry</Text>
</TouchableOpacity>
</View>
))}
</View>
)}
{/* Help Info */}
<View style={styles.helpCard}>
<View style={styles.helpHeader}>
<Ionicons name="information-circle" size={20} color={AppColors.info} />
<Text style={styles.helpTitle}>What's Next</Text>
</View>
<Text style={styles.helpText}>
{successSensors.length > 0 ? (
'• Successfully connected sensors will appear in Equipment\n' +
'• It may take up to 1 minute for sensors to come online\n' +
'• You can configure sensor locations in Device Settings'
) : (
'• Return to Equipment and try adding sensors again\n' +
'• Make sure sensors are powered on and nearby\n' +
'• Check that WiFi password is correct'
)}
</Text>
</View>
</ScrollView>
{/* Bottom Actions */}
<View style={styles.bottomActions}>
{(failedSensors.length > 0 || skippedSensors.length > 0) && (
<TouchableOpacity
style={styles.retryAllButton}
onPress={() => {
[...failedSensors, ...skippedSensors].forEach(s => onRetry(s.deviceId));
}}
>
<Ionicons name="refresh" size={18} color={AppColors.primary} />
<Text style={styles.retryAllButtonText}>
Retry All Failed ({failedSensors.length + skippedSensors.length})
</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.doneButton} onPress={onDone}>
<Text style={styles.doneButtonText}>Done</Text>
</TouchableOpacity>
</View>
</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,
backgroundColor: AppColors.surface,
},
headerTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
placeholder: {
width: 32,
},
content: {
flex: 1,
},
scrollContent: {
padding: Spacing.lg,
paddingBottom: Spacing.xxl,
},
// Summary Card
summaryCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.xl,
alignItems: 'center',
marginBottom: Spacing.lg,
...Shadows.sm,
},
summaryIcon: {
width: 100,
height: 100,
borderRadius: 50,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.md,
},
summaryTitle: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
marginBottom: Spacing.xs,
textAlign: 'center',
},
summarySubtitle: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
marginBottom: Spacing.md,
textAlign: 'center',
},
statsRow: {
flexDirection: 'row',
gap: Spacing.lg,
marginBottom: Spacing.sm,
},
statItem: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
},
statDot: {
width: 8,
height: 8,
borderRadius: 4,
},
statText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
fontWeight: FontWeights.medium,
},
totalTime: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
marginTop: Spacing.xs,
},
// Sections
section: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
marginBottom: Spacing.md,
...Shadows.xs,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
marginBottom: Spacing.md,
paddingBottom: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
sectionTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
// Sensor Items
sensorItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: AppColors.borderLight,
},
sensorItemWithAction: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: AppColors.borderLight,
},
sensorInfo: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
sensorIcon: {
width: 36,
height: 36,
borderRadius: BorderRadius.md,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.sm,
},
sensorDetails: {
flex: 1,
},
sensorName: {
fontSize: FontSizes.base,
fontWeight: FontWeights.medium,
color: AppColors.textPrimary,
marginBottom: 2,
},
sensorMeta: {
flexDirection: 'row',
gap: Spacing.sm,
},
sensorMetaText: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
},
errorText: {
fontSize: FontSizes.xs,
color: AppColors.error,
},
skippedText: {
fontSize: FontSizes.xs,
color: AppColors.warning,
},
// Buttons
retryButton: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.sm,
backgroundColor: AppColors.primaryLighter,
borderRadius: BorderRadius.md,
},
retryButtonText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.primary,
},
// Help Card
helpCard: {
backgroundColor: AppColors.infoLight,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
marginTop: Spacing.sm,
},
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,
},
// Bottom Actions
bottomActions: {
padding: Spacing.lg,
borderTopWidth: 1,
borderTopColor: AppColors.border,
backgroundColor: AppColors.surface,
gap: Spacing.sm,
},
retryAllButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.sm,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
borderWidth: 1,
borderColor: AppColors.primary,
backgroundColor: AppColors.primaryLighter,
},
retryAllButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.primary,
},
doneButton: {
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
alignItems: 'center',
...Shadows.md,
},
doneButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
});

577
docs/SENSORS_SYSTEM.md Normal file
View File

@ -0,0 +1,577 @@
# WellNuo Sensors System Documentation
## Overview
This document describes the architecture and user flows for managing BLE/WiFi sensors in the WellNuo app. Sensors monitor elderly people (beneficiaries) and send activity data via WiFi.
---
## Table of Contents
1. [Entities](#entities)
2. [Data Storage](#data-storage)
3. [Entity Relationships](#entity-relationships)
4. [User Flows](#user-flows)
5. [BLE Protocol](#ble-protocol)
6. [Legacy API Endpoints](#legacy-api-endpoints)
7. [UI Screens](#ui-screens)
8. [Batch Sensor Setup](#batch-sensor-setup)
9. [Error Handling](#error-handling)
10. [Open Questions](#open-questions)
---
## Entities
### User (Caregiver)
The person who uses the app to monitor beneficiaries.
- Can have multiple beneficiaries
- Stored in: **WellNuo API**
### Beneficiary
An elderly person being monitored.
- Has profile data (name, avatar, date of birth)
- Linked to exactly one Deployment
- One user can have **multiple beneficiaries** (e.g., 5)
- Stored in: **WellNuo API**
### Deployment
A "household" in Legacy API where the beneficiary lives.
- Contains address, timezone
- Contains array of devices
- One beneficiary = one deployment
- Stored in: **Legacy API**
### Device (WP Sensor)
Physical BLE/WiFi sensor installed in beneficiary's home.
- Measures activity, temperature, etc.
- Sends data via WiFi to Legacy API
- Up to **5 sensors per beneficiary**
- Stored in: **Legacy API**
---
## Data Storage
| Data | Storage | Notes |
|------|---------|-------|
| User profile | WellNuo API | Our database |
| Beneficiary profile | WellNuo API | Our database |
| Beneficiary.deploymentId | WellNuo API | Links to Legacy |
| Deployment | Legacy API | External service |
| Device | Legacy API | External service |
| Sensor readings | Legacy API | External service |
**Important:** We do NOT have access to Legacy API source code. We only use their API endpoints.
---
## Entity Relationships
```
User (WellNuo API)
├── Beneficiary 1
│ └── deploymentId: 22 ──► Deployment 22 (Legacy API)
│ ├── Device (WP_497)
│ ├── Device (WP_498)
│ ├── Device (WP_499)
│ ├── Device (WP_500)
│ └── Device (WP_501)
├── Beneficiary 2
│ └── deploymentId: 23 ──► Deployment 23 (Legacy API)
│ ├── Device (WP_510)
│ └── Device (WP_511)
├── Beneficiary 3
│ └── deploymentId: 24 ──► Deployment 24 (Legacy API)
│ └── (no devices yet)
└── ... up to N beneficiaries
```
### Key Points
1. Each beneficiary has their own deployment
2. Devices are attached to deployment, not directly to beneficiary
3. When adding sensors, user must first select which beneficiary
4. Sensors found via BLE scan could belong to ANY beneficiary's home
---
## User Flows
### Flow 1: View Sensors for a Beneficiary
```
1. User opens Beneficiaries list
2. User taps on a specific beneficiary (e.g., "Maria")
3. User navigates to Equipment tab
4. App fetches:
a. GET /me/beneficiaries/{id} → get deploymentId
b. POST Legacy API device_list_by_deployment → get devices
5. App displays sensor list with status (online/warning/offline)
```
### Flow 2: Add Sensors (Batch)
**Prerequisites:**
- User has selected a specific beneficiary
- User is physically at that beneficiary's location
- Sensors are powered on
```
1. User is on Equipment screen for "Maria"
2. User taps "Add Sensor"
3. App shows instructions
4. User taps "Scan for Sensors"
5. App performs BLE scan (10 seconds)
6. App filters devices with name starting with "WP_"
7. App shows list with checkboxes (all selected by default)
8. User can uncheck sensors they don't want to add
9. User taps "Add Selected (N)"
10. App shows WiFi selection screen
11. User selects WiFi network (from sensor's scan)
12. User enters WiFi password
13. User taps "Connect All"
14. App performs batch setup (see Batch Sensor Setup section)
15. App shows results screen
16. User taps "Done"
```
### Flow 3: Edit Sensor Location/Description
**TODO:** Not implemented yet. Needs UI.
```
1. User taps on a sensor in Equipment list
2. App shows Device Settings screen
3. User can edit:
- location (text field, e.g., "Bedroom, near bed")
- description (text field)
4. User taps "Save"
5. App calls Legacy API device_form to update
```
### Flow 4: Detach Sensor
```
1. User taps detach button on a sensor
2. App shows confirmation dialog
3. User confirms
4. App calls Legacy API device_form with deployment_id=0
5. Sensor is unlinked from this beneficiary
6. Sensor can now be attached to another beneficiary
```
---
## BLE Protocol
### Service UUID
```
4fafc201-1fb5-459e-8fcc-c5c9c331914b
```
### Characteristic UUID
```
beb5483e-36e1-4688-b7f5-ea07361b26a8
```
### Device Name Format
```
WP_{wellId}_{mac6chars}
Example: WP_497_81a14c
- wellId: 497
- mac last 6 chars: 81a14c
```
### Commands
| Command | Format | Description |
|---------|--------|-------------|
| PIN Unlock | `pin\|7856` | Unlock device for configuration |
| Get WiFi List | `w` | Request available WiFi networks |
| Set WiFi | `W\|SSID,PASSWORD` | Configure WiFi connection |
| Get WiFi Status | `a` | Get current WiFi connection |
| Reboot | `s` | Restart the sensor |
| Disconnect | `D` | Disconnect BLE |
### WiFi List Response Format
```
SSID1,-55;SSID2,-70;SSID3,-80
```
Where -55, -70, -80 are RSSI values in dBm.
### Timeouts
- Scan: 10 seconds
- Command response: 5 seconds
### WiFi Status Response (command `a`)
When connected via BLE, you can query current WiFi status:
**Command:** `a`
**Response format:**
```
{ssid},{rssi},{connected}
Example: Home_Network,-62,1
- ssid: "Home_Network"
- rssi: -62 dBm
- connected: 1 (true) or 0 (false)
```
**Use cases:**
- Show which WiFi network the sensor is connected to
- Display WiFi signal strength
- Diagnose connectivity issues
---
## Legacy API Endpoints
Base URL: `https://eluxnetworks.net/function/well-api/api`
Authentication: Form-encoded POST with `user_name` and `token`
### device_list_by_deployment
Get all devices for a deployment.
**Request:**
```
function=device_list_by_deployment
user_name={username}
token={token}
deployment_id={id}
first=0
last=100
```
**Response:**
```json
{
"result_list": [
[deviceId, wellId, mac, lastSeenTimestamp, location, description],
[1, 497, "142B2F81A14C", 1705426800, "Bedroom", "Main sensor"],
[2, 498, "142B2F82B25D", 1705426700, "", ""]
]
}
```
### device_form
Create or update a device.
**Request:**
```
function=device_form
user_name={username}
token={token}
well_id={wellId}
device_mac={mac}
location={location}
description={description}
deployment_id={deploymentId}
```
### request_devices
Get online devices (with fresh=true filter).
**Request:**
```
function=request_devices
user_name={username}
token={token}
deployment_id={id}
group_id=All
location=All
fresh=true
```
---
## UI Screens
### Equipment Screen
**Path:** `/(tabs)/beneficiaries/:id/equipment`
**Features:**
- Summary card (total/online/warning/offline counts)
- List of connected sensors
- BLE scan button for nearby sensors
- Detach sensor button
**Current limitations:**
- No editing of location/description
### Add Sensor Screen
**Path:** `/(tabs)/beneficiaries/:id/add-sensor`
**Features:**
- Instructions (4 steps)
- Scan button
- List of found devices with RSSI
**TODO:** Add checkbox selection for batch setup
### Setup WiFi Screen
**Path:** `/(tabs)/beneficiaries/:id/setup-wifi`
**Features:**
- WiFi network list (from sensor)
- Password input
- Connect button
**TODO:** Support batch setup (multiple sensors, one WiFi config)
### Device Settings Screen
**Path:** `/(tabs)/beneficiaries/:id/device-settings/:deviceId`
**Features:**
- View Well ID, MAC, Deployment ID
- Current WiFi status
- Change WiFi button
- Reboot button
**TODO:** Add location/description editing
---
## Batch Sensor Setup
### Overview
When adding multiple sensors (up to 5), the app should:
1. Allow selecting multiple sensors from BLE scan
2. Configure WiFi once for all sensors
3. Process sensors sequentially with progress UI
4. Handle errors gracefully
### Sensor States During Setup
```
pending — waiting in queue
connecting — BLE connection in progress
unlocking — sending PIN command
setting_wifi — configuring WiFi
attaching — calling Legacy API to link to deployment
rebooting — restarting sensor
success — completed successfully
error — failed (with error message)
skipped — user chose to skip after error
```
### Progress UI
```
Connecting sensors to WiFi "Home_Network"...
WP_497_81a14c
✓ Connected
✓ Unlocked
✓ WiFi configured
✓ Attached to Maria
● Rebooting...
WP_498_82b25d
✓ Connected
✓ Unlocked
● Setting WiFi...
WP_499_83c36e
○ Waiting...
WP_500_84d47f
✗ Error: Could not connect
[Retry] [Skip]
```
### Results Screen
```
Setup Complete
Successfully connected:
✓ WP_497_81a14c
✓ WP_498_82b25d
✓ WP_499_83c36e
Failed:
✗ WP_500_84d47f — Connection timeout
[Retry This Sensor]
[Done]
```
---
## Error Handling
### Possible Errors
| Error | Cause | User Message | Actions |
|-------|-------|--------------|---------|
| BLE Connect Failed | Sensor too far, off, or busy | "Could not connect. Move closer or check if sensor is on." | Retry, Skip |
| PIN Unlock Failed | Wrong PIN (unlikely) | "Could not unlock sensor." | Retry, Skip |
| WiFi Set Failed | Command timeout | "Could not configure WiFi." | Retry, Skip |
| API Attach Failed | No internet, server down | "Could not register sensor. Check internet." | Retry, Skip |
| Timeout | Sensor not responding | "Sensor not responding." | Retry, Skip |
### Error Recovery Options
When an error occurs on sensor N:
1. **Pause** the batch process
2. **Show** error with options:
- `[Retry]` — try this sensor again
- `[Skip]` — move to next sensor
- `[Cancel All]` — abort entire process
3. **Continue** based on user choice
### All Sensors Failed
```
Setup Failed
Could not connect any sensors.
Possible reasons:
• Sensors are too far away
• Sensors are not powered on
• Bluetooth is disabled
[Try Again] [Cancel]
```
### Logging
For debugging, log all operations:
```
[BLE] Starting batch setup for 5 sensors
[BLE] [1/5] WP_497 — connecting...
[BLE] [1/5] WP_497 — connected (took 1.2s)
[BLE] [1/5] WP_497 — sending PIN...
[BLE] [1/5] WP_497 — unlocked
[BLE] [1/5] WP_497 — setting WiFi "Home"...
[BLE] [1/5] WP_497 — WiFi configured
[API] [1/5] WP_497 — attaching to deployment 22...
[API] [1/5] WP_497 — attached (device_id: 1)
[BLE] [1/5] WP_497 — rebooting...
[BLE] [1/5] WP_497 — SUCCESS (total: 8.5s)
[BLE] [2/5] WP_498 — connecting...
[BLE] [2/5] WP_498 — ERROR: connection timeout after 5s
```
---
## Sensor Activity & Status
### Status Calculation
Sensor status is calculated based on `lastSeen` timestamp from Legacy API:
| Time since last signal | Status | Color | Meaning |
|------------------------|--------|-------|---------|
| < 5 minutes | online | Green | Sensor is working normally |
| 5-60 minutes | warning | Yellow | Possible issue, check sensor |
| > 60 minutes | offline | Red | Sensor is not sending data |
### Activity Display in UI
**Equipment Screen shows for each sensor:**
- Sensor name (WP_497_81a14c)
- Status badge (Online/Warning/Offline)
- Last activity time ("5 min ago", "2 hours ago")
- Location label (if set)
### Detailed WiFi Status (via BLE)
When user opens Device Settings for an ONLINE sensor, app can:
1. Connect to sensor via BLE
2. Send command `a` (Get WiFi Status)
3. Display:
- WiFi network name
- Signal strength (RSSI in dBm or as Excellent/Good/Fair/Weak)
- Connection status
**WiFi Signal Strength Interpretation:**
| RSSI | Quality |
|------|---------|
| -50 or better | Excellent |
| -50 to -60 | Good |
| -60 to -70 | Fair |
| -70 or worse | Weak |
This helps diagnose connectivity issues — if sensor goes offline, user can check if WiFi signal is weak.
---
## Open Questions
### Q1: Multiple Beneficiaries Context
**Question:** User has 5 beneficiaries. When scanning BLE, sensors from different locations might appear. How to handle?
**Decision:** Trust the user. They select beneficiary first, then add sensors. If they make a mistake, they can detach and reattach to correct beneficiary. No confirmation needed.
### Q2: WiFi Configuration Timing
**Question:** When should user set sensor location label?
**Options:**
- A) During initial setup (add step after WiFi)
- B) Later, in Device Settings screen
- C) Both (optional during setup, editable later)
### Q3: Background Processing
**Question:** If user closes app during batch setup, what happens?
**Options:**
- A) Continue in background (complex)
- B) Cancel everything (simple, but frustrating)
- C) Pause and resume when app reopens
### Q4: Sensor Already Attached
**Question:** What if scanned sensor is already attached to ANOTHER beneficiary (different deployment)?
**Options:**
- A) Show error: "This sensor is already in use"
- B) Offer to move it: "Move to Maria's home?"
- C) Just attach it (will detach from previous)
---
## File References
| File | Description |
|------|-------------|
| `services/ble/BLEManager.ts` | BLE operations |
| `services/ble/types.ts` | BLE types |
| `services/ble/MockBLEManager.ts` | Mock for simulator |
| `contexts/BLEContext.tsx` | BLE React context |
| `services/api.ts` | API methods |
| `types/index.ts` | WPSensor type (lines 47-59) |
| `app/(tabs)/beneficiaries/[id]/equipment.tsx` | Equipment screen |
| `app/(tabs)/beneficiaries/[id]/add-sensor.tsx` | Add sensor screen |
| `app/(tabs)/beneficiaries/[id]/setup-wifi.tsx` | WiFi setup screen |
| `app/(tabs)/beneficiaries/[id]/device-settings/[deviceId].tsx` | Device settings |
---
## Revision History
| Date | Author | Changes |
|------|--------|---------|
| 2026-01-19 | Claude | Initial documentation |

View File

@ -48,6 +48,10 @@ function formatTimeAgo(date: Date): string {
}
class ApiService {
// API URLs as instance properties for consistency
private readonly baseUrl = WELLNUO_API_URL;
private readonly legacyApiUrl = API_BASE_URL;
// Public method to get the access token (used by AuthContext)
async getToken(): Promise<string | null> {
try {
@ -1535,6 +1539,16 @@ class ApiService {
return this.DEMO_DEPLOYMENT_ID;
}
/**
* Get Legacy API credentials for device operations
* Uses the same credentials as getLegacyWebViewCredentials but returns only what's needed
*/
async getLegacyCredentials(): Promise<{ userName: string; token: string } | null> {
const creds = await this.getLegacyWebViewCredentials();
if (!creds) return null;
return { userName: creds.userName, token: creds.token };
}
// ============================================================================
// WP SENSORS / DEVICES MANAGEMENT
// ============================================================================
@ -1545,8 +1559,17 @@ class ApiService {
*/
async getDevicesForBeneficiary(beneficiaryId: string) {
try {
// Get auth token for WellNuo API
const token = await this.getToken();
if (!token) return { ok: false, error: 'Not authenticated' };
// Get beneficiary's deployment_id from PostgreSQL
const response = await fetch(`${this.baseUrl}/me/beneficiaries/${beneficiaryId}`);
const response = await fetch(`${this.baseUrl}/me/beneficiaries/${beneficiaryId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) throw new Error('Failed to get beneficiary');
const beneficiary = await response.json();
@ -1679,8 +1702,17 @@ class ApiService {
password: string
) {
try {
// Get auth token for WellNuo API
const token = await this.getToken();
if (!token) throw new Error('Not authenticated');
// Get beneficiary details
const response = await fetch(`${this.baseUrl}/me/beneficiaries/${beneficiaryId}`);
const response = await fetch(`${this.baseUrl}/me/beneficiaries/${beneficiaryId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) throw new Error('Failed to get beneficiary');
const beneficiary = await response.json();
@ -1727,6 +1759,120 @@ class ApiService {
}
}
/**
* Update device metadata (location, description) in Legacy API
* Uses device_form endpoint
*/
async updateDeviceMetadata(
deviceId: string,
updates: {
location?: string;
description?: string;
}
): Promise<ApiResponse<{ success: boolean }>> {
try {
const creds = await this.getLegacyWebViewCredentials();
if (!creds) {
return { ok: false, error: { message: 'Not authenticated with Legacy API', code: 'UNAUTHORIZED' } };
}
const formData = new URLSearchParams({
function: 'device_form',
user_name: creds.userName,
token: creds.token,
device_id: deviceId,
});
// Add optional fields if provided
if (updates.location !== undefined) {
formData.append('location', updates.location);
}
if (updates.description !== undefined) {
formData.append('description', updates.description);
}
const response = await fetch(API_BASE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
});
if (!response.ok) {
return { ok: false, error: { message: 'Failed to update device' } };
}
const data = await response.json();
if (data.status !== '200 OK') {
return { ok: false, error: { message: data.message || 'Failed to update device' } };
}
return { ok: true, data: { success: true } };
} catch (error: any) {
console.error('[API] updateDeviceMetadata error:', error);
return { ok: false, error: { message: error.message || 'Network error', code: 'NETWORK_ERROR' } };
}
}
/**
* Attach device to deployment via Legacy API
* Uses set_deployment endpoint to link a WP sensor to a beneficiary's deployment
*
* @param deploymentId - The deployment ID to attach the device to
* @param wellId - The device's well_id (from BLE scan, e.g., 497)
* @param ssid - WiFi network SSID
* @param password - WiFi network password
*/
async attachDeviceToDeployment(
deploymentId: number,
wellId: number,
ssid: string,
password: string
): Promise<ApiResponse<{ success: boolean }>> {
try {
const creds = await this.getLegacyWebViewCredentials();
if (!creds) {
return { ok: false, error: { message: 'Not authenticated with Legacy API', code: 'UNAUTHORIZED' } };
}
// 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',
});
console.log('[API] attachDeviceToDeployment: deployment=', deploymentId, 'wellId=', wellId, 'ssid=', ssid);
const response = await fetch(API_BASE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
});
if (!response.ok) {
return { ok: false, error: { message: 'Failed to attach device to deployment' } };
}
const data = await response.json();
if (data.status !== '200 OK') {
console.error('[API] attachDeviceToDeployment failed:', data);
return { ok: false, error: { message: data.message || 'Failed to attach device' } };
}
console.log('[API] attachDeviceToDeployment success');
return { ok: true, data: { success: true } };
} catch (error: any) {
console.error('[API] attachDeviceToDeployment error:', error);
return { ok: false, error: { message: error.message || 'Network error', code: 'NETWORK_ERROR' } };
}
}
/**
* Detach device from beneficiary
*/

View File

@ -67,6 +67,18 @@ export type EquipmentStatus =
| 'active' // Equipment activated and working
| 'demo'; // Demo mode (DEMO-00000)
// Deployment (location where beneficiary can be monitored)
export interface Deployment {
id: number;
beneficiary_id: number;
name: string; // e.g., "Home", "Office", "Vacation Home"
address?: string;
is_primary: boolean; // One deployment per beneficiary is primary
legacy_deployment_id?: number; // Link to Legacy API deployment
created_at: string;
updated_at: string;
}
// Beneficiary Types (elderly people being monitored)
export interface Beneficiary {
id: number;
@ -193,3 +205,48 @@ export interface ApiResponse<T> {
error?: ApiError;
ok: boolean;
}
// Batch Sensor Setup Types
/** States a sensor can be in during batch setup */
export type SensorSetupStatus =
| 'pending' // Waiting in queue
| 'connecting' // BLE connection in progress
| 'unlocking' // Sending PIN command
| 'setting_wifi' // Configuring WiFi
| 'attaching' // Calling Legacy API to link to deployment
| 'rebooting' // Restarting sensor
| 'success' // Completed successfully
| 'error' // Failed (with error message)
| 'skipped'; // User chose to skip after error
/** Step within a sensor's setup process */
export interface SensorSetupStep {
name: 'connect' | 'unlock' | 'wifi' | 'attach' | 'reboot';
status: 'pending' | 'in_progress' | 'completed' | 'failed';
error?: string;
}
/** State of a single sensor during batch setup */
export interface SensorSetupState {
deviceId: string;
deviceName: string;
wellId?: number;
mac: string;
status: SensorSetupStatus;
steps: SensorSetupStep[];
error?: string;
startTime?: number;
endTime?: number;
}
/** Overall batch setup state */
export interface BatchSetupState {
sensors: SensorSetupState[];
currentIndex: number;
ssid: string;
password: string;
isPaused: boolean;
isComplete: boolean;
startTime: number;
}