From 9cb51c13c0cbd20518e5765f806ecc81f550652f Mon Sep 17 00:00:00 2001 From: Sergei Date: Tue, 20 Jan 2026 15:13:44 -0800 Subject: [PATCH] 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. --- backend/src/routes/beneficiaries.js | 101 +++++++- backend/src/routes/deployments.js | 311 +++++++++++++++++++++++ backend/src/services/legacyAPI.js | 258 +++++++++++++++++++ docs/LEGACY_API_BUG_REPORT.md | 109 ++++++++ scripts/legacy-api/create_deployment.sh | 38 +++ scripts/legacy-api/find_deployments.sh | 8 + specs/wellnuo/FEATURE-SENSORS-SYSTEM.md | 320 ++++++++++++++++++++++++ 7 files changed, 1143 insertions(+), 2 deletions(-) create mode 100644 backend/src/routes/deployments.js create mode 100644 backend/src/services/legacyAPI.js create mode 100644 docs/LEGACY_API_BUG_REPORT.md create mode 100644 scripts/legacy-api/create_deployment.sh create mode 100644 scripts/legacy-api/find_deployments.sh create mode 100644 specs/wellnuo/FEATURE-SENSORS-SYSTEM.md diff --git a/backend/src/routes/beneficiaries.js b/backend/src/routes/beneficiaries.js index 9c1456b..f136dd9 100644 --- a/backend/src/routes/beneficiaries.js +++ b/backend/src/routes/beneficiaries.js @@ -4,6 +4,7 @@ const jwt = require('jsonwebtoken'); const Stripe = require('stripe'); const { supabase } = require('../config/supabase'); const storage = require('../services/storage'); +const legacyAPI = require('../services/legacyAPI'); 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) 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 const { data: orders } = await supabase .from('orders') @@ -320,7 +329,9 @@ router.get('/:id', async (req, res) => { orders: orders || [], // Equipment status from beneficiaries table - CRITICAL for navigation! 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) { @@ -333,6 +344,7 @@ router.get('/:id', async (req, res) => { * POST /api/me/beneficiaries * Creates a new beneficiary and grants custodian access to creator * Now uses the proper beneficiaries table (not users) + * AUTO-CREATES FIRST DEPLOYMENT */ router.post('/', async (req, res) => { try { @@ -388,6 +400,90 @@ router.post('/', async (req, res) => { 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({ success: true, beneficiary: { @@ -397,7 +493,8 @@ router.post('/', async (req, res) => { address: beneficiary.address || null, avatarUrl: beneficiary.avatar_url, role: 'custodian', - equipmentStatus: 'none' + equipmentStatus: 'none', + primaryDeploymentId: deployment.id // Return deployment ID for future use } }); diff --git a/backend/src/routes/deployments.js b/backend/src/routes/deployments.js new file mode 100644 index 0000000..bc78e2d --- /dev/null +++ b/backend/src/routes/deployments.js @@ -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; diff --git a/backend/src/services/legacyAPI.js b/backend/src/services/legacyAPI.js new file mode 100644 index 0000000..d57709d --- /dev/null +++ b/backend/src/services/legacyAPI.js @@ -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} 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} params.wifis - WiFi credentials ["SSID|password", ...] + * @param {Array} params.devices - Device well_ids [497, 523] + * @returns {Promise} 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} 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} 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} 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} 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 +}; diff --git a/docs/LEGACY_API_BUG_REPORT.md b/docs/LEGACY_API_BUG_REPORT.md new file mode 100644 index 0000000..5838b1f --- /dev/null +++ b/docs/LEGACY_API_BUG_REPORT.md @@ -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="" +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": , "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 diff --git a/scripts/legacy-api/create_deployment.sh b/scripts/legacy-api/create_deployment.sh new file mode 100644 index 0000000..abc7668 --- /dev/null +++ b/scripts/legacy-api/create_deployment.sh @@ -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" diff --git a/scripts/legacy-api/find_deployments.sh b/scripts/legacy-api/find_deployments.sh new file mode 100644 index 0000000..b71f01a --- /dev/null +++ b/scripts/legacy-api/find_deployments.sh @@ -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" diff --git a/specs/wellnuo/FEATURE-SENSORS-SYSTEM.md b/specs/wellnuo/FEATURE-SENSORS-SYSTEM.md new file mode 100644 index 0000000..9ca8c89 --- /dev/null +++ b/specs/wellnuo/FEATURE-SENSORS-SYSTEM.md @@ -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 && ( + {sensor.location} +)} +``` + +Сделать: +```tsx + openLocationEditor(sensor)}> + + {sensor.location || 'Tap to set location'} + + +``` + +### 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 режим работает в симуляторе