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>
This commit is contained in:
parent
1301c6e093
commit
9f9124fdab
17
.ralphy/config.yaml
Normal file
17
.ralphy/config.yaml
Normal 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
25
.ralphy/progress.txt
Normal 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
355
PRD-SENSORS.md
Normal 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
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Alert,
|
Alert,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
TextInput,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
@ -29,10 +30,12 @@ interface SensorInfo {
|
|||||||
wellId: number;
|
wellId: number;
|
||||||
mac: string;
|
mac: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'online' | 'offline';
|
status: 'online' | 'warning' | 'offline';
|
||||||
lastSeen: Date;
|
lastSeen: Date;
|
||||||
beneficiaryId: string;
|
beneficiaryId: string;
|
||||||
deploymentId: number;
|
deploymentId: number;
|
||||||
|
location?: string;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DeviceSettingsScreen() {
|
export default function DeviceSettingsScreen() {
|
||||||
@ -52,6 +55,11 @@ export default function DeviceSettingsScreen() {
|
|||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
const [isLoadingWiFi, setIsLoadingWiFi] = useState(false);
|
const [isLoadingWiFi, setIsLoadingWiFi] = useState(false);
|
||||||
const [isRebooting, setIsRebooting] = 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!);
|
const isConnected = connectedDevices.has(deviceId!);
|
||||||
|
|
||||||
@ -66,14 +74,16 @@ export default function DeviceSettingsScreen() {
|
|||||||
// Get sensor info from API
|
// Get sensor info from API
|
||||||
const response = await api.getDevicesForBeneficiary(id!);
|
const response = await api.getDevicesForBeneficiary(id!);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok || !response.data) {
|
||||||
throw new Error('Failed to load sensor info');
|
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) {
|
if (sensor) {
|
||||||
setSensorInfo(sensor);
|
setSensorInfo(sensor);
|
||||||
|
setLocation(sensor.location || '');
|
||||||
|
setDescription(sensor.description || '');
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Sensor not found');
|
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 formatLastSeen = (lastSeen: Date): string => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffMs = now.getTime() - lastSeen.getTime();
|
const diffMs = now.getTime() - lastSeen.getTime();
|
||||||
@ -288,6 +347,55 @@ export default function DeviceSettingsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</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 */}
|
{/* BLE Connection Section */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Bluetooth Connection</Text>
|
<Text style={styles.sectionTitle}>Bluetooth Connection</Text>
|
||||||
@ -557,6 +665,45 @@ const styles = StyleSheet.create({
|
|||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: AppColors.border,
|
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
|
// BLE Connection
|
||||||
connectedCard: {
|
connectedCard: {
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
|
|||||||
@ -513,7 +513,6 @@ export default function EquipmentScreen() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.scanButton, isBLEScanning && styles.scanButtonActive]}
|
style={[styles.scanButton, isBLEScanning && styles.scanButtonActive]}
|
||||||
onPress={handleScanNearby}
|
onPress={handleScanNearby}
|
||||||
disabled={!isBLEAvailable}
|
|
||||||
>
|
>
|
||||||
{isBLEScanning ? (
|
{isBLEScanning ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import type {
|
|||||||
SensorSetupStatus,
|
SensorSetupStatus,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import BatchSetupProgress from '@/components/BatchSetupProgress';
|
import BatchSetupProgress from '@/components/BatchSetupProgress';
|
||||||
|
import SetupResultsScreen from '@/components/SetupResultsScreen';
|
||||||
import {
|
import {
|
||||||
AppColors,
|
AppColors,
|
||||||
BorderRadius,
|
BorderRadius,
|
||||||
@ -473,107 +474,12 @@ export default function SetupWiFiScreen() {
|
|||||||
|
|
||||||
// Results screen
|
// Results screen
|
||||||
if (phase === 'results') {
|
if (phase === 'results') {
|
||||||
const successSensors = sensors.filter(s => s.status === 'success');
|
|
||||||
const failedSensors = sensors.filter(s => s.status === 'error' || s.status === 'skipped');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SetupResultsScreen
|
||||||
<View style={styles.header}>
|
sensors={sensors}
|
||||||
<View style={styles.placeholder} />
|
onRetry={handleRetryFromResults}
|
||||||
<Text style={styles.headerTitle}>Setup Complete</Text>
|
onDone={handleDone}
|
||||||
<View style={styles.placeholder} />
|
/>
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
|
|
||||||
{/* Success Summary */}
|
|
||||||
<View style={styles.resultsSummary}>
|
|
||||||
<View style={[styles.summaryIcon, { backgroundColor: AppColors.successLight }]}>
|
|
||||||
<Ionicons
|
|
||||||
name={successSensors.length > 0 ? 'checkmark-circle' : 'alert-circle'}
|
|
||||||
size={48}
|
|
||||||
color={successSensors.length > 0 ? AppColors.success : AppColors.warning}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.summaryTitle}>
|
|
||||||
{successSensors.length === sensors.length
|
|
||||||
? 'All Sensors Connected!'
|
|
||||||
: successSensors.length > 0
|
|
||||||
? 'Partial Success'
|
|
||||||
: 'Setup Failed'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.summarySubtitle}>
|
|
||||||
{successSensors.length} of {sensors.length} sensors configured
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Success List */}
|
|
||||||
{successSensors.length > 0 && (
|
|
||||||
<View style={styles.resultsSection}>
|
|
||||||
<Text style={styles.resultsSectionTitle}>Successfully Connected</Text>
|
|
||||||
{successSensors.map(sensor => (
|
|
||||||
<View key={sensor.deviceId} style={styles.resultItem}>
|
|
||||||
<Ionicons name="checkmark-circle" size={20} color={AppColors.success} />
|
|
||||||
<Text style={styles.resultItemText}>{sensor.deviceName}</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Failed List */}
|
|
||||||
{failedSensors.length > 0 && (
|
|
||||||
<View style={styles.resultsSection}>
|
|
||||||
<Text style={styles.resultsSectionTitle}>Failed</Text>
|
|
||||||
{failedSensors.map(sensor => (
|
|
||||||
<View key={sensor.deviceId} style={styles.resultItemWithAction}>
|
|
||||||
<View style={styles.resultItemLeft}>
|
|
||||||
<Ionicons
|
|
||||||
name={sensor.status === 'skipped' ? 'remove-circle' : 'close-circle'}
|
|
||||||
size={20}
|
|
||||||
color={sensor.status === 'skipped' ? AppColors.warning : AppColors.error}
|
|
||||||
/>
|
|
||||||
<View style={styles.resultItemContent}>
|
|
||||||
<Text style={styles.resultItemText}>{sensor.deviceName}</Text>
|
|
||||||
{sensor.error && (
|
|
||||||
<Text style={styles.resultItemError}>{sensor.error}</Text>
|
|
||||||
)}
|
|
||||||
{sensor.status === 'skipped' && (
|
|
||||||
<Text style={styles.resultItemError}>Skipped</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.retryItemButton}
|
|
||||||
onPress={() => handleRetryFromResults(sensor.deviceId)}
|
|
||||||
>
|
|
||||||
<Ionicons name="refresh" size={16} color={AppColors.primary} />
|
|
||||||
<Text style={styles.retryItemButtonText}>Retry</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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 your Equipment list\n• It may take up to 1 minute for sensors to come online\n• You can configure sensor locations in Device Settings'
|
|
||||||
: '• Return to the Equipment screen and try adding sensors again\n• Make sure sensors are powered on and nearby'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* Done Button */}
|
|
||||||
<View style={styles.bottomActions}>
|
|
||||||
<TouchableOpacity style={styles.doneButton} onPress={handleDone}>
|
|
||||||
<Text style={styles.doneButtonText}>Done</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1021,107 +927,4 @@ const styles = StyleSheet.create({
|
|||||||
color: AppColors.info,
|
color: AppColors.info,
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
// Results Screen
|
|
||||||
resultsSummary: {
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingVertical: Spacing.xl,
|
|
||||||
},
|
|
||||||
summaryIcon: {
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
borderRadius: 40,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: Spacing.md,
|
|
||||||
},
|
|
||||||
summaryTitle: {
|
|
||||||
fontSize: FontSizes.xl,
|
|
||||||
fontWeight: FontWeights.bold,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
marginBottom: Spacing.xs,
|
|
||||||
},
|
|
||||||
summarySubtitle: {
|
|
||||||
fontSize: FontSizes.base,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
},
|
|
||||||
resultsSection: {
|
|
||||||
backgroundColor: AppColors.surface,
|
|
||||||
borderRadius: BorderRadius.lg,
|
|
||||||
padding: Spacing.md,
|
|
||||||
marginBottom: Spacing.md,
|
|
||||||
...Shadows.xs,
|
|
||||||
},
|
|
||||||
resultsSectionTitle: {
|
|
||||||
fontSize: FontSizes.sm,
|
|
||||||
fontWeight: FontWeights.semibold,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
marginBottom: Spacing.sm,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
},
|
|
||||||
resultItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
paddingVertical: Spacing.xs,
|
|
||||||
gap: Spacing.sm,
|
|
||||||
},
|
|
||||||
resultItemWithAction: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingVertical: Spacing.sm,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: AppColors.border,
|
|
||||||
},
|
|
||||||
resultItemLeft: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
flex: 1,
|
|
||||||
gap: Spacing.sm,
|
|
||||||
},
|
|
||||||
resultItemContent: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
resultItemText: {
|
|
||||||
fontSize: FontSizes.base,
|
|
||||||
fontWeight: FontWeights.medium,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
},
|
|
||||||
resultItemError: {
|
|
||||||
fontSize: FontSizes.xs,
|
|
||||||
color: AppColors.error,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
retryItemButton: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: Spacing.xs,
|
|
||||||
paddingVertical: Spacing.xs,
|
|
||||||
paddingHorizontal: Spacing.sm,
|
|
||||||
backgroundColor: AppColors.primaryLighter,
|
|
||||||
borderRadius: BorderRadius.md,
|
|
||||||
},
|
|
||||||
retryItemButtonText: {
|
|
||||||
fontSize: FontSizes.sm,
|
|
||||||
fontWeight: FontWeights.semibold,
|
|
||||||
color: AppColors.primary,
|
|
||||||
},
|
|
||||||
bottomActions: {
|
|
||||||
padding: Spacing.lg,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: AppColors.border,
|
|
||||||
backgroundColor: AppColors.surface,
|
|
||||||
},
|
|
||||||
doneButton: {
|
|
||||||
backgroundColor: AppColors.primary,
|
|
||||||
paddingVertical: Spacing.md,
|
|
||||||
borderRadius: BorderRadius.lg,
|
|
||||||
alignItems: 'center',
|
|
||||||
...Shadows.md,
|
|
||||||
},
|
|
||||||
doneButtonText: {
|
|
||||||
fontSize: FontSizes.base,
|
|
||||||
fontWeight: FontWeights.semibold,
|
|
||||||
color: AppColors.white,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
DimensionValue,
|
DimensionValue,
|
||||||
|
Modal,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import type { SensorSetupState, SensorSetupStep } from '@/types';
|
import type { SensorSetupState, SensorSetupStep } from '@/types';
|
||||||
@ -20,6 +21,73 @@ import {
|
|||||||
Shadows,
|
Shadows,
|
||||||
} from '@/constants/theme';
|
} 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 {
|
interface BatchSetupProgressProps {
|
||||||
sensors: SensorSetupState[];
|
sensors: SensorSetupState[];
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
@ -30,6 +98,197 @@ interface BatchSetupProgressProps {
|
|||||||
onCancelAll?: () => 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"
|
// Format elapsed time as "Xs" or "Xm Xs"
|
||||||
function formatElapsedTime(startTime?: number, endTime?: number): string {
|
function formatElapsedTime(startTime?: number, endTime?: number): string {
|
||||||
if (!startTime) return '';
|
if (!startTime) return '';
|
||||||
@ -189,23 +448,30 @@ function SensorCard({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message - enhanced display */}
|
||||||
{sensor.error && (
|
{sensor.error && (
|
||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<Ionicons name="alert-circle" size={16} color={AppColors.error} />
|
<View style={styles.errorHeader}>
|
||||||
<Text style={styles.errorText}>{sensor.error}</Text>
|
<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>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons for failed sensors */}
|
{/* Action buttons for failed sensors - improved styling */}
|
||||||
{showActions && (
|
{showActions && (
|
||||||
<View style={styles.actionButtons}>
|
<View style={styles.actionButtons}>
|
||||||
<TouchableOpacity style={styles.retryButton} onPress={onRetry}>
|
<TouchableOpacity style={styles.retryButton} onPress={onRetry}>
|
||||||
<Ionicons name="refresh" size={16} color={AppColors.primary} />
|
<Ionicons name="refresh" size={16} color={AppColors.white} />
|
||||||
<Text style={styles.retryText}>Retry</Text>
|
<Text style={styles.retryText}>Retry</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={styles.skipButton} onPress={onSkip}>
|
<TouchableOpacity style={styles.skipButton} onPress={onSkip}>
|
||||||
<Ionicons name="arrow-forward" size={16} color={AppColors.textMuted} />
|
<Ionicons name="arrow-forward" size={16} color={AppColors.textSecondary} />
|
||||||
<Text style={styles.skipText}>Skip</Text>
|
<Text style={styles.skipText}>Skip</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@ -226,6 +492,7 @@ export default function BatchSetupProgress({
|
|||||||
const scrollViewRef = useRef<ScrollView>(null);
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
const sensorCardRefs = useRef<{ [key: string]: View | null }>({});
|
const sensorCardRefs = useRef<{ [key: string]: View | null }>({});
|
||||||
const progressAnim = useRef(new Animated.Value(0)).current;
|
const progressAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||||
|
|
||||||
const completedCount = sensors.filter(s => s.status === 'success').length;
|
const completedCount = sensors.filter(s => s.status === 'success').length;
|
||||||
const failedCount = sensors.filter(s => s.status === 'error').length;
|
const failedCount = sensors.filter(s => s.status === 'error').length;
|
||||||
@ -233,6 +500,41 @@ export default function BatchSetupProgress({
|
|||||||
const totalProcessed = completedCount + failedCount + skippedCount;
|
const totalProcessed = completedCount + failedCount + skippedCount;
|
||||||
const progress = (totalProcessed / sensors.length) * 100;
|
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
|
// Animate progress bar
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Animated.timing(progressAnim, {
|
Animated.timing(progressAnim, {
|
||||||
@ -363,6 +665,15 @@ export default function BatchSetupProgress({
|
|||||||
<Text style={styles.cancelAllText}>Cancel Setup</Text>
|
<Text style={styles.cancelAllText}>Cancel Setup</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Error Action Modal */}
|
||||||
|
<ErrorActionModal
|
||||||
|
visible={showErrorModal}
|
||||||
|
sensor={failedSensor || null}
|
||||||
|
onRetry={handleRetryFromModal}
|
||||||
|
onSkip={handleSkipFromModal}
|
||||||
|
onCancelAll={handleCancelAllFromModal}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -559,51 +870,68 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
errorContainer: {
|
errorContainer: {
|
||||||
|
marginTop: Spacing.sm,
|
||||||
|
padding: Spacing.md,
|
||||||
|
backgroundColor: AppColors.errorLight,
|
||||||
|
borderRadius: BorderRadius.md,
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: AppColors.error,
|
||||||
|
},
|
||||||
|
errorHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: Spacing.sm,
|
|
||||||
padding: Spacing.sm,
|
|
||||||
backgroundColor: AppColors.errorLight,
|
|
||||||
borderRadius: BorderRadius.sm,
|
|
||||||
gap: Spacing.xs,
|
gap: Spacing.xs,
|
||||||
|
marginBottom: Spacing.xs,
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontSize: FontSizes.sm,
|
||||||
|
fontWeight: FontWeights.semibold,
|
||||||
|
color: AppColors.error,
|
||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
fontSize: FontSizes.xs,
|
fontSize: FontSizes.xs,
|
||||||
color: AppColors.error,
|
color: AppColors.textSecondary,
|
||||||
flex: 1,
|
lineHeight: 18,
|
||||||
},
|
},
|
||||||
actionButtons: {
|
actionButtons: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
marginTop: Spacing.md,
|
marginTop: Spacing.md,
|
||||||
gap: Spacing.md,
|
gap: Spacing.sm,
|
||||||
},
|
},
|
||||||
retryButton: {
|
retryButton: {
|
||||||
|
flex: 1,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: Spacing.xs,
|
justifyContent: 'center',
|
||||||
|
paddingVertical: Spacing.sm,
|
||||||
paddingHorizontal: Spacing.md,
|
paddingHorizontal: Spacing.md,
|
||||||
backgroundColor: AppColors.primaryLighter,
|
backgroundColor: AppColors.primary,
|
||||||
borderRadius: BorderRadius.md,
|
borderRadius: BorderRadius.md,
|
||||||
gap: Spacing.xs,
|
gap: Spacing.xs,
|
||||||
|
...Shadows.xs,
|
||||||
},
|
},
|
||||||
retryText: {
|
retryText: {
|
||||||
fontSize: FontSizes.sm,
|
fontSize: FontSizes.sm,
|
||||||
fontWeight: FontWeights.medium,
|
fontWeight: FontWeights.semibold,
|
||||||
color: AppColors.primary,
|
color: AppColors.white,
|
||||||
},
|
},
|
||||||
skipButton: {
|
skipButton: {
|
||||||
|
flex: 1,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: Spacing.xs,
|
justifyContent: 'center',
|
||||||
|
paddingVertical: Spacing.sm,
|
||||||
paddingHorizontal: Spacing.md,
|
paddingHorizontal: Spacing.md,
|
||||||
backgroundColor: AppColors.surfaceSecondary,
|
backgroundColor: AppColors.surfaceSecondary,
|
||||||
borderRadius: BorderRadius.md,
|
borderRadius: BorderRadius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: AppColors.border,
|
||||||
gap: Spacing.xs,
|
gap: Spacing.xs,
|
||||||
},
|
},
|
||||||
skipText: {
|
skipText: {
|
||||||
fontSize: FontSizes.sm,
|
fontSize: FontSizes.sm,
|
||||||
fontWeight: FontWeights.medium,
|
fontWeight: FontWeights.medium,
|
||||||
color: AppColors.textMuted,
|
color: AppColors.textSecondary,
|
||||||
},
|
},
|
||||||
cancelAllButton: {
|
cancelAllButton: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
529
components/SetupResultsScreen.tsx
Normal file
529
components/SetupResultsScreen.tsx
Normal 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
577
docs/SENSORS_SYSTEM.md
Normal 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 |
|
||||||
Loading…
x
Reference in New Issue
Block a user