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:
parent
9f9124fdab
commit
9cb51c13c0
@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
311
backend/src/routes/deployments.js
Normal file
311
backend/src/routes/deployments.js
Normal 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;
|
||||||
258
backend/src/services/legacyAPI.js
Normal file
258
backend/src/services/legacyAPI.js
Normal 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
|
||||||
|
};
|
||||||
109
docs/LEGACY_API_BUG_REPORT.md
Normal file
109
docs/LEGACY_API_BUG_REPORT.md
Normal 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
|
||||||
38
scripts/legacy-api/create_deployment.sh
Normal file
38
scripts/legacy-api/create_deployment.sh
Normal 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"
|
||||||
8
scripts/legacy-api/find_deployments.sh
Normal file
8
scripts/legacy-api/find_deployments.sh
Normal 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"
|
||||||
320
specs/wellnuo/FEATURE-SENSORS-SYSTEM.md
Normal file
320
specs/wellnuo/FEATURE-SENSORS-SYSTEM.md
Normal 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 режим работает в симуляторе
|
||||||
Loading…
x
Reference in New Issue
Block a user