Add Legacy API integration for sensors system

- Add legacyAPI.js service for authentication and deployment management
- Add deployments.js routes for device listing
- Add FEATURE-SENSORS-SYSTEM.md spec
- Add bug report: set_deployment missing deployment_id in response
- Add test scripts for Legacy API (create_deployment, find_deployments)
- Update beneficiaries.js to return deploymentId

BUG: Legacy API set_deployment returns {"ok": 1} but does NOT return
deployment_id. Waiting for Robert to fix this before we can auto-create
deployments for new beneficiaries.
This commit is contained in:
Sergei 2026-01-20 15:13:44 -08:00
parent 9f9124fdab
commit 9cb51c13c0
7 changed files with 1143 additions and 2 deletions

View File

@ -4,6 +4,7 @@ const jwt = require('jsonwebtoken');
const Stripe = require('stripe'); const Stripe = require('stripe');
const { supabase } = require('../config/supabase'); const { supabase } = require('../config/supabase');
const storage = require('../services/storage'); const storage = require('../services/storage');
const legacyAPI = require('../services/legacyAPI');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
@ -301,6 +302,14 @@ router.get('/:id', async (req, res) => {
// Get subscription status from Stripe (source of truth) // Get subscription status from Stripe (source of truth)
const subscription = await getStripeSubscriptionStatus(beneficiary.stripe_customer_id); const subscription = await getStripeSubscriptionStatus(beneficiary.stripe_customer_id);
// Get primary deployment with legacy_deployment_id
const { data: deployment } = await supabase
.from('beneficiary_deployments')
.select('id, legacy_deployment_id')
.eq('beneficiary_id', beneficiaryId)
.eq('is_primary', true)
.single();
// Get orders for this beneficiary // Get orders for this beneficiary
const { data: orders } = await supabase const { data: orders } = await supabase
.from('orders') .from('orders')
@ -320,7 +329,9 @@ router.get('/:id', async (req, res) => {
orders: orders || [], orders: orders || [],
// Equipment status from beneficiaries table - CRITICAL for navigation! // Equipment status from beneficiaries table - CRITICAL for navigation!
hasDevices: beneficiary.equipment_status === 'active' || beneficiary.equipment_status === 'demo', hasDevices: beneficiary.equipment_status === 'active' || beneficiary.equipment_status === 'demo',
equipmentStatus: beneficiary.equipment_status || 'none' equipmentStatus: beneficiary.equipment_status || 'none',
// Legacy deployment ID for fetching sensors from Legacy API
deploymentId: deployment?.legacy_deployment_id || null
}); });
} catch (error) { } catch (error) {
@ -333,6 +344,7 @@ router.get('/:id', async (req, res) => {
* POST /api/me/beneficiaries * POST /api/me/beneficiaries
* Creates a new beneficiary and grants custodian access to creator * Creates a new beneficiary and grants custodian access to creator
* Now uses the proper beneficiaries table (not users) * Now uses the proper beneficiaries table (not users)
* AUTO-CREATES FIRST DEPLOYMENT
*/ */
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
try { try {
@ -388,6 +400,90 @@ router.post('/', async (req, res) => {
console.log('[BENEFICIARY] Custodian access granted'); console.log('[BENEFICIARY] Custodian access granted');
// AUTO-CREATE FIRST DEPLOYMENT
// This is the "Home" deployment - primary deployment for this beneficiary
const { data: deployment, error: deploymentError } = await supabase
.from('beneficiary_deployments')
.insert({
beneficiary_id: beneficiary.id,
name: 'Home',
address: address || null,
is_primary: true,
legacy_deployment_id: null, // Will be set after Legacy API call
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
})
.select()
.single();
if (deploymentError) {
console.error('[BENEFICIARY] Deployment create error:', deploymentError);
// Rollback - delete access and beneficiary
await supabase.from('user_access').delete().eq('accessor_id', userId).eq('beneficiary_id', beneficiary.id);
await supabase.from('beneficiaries').delete().eq('id', beneficiary.id);
return res.status(500).json({ error: 'Failed to create deployment' });
}
console.log('[BENEFICIARY] Created primary deployment:', deployment.id);
// CREATE DEPLOYMENT IN LEGACY API
// This links our beneficiary to the Legacy API system for device management
try {
const legacyUsername = process.env.LEGACY_API_USERNAME || '';
const legacyPassword = process.env.LEGACY_API_PASSWORD || '';
if (!legacyUsername || !legacyPassword) {
console.warn('[BENEFICIARY] Legacy API credentials not configured, skipping Legacy deployment creation');
} else {
// Get Legacy API token
const legacyToken = await legacyAPI.getLegacyToken(legacyUsername, legacyPassword);
// Create deployment in Legacy API
const legacyDeploymentId = await legacyAPI.createLegacyDeployment({
username: legacyUsername,
token: legacyToken,
beneficiaryName: name,
beneficiaryEmail: `beneficiary-${beneficiary.id}@wellnuo.app`, // Auto-generated email
beneficiaryUsername: `beneficiary_${beneficiary.id}`,
beneficiaryPassword: Math.random().toString(36).substring(2, 15), // Random password
address: address || '',
caretakerUsername: legacyUsername,
caretakerEmail: '', // Can be set later
persons: 1,
pets: 0,
gender: 'Other',
race: 0,
born: new Date().getFullYear() - 65,
lat: 0,
lng: 0,
wifis: [],
devices: []
});
console.log('[BENEFICIARY] Created Legacy deployment:', legacyDeploymentId);
// Update our deployment with legacy_deployment_id
const { error: updateError } = await supabase
.from('beneficiary_deployments')
.update({
legacy_deployment_id: legacyDeploymentId,
updated_at: new Date().toISOString()
})
.eq('id', deployment.id);
if (updateError) {
console.error('[BENEFICIARY] Failed to update legacy_deployment_id:', updateError);
// Not critical - deployment still works without this link
} else {
deployment.legacy_deployment_id = legacyDeploymentId;
}
}
} catch (legacyError) {
console.error('[BENEFICIARY] Legacy API deployment failed:', legacyError);
// Not critical - continue without Legacy API link
// Beneficiary and deployment are still created in our database
}
res.status(201).json({ res.status(201).json({
success: true, success: true,
beneficiary: { beneficiary: {
@ -397,7 +493,8 @@ router.post('/', async (req, res) => {
address: beneficiary.address || null, address: beneficiary.address || null,
avatarUrl: beneficiary.avatar_url, avatarUrl: beneficiary.avatar_url,
role: 'custodian', role: 'custodian',
equipmentStatus: 'none' equipmentStatus: 'none',
primaryDeploymentId: deployment.id // Return deployment ID for future use
} }
}); });

View File

@ -0,0 +1,311 @@
const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const { supabase } = require('../config/supabase');
const legacyAPI = require('../services/legacyAPI');
/**
* Middleware to verify JWT token
*/
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const token = authHeader.split(' ')[1];
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}
// Apply auth middleware to all routes
router.use(authMiddleware);
/**
* GET /api/me/beneficiaries/:beneficiaryId/deployments
* Returns list of deployments for a beneficiary
*/
router.get('/beneficiaries/:beneficiaryId/deployments', async (req, res) => {
try {
const userId = req.user.userId;
const beneficiaryId = parseInt(req.params.beneficiaryId, 10);
// Check user has access to this beneficiary
const { data: access, error: accessError } = await supabase
.from('user_access')
.select('role')
.eq('accessor_id', userId)
.eq('beneficiary_id', beneficiaryId)
.single();
if (accessError || !access) {
return res.status(403).json({ error: 'Access denied to this beneficiary' });
}
// Get all deployments for this beneficiary
const { data: deployments, error } = await supabase
.from('beneficiary_deployments')
.select('*')
.eq('beneficiary_id', beneficiaryId)
.order('is_primary', { ascending: false })
.order('created_at', { ascending: true });
if (error) {
console.error('[DEPLOYMENTS] Get error:', error);
return res.status(500).json({ error: 'Failed to get deployments' });
}
res.json({ deployments: deployments || [] });
} catch (error) {
console.error('[DEPLOYMENTS] Error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/me/beneficiaries/:beneficiaryId/deployments
* Creates a new deployment for a beneficiary
*/
router.post('/beneficiaries/:beneficiaryId/deployments', async (req, res) => {
try {
const userId = req.user.userId;
const beneficiaryId = parseInt(req.params.beneficiaryId, 10);
const { name, address } = req.body;
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
console.log('[DEPLOYMENTS] Create request:', { userId, beneficiaryId, name });
// Check user has custodian or guardian access
const { data: access, error: accessError } = await supabase
.from('user_access')
.select('role')
.eq('accessor_id', userId)
.eq('beneficiary_id', beneficiaryId)
.single();
if (accessError || !access || !['custodian', 'guardian'].includes(access.role)) {
return res.status(403).json({ error: 'Only custodian or guardian can create deployments' });
}
// Create deployment
const { data: deployment, error } = await supabase
.from('beneficiary_deployments')
.insert({
beneficiary_id: beneficiaryId,
name: name,
address: address || null,
is_primary: false, // New deployments are not primary by default
legacy_deployment_id: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
})
.select()
.single();
if (error) {
console.error('[DEPLOYMENTS] Create error:', error);
return res.status(500).json({ error: 'Failed to create deployment' });
}
console.log('[DEPLOYMENTS] Created:', deployment.id);
res.status(201).json({
success: true,
deployment: deployment
});
} catch (error) {
console.error('[DEPLOYMENTS] Create error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* PATCH /api/me/deployments/:id
* Updates deployment info
*/
router.patch('/deployments/:id', async (req, res) => {
try {
const userId = req.user.userId;
const deploymentId = parseInt(req.params.id, 10);
const { name, address, is_primary } = req.body;
console.log('[DEPLOYMENTS] Update request:', { userId, deploymentId, body: req.body });
// Get deployment and check access
const { data: deployment, error: deploymentError } = await supabase
.from('beneficiary_deployments')
.select('beneficiary_id')
.eq('id', deploymentId)
.single();
if (deploymentError || !deployment) {
return res.status(404).json({ error: 'Deployment not found' });
}
// Check user has custodian or guardian access to the beneficiary
const { data: access, error: accessError } = await supabase
.from('user_access')
.select('role')
.eq('accessor_id', userId)
.eq('beneficiary_id', deployment.beneficiary_id)
.single();
if (accessError || !access || !['custodian', 'guardian'].includes(access.role)) {
return res.status(403).json({ error: 'Only custodian or guardian can update deployments' });
}
const updateData = {
updated_at: new Date().toISOString()
};
if (name !== undefined) updateData.name = name;
if (address !== undefined) updateData.address = address;
if (is_primary !== undefined) updateData.is_primary = is_primary;
// Update deployment
const { data: updated, error } = await supabase
.from('beneficiary_deployments')
.update(updateData)
.eq('id', deploymentId)
.select()
.single();
if (error) {
console.error('[DEPLOYMENTS] Update error:', error);
return res.status(500).json({ error: 'Failed to update deployment' });
}
console.log('[DEPLOYMENTS] Updated:', deploymentId);
res.json({
success: true,
deployment: updated
});
} catch (error) {
console.error('[DEPLOYMENTS] Update error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* DELETE /api/me/deployments/:id
* Deletes a deployment (custodian only)
*/
router.delete('/deployments/:id', async (req, res) => {
try {
const userId = req.user.userId;
const deploymentId = parseInt(req.params.id, 10);
console.log('[DEPLOYMENTS] Delete request:', { userId, deploymentId });
// Get deployment and check access
const { data: deployment, error: deploymentError } = await supabase
.from('beneficiary_deployments')
.select('beneficiary_id, is_primary')
.eq('id', deploymentId)
.single();
if (deploymentError || !deployment) {
return res.status(404).json({ error: 'Deployment not found' });
}
// Check user has custodian access
const { data: access, error: accessError } = await supabase
.from('user_access')
.select('role')
.eq('accessor_id', userId)
.eq('beneficiary_id', deployment.beneficiary_id)
.single();
if (accessError || !access || access.role !== 'custodian') {
return res.status(403).json({ error: 'Only custodian can delete deployments' });
}
// Cannot delete primary deployment
if (deployment.is_primary) {
return res.status(400).json({ error: 'Cannot delete primary deployment' });
}
// Delete deployment
const { error } = await supabase
.from('beneficiary_deployments')
.delete()
.eq('id', deploymentId);
if (error) {
console.error('[DEPLOYMENTS] Delete error:', error);
return res.status(500).json({ error: 'Failed to delete deployment' });
}
console.log('[DEPLOYMENTS] Deleted:', deploymentId);
res.json({ success: true, message: 'Deployment deleted' });
} catch (error) {
console.error('[DEPLOYMENTS] Delete error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/me/deployments/:id/devices
* Returns list of devices for a deployment (from Legacy API)
*/
router.get('/deployments/:id/devices', async (req, res) => {
try {
const userId = req.user.userId;
const deploymentId = parseInt(req.params.id, 10);
console.log('[DEPLOYMENTS] Get devices request:', { userId, deploymentId });
// Get deployment and check access
const { data: deployment, error: deploymentError } = await supabase
.from('beneficiary_deployments')
.select('beneficiary_id, legacy_deployment_id')
.eq('id', deploymentId)
.single();
if (deploymentError || !deployment) {
return res.status(404).json({ error: 'Deployment not found' });
}
// Check user has access to beneficiary
const { data: access, error: accessError } = await supabase
.from('user_access')
.select('role')
.eq('accessor_id', userId)
.eq('beneficiary_id', deployment.beneficiary_id)
.single();
if (accessError || !access) {
return res.status(403).json({ error: 'Access denied' });
}
// If no legacy_deployment_id, return empty list
if (!deployment.legacy_deployment_id) {
return res.json({ devices: [] });
}
// TODO: Get devices from Legacy API using legacy_deployment_id
// For now, return empty list
// When ready, use: legacyAPI.getDeploymentDevices(username, token, legacy_deployment_id)
res.json({ devices: [] });
} catch (error) {
console.error('[DEPLOYMENTS] Get devices error:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;

View File

@ -0,0 +1,258 @@
/**
* Legacy API Integration Service
*
* This service handles communication with the Legacy WellNuo API
* at eluxnetworks.net for device management and deployments.
*/
const axios = require('axios');
const LEGACY_API_BASE = 'https://eluxnetworks.net/function/well-api/api';
/**
* Room location codes mapping
* These numeric codes are used by Legacy API to identify room types
*/
const ROOM_LOCATIONS = {
'Bedroom': 102,
'Living Room': 103,
'Kitchen': 104,
'Bathroom': 105,
'Hallway': 106,
'Office': 107,
'Garage': 108,
'Dining Room': 109,
'Basement': 110,
'Other': 200
};
const LOCATION_NAMES = Object.fromEntries(
Object.entries(ROOM_LOCATIONS).map(([k, v]) => [v, k])
);
/**
* Get authentication token from Legacy API
* @param {string} username - Legacy API username
* @param {string} password - Legacy API password
* @returns {Promise<string>} Access token
*/
async function getLegacyToken(username, password) {
const formData = new URLSearchParams({
function: 'credentials',
user_name: username,
ps: password,
clientId: '001',
nonce: Date.now().toString()
});
const response = await axios.post(LEGACY_API_BASE, formData, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
if (response.data.status !== '200 OK') {
throw new Error('Legacy API authentication failed');
}
return response.data.access_token;
}
/**
* Create deployment in Legacy API
* @param {object} params - Deployment parameters
* @param {string} params.username - Installer username
* @param {string} params.token - Access token
* @param {string} params.beneficiaryName - Full name
* @param {string} params.beneficiaryEmail - Email
* @param {string} params.beneficiaryUsername - Login username
* @param {string} params.beneficiaryPassword - Password
* @param {string} params.address - Address
* @param {string} params.caretakerUsername - Caretaker username
* @param {string} params.caretakerEmail - Caretaker email
* @param {number} params.persons - Number of persons
* @param {number} params.pets - Number of pets
* @param {string} params.gender - Gender
* @param {number} params.race - Race index
* @param {number} params.born - Year born
* @param {number} params.lat - GPS latitude
* @param {number} params.lng - GPS longitude
* @param {Array<string>} params.wifis - WiFi credentials ["SSID|password", ...]
* @param {Array<number>} params.devices - Device well_ids [497, 523]
* @returns {Promise<number>} Created deployment_id
*/
async function createLegacyDeployment(params) {
const formData = new URLSearchParams({
function: 'set_deployment',
user_name: params.username,
token: params.token,
deployment: 'NEW',
beneficiary_name: params.beneficiaryName,
beneficiary_email: params.beneficiaryEmail,
beneficiary_user_name: params.beneficiaryUsername,
beneficiary_password: params.beneficiaryPassword,
beneficiary_address: params.address || '',
caretaker_username: params.caretakerUsername || params.username,
caretaker_email: params.caretakerEmail || params.beneficiaryEmail,
persons: params.persons || 1,
pets: params.pets || 0,
gender: params.gender || 'Other',
race: params.race || 0,
born: params.born || new Date().getFullYear() - 65,
lat: params.lat || 0,
lng: params.lng || 0,
wifis: JSON.stringify(params.wifis || []),
devices: JSON.stringify(params.devices || []),
reuse_existing_devices: params.devices && params.devices.length > 0 ? 1 : 0
});
const response = await axios.post(LEGACY_API_BASE, formData, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
if (response.data.status !== '200 OK') {
throw new Error('Failed to create deployment in Legacy API');
}
// Extract deployment_id from response
// Response format varies, need to handle different cases
return response.data.deployment_id || response.data.result;
}
/**
* Assign device to deployment (set well_id)
* @param {string} username - Username
* @param {string} token - Access token
* @param {number} deviceId - Internal device ID
* @param {number} wellId - Well ID to assign
* @param {string} mac - Device MAC address
* @returns {Promise<boolean>} Success status
*/
async function assignDeviceToDeployment(username, token, deviceId, wellId, mac) {
const formData = new URLSearchParams({
function: 'device_set_well_id',
user_name: username,
token: token,
device_id: deviceId,
well_id: wellId,
mac: mac.toUpperCase().replace(/:/g, '')
});
const response = await axios.post(LEGACY_API_BASE, formData, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
return response.data.success === true;
}
/**
* Update device location/room
* @param {string} username - Username
* @param {string} token - Access token
* @param {number} wellId - Device well_id
* @param {string} deviceMac - MAC address
* @param {string} roomName - Room name (Bedroom, Kitchen, etc.)
* @param {object} options - Additional options
* @param {string} options.description - Device description
* @param {string} options.closeTo - Position description
* @param {number} options.radarThreshold - Radar sensitivity (0-100)
* @param {number} options.group - Group ID
* @returns {Promise<boolean>} Success status
*/
async function updateDeviceLocation(username, token, wellId, deviceMac, roomName, options = {}) {
const locationCode = ROOM_LOCATIONS[roomName] || ROOM_LOCATIONS['Other'];
const formData = new URLSearchParams({
function: 'device_form',
user_name: username,
token: token,
well_id: wellId,
device_mac: deviceMac.toUpperCase().replace(/:/g, ''),
location: locationCode.toString(),
description: options.description || '',
close_to: options.closeTo || '',
radar_threshold: options.radarThreshold || 50,
group: options.group || 0
});
const response = await axios.post(LEGACY_API_BASE, formData, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
return response.data.status === '200 OK';
}
/**
* Get devices for deployment (only recently active)
* @param {string} username - Username
* @param {string} token - Access token
* @param {number} deploymentId - Deployment ID
* @param {boolean} onlineOnly - If true, return only online devices (fresh=true)
* @returns {Promise<Array>} List of devices
*/
async function getDeploymentDevices(username, token, deploymentId, onlineOnly = true) {
const formData = new URLSearchParams({
function: 'request_devices',
user_name: username,
token: token,
deployment_id: deploymentId,
group_id: 'All',
location: 'All',
fresh: onlineOnly ? 'true' : 'false'
});
const response = await axios.post(LEGACY_API_BASE, formData, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
if (response.data.status !== '200 OK') {
return [];
}
// Parse device array format:
// [device_id, well_id, MAC, timestamp, location, description, deployment_id]
const devices = response.data.result_list || [];
return devices.map(device => ({
deviceId: device[0],
wellId: device[1],
mac: device[2],
lastSeen: device[3],
locationCode: device[4],
locationName: LOCATION_NAMES[device[4]] || 'Unknown',
description: device[5],
deploymentId: device[6],
isOnline: true // If fresh=true was used, all returned devices are online
}));
}
/**
* Reboot device
* @param {string} username - Username
* @param {string} token - Access token
* @param {number} deviceId - Device ID
* @returns {Promise<boolean>} Success status
*/
async function rebootDevice(username, token, deviceId) {
const formData = new URLSearchParams({
function: 'device_reboot',
user_name: username,
token: token,
device_id: deviceId
});
const response = await axios.post(LEGACY_API_BASE, formData, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
return response.data.status === '200 OK';
}
module.exports = {
getLegacyToken,
createLegacyDeployment,
assignDeviceToDeployment,
updateDeviceLocation,
getDeploymentDevices,
rebootDevice,
ROOM_LOCATIONS,
LOCATION_NAMES
};

View File

@ -0,0 +1,109 @@
# Legacy API Bug Report: set_deployment Missing deployment_id
## Summary
The `set_deployment` function successfully creates a deployment but does not return the `deployment_id` in the response. This prevents automated integration.
## Current Behavior
```bash
# Request
curl -X POST "https://eluxnetworks.net/function/well-api/api" \
-d "function=set_deployment" \
-d "user_name=robster" \
-d "token=$TOKEN" \
-d "deployment=NEW" \
-d "beneficiary_name=Test User" \
# ... other required fields
# Response
{"ok": 1, "status": "200 OK"}
```
## Expected Behavior
```json
{"ok": 1, "deployment_id": 78, "status": "200 OK"}
```
## Impact
WellNuo app needs to:
1. Create deployment in Legacy API when beneficiary is created
2. Save `deployment_id` to `beneficiary_deployments.legacy_deployment_id`
3. Use this ID to fetch sensors via `device_list_by_deployment`
Without `deployment_id` in response, we cannot link the systems.
## Workaround Attempted
Tried to find newly created deployment by searching recent IDs:
```bash
curl -X POST "..." -d "function=find_deployments" -d "well_ids=70,71,72,73,74,75"
```
This is unreliable because:
- Race conditions with concurrent deployments
- No guaranteed sequential IDs
- Additional API call required
## Full Working Request
```bash
#!/bin/bash
TOKEN="<robster_token>"
TS=$(date +%s)
PHOTO=$(base64 -i /tmp/no-photo.jpg | tr -d '\n')
curl -s -X POST "https://eluxnetworks.net/function/well-api/api" \
-d "function=set_deployment" \
-d "user_name=robster" \
-d "token=$TOKEN" \
-d "deployment=NEW" \
-d "beneficiary_name=WellNuo Test" \
-d "beneficiary_email=wellnuo-test-${TS}@wellnuo.app" \
-d "beneficiary_user_name=wellnuo_test_${TS}" \
-d "beneficiary_password=wellnuo123" \
-d "beneficiary_address=WellNuo App" \
-d "caretaker_username=anandk" \
-d "caretaker_email=anandk@wellnuo.app" \
-d "firstName=WellNuo" \
-d "lastName=Test" \
-d "first_name=WellNuo" \
-d "last_name=Test" \
-d "new_user_name=wellnuo_test_${TS}" \
-d "phone_number=+10000000000" \
-d "key=wellnuo123" \
-d "signature=WellNuo" \
-d "persons=1" \
-d "pets=0" \
-d "gender=0" \
-d "race=0" \
-d "born=1960" \
-d "lat=0" \
-d "lng=0" \
-d "gps_age=0" \
-d "wifis=[]" \
-d "devices=[]" \
-d "reuse_existing_devices=0" \
--data-urlencode "beneficiary_photo=$PHOTO"
```
## Required Fix
Modify `set_deployment` to return created deployment ID:
```json
{"ok": 1, "deployment_id": <newly_created_id>, "status": "200 OK"}
```
## Notes
- User `robster` (max_role=-1, installer) is required for creating deployments
- User `anandk` (max_role=2, caretaker) can only view assigned deployments
- Credentials function works correctly for authentication
- device_list_by_deployment works correctly once deployment_id is known
## Date
2025-01-20

View File

@ -0,0 +1,38 @@
#!/bin/bash
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InJvYnN0ZXIiLCJleHAiOjE3NjkwMjczNDd9.UWJ4pZsRA1sKJqff61OaNDlQfLG5UgDu7qaubz53hUQ"
TS=$(date +%s)
PHOTO=$(base64 -i /tmp/no-photo.jpg | tr -d '\n')
# Create deployment via robster (installer)
curl -s -X POST "https://eluxnetworks.net/function/well-api/api" \
-d "function=set_deployment" \
-d "user_name=robster" \
-d "token=$TOKEN" \
-d "deployment=NEW" \
-d "beneficiary_name=WellNuo Test" \
-d "beneficiary_email=wellnuo-test-${TS}@wellnuo.app" \
-d "beneficiary_user_name=wellnuo_test_${TS}" \
-d "beneficiary_password=wellnuo123" \
-d "beneficiary_address=WellNuo App" \
-d "caretaker_username=anandk" \
-d "caretaker_email=anandk@wellnuo.app" \
-d "firstName=WellNuo" \
-d "lastName=Test" \
-d "first_name=WellNuo" \
-d "last_name=Test" \
-d "new_user_name=wellnuo_test_${TS}" \
-d "phone_number=+10000000000" \
-d "key=wellnuo123" \
-d "signature=WellNuo" \
-d "persons=1" \
-d "pets=0" \
-d "gender=0" \
-d "race=0" \
-d "born=1960" \
-d "lat=0" \
-d "lng=0" \
-d "gps_age=0" \
-d "wifis=[]" \
-d "devices=[]" \
-d "reuse_existing_devices=0" \
--data-urlencode "beneficiary_photo=$PHOTO"

View File

@ -0,0 +1,8 @@
#!/bin/bash
TOKEN=$(cat /tmp/legacy_token.txt | tr -d '\n')
# Ищем deployment по последним ID
curl -s -X POST "https://eluxnetworks.net/function/well-api/api" \
-d "function=find_deployments" \
-d "user_name=anandk" \
-d "token=$TOKEN" \
-d "well_ids=40,41,42,43,44,45,46,47,48,49,50"

View File

@ -0,0 +1,320 @@
# FEATURE: Sensors Management System
**ID:** FEATURE-SENSORS
**Status:** 🟡 In Progress
**Priority:** High
**Created:** 2026-01-19
---
## Overview
Система управления BLE-сенсорами WP для мониторинга пожилых людей (beneficiaries).
Сенсоры измеряют активность в помещении и передают данные через WiFi на Legacy API.
**Схема архитектуры:**
https://diagrams.love/canvas?schema=cmkm6nt6x0063ll5lqaq49lbt
---
## Сущности
### Beneficiary (Подопечный)
- Пожилой человек, за которым ухаживают
- Хранится в: **WellNuo API** (PostgreSQL)
- Поля: `firstName`, `lastName`, `avatar`, `dateOfBirth`
### Deployment (Домовладение)
- Место проживания beneficiary в Legacy API
- Хранится в: **Legacy API** (eluxnetworks.net)
- Поля: `address`, `timezone`, `devices[]`, `beneficiary_id`
- Связь: 1 Beneficiary = 1 Deployment
### Device (WP Sensor)
- Физический BLE/WiFi сенсор
- Хранится в: **Legacy API**
- Поля:
- `well_id` — уникальный ID устройства
- `mac` — MAC адрес
- `location` — где установлен (текстовое поле, редактируемое)
- `description` — описание (текстовое поле, редактируемое)
- `deployment_id` — привязка к домовладению
- `status` — online/warning/offline (вычисляется по lastSeen)
---
## API
### WellNuo API (Primary)
**Base URL:** `https://wellnuo.smartlaunchhub.com/api`
**Auth:** Bearer JWT token
Используется для:
- Авторизация пользователей
- CRUD beneficiaries
- Подписки и платежи
- Профиль пользователя
### Legacy API (External)
**Base URL:** `https://eluxnetworks.net/function/well-api/api`
**Auth:** Form-encoded (user_name, token)
⚠️ **Внешний сервис — нет доступа к коду, только API**
#### Ключевые endpoints для устройств:
| Endpoint | Описание | Параметры |
|----------|----------|-----------|
| `device_list_by_deployment` | Список устройств deployment | deployment_id |
| `device_form` | Создать/обновить устройство | well_id, device_mac, location, description, deployment_id |
| `device_set_group` | Установить группу | device_id, group_id |
| `request_devices` | Получить online устройства | deployment_id, fresh=true |
#### Формат device_form:
```
POST /function/well-api/api
Content-Type: application/x-www-form-urlencoded
function=device_form
user_name={{user_name}}
token={{token}}
well_id=497
device_mac=142B2F81A14C
location=Спальня, у кровати
description=Основной сенсор
deployment_id=22
```
---
## Bluetooth Flow
### BLE Manager
**Файл:** `services/ble/BLEManager.ts`
**Библиотека:** `react-native-ble-plx`
### UUID и протокол:
```typescript
SERVICE_UUID: '4fafc201-1fb5-459e-8fcc-c5c9c331914b'
CHAR_UUID: 'beb5483e-36e1-4688-b7f5-ea07361b26a8'
```
### BLE команды:
| Команда | Формат | Описание |
|---------|--------|----------|
| PIN Unlock | `pin\|7856` | Разблокировка устройства |
| Get WiFi List | `w` | Получить доступные WiFi |
| Set WiFi | `W\|SSID,PASSWORD` | Установить WiFi |
| Get WiFi Status | `a` | Текущее подключение |
| Reboot | `s` | Перезагрузка |
| Disconnect | `D` | Отключение BLE |
### Таймауты:
- Сканирование: 10 сек
- Команда: 5 сек
### Формат ответа WiFi List:
```
SSID1,-55;SSID2,-70;SSID3,-80
```
---
## Экраны
### 1. Equipment Screen
**Путь:** `/(tabs)/beneficiaries/:id/equipment`
**Файл:** `app/(tabs)/beneficiaries/[id]/equipment.tsx`
**Функции:**
- Summary card (total/online/warning/offline)
- Список подключённых сенсоров (из API)
- BLE сканирование поблизости
- Detach сенсор
**Текущие ограничения:**
- ❌ НЕТ редактирования location/description
### 2. Add Sensor Screen
**Путь:** `/(tabs)/beneficiaries/:id/add-sensor`
**Файл:** `app/(tabs)/beneficiaries/[id]/add-sensor.tsx`
**Функции:**
- Инструкции по добавлению (4 шага)
- BLE сканирование
- Список найденных устройств с RSSI
### 3. Setup WiFi Screen
**Путь:** `/(tabs)/beneficiaries/:id/setup-wifi`
**Файл:** `app/(tabs)/beneficiaries/[id]/setup-wifi.tsx`
**Функции:**
- Получить WiFi список от сенсора (BLE)
- Выбор сети и ввод пароля
- Подключение и привязка к beneficiary
### 4. Device Settings Screen
**Путь:** `/(tabs)/beneficiaries/:id/device-settings/:deviceId`
**Файл:** `app/(tabs)/beneficiaries/[id]/device-settings/[deviceId].tsx`
**Функции:**
- Просмотр Well ID, MAC, Deployment ID
- Текущий WiFi статус
- Изменение WiFi
- Перезагрузка
**Текущие ограничения:**
- ❌ НЕТ редактирования location/description
---
## Что нужно доработать
### TASK-1: Добавить редактирование location/description
**Проблема:**
Сейчас `location` и `description` показываются в UI (equipment.tsx:454-456), но нет возможности их редактировать.
**Решение:**
1. Добавить в Device Settings Screen форму для редактирования
2. Создать метод `updateDevice()` в api.ts
3. Использовать Legacy API endpoint `device_form`
**Файлы для изменения:**
- `services/api.ts` — добавить `updateDeviceMetadata(deviceId, location, description)`
- `app/(tabs)/beneficiaries/[id]/device-settings/[deviceId].tsx` — добавить форму
**API вызов:**
```typescript
async updateDeviceMetadata(
deviceId: string,
wellId: number,
mac: string,
location: string,
description: string
) {
const creds = await this.getLegacyCredentials();
const formData = new URLSearchParams({
function: 'device_form',
user_name: creds.userName,
token: creds.token,
editing_device_id: deviceId,
well_id: wellId.toString(),
device_mac: mac,
location: location,
description: description,
});
// POST to Legacy API
}
```
### TASK-2: Показывать placeholder для пустого location
**Проблема:**
Если location пустой — ничего не показывается.
**Решение:**
В equipment.tsx вместо:
```tsx
{sensor.location && (
<Text style={styles.deviceLocation}>{sensor.location}</Text>
)}
```
Сделать:
```tsx
<TouchableOpacity onPress={() => openLocationEditor(sensor)}>
<Text style={styles.deviceLocation}>
{sensor.location || 'Tap to set location'}
</Text>
</TouchableOpacity>
```
### TASK-3: Quick edit location из Equipment Screen
**Проблема:**
Сейчас для редактирования location нужно заходить в Device Settings.
**Решение:**
Добавить inline редактирование или ActionSheet с опцией "Edit Location".
---
## Ключевые файлы
| Файл | Описание |
|------|----------|
| `services/ble/BLEManager.ts` | BLE менеджер |
| `services/ble/types.ts` | Типы BLE |
| `contexts/BLEContext.tsx` | Global BLE state |
| `services/api.ts` | API методы |
| `types/index.ts` | WPSensor тип (строки 47-59) |
| `app/(tabs)/beneficiaries/[id]/equipment.tsx` | Equipment Screen |
| `app/(tabs)/beneficiaries/[id]/add-sensor.tsx` | Add Sensor |
| `app/(tabs)/beneficiaries/[id]/setup-wifi.tsx` | Setup WiFi |
| `app/(tabs)/beneficiaries/[id]/device-settings/[deviceId].tsx` | Device Settings |
| `api/Wellnuo_API.postman_collection.json` | Postman коллекция |
---
## Статусы сенсора
Вычисляются на клиенте по `lastSeen`:
| Статус | Условие | Цвет |
|--------|---------|------|
| `online` | lastSeen < 5 мин | Зелёный |
| `warning` | 5 мин < lastSeen < 60 мин | Жёлтый |
| `offline` | lastSeen > 60 мин | Красный |
---
## Mock режим
Когда приложение работает в iOS Simulator, используется `MockBLEManager` с тестовыми данными:
```javascript
{
id: 'mock-743',
name: 'WP_497_81a14c',
mac: '142B2F81A14C',
rssi: -55,
wellId: 497,
}
```
Это позволяет разрабатывать без физического устройства.
---
## Implementation Steps
### Phase 1: API метод для обновления устройства
- [ ] Создать `updateDeviceMetadata()` в api.ts
- [ ] Тестировать через curl/Postman
### Phase 2: UI для редактирования в Device Settings
- [ ] Добавить TextInput для location
- [ ] Добавить TextInput для description
- [ ] Кнопка Save
- [ ] Loading state при сохранении
### Phase 3: Quick edit из Equipment Screen
- [ ] Добавить ActionSheet с опцией "Edit Location"
- [ ] Или inline edit по тапу
### Phase 4: Валидация и UX
- [ ] Показывать placeholder для пустого location
- [ ] Ограничить длину текста (если есть лимит API)
- [ ] Показывать success/error feedback
---
## Verification Checklist
- [ ] Сенсоры загружаются из Legacy API
- [ ] BLE сканирование находит WP_* устройства
- [ ] WiFi настройка работает через BLE
- [ ] Location/description сохраняется в Legacy API
- [ ] Location отображается в Equipment Screen
- [ ] Mock режим работает в симуляторе