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
EMOTIONS-HUNTER 1 month ago
parent 0ba3e59fb6
commit 426b9c247b

@ -16,7 +16,7 @@ El sistema implementa una arquitectura de cuatro capas desacopladas que operan d
┌──────────────────────────────────────────────────────────────────────┐
│ CAPA 4 — PRESENTACIÓN │
│ Navegador web (HTML5, CSS3, Vanilla JS ES6+, Chart.js, SheetJS) │
│ Polling cada 1000 ms → GET /api/sensors/:type
│ Polling cada 1000 ms → GET /data/*.json
│ Envío de comandos → POST /api/sensors/:type/command │
├──────────────────────────────────────────────────────────────────────┤
│ CAPA 3 — ENRUTAMIENTO Y PROXY │
@ -26,7 +26,7 @@ El sistema implementa una arquitectura de cuatro capas desacopladas que operan d
├──────────────────────────────────────────────────────────────────────┤
│ CAPA 2 — LÓGICA DE NEGOCIO Y API │
│ Node.js + Express, puerto 3000 │
│ - GET /api/sensors/:type → 60 registros simulados, latencia 400 ms
│ - GET /api/sensors/:type → historial simulado para exportación
│ - POST /api/sensors/:type/command → parser léxico EZO completo │
├──────────────────────────────────────────────────────────────────────┤
│ CAPA 1 — ADQUISICIÓN DE DATOS (HARDWARE / SIMULADO) │
@ -69,28 +69,30 @@ El sistema opera exclusivamente en una red de área local (LAN). Un único nodo
### 2.2. Flujo de Datos Asíncrono — Lectura en Vivo
```
1. El navegador ejecuta setInterval(updateDashboard, 1000)
2. dashboard.js llama a fetch('/api/sensors/rtd'), fetch('/api/sensors/ph'), etc.
3. Las peticiones alcanzan Nginx en el puerto 8888
4. Nginx reenvía /api/* → http://127.0.0.1:3000/api/*
5. Express evalúa el parámetro :type y genera 60 registros simulados
6. El servidor responde con un arreglo JSON tras una latencia de 400 ms
7. dashboard.js parsea la respuesta, actualiza el DOM y evalúa alarmas
8. renderAlarmSummary() actualiza el resumen de riesgo global
1. El navegador ejecuta `setInterval(updateDashboard, 1000)`.
2. `dashboard.js` lee `data/EZORTD.json`, `data/EZOPH.json`, `data/EZODO.json` y `data/EZOEC.json`.
3. En paralelo, lee los umbrales desde `config/alarms.json`.
4. El navegador valida cada valor, actualiza el DOM y evalúa alarmas.
5. `renderAlarmSummary()` actualiza el resumen de riesgo global.
6. Las gráficas leen los CSV de `logs/` con un intervalo configurable.
La API Express no participa actualmente en la lectura en vivo. Se utiliza para
comandos EZO reales o de demostracion, exportaciones con datos simulados y
operaciones de administracion del historial.
```
### 2.3. Flujo de Datos Asíncrono — Comando EZO (Consola de Hardware)
```
1. El operador selecciona el sensor y emite un comando desde la interfaz
2. mock-service.js ejecuta: POST /api/sensors/:type/command
2. ezo-service.js ejecuta: POST /api/sensors/:type/command
Body: { "command": "cal,mid,7.00" }
3. Nginx reenvía la petición al puerto 3000
4. El parser léxico de Express descompone el comando por comas:
parts = ["cal", "mid", "7.00"], baseCmd = "cal"
5. Se aplica la latencia de procesamiento químico correspondiente (600 ms para CAL)
6. El servidor responde: { "success": true, "sensor": "PH", "response": "*OK" }
7. mock-service.js imprime el intercambio TX/RX en el terminal virtual de la UI
7. ezo-service.js imprime el intercambio TX/RX en el terminal virtual de la UI
```
---

@ -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.

@ -78,7 +78,7 @@ La arquitectura del sistema implementa una topología de red distribuida localme
│ ├── index.html # Punto de entrada del dashboard de monitoreo
│ ├── dashboard.css # Hoja de estilos responsiva del sistema
│ ├── dashboard.js # Lógica principal: polling, evaluación de alarmas, gráficas
│ ├── mock-service.js # Capa de comunicación asíncrona con el backend (fetch/POST)
│ ├── ezo-service.js # Consola EZO y panel de calibración por sensor
│ ├── export-service.js # Serialización de datos históricos a formato CSV
│ └── export-excel.js # Generación de reportes multipagina en formato XLSX
@ -146,6 +146,15 @@ El servidor quedará escuchando en `http://localhost:3000`. Verificar el inicio
[MOCK SERVER] Backend Node.js corriendo en http://localhost:3000
```
Para desarrollo local, abrir directamente:
```text
http://localhost:3000/frontend/index.html
```
Express sirve las carpetas necesarias para el dashboard. Nginx sigue siendo la
opcion recomendada para el despliegue final en Raspberry Pi.
Para ejecución persistente en segundo plano se recomienda el gestor de procesos `pm2`:
```bash
@ -199,7 +208,13 @@ Abrir un navegador web y dirigirse a:
http://localhost:8888/frontend/index.html
```
El dashboard iniciará automáticamente el ciclo de polling hacia la API y las lecturas en vivo comenzarán a actualizarse con los datos simulados del backend.
El dashboard inicia automáticamente el ciclo de polling de los archivos JSON
ubicados en `data/`. La API se usa por separado para la consola de comandos
simulada, las exportaciones simuladas y las operaciones administrativas.
La consola EZO detecta automaticamente el transporte disponible. En Raspberry
Pi puede enviar comandos reales por I2C mediante `sensors/EZOCommand/EZO_COMMAND`;
en otros equipos permanece en modo demo. Consulte `docs/EZO_COMMANDS.md`.
### 4.5. Construcción de los Demonios en C (Hardware Real)

@ -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
};

@ -1,11 +1,51 @@
// api/server.js
const express = require('express');
const cors = require('cors');
const fs = require('fs/promises');
const path = require('path');
const {
detectMode,
executeEzoCommand
} = require('./ezo-command-service');
const app = express();
const PORT = 3000;
const PORT = Number(process.env.PORT || 3000);
const HISTORY_FILES = {
temperature: 'timestamp,temperature\n',
ph: 'timestamp,ph\n',
do: 'timestamp,do\n',
ec: 'timestamp,ec\n'
};
const LOGS_DIRECTORY = process.env.LOGS_DIRECTORY
? path.resolve(process.env.LOGS_DIRECTORY)
: path.join(__dirname, '..', 'logs');
app.use(cors());
app.use(express.json());
app.use('/frontend', express.static(path.join(__dirname, '..', 'frontend')));
app.use('/data', express.static(path.join(__dirname, '..', 'data'), {
etag: false,
maxAge: 0
}));
app.use('/logs', express.static(LOGS_DIRECTORY, {
etag: false,
maxAge: 0
}));
app.use('/config', express.static(path.join(__dirname, '..', 'config'), {
etag: false,
maxAge: 0
}));
app.get('/', (req, res) => {
res.redirect('/frontend/index.html');
});
app.get('/api/system/ezo', (req, res) => {
const mode = detectMode();
res.json({
mode: mode.mode,
message: mode.message
});
});
// Función auxiliar para simular latencia
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
@ -55,152 +95,75 @@ app.get('/api/sensors/:type', async (req, res) => {
res.json(data);
});
// Endpoint DEFINITIVO para comandos Atlas Scientific EZO
// Consola EZO: usa I2C real en Raspberry Pi y modo demo en otros equipos.
app.post('/api/sensors/:type/command', async (req, res) => {
const sensorType = req.params.type.toUpperCase();
const rawCommand = req.body.command ? req.body.command.toLowerCase().trim() : '';
console.log(`[COMANDO] RX: '${rawCommand}' para ${sensorType}`);
// Dividimos el comando por comas para analizar base y parámetros (ej. "cal,mid,7.00" -> ["cal", "mid", "7.00"])
const parts = rawCommand.split(',');
const baseCmd = parts[0];
let ezoResponse = "";
// 1. COMANDOS DE ESTADO Y CONFIGURACIÓN GLOBAL (Comunes a todos los EZO)
if (baseCmd === 'i') {
await delay(300);
ezoResponse = `?I,${sensorType},2.12`;
}
else if (baseCmd === 'status') {
await delay(300);
ezoResponse = `?STATUS,P,5.03`; // P = Power On, 5.03 = Voltaje
}
else if (baseCmd === 'sleep') {
ezoResponse = `[SLEEP MODE ACTIVADO]`;
}
else if (baseCmd === 'factory') {
await delay(800);
ezoResponse = `*OK`;
}
else if (baseCmd === 'find') {
await delay(300);
ezoResponse = `*OK`; // Hace parpadear el LED en blanco
}
else if (baseCmd === 'led') {
await delay(300);
if (parts[1] === '?') ezoResponse = `?LED,1`;
else ezoResponse = `*OK`;
}
else if (baseCmd === 'plock') {
// Protocol Lock (Evita cambios accidentales de I2C a UART)
await delay(300);
if (parts[1] === '?') ezoResponse = `?PLOCK,1`;
else ezoResponse = `*OK`;
}
else if (baseCmd === 'i2c') {
// Cambio de dirección I2C (ej. i2c,100)
await delay(300);
ezoResponse = `*OK`;
}
// 2. COMANDO DE LECTURA PRINCIPAL
else if (baseCmd === 'r') {
await delay(900); // Latencia real de procesamiento químico/eléctrico
if (sensorType === 'RTD') ezoResponse = (25.0 + Math.random() * 0.1).toFixed(3);
if (sensorType === 'PH') ezoResponse = (7.2 + Math.random() * 0.05).toFixed(2);
if (sensorType === 'DO') ezoResponse = (8.5 + Math.random() * 0.1).toFixed(2);
if (sensorType === 'EC') ezoResponse = (1050 + Math.random() * 5).toFixed(0);
}
// 3. SISTEMA DE CALIBRACIÓN UNIVERSAL
else if (baseCmd === 'cal') {
await delay(600);
if (parts[1] === 'clear') {
ezoResponse = `*OK`;
} else if (parts[1] === '?') {
ezoResponse = `?CAL,1`; // Devuelve puntos calibrados
} else {
// Acepta cal,mid,7.00 (pH) | cal,atm (DO) | cal,dry (EC) | cal,t (RTD)
ezoResponse = `*OK`;
}
}
// 4. COMPENSACIONES AMBIENTALES (Temperatura, Salinidad, Presión)
else if (baseCmd === 't' || baseCmd === 's' || baseCmd === 'p') {
await delay(300);
if (parts[1] === '?') {
const defaultVals = { 't': '25.0', 's': '0.00', 'p': '101.3' };
ezoResponse = `?${baseCmd.toUpperCase()},${defaultVals[baseCmd]}`;
} else {
ezoResponse = `*OK`;
}
try {
const result = await executeEzoCommand(
req.params.type.toLowerCase(),
req.body.command,
{ dangerousConfirmed: req.body.dangerousConfirmed === true }
);
res.json(result);
} catch (error) {
res.status(error.status || 500).json({
success: false,
error: error.message
});
}
});
// 5. COMANDOS ESPECÍFICOS POR SENSOR
else {
await delay(300);
// --- RTD (Temperatura) ---
if (sensorType === 'RTD' && baseCmd === 's') {
// Escala (c=Celsius, k=Kelvin, f=Fahrenheit)
ezoResponse = parts[1] === '?' ? `?S,C` : `*OK`;
}
// --- RUTAS DE GESTIÓN DE ALMACENAMIENTO ---
// --- PH ---
else if (sensorType === 'PH' && baseCmd === 'slope') {
// Estado de salud del vidrio de la sonda
ezoResponse = parts[1] === '?' ? `?Slope,99.7,100.3,-0.89` : `*ER`;
}
// Variable global para simular la configuración de escritura del demonio C++
let loggingRateSeconds = 1;
// --- EC (Conductividad) ---
else if (sensorType === 'EC') {
if (baseCmd === 'k') {
// Constante de la sonda (0.1, 1.0, 10)
ezoResponse = parts[1] === '?' ? `?K,1.0` : `*OK`;
} else if (baseCmd === 'tc') {
// Coeficiente de temperatura
ezoResponse = parts[1] === '?' ? `?TC,1.90` : `*OK`;
} else if (baseCmd === 'o') {
// Habilitar/Deshabilitar parámetros de salida (TDS, Salinidad, Gravedad Específica)
ezoResponse = parts[1] === '?' ? `?O,EC,TDS,S,SG` : `*OK`;
} else {
ezoResponse = `*ER`;
}
}
app.post('/api/config/logging', (req, res) => {
const requestedRate = Number(req.body.rate);
const allowedRates = new Set([1, 5, 10, 60]);
// Si ninguna regla coincide, el comando no existe en el datasheet
else {
ezoResponse = `*ER`;
}
if (!allowedRates.has(requestedRate)) {
return res.status(400).json({ success: false, error: 'Frecuencia no válida' });
}
loggingRateSeconds = requestedRate;
console.log(`[SIMULACIÓN] Frecuencia configurada a: 1 registro cada ${loggingRateSeconds}s`);
res.json({
success: true,
sensor: sensorType,
command: rawCommand,
response: ezoResponse
simulated: true,
rate: loggingRateSeconds,
message: 'Configuración aplicada únicamente al backend simulado.'
});
});
// --- RUTAS DE GESTIÓN DE ALMACENAMIENTO ---
app.post('/api/history/clear', async (req, res) => {
try {
await Promise.all(
Object.entries(HISTORY_FILES).map(([name, header]) =>
fs.writeFile(path.join(LOGS_DIRECTORY, `${name}.csv`), header, 'utf8')
)
);
// Variable global para simular la configuración de escritura del demonio C++
let loggingRateSeconds = 1;
app.post('/api/config/logging', (req, res) => {
loggingRateSeconds = req.body.rate || 1;
console.log(`[SISTEMA] Demonio de escritura configurado a: 1 registro cada ${loggingRateSeconds}s`);
res.json({ success: true, rate: loggingRateSeconds });
});
app.post('/api/history/clear', (req, res) => {
console.log(`[SISTEMA] Purgado de base de datos histórico solicitado por el usuario.`);
// En el hardware real, aquí se ejecutaría 'fs.unlink()' o se truncarían los archivos CSV en /logs/
res.json({ success: true, message: "Archivos de registro truncados correctamente." });
console.log('[SISTEMA] Archivos CSV históricos truncados.');
res.json({
success: true,
message: 'Archivos de registro truncados correctamente.'
});
} catch (error) {
console.error('[ERROR] No se pudieron truncar los historiales:', error);
res.status(500).json({
success: false,
error: 'No se pudieron borrar los archivos históricos.'
});
}
});
app.listen(PORT, () => {
if (require.main === module) {
app.listen(PORT, () => {
console.log(`[MOCK SERVER] Backend Node.js corriendo en http://localhost:${PORT}`);
});
});
}
module.exports = {
app,
HISTORY_FILES
};

@ -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

@ -109,6 +109,7 @@ h1 {
.metric-card,
.system-panel,
.hardware-panel,
.trends-panel {
border: 1px solid var(--border);
border-radius: 8px;
@ -216,6 +217,7 @@ h1 {
.system-panel,
.alarm-panel,
.hardware-panel,
.trends-panel {
margin-top: 18px;
padding: 20px;
@ -503,3 +505,191 @@ h1 {
color: white;
border-color: var(--accent);
}
.card {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
}
.export-card {
margin-bottom: 20px;
padding: 20px;
}
.button-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.btn-export,
.btn-command {
min-height: 36px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font-weight: 700;
cursor: pointer;
}
.btn-export:hover,
.btn-command:hover {
border-color: var(--accent);
}
.config-card label {
color: var(--muted) !important;
}
.terminal-card,
.calibration-card {
overflow: hidden;
}
.hardware-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
margin-top: 16px;
}
.terminal-card,
.calibration-card {
padding: 18px;
}
.terminal-card h3,
.calibration-card h3 {
margin: 0;
}
.control-row,
.calibration-toolbar,
.calibration-action {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 14px;
}
.control-row select,
.control-row input,
.calibration-heading select,
.calibration-action input,
.calibration-action select {
min-height: 38px;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--text);
}
.control-row input {
flex: 1 1 240px;
}
.btn-command.primary {
border-color: var(--accent);
background: var(--accent);
color: white;
}
.btn-command.danger {
border-color: var(--critical);
color: var(--critical);
}
.terminal-output {
height: 210px;
margin-top: 14px;
padding: 14px;
overflow-y: auto;
border-radius: 6px;
background: #171b19;
color: #63e6be;
font-family: Consolas, "Courier New", monospace;
font-size: 0.86rem;
line-height: 1.5;
}
.terminal-muted {
color: #9aa5a0;
}
.calibration-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.calibration-heading p,
.calibration-note {
margin: 8px 0 0;
color: var(--muted);
font-size: 0.88rem;
line-height: 1.5;
}
.calibration-group {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.calibration-group h4 {
margin: 0 0 4px;
font-size: 0.95rem;
}
.calibration-group p {
margin: 0;
color: var(--muted);
font-size: 0.84rem;
line-height: 1.45;
}
.transport-hardware {
color: var(--ok);
}
.transport-demo {
color: var(--warning);
}
.transport-unavailable {
color: var(--critical);
}
@media (max-width: 640px) {
.calibration-card > div {
grid-template-columns: 1fr !important;
}
.button-group .btn-export {
width: 100%;
}
.hardware-grid {
grid-template-columns: 1fr;
}
.calibration-heading {
flex-direction: column;
}
.calibration-heading select,
.control-row select,
.control-row input,
.control-row .btn-command,
.calibration-action input,
.calibration-action select,
.calibration-action .btn-command {
width: 100%;
}
}

@ -5,6 +5,7 @@ const WARNING_MARGIN_RATIO = 0.1;
// Variables dinámicas para el control de históricos
let currentHistoryInterval = 10000;
let historyIntervalId = null;
let dashboardUpdateInProgress = false;
// Diccionario para traducir estados en la UI sin romper las clases CSS
const stateTranslations = {
@ -205,6 +206,13 @@ function renderSystemStatus(results) {
}
async function updateDashboard() {
if (dashboardUpdateInProgress) {
return;
}
dashboardUpdateInProgress = true;
try {
const [results, alarmConfig] = await Promise.all([
Promise.all(sensors.map(readSensor)),
readAlarmConfig()
@ -216,6 +224,9 @@ async function updateDashboard() {
evaluatedResults.forEach(setSensorState);
renderSystemStatus(evaluatedResults);
renderAlarmSummary(evaluatedResults, alarmConfig);
} finally {
dashboardUpdateInProgress = false;
}
}
async function readAlarmConfig() {
@ -599,6 +610,8 @@ function startHistoryPolling() {
}
// Inicialización de renderizado
updateDashboard();
setInterval(updateDashboard, POLLING_INTERVAL_MS);
updateHistoricalTrends();
startHistoryPolling();
@ -626,7 +639,12 @@ window.updateLoggingRate = async function () {
});
if (!response.ok) throw new Error("Fallo en la sincronización con el servidor");
alert(`Configuración de hardware actualizada:\nLos demonios C++ guardarán 1 registro cada ${rate} segundos.`);
const result = await response.json();
alert(
`Frecuencia del backend simulado actualizada a ${result.rate} segundos.\n` +
"Esta opción todavía no modifica los daemons C de la Raspberry Pi."
);
} catch (error) {
console.error("Error configurando la tasa de registro:", error);
}
@ -658,33 +676,3 @@ window.clearHistoricalData = async function () {
alert("Error de sistema al intentar purgar los archivos.");
}
};
const chartCards = {
temperature: document.getElementById("chart-card-temperature"),
ph: document.getElementById("chart-card-ph"),
do: document.getElementById("chart-card-do"),
ec: document.getElementById("chart-card-ec")
};
document.querySelectorAll(".chart-filter").forEach((button) => {
button.addEventListener("click", () => {
const selected = button.dataset.chart;
document.querySelectorAll(".chart-filter").forEach((btn) => {
btn.classList.remove("active");
});
button.classList.add("active");
if (selected === "all") {
Object.values(chartCards).forEach((card) => {
if (card) card.style.display = "";
});
return;
}
Object.entries(chartCards).forEach(([key, card]) => {
if (card) card.style.display = key === selected ? "" : "none";
});
});
});

@ -20,6 +20,10 @@ async function handleExportExcel() {
// Reutilizamos el mock de la Fase 4
const data = await fetchHistoricalData(sensor.id);
if (!Array.isArray(data) || data.length === 0) {
throw new Error(`No hay datos históricos para ${sensor.sheetName}.`);
}
// SheetJS convierte automáticamente un array de objetos JSON a una hoja de cálculo
const worksheet = XLSX.utils.json_to_sheet(data);

@ -5,22 +5,25 @@
*/
function convertToCSV(objArray) {
const array = typeof objArray !== 'object' ? JSON.parse(objArray) : objArray;
let str = '';
// Extraer cabeceras (keys del primer objeto)
const headers = Object.keys(array[0]).join(',');
str += headers + '\r\n';
// Rellenar filas
for (let i = 0; i < array.length; i++) {
let line = '';
for (let index in array[i]) {
if (line != '') line += ',';
line += array[i][index];
}
str += line + '\r\n';
if (!Array.isArray(array) || array.length === 0) {
throw new Error('No hay datos históricos disponibles para exportar.');
}
return str;
const headers = Object.keys(array[0]);
const rows = array.map((item) =>
headers.map((header) => escapeCsvValue(item[header])).join(',')
);
return [
headers.map(escapeCsvValue).join(','),
...rows
].join('\r\n');
}
function escapeCsvValue(value) {
const text = value == null ? '' : String(value);
return `"${text.replace(/"/g, '""')}"`;
}
/**
@ -48,6 +51,7 @@ async function handleExport(sensorType) {
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
console.log(`[EXITO] Archivo CSV descargado para ${sensorType}`);

@ -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();
});

@ -31,7 +31,7 @@
<span id="temperature-value">--</span>
<span class="metric-unit">&deg;C</span>
</div>
<p class="metric-source">EZO-RTD &middot; API Local</p>
<p class="metric-source">EZO-RTD &middot; data/EZORTD.json</p>
</article>
<article class="metric-card" id="card-ph">
@ -43,7 +43,7 @@
<span id="ph-value">--</span>
<span class="metric-unit">pH</span>
</div>
<p class="metric-source">EZO-pH &middot; API Local</p>
<p class="metric-source">EZO-pH &middot; data/EZOPH.json</p>
</article>
<article class="metric-card" id="card-do">
@ -55,7 +55,7 @@
<span id="do-value">--</span>
<span class="metric-unit">mg/L</span>
</div>
<p class="metric-source">EZO-DO &middot; API Local</p>
<p class="metric-source">EZO-DO &middot; data/EZODO.json</p>
</article>
<article class="metric-card" id="card-ec">
@ -67,7 +67,7 @@
<span id="ec-value">--</span>
<span class="metric-unit">&micro;S/cm</span>
</div>
<p class="metric-source">EZO-EC &middot; API Local</p>
<p class="metric-source">EZO-EC &middot; data/EZOEC.json</p>
</article>
</section>
@ -84,7 +84,7 @@
</div>
<div class="status-item">
<span class="status-label">Fuente de datos</span>
<strong>API REST Simulada</strong>
<strong>Archivos JSON locales</strong>
</div>
<div class="status-item">
<span class="status-label">Sensores activos</span>
@ -132,6 +132,7 @@
<section class="trends-panel" aria-label="Tendencias históricas">
<div class="panel-heading">
<h2>Tendencias Históricas</h2>
<p>Actualización: <span id="history-interval">10 segundos</span></p>
</div>
<div class="card config-card" style="margin-bottom: 20px; padding: 20px; border-left: 4px solid #9c27b0;">
<h3>Configuración de Almacenamiento</h3>
@ -188,7 +189,7 @@
<article class="chart-card">
<div class="chart-heading">
<h3>Temperatura vs Tiempo</h3>
<span>API/Historial</span>
<span>logs/temperature.csv</span>
</div>
<div class="chart-frame">
<canvas id="temperature-chart"></canvas>
@ -199,7 +200,7 @@
<article class="chart-card">
<div class="chart-heading">
<h3>pH vs Tiempo</h3>
<span>API/Historial</span>
<span>logs/ph.csv</span>
</div>
<div class="chart-frame">
<canvas id="ph-chart"></canvas>
@ -210,7 +211,7 @@
<article class="chart-card">
<div class="chart-heading">
<h3>Oxígeno Disuelto vs Tiempo</h3>
<span>API/Historial</span>
<span>logs/do.csv</span>
</div>
<div class="chart-frame">
<canvas id="do-chart"></canvas>
@ -221,7 +222,7 @@
<article class="chart-card">
<div class="chart-heading">
<h3>Conductividad vs Tiempo</h3>
<span>API/Historial</span>
<span>logs/ec.csv</span>
</div>
<div class="chart-frame">
<canvas id="ec-chart"></canvas>
@ -231,87 +232,72 @@
</div>
</section>
<div class="card terminal-card" style="margin-top: 20px; padding: 20px; border-left: 4px solid #00ffcc;">
<div class="card terminal-card" style="margin-top: 20px; padding: 20px; border-left: 4px solid #00ffcc;">
<h3>Consola de Hardware (Atlas Scientific EZO)</h3>
<section class="hardware-panel" aria-label="Control de circuitos EZO">
<div class="panel-heading">
<h2>Control Atlas Scientific EZO</h2>
<p>Transporte: <strong id="ezo-transport-status">comprobando...</strong></p>
</div>
<div style="display: flex; gap: 10px; margin-bottom: 15px; align-items: center; flex-wrap: wrap;">
<select id="terminal-sensor-select" style="padding: 5px;">
<div class="hardware-grid">
<div class="card terminal-card">
<h3>Consola de Comandos</h3>
<div class="control-row">
<select id="terminal-sensor-select">
<option value="rtd">Temperatura (RTD)</option>
<option value="ph">Sensor de pH</option>
<option value="do">Oxígeno Disuelto (DO)</option>
<option value="ec">Conductividad (EC)</option>
</select>
<button class="btn-command" onclick="sendCommand('i')"
style="padding: 5px 15px; cursor: pointer;">Info (i)</button>
<button class="btn-command" onclick="sendCommand('status')"
style="padding: 5px 15px; cursor: pointer;">Estado</button>
<button class="btn-command" onclick="sendCommand('r')"
style="padding: 5px 15px; background-color: #4f46e5; color: white; border: none; border-radius: 4px; cursor: pointer;">Leer
(r)</button>
<span style="color: #666; margin: 0 10px;">|</span>
<input type="text" id="custom-command" placeholder="Ej: factory, t,25.0, led,1"
style="padding: 5px; width: 200px; background-color: #222; color: #00ffcc; border: 1px solid #444;">
<button class="btn-command" onclick="sendCustomCommand()"
style="padding: 5px 15px; background-color: #00ffcc; color: black; font-weight: bold; border: none; border-radius: 4px; cursor: pointer;">Enviar
</button>
<button class="btn-command" onclick="sendCommand('i')">Información</button>
<button class="btn-command" onclick="sendCommand('Status')">Estado</button>
<button class="btn-command primary" onclick="sendCommand('R')">Leer</button>
<button class="btn-command" onclick="sendCommand('Cal,?')">Calibración</button>
</div>
<div id="terminal-output"
style="background-color: #1e1e1e; color: #00ff00; padding: 15px; height: 150px; overflow-y: auto; font-family: 'Courier New', Courier, monospace; border-radius: 4px;">
<div style="color: #888;">[SISTEMA] Consola I2C iniciada. Esperando comandos...</div>
<div class="control-row">
<input type="text" id="custom-command"
placeholder="Ej: T,25.0 · L,? · Plock,?">
<button class="btn-command primary" onclick="sendCustomCommand()">Enviar</button>
</div>
<div id="terminal-output" class="terminal-output">
<div class="terminal-muted">[SISTEMA] Esperando comandos...</div>
</div>
</div>
<div class="card calibration-card" style="margin-top: 20px; padding: 20px; border-left: 4px solid #ffcc00;">
<h3>Panel de Calibración</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div class="card calibration-card">
<div class="calibration-heading">
<div>
<label style="display: block; margin-bottom: 5px;">Seleccionar Sensor a Calibrar:</label>
<select id="cal-sensor-select" style="padding: 8px; width: 100%; margin-bottom: 15px;">
<h3>Panel de Calibración</h3>
<p id="calibration-description"></p>
</div>
<select id="cal-sensor-select" onchange="renderCalibrationPanel()">
<option value="rtd">Temperatura (RTD)</option>
<option value="ph">Sensor de pH</option>
<option value="do">Oxígeno Disuelto (DO)</option>
<option value="ec">Conductividad (EC)</option>
</select>
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px;">Comandos Globales de Calibración:</label>
<button class="btn-command" onclick="sendCalibrationCmd('cal,?')"
style="padding: 5px 10px; margin-right: 5px; cursor: pointer;">Verificar Estado
(?)</button>
<button class="btn-command" onclick="sendCalibrationCmd('cal,clear')"
style="padding: 5px 10px; background-color: #ff4444; color: white; border: none; border-radius: 4px; cursor: pointer;">Borrar
Memoria (Clear)</button>
</div>
</div>
<div style="background-color: #f5f5f5; padding: 15px; border-radius: 4px;">
<h4 style="margin-top: 0; color: #333;">Calibración Multipunto</h4>
<p style="font-size: 0.9em; color: #666;">Ingresa el valor de referencia físico (ej. solución
pH, o temperatura del agua).</p>
<div style="display: flex; gap: 10px; align-items: center;">
<input type="number" id="cal-value" placeholder="Ej: 7.00" step="0.01"
style="padding: 8px; width: 100px; border: 1px solid #ccc; border-radius: 4px;">
<button class="btn-command" onclick="calibratePoint()"
style="padding: 8px 15px; background-color: #00ffcc; color: black; font-weight: bold; border: none; border-radius: 4px; cursor: pointer;">Calibrar
Punto</button>
</div>
<div class="calibration-toolbar">
<button class="btn-command" onclick="sendCalibrationCommand('Cal,?')">
Ver estado
</button>
<button class="btn-command danger" onclick="clearCalibration()">
Borrar calibración
</button>
</div>
<div id="calibration-controls"></div>
<p class="calibration-note" id="calibration-note"></p>
</div>
</div>
</section>
</main>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.sheetjs.com/xlsx-latest/package/dist/xlsx.full.min.js"></script>
<script src="dashboard.js"></script>
<script src="mock-service.js"></script>
<script src="ezo-service.js"></script>
<script src="export-service.js"></script>
<script src="export-excel.js"></script>
</body>

@ -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
}
}

@ -4,7 +4,8 @@
"description": "This project implements a real-time temperature monitoring system based on the **Atlas Scientific EZO-RTD™** sensor module, interfaced via I²C to a Raspberry Pi. It extends a previous air quality monitoring platform (originally built around the HTU21D sensor) by replacing the temperature/humidity measurement stage with a high-precision RTD (Resistance Temperature Detector) circuit that provides temperature-only readings with significantly improved accuracy.",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"start": "node api/server.js",
"test": "node --test"
},
"keywords": [],
"author": "",

@ -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;
}

@ -1,9 +1,9 @@
CC=gcc
CFLAGS=-I.
OBJ=main.o sensor.o
OBJ=main.o ezodo.o
TARGET=sensor
TARGET=EZODO
%.o: %.c
$(CC) -c -o $@ $< $(CFLAGS)

@ -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;
}

@ -1,9 +1,9 @@
CC=gcc
CFLAGS=-I.
OBJ=main.o sensor.o
OBJ=main.o ezoec.o
TARGET=sensor
TARGET=EZOEC
%.o: %.c
$(CC) -c -o $@ $< $(CFLAGS)

@ -1,5 +1,8 @@
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include "ezoec.h"
@ -7,11 +10,22 @@ int main()
{
int fd = open("/dev/i2c-1",O_RDWR);
if(fd < 0)
{
fprintf(stderr,"%s\n",strerror(errno));
return -1;
}
double ec;
getEC(fd,&ec);
if(getEC(fd,&ec) < 0)
{
close(fd);
return -1;
}
printf("{ \"ec\": %.3f }\n",ec);
close(fd);
return 0;
}

@ -1,9 +1,9 @@
CC=gcc
CFLAGS=-I.
OBJ=main.o sensor.o
OBJ=main.o ezoph.o
TARGET=sensor
TARGET=EZOPH
%.o: %.c
$(CC) -c -o $@ $< $(CFLAGS)

@ -2,6 +2,7 @@
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include "ezoph.h"
@ -19,10 +20,12 @@ int main()
if(getPH(fd,&ph) < 0)
{
close(fd);
return -1;
}
printf("{ \"ph\": %.3f }\n",ph);
close(fd);
return 0;
}

@ -1,6 +1,7 @@
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/file.h>
#include <time.h>
#include "ezortd.h"
@ -20,8 +21,10 @@ int main()
while (1)
{
double temperature;
int lock_fd = open("/tmp/photobioreactor-i2c.lock", O_CREAT | O_RDWR, 0660);
if (getTemperature(fd, &temperature) == 0)
if (lock_fd >= 0 && flock(lock_fd, LOCK_EX) == 0 &&
getTemperature(fd, &temperature) == 0)
{
FILE *f = fopen(
"/home/cristian/airquality/basic-ui-dashboard/data/EZORTD.json",
@ -60,6 +63,12 @@ int main()
}
}
if (lock_fd >= 0)
{
flock(lock_fd, LOCK_UN);
close(lock_fd);
}
sleep(1);
}

@ -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…
Cancel
Save