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