Integrar dashboard y control EZO por I2C
Mejora el monitoreo, alarmas, historiales y exportaciones del dashboard. Agrega consola y calibración Atlas Scientific con transporte I2C real y modo demo, corrige módulos de sensores, incorpora documentación oficial y pruebas automáticas.main
parent
0ba3e59fb6
commit
426b9c247b
@ -0,0 +1,77 @@
|
|||||||
|
# Estado del Proyecto
|
||||||
|
|
||||||
|
## Estado actual
|
||||||
|
|
||||||
|
El dashboard integra monitoreo en vivo, alarmas configurables, tendencias
|
||||||
|
historicas, exportacion de datos y una consola Atlas Scientific EZO capaz de
|
||||||
|
usar I2C real en Raspberry Pi o modo demo en desarrollo.
|
||||||
|
|
||||||
|
## Funciones operativas
|
||||||
|
|
||||||
|
- Lectura de `data/EZORTD.json`, `data/EZOPH.json`, `data/EZODO.json` y
|
||||||
|
`data/EZOEC.json` cada segundo.
|
||||||
|
- Evaluacion de alarmas mediante `config/alarms.json`.
|
||||||
|
- Graficas Chart.js alimentadas por los CSV de `logs/`.
|
||||||
|
- Intervalo configurable para actualizar graficas.
|
||||||
|
- Exportacion CSV y Excel usando datos historicos simulados de la API.
|
||||||
|
- Borrado real de los cuatro CSV mediante `POST /api/history/clear`, conservando
|
||||||
|
sus encabezados.
|
||||||
|
- API Express con validacion basica para sensores, comandos y frecuencia de
|
||||||
|
registro simulada.
|
||||||
|
- Pruebas automaticas de los endpoints principales con `node --test`.
|
||||||
|
|
||||||
|
## Arquitectura de datos
|
||||||
|
|
||||||
|
| Funcion | Fuente actual |
|
||||||
|
|---|---|
|
||||||
|
| Lecturas en vivo | Archivos JSON en `data/` |
|
||||||
|
| Alarmas | `config/alarms.json` |
|
||||||
|
| Graficas | Archivos CSV en `logs/` |
|
||||||
|
| Exportaciones | API Express con datos simulados |
|
||||||
|
| Comandos y calibracion | API Express + `EZO_COMMAND` en hardware; demo fuera de Raspberry Pi |
|
||||||
|
|
||||||
|
Nginx sirve los archivos estaticos y redirige `/api/*` al proceso Express en el
|
||||||
|
puerto 3000.
|
||||||
|
|
||||||
|
Para desarrollo local, Express tambien sirve exclusivamente `frontend/`,
|
||||||
|
`data/`, `logs/` y `config/`. El dashboard puede abrirse directamente en
|
||||||
|
`http://localhost:3000/frontend/index.html` sin configurar Nginx.
|
||||||
|
|
||||||
|
## Hardware
|
||||||
|
|
||||||
|
- `EZO-RTD`, `EZO-pH`, `EZO-DO` y `EZO-EC` tienen ejecutables C de lectura unica.
|
||||||
|
- Los Makefiles usan los objetos correspondientes a cada modulo.
|
||||||
|
- El daemon continuo existente corresponde solamente a EZO-RTD.
|
||||||
|
- La conexion de comandos web con el bus I2C real usa
|
||||||
|
`sensors/EZOCommand/EZO_COMMAND`.
|
||||||
|
- La frecuencia seleccionada en la interfaz afecta solamente al backend
|
||||||
|
simulado; no reconfigura los procesos C.
|
||||||
|
|
||||||
|
## Dependencias externas
|
||||||
|
|
||||||
|
- Express y CORS mediante npm.
|
||||||
|
- Chart.js mediante CDN.
|
||||||
|
- SheetJS mediante CDN.
|
||||||
|
|
||||||
|
Para operar sin internet en la Raspberry Pi se recomienda almacenar Chart.js y
|
||||||
|
SheetJS localmente dentro de `frontend/vendor/`.
|
||||||
|
|
||||||
|
## Verificacion
|
||||||
|
|
||||||
|
Ejecutar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
La compilacion de los controladores C debe verificarse en Raspberry Pi OS,
|
||||||
|
porque depende de `/dev/i2c-1` y de los encabezados Linux I2C.
|
||||||
|
|
||||||
|
## Trabajo pendiente
|
||||||
|
|
||||||
|
1. Crear daemons continuos para EZO-pH, EZO-DO y EZO-EC.
|
||||||
|
2. Verificar la capa I2C y cada rutina de calibracion con las sondas fisicas.
|
||||||
|
3. Hacer que la frecuencia de registro configure los daemons.
|
||||||
|
4. Servir Chart.js y SheetJS localmente para despliegues sin internet.
|
||||||
|
5. Agregar pruebas de navegador para el dashboard completo.
|
||||||
@ -0,0 +1,311 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const SENSOR_CONFIG = {
|
||||||
|
rtd: { address: '0x66', name: 'RTD' },
|
||||||
|
ph: { address: '0x63', name: 'pH' },
|
||||||
|
do: { address: '0x61', name: 'DO' },
|
||||||
|
ec: { address: '0x64', name: 'EC' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const DANGEROUS_COMMANDS = new Set(['factory', 'i2c', 'baud', 'sleep']);
|
||||||
|
const COMMON_COMMANDS = new Set([
|
||||||
|
'r', 'i', 'status', 'find', 'l', 'plock', 'name', 'cal',
|
||||||
|
'export', 'import', 't', 'rt', '*ok'
|
||||||
|
]);
|
||||||
|
const SENSOR_COMMANDS = {
|
||||||
|
rtd: new Set(['s', 'd', 'm']),
|
||||||
|
ph: new Set(['slope', 'phext']),
|
||||||
|
do: new Set(['s', 'p', 'o']),
|
||||||
|
ec: new Set(['k', 'tc', 'tds', 'o'])
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_STATE = {
|
||||||
|
rtd: { calibrationPoints: 0 },
|
||||||
|
ph: { calibrationPoints: 0 },
|
||||||
|
do: { calibrationPoints: 0 },
|
||||||
|
ec: { calibrationPoints: 0, k: 1.0 }
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeCommand(rawCommand) {
|
||||||
|
return String(rawCommand || '').trim().replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseCommand(command) {
|
||||||
|
return command.split(',')[0].toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCommand(sensor, command, dangerousConfirmed) {
|
||||||
|
if (!SENSOR_CONFIG[sensor]) {
|
||||||
|
throw createHttpError(400, 'Sensor no válido.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!command || command.length > 64 || !/^[a-zA-Z0-9*?.+\-,]+$/.test(command)) {
|
||||||
|
throw createHttpError(400, 'Comando EZO no válido.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseCommand = getBaseCommand(command);
|
||||||
|
const isAllowed = COMMON_COMMANDS.has(baseCommand) ||
|
||||||
|
SENSOR_COMMANDS[sensor].has(baseCommand) ||
|
||||||
|
DANGEROUS_COMMANDS.has(baseCommand);
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
throw createHttpError(400, `El comando ${baseCommand} no aplica a ${sensor.toUpperCase()}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DANGEROUS_COMMANDS.has(baseCommand) && !dangerousConfirmed) {
|
||||||
|
throw createHttpError(409, 'El comando requiere confirmación explícita.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchesOfficialSyntax(sensor, command)) {
|
||||||
|
throw createHttpError(
|
||||||
|
400,
|
||||||
|
`La sintaxis "${command}" no coincide con los comandos documentados para ${sensor.toUpperCase()}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesOfficialSyntax(sensor, command) {
|
||||||
|
const normalized = command.toLowerCase();
|
||||||
|
const commonPatterns = [
|
||||||
|
/^(r|i|status|find|sleep|factory)$/,
|
||||||
|
/^l,(0|1|\?)$/,
|
||||||
|
/^plock,(0|1|\?)$/,
|
||||||
|
/^name,(|\?|[a-z0-9_.-]{1,16})$/,
|
||||||
|
/^\*ok,(0|1|\?)$/,
|
||||||
|
/^export(\,?)?$/,
|
||||||
|
/^export,\?$/,
|
||||||
|
/^import,[0-9a-f ]+$/,
|
||||||
|
/^i2c,([1-9]|[1-9]\d|1[01]\d|12[0-7])$/,
|
||||||
|
/^baud,(300|1200|2400|9600|19200|38400|57600|115200)$/
|
||||||
|
];
|
||||||
|
|
||||||
|
if (commonPatterns.some((pattern) => pattern.test(normalized))) return true;
|
||||||
|
|
||||||
|
const sensorPatterns = {
|
||||||
|
rtd: [
|
||||||
|
/^cal,(\?|clear|[-+]?\d+(\.\d+)?)$/,
|
||||||
|
/^s,(c|k|f|\?)$/,
|
||||||
|
/^d,(0|1|\?)$/,
|
||||||
|
/^m,(clear|\?)$/
|
||||||
|
],
|
||||||
|
ph: [
|
||||||
|
/^cal,(\?|clear)$/,
|
||||||
|
/^cal,(mid|low|high),[-+]?\d+(\.\d+)?$/,
|
||||||
|
/^slope,\?$/,
|
||||||
|
/^phext,(0|1|\?)$/,
|
||||||
|
/^t,(\?|[-+]?\d+(\.\d+)?)$/,
|
||||||
|
/^rt,[-+]?\d+(\.\d+)?$/
|
||||||
|
],
|
||||||
|
do: [
|
||||||
|
/^cal$/,
|
||||||
|
/^cal,(0|\?|clear)$/,
|
||||||
|
/^t,(\?|[-+]?\d+(\.\d+)?)$/,
|
||||||
|
/^rt,[-+]?\d+(\.\d+)?$/,
|
||||||
|
/^s,(\?|[-+]?\d+(\.\d+)?(,ppt)?)$/,
|
||||||
|
/^p,(\?|[-+]?\d+(\.\d+)?)$/,
|
||||||
|
/^o,\?$/,
|
||||||
|
/^o,(mg|%),(0|1)$/
|
||||||
|
],
|
||||||
|
ec: [
|
||||||
|
/^cal,(\?|clear|dry|[-+]?\d+(\.\d+)?)$/,
|
||||||
|
/^cal,(low|high),[-+]?\d+(\.\d+)?$/,
|
||||||
|
/^k,(\?|[-+]?\d+(\.\d+)?)$/,
|
||||||
|
/^t,(\?|[-+]?\d+(\.\d+)?)$/,
|
||||||
|
/^rt,[-+]?\d+(\.\d+)?$/,
|
||||||
|
/^tc,(\?|[-+]?\d+(\.\d+)?)$/,
|
||||||
|
/^tds,(\?|[-+]?\d+(\.\d+)?)$/,
|
||||||
|
/^o,\?$/,
|
||||||
|
/^o,(ec|tds|s|sg),(0|1)$/
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return sensorPatterns[sensor].some((pattern) => pattern.test(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProcessingDelay(command) {
|
||||||
|
const normalized = command.toLowerCase();
|
||||||
|
|
||||||
|
if (normalized === 'r') return 1000;
|
||||||
|
if (normalized.startsWith('rt,')) return 1000;
|
||||||
|
if (normalized === 'cal' || normalized === 'cal,0') return 1300;
|
||||||
|
if (normalized.startsWith('cal,') && !['cal,?', 'cal,clear'].includes(normalized)) {
|
||||||
|
return normalized.includes('mid') || normalized.includes('low') ||
|
||||||
|
normalized.includes('high') ? 900 : 600;
|
||||||
|
}
|
||||||
|
return 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandExpectsNoResponse(command) {
|
||||||
|
return ['sleep', 'factory'].includes(getBaseCommand(command)) ||
|
||||||
|
getBaseCommand(command) === 'i2c' ||
|
||||||
|
getBaseCommand(command) === 'baud';
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectMode() {
|
||||||
|
const requestedMode = String(process.env.EZO_MODE || 'auto').toLowerCase();
|
||||||
|
const helperPath = process.env.EZO_HELPER ||
|
||||||
|
path.join(__dirname, '..', 'sensors', 'EZOCommand', 'EZO_COMMAND');
|
||||||
|
const hardwareReady = process.platform === 'linux' &&
|
||||||
|
fs.existsSync('/dev/i2c-1') &&
|
||||||
|
fs.existsSync(helperPath);
|
||||||
|
|
||||||
|
if (requestedMode === 'hardware' && !hardwareReady) {
|
||||||
|
return {
|
||||||
|
mode: 'unavailable',
|
||||||
|
helperPath,
|
||||||
|
message: 'Se solicitó hardware, pero /dev/i2c-1 o EZO_COMMAND no está disponible.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedMode === 'hardware' || (requestedMode === 'auto' && hardwareReady)) {
|
||||||
|
return { mode: 'hardware', helperPath, message: 'Bus I2C real activo.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'demo',
|
||||||
|
helperPath,
|
||||||
|
message: 'Modo demo activo; no se enviarán comandos al bus I2C.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeHardwareCommand(sensor, command, modeInfo) {
|
||||||
|
const config = SENSOR_CONFIG[sensor];
|
||||||
|
const args = [
|
||||||
|
'/dev/i2c-1',
|
||||||
|
config.address,
|
||||||
|
String(getProcessingDelay(command)),
|
||||||
|
command
|
||||||
|
];
|
||||||
|
|
||||||
|
if (commandExpectsNoResponse(command)) {
|
||||||
|
args.push('--no-response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdout } = await execFileAsync(modeInfo.helperPath, args, {
|
||||||
|
timeout: getProcessingDelay(command) + 2500,
|
||||||
|
windowsHide: true
|
||||||
|
});
|
||||||
|
const result = JSON.parse(stdout.trim());
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw createHttpError(502, result.error || 'El circuito EZO rechazó el comando.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.response || '*OK';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDemoCommand(sensor, command) {
|
||||||
|
const normalized = command.toLowerCase();
|
||||||
|
const state = MOCK_STATE[sensor];
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, Math.min(getProcessingDelay(command), 80)));
|
||||||
|
|
||||||
|
if (normalized === 'r') {
|
||||||
|
const values = {
|
||||||
|
rtd: (25 + Math.random() * 0.1).toFixed(3),
|
||||||
|
ph: (7.2 + Math.random() * 0.05).toFixed(3),
|
||||||
|
do: (8.5 + Math.random() * 0.1).toFixed(2),
|
||||||
|
ec: (1050 + Math.random() * 5).toFixed(0)
|
||||||
|
};
|
||||||
|
return values[sensor];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === 'i') return `?i,${SENSOR_CONFIG[sensor].name},demo`;
|
||||||
|
if (normalized === 'status') return '?Status,P,5.000';
|
||||||
|
if (normalized === 'cal,?') return `?Cal,${state.calibrationPoints}`;
|
||||||
|
if (normalized === 'cal,clear') {
|
||||||
|
state.calibrationPoints = 0;
|
||||||
|
return '*OK';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith('cal')) {
|
||||||
|
updateDemoCalibration(sensor, normalized);
|
||||||
|
return '*OK';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === 'slope,?') return '?Slope,98.2,97.8,-1.20';
|
||||||
|
if (normalized === 'k,?') return `?K,${state.k.toFixed(1)}`;
|
||||||
|
if (normalized.startsWith('k,')) {
|
||||||
|
state.k = Number(normalized.split(',')[1]);
|
||||||
|
return '*OK';
|
||||||
|
}
|
||||||
|
if (normalized === 't,?') return '?T,25.0';
|
||||||
|
if (normalized === 's,?' && sensor === 'do') return '?S,0,µS';
|
||||||
|
if (normalized === 'p,?') return '?P,101.3';
|
||||||
|
if (normalized === 'o,?') {
|
||||||
|
return sensor === 'do' ? '?O,mg,%' : '?O,EC,TDS,S,SG';
|
||||||
|
}
|
||||||
|
if (normalized === 'l,?') return '?L,1';
|
||||||
|
if (normalized === 'plock,?') return '?Plock,1';
|
||||||
|
if (normalized === 'phext,?') return '?pHext,0';
|
||||||
|
if (normalized === 's,?' && sensor === 'rtd') return '?S,C';
|
||||||
|
|
||||||
|
return '*OK';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDemoCalibration(sensor, command) {
|
||||||
|
if (sensor === 'ph') {
|
||||||
|
if (command.startsWith('cal,mid,')) MOCK_STATE.ph.calibrationPoints = 1;
|
||||||
|
if (command.startsWith('cal,low,')) MOCK_STATE.ph.calibrationPoints = 2;
|
||||||
|
if (command.startsWith('cal,high,')) MOCK_STATE.ph.calibrationPoints = 3;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sensor === 'do') {
|
||||||
|
if (command === 'cal,0') MOCK_STATE.do.calibrationPoints = 1;
|
||||||
|
if (command === 'cal') MOCK_STATE.do.calibrationPoints = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sensor === 'ec') {
|
||||||
|
if (command === 'cal,dry') MOCK_STATE.ec.calibrationPoints = 0;
|
||||||
|
if (command.startsWith('cal,low,')) MOCK_STATE.ec.calibrationPoints = 1;
|
||||||
|
if (command.startsWith('cal,high,')) MOCK_STATE.ec.calibrationPoints = 2;
|
||||||
|
if (/^cal,[\d.]+$/.test(command)) MOCK_STATE.ec.calibrationPoints = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_STATE.rtd.calibrationPoints = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeEzoCommand(sensor, rawCommand, options = {}) {
|
||||||
|
const command = normalizeCommand(rawCommand);
|
||||||
|
validateCommand(sensor, command, options.dangerousConfirmed);
|
||||||
|
const modeInfo = detectMode();
|
||||||
|
|
||||||
|
if (modeInfo.mode === 'unavailable') {
|
||||||
|
throw createHttpError(503, modeInfo.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = modeInfo.mode === 'hardware'
|
||||||
|
? await executeHardwareCommand(sensor, command, modeInfo)
|
||||||
|
: await executeDemoCommand(sensor, command);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
mode: modeInfo.mode,
|
||||||
|
sensor: SENSOR_CONFIG[sensor].name,
|
||||||
|
command,
|
||||||
|
response
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHttpError(status, message) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.status = status;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SENSOR_CONFIG,
|
||||||
|
detectMode,
|
||||||
|
executeEzoCommand,
|
||||||
|
getProcessingDelay,
|
||||||
|
matchesOfficialSyntax,
|
||||||
|
normalizeCommand,
|
||||||
|
validateCommand
|
||||||
|
};
|
||||||
@ -0,0 +1,130 @@
|
|||||||
|
# Comandos y calibracion Atlas Scientific EZO
|
||||||
|
|
||||||
|
Esta implementacion sigue los datasheets oficiales vigentes consultados para:
|
||||||
|
|
||||||
|
- EZO-pH, datasheet V6.1, revision 02/2024.
|
||||||
|
- EZO-DO, datasheet V5.8, revision 03/2025.
|
||||||
|
- EZO-EC, datasheet V5.5.
|
||||||
|
- EZO-RTD, datasheet V3.7, revision 10/2024.
|
||||||
|
|
||||||
|
## Transporte I2C
|
||||||
|
|
||||||
|
Los comandos son cadenas ASCII sin retorno de carro. Despues de escribir el
|
||||||
|
comando se espera el tiempo de procesamiento y se solicita la respuesta.
|
||||||
|
|
||||||
|
El primer byte de una respuesta I2C es:
|
||||||
|
|
||||||
|
| Codigo | Significado |
|
||||||
|
|---|---|
|
||||||
|
| `1` | Solicitud procesada correctamente |
|
||||||
|
| `2` | Error de sintaxis |
|
||||||
|
| `254` | Procesando; aun no esta lista |
|
||||||
|
| `255` | No hay datos |
|
||||||
|
|
||||||
|
El ejecutable `sensors/EZOCommand/EZO_COMMAND` implementa este intercambio y
|
||||||
|
reintenta las respuestas pendientes.
|
||||||
|
|
||||||
|
## Calibracion EZO-RTD
|
||||||
|
|
||||||
|
Calibracion de un punto:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cal,<temperatura>
|
||||||
|
Cal,?
|
||||||
|
Cal,clear
|
||||||
|
```
|
||||||
|
|
||||||
|
Ejemplo: `Cal,25.00`.
|
||||||
|
|
||||||
|
## Calibracion EZO-pH
|
||||||
|
|
||||||
|
Orden recomendado:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cal,mid,7.00
|
||||||
|
Cal,low,4.00
|
||||||
|
Cal,high,10.00
|
||||||
|
```
|
||||||
|
|
||||||
|
El punto medio siempre debe realizarse primero. Ejecutar `Cal,mid` sobre una
|
||||||
|
calibracion existente elimina los otros puntos. El estado se consulta con
|
||||||
|
`Cal,?` y la salud de la sonda con `Slope,?`.
|
||||||
|
|
||||||
|
## Calibracion EZO-DO
|
||||||
|
|
||||||
|
Un punto:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cal
|
||||||
|
```
|
||||||
|
|
||||||
|
Dos puntos, en este orden:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cal,0
|
||||||
|
Cal
|
||||||
|
```
|
||||||
|
|
||||||
|
`Cal,0` usa solucion de cero oxigeno. `Cal` usa la sonda estabilizada en aire
|
||||||
|
atmosferico. Compensaciones disponibles:
|
||||||
|
|
||||||
|
```text
|
||||||
|
T,<grados Celsius>
|
||||||
|
S,<conductividad en uS/cm>
|
||||||
|
S,<salinidad>,ppt
|
||||||
|
P,<presion en kPa>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Calibracion EZO-EC
|
||||||
|
|
||||||
|
Primero se configura la constante de la sonda con `K,<valor>` y se realiza la
|
||||||
|
calibracion en seco:
|
||||||
|
|
||||||
|
```text
|
||||||
|
K,1.0
|
||||||
|
Cal,dry
|
||||||
|
```
|
||||||
|
|
||||||
|
Calibracion de dos puntos:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cal,dry
|
||||||
|
Cal,<valor>
|
||||||
|
```
|
||||||
|
|
||||||
|
Calibracion de tres puntos:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cal,dry
|
||||||
|
Cal,low,<valor>
|
||||||
|
Cal,high,<valor>
|
||||||
|
```
|
||||||
|
|
||||||
|
No se debe usar `Cal,0` en EC. El valor cero corresponde unicamente al paso
|
||||||
|
`Cal,dry`.
|
||||||
|
|
||||||
|
## Modos del backend
|
||||||
|
|
||||||
|
- `EZO_MODE=auto`: usa hardware si encuentra `/dev/i2c-1` y `EZO_COMMAND`;
|
||||||
|
de lo contrario usa demo.
|
||||||
|
- `EZO_MODE=hardware`: exige bus y ejecutable reales; si faltan, la API devuelve
|
||||||
|
error en vez de simular.
|
||||||
|
- `EZO_MODE=demo`: genera respuestas de desarrollo sin acceder al bus.
|
||||||
|
|
||||||
|
Para preparar Raspberry Pi:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make -C sensors/EZOCommand
|
||||||
|
sudo usermod -aG i2c $USER
|
||||||
|
EZO_MODE=hardware npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Es necesario cerrar sesion y volver a entrar despues de agregar el usuario al
|
||||||
|
grupo `i2c`.
|
||||||
|
|
||||||
|
## Fuentes oficiales
|
||||||
|
|
||||||
|
- https://files.atlas-scientific.com/pH_EZO_Datasheet.pdf
|
||||||
|
- https://files.atlas-scientific.com/DO_EZO_Datasheet.pdf
|
||||||
|
- https://files.atlas-scientific.com/EC_EZO_Datasheet.pdf
|
||||||
|
- https://files.atlas-scientific.com/EZO_RTD_Datasheet.pdf
|
||||||
@ -0,0 +1,339 @@
|
|||||||
|
const calibrationProfiles = {
|
||||||
|
rtd: {
|
||||||
|
description: "Calibración de un punto contra una temperatura de referencia.",
|
||||||
|
note: "Espere a que la lectura se estabilice antes de enviar Cal,<temperatura>.",
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
title: "Punto de referencia",
|
||||||
|
help: "Use una referencia térmica trazable en grados Celsius.",
|
||||||
|
value: "25.00",
|
||||||
|
step: "0.01",
|
||||||
|
button: "Calibrar temperatura",
|
||||||
|
command(value) { return `Cal,${value}`; }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ph: {
|
||||||
|
description: "Calibración de uno, dos o tres puntos. El punto medio siempre va primero.",
|
||||||
|
note: "Orden Atlas: medio (pH 7), bajo (pH 4), alto (pH 10). Cal,mid borra los otros puntos.",
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
title: "Punto medio",
|
||||||
|
help: "Enjuague la sonda, colóquela en buffer pH 7 y espere estabilidad.",
|
||||||
|
value: "7.00",
|
||||||
|
step: "0.01",
|
||||||
|
button: "Calibrar medio",
|
||||||
|
command(value) { return `Cal,mid,${value}`; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Punto bajo",
|
||||||
|
help: "Después del punto medio, use buffer ácido, normalmente pH 4.",
|
||||||
|
value: "4.00",
|
||||||
|
step: "0.01",
|
||||||
|
button: "Calibrar bajo",
|
||||||
|
command(value) { return `Cal,low,${value}`; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Punto alto",
|
||||||
|
help: "Después del punto bajo, use buffer básico, normalmente pH 10.",
|
||||||
|
value: "10.00",
|
||||||
|
step: "0.01",
|
||||||
|
button: "Calibrar alto",
|
||||||
|
command(value) { return `Cal,high,${value}`; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Diagnóstico de pendiente",
|
||||||
|
help: "Muestra pendiente ácida, básica y desplazamiento del punto neutro.",
|
||||||
|
button: "Consultar pendiente",
|
||||||
|
command() { return "Slope,?"; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Compensación de temperatura",
|
||||||
|
help: "La compensación es temporal y siempre se expresa en grados Celsius.",
|
||||||
|
value: "25.0",
|
||||||
|
step: "0.1",
|
||||||
|
button: "Aplicar temperatura",
|
||||||
|
command(value) { return `T,${value}`; },
|
||||||
|
secondaryButton: "Consultar temperatura",
|
||||||
|
secondaryCommand: "T,?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
do: {
|
||||||
|
description: "Calibración atmosférica de un punto o calibración de dos puntos con cero.",
|
||||||
|
note: "Para dos puntos, Atlas indica calibrar primero cero (Cal,0) y después aire (Cal).",
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
title: "Cero oxígeno",
|
||||||
|
help: "Use solución de cero O₂, elimine burbujas y espere una lectura estable.",
|
||||||
|
button: "Calibrar cero",
|
||||||
|
command() { return "Cal,0"; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Oxígeno atmosférico",
|
||||||
|
help: "Deje la sonda expuesta al aire hasta que la lectura se estabilice.",
|
||||||
|
button: "Calibrar aire",
|
||||||
|
command() { return "Cal"; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Compensaciones",
|
||||||
|
help: "Temperatura en °C, salinidad en µS/cm y presión atmosférica en kPa.",
|
||||||
|
fields: [
|
||||||
|
{ label: "Temperatura", value: "20.0", step: "0.1", command: "T" },
|
||||||
|
{ label: "Salinidad", value: "0", step: "1", command: "S" },
|
||||||
|
{ label: "Presión", value: "101.3", step: "0.1", command: "P" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ec: {
|
||||||
|
description: "Calibración de dos o tres puntos; la calibración en seco siempre va primero.",
|
||||||
|
note: "Configure primero la constante K de la sonda. Nunca calibre EC a cero con Cal,0.",
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
title: "Constante de la sonda",
|
||||||
|
help: "Valores habituales: K 0.1, K 1.0 o K 10.",
|
||||||
|
value: "1.0",
|
||||||
|
step: "0.1",
|
||||||
|
button: "Configurar K",
|
||||||
|
command(value) { return `K,${value}`; },
|
||||||
|
secondaryButton: "Consultar K",
|
||||||
|
secondaryCommand: "K,?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Calibración en seco",
|
||||||
|
help: "Conecte la sonda completamente seca. Este paso siempre debe ser primero.",
|
||||||
|
button: "Calibrar seco",
|
||||||
|
command() { return "Cal,dry"; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Segundo punto",
|
||||||
|
help: "Para calibración de dos puntos use Cal,<valor>, por ejemplo 1413.",
|
||||||
|
value: "1413",
|
||||||
|
step: "1",
|
||||||
|
button: "Calibrar punto único",
|
||||||
|
command(value) { return `Cal,${value}`; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Puntos bajo y alto",
|
||||||
|
help: "Para tres puntos use primero bajo y finalmente alto.",
|
||||||
|
fields: [
|
||||||
|
{ label: "Bajo", value: "12880", step: "1", command: "Cal,low" },
|
||||||
|
{ label: "Alto", value: "80000", step: "1", command: "Cal,high" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Compensación de temperatura",
|
||||||
|
help: "La compensación se expresa en °C y no se conserva al apagar.",
|
||||||
|
value: "25.0",
|
||||||
|
step: "0.1",
|
||||||
|
button: "Aplicar temperatura",
|
||||||
|
command(value) { return `T,${value}`; },
|
||||||
|
secondaryButton: "Consultar temperatura",
|
||||||
|
secondaryCommand: "T,?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchHistoricalData(sensorType) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/sensors/${sensorType}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ERROR] Fallo al conectar con el backend:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEzoTransportStatus() {
|
||||||
|
const statusElement = document.getElementById("ezo-transport-status");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/system/ezo", { cache: "no-store" });
|
||||||
|
const data = await response.json();
|
||||||
|
statusElement.textContent = data.mode === "hardware"
|
||||||
|
? "HARDWARE I2C"
|
||||||
|
: data.mode === "demo" ? "DEMO" : "NO DISPONIBLE";
|
||||||
|
statusElement.className = `transport-${data.mode}`;
|
||||||
|
appendTerminalLine(
|
||||||
|
document.getElementById("terminal-output"),
|
||||||
|
`[SISTEMA] ${data.message}`,
|
||||||
|
data.mode === "hardware" ? "#63e6be" : "#ffd166"
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
statusElement.textContent = "NO DISPONIBLE";
|
||||||
|
statusElement.className = "transport-unavailable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCommand(command, options = {}) {
|
||||||
|
const sensorType = options.sensor ||
|
||||||
|
document.getElementById("terminal-sensor-select").value;
|
||||||
|
const terminalOutput = document.getElementById("terminal-output");
|
||||||
|
const dangerous = ["factory", "i2c", "baud", "sleep"]
|
||||||
|
.includes(String(command).split(",")[0].toLowerCase());
|
||||||
|
let dangerousConfirmed = false;
|
||||||
|
|
||||||
|
if (dangerous) {
|
||||||
|
dangerousConfirmed = confirm(
|
||||||
|
`El comando ${command} puede reiniciar, dormir o cambiar la comunicación del circuito. ¿Desea enviarlo?`
|
||||||
|
);
|
||||||
|
if (!dangerousConfirmed) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeString = new Date().toLocaleTimeString();
|
||||||
|
appendTerminalLine(terminalOutput, `[${timeString}] TX (${sensorType}): ${command}`, "#ffffff");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/sensors/${sensorType}/command`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ command, dangerousConfirmed })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = data.mode === "hardware" ? "I2C" : "DEMO";
|
||||||
|
appendTerminalLine(
|
||||||
|
terminalOutput,
|
||||||
|
`[${timeString}] RX (${mode}/${sensorType}): ${data.response}`,
|
||||||
|
data.response === "*ER" ? "#ff6b6b" : "#63e6be"
|
||||||
|
);
|
||||||
|
terminalOutput.scrollTop = terminalOutput.scrollHeight;
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
appendTerminalLine(
|
||||||
|
terminalOutput,
|
||||||
|
`[${timeString}] ERROR: ${error.message}`,
|
||||||
|
"#ff6b6b"
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCalibrationCommand(command) {
|
||||||
|
const sensor = document.getElementById("cal-sensor-select").value;
|
||||||
|
document.getElementById("terminal-sensor-select").value = sensor;
|
||||||
|
return sendCommand(command, { sensor });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCalibration() {
|
||||||
|
const sensor = document.getElementById("cal-sensor-select").value;
|
||||||
|
const confirmed = confirm(
|
||||||
|
`Se borrarán todos los puntos de calibración del sensor ${sensor.toUpperCase()}. ¿Continuar?`
|
||||||
|
);
|
||||||
|
if (confirmed) {
|
||||||
|
await sendCalibrationCommand("Cal,clear");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalibrationPanel() {
|
||||||
|
const sensor = document.getElementById("cal-sensor-select").value;
|
||||||
|
const profile = calibrationProfiles[sensor];
|
||||||
|
const controls = document.getElementById("calibration-controls");
|
||||||
|
|
||||||
|
document.getElementById("calibration-description").textContent = profile.description;
|
||||||
|
document.getElementById("calibration-note").textContent = profile.note;
|
||||||
|
controls.replaceChildren();
|
||||||
|
|
||||||
|
profile.groups.forEach((group, groupIndex) => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.className = "calibration-group";
|
||||||
|
const title = document.createElement("h4");
|
||||||
|
title.textContent = group.title;
|
||||||
|
const help = document.createElement("p");
|
||||||
|
help.textContent = group.help;
|
||||||
|
container.append(title, help);
|
||||||
|
|
||||||
|
if (group.fields) {
|
||||||
|
group.fields.forEach((field, fieldIndex) => {
|
||||||
|
container.appendChild(createCalibrationAction(
|
||||||
|
`${sensor}-${groupIndex}-${fieldIndex}`,
|
||||||
|
field.label,
|
||||||
|
field.value,
|
||||||
|
field.step,
|
||||||
|
(value) => `${field.command},${value}`
|
||||||
|
));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
container.appendChild(createCalibrationAction(
|
||||||
|
`${sensor}-${groupIndex}`,
|
||||||
|
null,
|
||||||
|
group.value,
|
||||||
|
group.step,
|
||||||
|
group.command,
|
||||||
|
group.button
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.secondaryCommand) {
|
||||||
|
const secondary = document.createElement("button");
|
||||||
|
secondary.className = "btn-command";
|
||||||
|
secondary.textContent = group.secondaryButton;
|
||||||
|
secondary.addEventListener("click", () =>
|
||||||
|
sendCalibrationCommand(group.secondaryCommand)
|
||||||
|
);
|
||||||
|
container.appendChild(secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
controls.appendChild(container);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCalibrationAction(id, label, value, step, commandBuilder, buttonText) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "calibration-action";
|
||||||
|
let input = null;
|
||||||
|
|
||||||
|
if (value !== undefined) {
|
||||||
|
input = document.createElement("input");
|
||||||
|
input.type = "number";
|
||||||
|
input.id = `cal-${id}`;
|
||||||
|
input.value = value;
|
||||||
|
input.step = step || "0.01";
|
||||||
|
input.setAttribute("aria-label", label || buttonText || "Valor de calibración");
|
||||||
|
row.appendChild(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = "btn-command";
|
||||||
|
button.textContent = buttonText || label;
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
const command = commandBuilder(input ? input.value : undefined);
|
||||||
|
if (!command || (input && input.value === "")) return;
|
||||||
|
await sendCalibrationCommand(command);
|
||||||
|
});
|
||||||
|
row.appendChild(button);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCustomCommand() {
|
||||||
|
const input = document.getElementById("custom-command");
|
||||||
|
const command = input.value.trim();
|
||||||
|
|
||||||
|
if (command) {
|
||||||
|
sendCommand(command);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTerminalLine(container, message, color) {
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.textContent = message;
|
||||||
|
line.style.color = color;
|
||||||
|
container.appendChild(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
loadEzoTransportStatus();
|
||||||
|
renderCalibrationPanel();
|
||||||
|
});
|
||||||
@ -1,109 +0,0 @@
|
|||||||
// Actualización de mock-service.js (que ahora actúa como un api-service real)
|
|
||||||
|
|
||||||
async function fetchHistoricalData(sensorType) {
|
|
||||||
console.log(`[FETCH] Solicitando datos al backend vía Nginx para: ${sensorType}...`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Hacemos la petición a la ruta que Nginx está interceptando (/api/...)
|
|
||||||
const response = await fetch(`/api/sensors/${sensorType}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[ERROR] Fallo al conectar con el backend:", error);
|
|
||||||
// Retornar array vacío para evitar que la UI colapse
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendCommand(command) {
|
|
||||||
const sensorType = document.getElementById('terminal-sensor-select').value;
|
|
||||||
const terminalOutput = document.getElementById('terminal-output');
|
|
||||||
|
|
||||||
// 1. Imprimir el comando enviado en la consola UI
|
|
||||||
const timeString = new Date().toLocaleTimeString();
|
|
||||||
terminalOutput.innerHTML += `<div><span style="color: #fff;">[${timeString}] TX (${sensorType}):</span> ${command}</div>`;
|
|
||||||
|
|
||||||
// Hacer scroll automático hacia abajo
|
|
||||||
terminalOutput.scrollTop = terminalOutput.scrollHeight;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 2. Hacer la petición POST al backend mediante Nginx
|
|
||||||
const response = await fetch(`/api/sensors/${sensorType}/command`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
// El cuerpo viaja en formato JSON
|
|
||||||
body: JSON.stringify({ command: command })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Error HTTP: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// 3. Imprimir la respuesta simulada del circuito EZO en la UI
|
|
||||||
let color = data.response === '*ER' ? '#ff3333' : '#00ffcc';
|
|
||||||
terminalOutput.innerHTML += `<div><span style="color: #fff;">[${timeString}] RX (${sensorType}):</span> <span style="color: ${color};">${data.response}</span></div>`;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error enviando comando:", error);
|
|
||||||
terminalOutput.innerHTML += `<div><span style="color: #ff3333;">[${timeString}] SYS ERROR: Fallo de bus I2C simulado o servidor desconectado.</span></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mantener el scroll al final
|
|
||||||
terminalOutput.scrollTop = terminalOutput.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Envía un comando de calibración predefinido y sincroniza el select de la consola.
|
|
||||||
*/
|
|
||||||
function sendCalibrationCmd(cmd) {
|
|
||||||
const sensorSelect = document.getElementById('cal-sensor-select').value;
|
|
||||||
|
|
||||||
// Sincronizar el select de la terminal para que los logs sean coherentes
|
|
||||||
document.getElementById('terminal-sensor-select').value = sensorSelect;
|
|
||||||
|
|
||||||
// Reutilizar la función de la consola para enviar la petición POST
|
|
||||||
sendCommand(cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toma el valor introducido por el usuario y construye el comando (ej. "cal,7.00").
|
|
||||||
*/
|
|
||||||
function calibratePoint() {
|
|
||||||
const sensorSelect = document.getElementById('cal-sensor-select').value;
|
|
||||||
const calValue = document.getElementById('cal-value').value;
|
|
||||||
|
|
||||||
if (!calValue) {
|
|
||||||
alert("Por favor, ingresa un valor de referencia para calibrar.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sincronizar selectores
|
|
||||||
document.getElementById('terminal-sensor-select').value = sensorSelect;
|
|
||||||
|
|
||||||
// Construir el comando Atlas Scientific y enviarlo a la consola
|
|
||||||
const fullCommand = `cal,${calValue}`;
|
|
||||||
sendCommand(fullCommand);
|
|
||||||
|
|
||||||
// Limpiar el input después de enviar
|
|
||||||
document.getElementById('cal-value').value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendCustomCommand() {
|
|
||||||
const input = document.getElementById('custom-command');
|
|
||||||
const command = input.value.trim();
|
|
||||||
|
|
||||||
if (command !== "") {
|
|
||||||
sendCommand(command);
|
|
||||||
input.value = ""; // Limpiar el input después de enviar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
CC=gcc
|
||||||
|
CFLAGS=-Wall -Wextra -O2
|
||||||
|
TARGET=EZO_COMMAND
|
||||||
|
|
||||||
|
$(TARGET): main.c
|
||||||
|
$(CC) $(CFLAGS) -o $(TARGET) main.c
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(TARGET)
|
||||||
@ -0,0 +1,124 @@
|
|||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <linux/i2c-dev.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/file.h>
|
||||||
|
#include <sys/ioctl.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
static void print_json_error(const char *message)
|
||||||
|
{
|
||||||
|
printf("{\"success\":false,\"error\":\"%s\"}\n", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char *argv[])
|
||||||
|
{
|
||||||
|
if (argc < 5)
|
||||||
|
{
|
||||||
|
print_json_error("Uso: EZO_COMMAND bus address delay_ms command [--no-response]");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *bus = argv[1];
|
||||||
|
int address = (int)strtol(argv[2], NULL, 0);
|
||||||
|
int delay_ms = atoi(argv[3]);
|
||||||
|
const char *command = argv[4];
|
||||||
|
int no_response = argc > 5 && strcmp(argv[5], "--no-response") == 0;
|
||||||
|
int lock_fd = open("/tmp/photobioreactor-i2c.lock", O_CREAT | O_RDWR, 0660);
|
||||||
|
|
||||||
|
if (lock_fd < 0 || flock(lock_fd, LOCK_EX) < 0)
|
||||||
|
{
|
||||||
|
print_json_error("No se pudo bloquear el bus I2C");
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
int fd = open(bus, O_RDWR);
|
||||||
|
|
||||||
|
if (fd < 0)
|
||||||
|
{
|
||||||
|
print_json_error("No se pudo abrir el bus I2C");
|
||||||
|
flock(lock_fd, LOCK_UN);
|
||||||
|
close(lock_fd);
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ioctl(fd, I2C_SLAVE, address) < 0)
|
||||||
|
{
|
||||||
|
print_json_error("No se pudo seleccionar el circuito EZO");
|
||||||
|
close(fd);
|
||||||
|
flock(lock_fd, LOCK_UN);
|
||||||
|
close(lock_fd);
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (write(fd, command, strlen(command)) < 0)
|
||||||
|
{
|
||||||
|
print_json_error("Fallo al escribir el comando EZO");
|
||||||
|
close(fd);
|
||||||
|
flock(lock_fd, LOCK_UN);
|
||||||
|
close(lock_fd);
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep((useconds_t)delay_ms * 1000);
|
||||||
|
|
||||||
|
if (no_response)
|
||||||
|
{
|
||||||
|
printf("{\"success\":true,\"response\":\"SENT\"}\n");
|
||||||
|
close(fd);
|
||||||
|
flock(lock_fd, LOCK_UN);
|
||||||
|
close(lock_fd);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned char response[64] = {0};
|
||||||
|
int bytes_read = read(fd, response, sizeof(response) - 1);
|
||||||
|
|
||||||
|
for (int retry = 0; bytes_read > 0 && response[0] == 254 && retry < 10; retry++)
|
||||||
|
{
|
||||||
|
usleep(100000);
|
||||||
|
memset(response, 0, sizeof(response));
|
||||||
|
bytes_read = read(fd, response, sizeof(response) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes_read < 1)
|
||||||
|
{
|
||||||
|
print_json_error("El circuito EZO no respondió");
|
||||||
|
close(fd);
|
||||||
|
flock(lock_fd, LOCK_UN);
|
||||||
|
close(lock_fd);
|
||||||
|
return 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response[0] != 1)
|
||||||
|
{
|
||||||
|
char message[64];
|
||||||
|
snprintf(message, sizeof(message), "Código de respuesta EZO: %u", response[0]);
|
||||||
|
print_json_error(message);
|
||||||
|
close(fd);
|
||||||
|
flock(lock_fd, LOCK_UN);
|
||||||
|
close(lock_fd);
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
char *payload = (char *)&response[1];
|
||||||
|
payload[bytes_read > 1 ? bytes_read - 1 : 0] = '\0';
|
||||||
|
|
||||||
|
for (int i = 0; payload[i] != '\0'; i++)
|
||||||
|
{
|
||||||
|
if (payload[i] == '"' || payload[i] == '\\')
|
||||||
|
{
|
||||||
|
payload[i] = ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("{\"success\":true,\"response\":\"%s\"}\n",
|
||||||
|
payload[0] == '\0' ? "*OK" : payload);
|
||||||
|
|
||||||
|
close(fd);
|
||||||
|
flock(lock_fd, LOCK_UN);
|
||||||
|
close(lock_fd);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include "ezodo.h"
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
int fd = open("/dev/i2c-1", O_RDWR);
|
||||||
|
|
||||||
|
if (fd < 0)
|
||||||
|
{
|
||||||
|
fprintf(stderr, "%s\n", strerror(errno));
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
double oxygen;
|
||||||
|
|
||||||
|
if (getDO(fd, &oxygen) < 0)
|
||||||
|
{
|
||||||
|
close(fd);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("{ \"do\": %.3f }\n", oxygen);
|
||||||
|
close(fd);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const temporaryLogs = fs.mkdtempSync(path.join(os.tmpdir(), 'photobioreactor-'));
|
||||||
|
process.env.LOGS_DIRECTORY = temporaryLogs;
|
||||||
|
|
||||||
|
const { app, HISTORY_FILES } = require('../api/server');
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let baseUrl;
|
||||||
|
|
||||||
|
test.before(async () => {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
server = app.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after(async () => {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.close((error) => error ? reject(error) : resolve());
|
||||||
|
});
|
||||||
|
fs.rmSync(temporaryLogs, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns simulated history for all sensors', async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/sensors/all`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(data.length, 61);
|
||||||
|
assert.ok(data[0].Timestamp);
|
||||||
|
assert.ok(data[0].Temperatura_C);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serves the dashboard in local development mode', async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/`);
|
||||||
|
const html = await response.text();
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.match(html, /Panel del Fotobiorreactor/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates sensor commands', async () => {
|
||||||
|
const invalidResponse = await fetch(`${baseUrl}/api/sensors/invalid/command`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ command: 'r' })
|
||||||
|
});
|
||||||
|
assert.equal(invalidResponse.status, 400);
|
||||||
|
|
||||||
|
const validResponse = await fetch(`${baseUrl}/api/sensors/ph/command`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ command: 'r' })
|
||||||
|
});
|
||||||
|
const validData = await validResponse.json();
|
||||||
|
|
||||||
|
assert.equal(validResponse.status, 200);
|
||||||
|
assert.equal(validData.success, true);
|
||||||
|
assert.equal(validData.mode, 'demo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects undocumented calibration syntax', async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/sensors/do/command`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ command: 'Cal,atm' })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
assert.equal(response.status, 400);
|
||||||
|
assert.match(data.error, /documentados/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates simulated logging rates', async () => {
|
||||||
|
const invalidResponse = await fetch(`${baseUrl}/api/config/logging`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ rate: 3 })
|
||||||
|
});
|
||||||
|
assert.equal(invalidResponse.status, 400);
|
||||||
|
|
||||||
|
const validResponse = await fetch(`${baseUrl}/api/config/logging`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ rate: 5 })
|
||||||
|
});
|
||||||
|
const validData = await validResponse.json();
|
||||||
|
|
||||||
|
assert.equal(validResponse.status, 200);
|
||||||
|
assert.equal(validData.rate, 5);
|
||||||
|
assert.equal(validData.simulated, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clears historical CSV files and preserves headers', async () => {
|
||||||
|
for (const name of Object.keys(HISTORY_FILES)) {
|
||||||
|
fs.writeFileSync(path.join(temporaryLogs, `${name}.csv`), 'old,data\n1,2\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/history/clear`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
|
||||||
|
for (const [name, header] of Object.entries(HISTORY_FILES)) {
|
||||||
|
const content = fs.readFileSync(
|
||||||
|
path.join(temporaryLogs, `${name}.csv`),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
assert.equal(content, header);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const {
|
||||||
|
getProcessingDelay,
|
||||||
|
matchesOfficialSyntax
|
||||||
|
} = require('../api/ezo-command-service');
|
||||||
|
|
||||||
|
test('accepts official calibration commands by sensor', () => {
|
||||||
|
assert.equal(matchesOfficialSyntax('rtd', 'Cal,25.00'), true);
|
||||||
|
assert.equal(matchesOfficialSyntax('ph', 'Cal,mid,7.00'), true);
|
||||||
|
assert.equal(matchesOfficialSyntax('ph', 'Cal,low,4.00'), true);
|
||||||
|
assert.equal(matchesOfficialSyntax('ph', 'Cal,high,10.00'), true);
|
||||||
|
assert.equal(matchesOfficialSyntax('do', 'Cal'), true);
|
||||||
|
assert.equal(matchesOfficialSyntax('do', 'Cal,0'), true);
|
||||||
|
assert.equal(matchesOfficialSyntax('ec', 'Cal,dry'), true);
|
||||||
|
assert.equal(matchesOfficialSyntax('ec', 'Cal,1413'), true);
|
||||||
|
assert.equal(matchesOfficialSyntax('ec', 'Cal,low,12880'), true);
|
||||||
|
assert.equal(matchesOfficialSyntax('ec', 'Cal,high,80000'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects commands assigned to the wrong sensor', () => {
|
||||||
|
assert.equal(matchesOfficialSyntax('do', 'Cal,atm'), false);
|
||||||
|
assert.equal(matchesOfficialSyntax('rtd', 'Cal,mid,7'), false);
|
||||||
|
assert.equal(matchesOfficialSyntax('ph', 'Cal,dry'), false);
|
||||||
|
assert.equal(matchesOfficialSyntax('rtd', 'T,25'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses Atlas processing delays for critical commands', () => {
|
||||||
|
assert.equal(getProcessingDelay('R'), 1000);
|
||||||
|
assert.equal(getProcessingDelay('Cal'), 1300);
|
||||||
|
assert.equal(getProcessingDelay('Cal,0'), 1300);
|
||||||
|
assert.equal(getProcessingDelay('Cal,mid,7'), 900);
|
||||||
|
assert.equal(getProcessingDelay('Cal,dry'), 600);
|
||||||
|
assert.equal(getProcessingDelay('Status'), 300);
|
||||||
|
});
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const root = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(root, relativePath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('dashboard starts live polling and exposes required controls', () => {
|
||||||
|
const html = read('frontend/index.html');
|
||||||
|
const dashboard = read('frontend/dashboard.js');
|
||||||
|
|
||||||
|
assert.match(html, /id="history-interval"/);
|
||||||
|
assert.match(dashboard, /updateDashboard\(\);/);
|
||||||
|
assert.match(
|
||||||
|
dashboard,
|
||||||
|
/setInterval\(updateDashboard,\s*POLLING_INTERVAL_MS\)/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard HTML has balanced structural containers', () => {
|
||||||
|
const html = read('frontend/index.html');
|
||||||
|
const openDivs = (html.match(/<div\b/g) || []).length;
|
||||||
|
const closeDivs = (html.match(/<\/div>/g) || []).length;
|
||||||
|
const openSections = (html.match(/<section\b/g) || []).length;
|
||||||
|
const closeSections = (html.match(/<\/section>/g) || []).length;
|
||||||
|
|
||||||
|
assert.equal(openDivs, closeDivs);
|
||||||
|
assert.equal(openSections, closeSections);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terminal output does not append untrusted HTML', () => {
|
||||||
|
const service = read('frontend/ezo-service.js');
|
||||||
|
|
||||||
|
assert.doesNotMatch(service, /terminalOutput\.innerHTML/);
|
||||||
|
assert.match(service, /line\.textContent = message/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sensor Makefiles reference their real implementation objects', () => {
|
||||||
|
assert.match(read('sensors/EZOPH/Makefile'), /OBJ=main\.o ezoph\.o/);
|
||||||
|
assert.match(read('sensors/EZODO/Makefile'), /OBJ=main\.o ezodo\.o/);
|
||||||
|
assert.match(read('sensors/EZOEC/Makefile'), /OBJ=main\.o ezoec\.o/);
|
||||||
|
assert.ok(read('sensors/EZODO/main.c').trim().length > 0);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue