From 426b9c247b93cf68a25f6d6b19747784e9487c41 Mon Sep 17 00:00:00 2001 From: EMOTIONS-HUNTER Date: Mon, 22 Jun 2026 12:36:35 -0600 Subject: [PATCH] Integrar dashboard y control EZO por I2C MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ARCHITECTURE.md | 28 +-- PROJECT_STATUS.md | 77 +++++++ README.md | 21 +- api/ezo-command-service.js | 311 ++++++++++++++++++++++++++++ api/server.js | 231 +++++++++------------ docs/EZO_COMMANDS.md | 130 ++++++++++++ frontend/dashboard.css | 192 ++++++++++++++++- frontend/dashboard.js | 70 +++---- frontend/export-excel.js | 6 +- frontend/export-service.js | 34 ++-- frontend/ezo-service.js | 339 +++++++++++++++++++++++++++++++ frontend/index.html | 128 ++++++------ frontend/mock-service.js | 109 ---------- package.json | 3 +- sensors/EZOCommand/Makefile | 9 + sensors/EZOCommand/main.c | 124 +++++++++++ sensors/EZODO/Makefile | 6 +- sensors/EZODO/main.c | 31 +++ sensors/EZOEC/Makefile | 6 +- sensors/EZOEC/main.c | 18 +- sensors/EZOPH/Makefile | 6 +- sensors/EZOPH/main.c | 5 +- sensors/EZORTD/ezortd_daemon.c | 11 +- test/api.test.js | 119 +++++++++++ test/ezo-command-service.test.js | 36 ++++ test/static.test.js | 47 +++++ 26 files changed, 1695 insertions(+), 402 deletions(-) create mode 100644 PROJECT_STATUS.md create mode 100644 api/ezo-command-service.js create mode 100644 docs/EZO_COMMANDS.md create mode 100644 frontend/ezo-service.js delete mode 100644 frontend/mock-service.js create mode 100644 sensors/EZOCommand/Makefile create mode 100644 sensors/EZOCommand/main.c create mode 100644 test/api.test.js create mode 100644 test/ezo-command-service.test.js create mode 100644 test/static.test.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 276c508..549c10b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 ``` --- @@ -320,4 +322,4 @@ La función `handleExport(sensorType)` en `export-service.js` recupera el histor ### 7.2. Exportación XLSX Multipagina -La función `handleExportExcel()` en `export-excel.js` itera sobre los cuatro sensores, recupera los datos de cada uno, crea una hoja de cálculo independiente con `XLSX.utils.json_to_sheet()` y las consolida en un único libro de trabajo (`Workbook`) mediante `XLSX.utils.book_append_sheet()`. El archivo binario `.xlsx` se descarga mediante `XLSX.writeFile()`. \ No newline at end of file +La función `handleExportExcel()` en `export-excel.js` itera sobre los cuatro sensores, recupera los datos de cada uno, crea una hoja de cálculo independiente con `XLSX.utils.json_to_sheet()` y las consolida en un único libro de trabajo (`Workbook`) mediante `XLSX.utils.book_append_sheet()`. El archivo binario `.xlsx` se descarga mediante `XLSX.writeFile()`. diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md new file mode 100644 index 0000000..9f939bc --- /dev/null +++ b/PROJECT_STATUS.md @@ -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. diff --git a/README.md b/README.md index 81f4fe2..ded8aab 100644 --- a/README.md +++ b/README.md @@ -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) @@ -216,4 +231,4 @@ Esto generará el ejecutable `EZORTD`. Antes de compilar el demonio continuo (`e ```bash sudo usermod -aG i2c $USER -``` \ No newline at end of file +``` diff --git a/api/ezo-command-service.js b/api/ezo-command-service.js new file mode 100644 index 0000000..3261970 --- /dev/null +++ b/api/ezo-command-service.js @@ -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 +}; diff --git a/api/server.js b/api/server.js index 13c2141..42f0949 100644 --- a/api/server.js +++ b/api/server.js @@ -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`; + 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 + }); } - 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`; - } - } - - // 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 --- - -// 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', async (req, res) => { + try { + await Promise.all( + Object.entries(HISTORY_FILES).map(([name, header]) => + fs.writeFile(path.join(LOGS_DIRECTORY, `${name}.csv`), header, 'utf8') + ) + ); + + 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.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." }); -}); +if (require.main === module) { + app.listen(PORT, () => { + console.log(`[MOCK SERVER] Backend Node.js corriendo en http://localhost:${PORT}`); + }); +} -app.listen(PORT, () => { - console.log(`[MOCK SERVER] Backend Node.js corriendo en http://localhost:${PORT}`); -}); \ No newline at end of file +module.exports = { + app, + HISTORY_FILES +}; diff --git a/docs/EZO_COMMANDS.md b/docs/EZO_COMMANDS.md new file mode 100644 index 0000000..8ce30b0 --- /dev/null +++ b/docs/EZO_COMMANDS.md @@ -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, +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, +S, +S,,ppt +P, +``` + +## Calibracion EZO-EC + +Primero se configura la constante de la sonda con `K,` y se realiza la +calibracion en seco: + +```text +K,1.0 +Cal,dry +``` + +Calibracion de dos puntos: + +```text +Cal,dry +Cal, +``` + +Calibracion de tres puntos: + +```text +Cal,dry +Cal,low, +Cal,high, +``` + +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 diff --git a/frontend/dashboard.css b/frontend/dashboard.css index ea0119e..afd6244 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -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; @@ -502,4 +504,192 @@ h1 { background: var(--accent); color: white; border-color: var(--accent); -} \ No newline at end of file +} + +.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%; + } +} diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 8e0c583..ccd2abe 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -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,17 +206,27 @@ function renderSystemStatus(results) { } async function updateDashboard() { - const [results, alarmConfig] = await Promise.all([ - Promise.all(sensors.map(readSensor)), - readAlarmConfig() - ]); - const evaluatedResults = results.map((result) => - evaluateSensorAlarm(result, alarmConfig.thresholds) - ); + if (dashboardUpdateInProgress) { + return; + } - evaluatedResults.forEach(setSensorState); - renderSystemStatus(evaluatedResults); - renderAlarmSummary(evaluatedResults, alarmConfig); + dashboardUpdateInProgress = true; + + try { + const [results, alarmConfig] = await Promise.all([ + Promise.all(sensors.map(readSensor)), + readAlarmConfig() + ]); + const evaluatedResults = results.map((result) => + evaluateSensorAlarm(result, alarmConfig.thresholds) + ); + + 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"; - }); - }); -}); \ No newline at end of file diff --git a/frontend/export-excel.js b/frontend/export-excel.js index 7975682..75ef6db 100644 --- a/frontend/export-excel.js +++ b/frontend/export-excel.js @@ -19,6 +19,10 @@ async function handleExportExcel() { for (const sensor of sensors) { // 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); @@ -45,4 +49,4 @@ async function handleExportExcel() { console.error("[ERROR] Fallo al exportar el Excel:", error); alert("Hubo un error al generar el Excel. Revisa la consola."); } -} \ No newline at end of file +} diff --git a/frontend/export-service.js b/frontend/export-service.js index c4f2d64..fb225f8 100644 --- a/frontend/export-service.js +++ b/frontend/export-service.js @@ -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}`); @@ -55,4 +59,4 @@ async function handleExport(sensorType) { console.error("[ERROR] Fallo al exportar el CSV:", error); alert("Hubo un error al generar el archivo. Revisa la consola."); } -} \ No newline at end of file +} diff --git a/frontend/ezo-service.js b/frontend/ezo-service.js new file mode 100644 index 0000000..fc0b173 --- /dev/null +++ b/frontend/ezo-service.js @@ -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,.", + 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,, 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(); +}); diff --git a/frontend/index.html b/frontend/index.html index 780b452..72555a0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -31,7 +31,7 @@ -- °C -

EZO-RTD · API Local

+

EZO-RTD · data/EZORTD.json

@@ -43,7 +43,7 @@ -- pH -

EZO-pH · API Local

+

EZO-pH · data/EZOPH.json

@@ -55,7 +55,7 @@ -- mg/L -

EZO-DO · API Local

+

EZO-DO · data/EZODO.json

@@ -67,7 +67,7 @@ -- µS/cm -

EZO-EC · API Local

+

EZO-EC · data/EZOEC.json

@@ -84,7 +84,7 @@
Fuente de datos - API REST Simulada + Archivos JSON locales
Sensores activos @@ -132,6 +132,7 @@ -
-
-

Consola de Hardware (Atlas Scientific EZO)

- -
- - - - - - - | - - - -
- -
-
[SISTEMA] Consola I2C iniciada. Esperando comandos...
-
+
+
+

Control Atlas Scientific EZO

+

Transporte: comprobando...

-
-

Panel de Calibración

- -
-
- - + + + + +
+
+ + +
+
+
[SISTEMA] Esperando comandos...
+
+
-
- - - +
+
+
+

Panel de Calibración

+

+
-
-

Calibración Multipunto

-

Ingresa el valor de referencia físico (ej. solución - pH, o temperatura del agua).

- -
- - -
+
+ +
+ +
+

+
- + - \ No newline at end of file + diff --git a/frontend/mock-service.js b/frontend/mock-service.js deleted file mode 100644 index dadd6fd..0000000 --- a/frontend/mock-service.js +++ /dev/null @@ -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 += `
[${timeString}] TX (${sensorType}): ${command}
`; - - // 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 += `
[${timeString}] RX (${sensorType}): ${data.response}
`; - - } catch (error) { - console.error("Error enviando comando:", error); - terminalOutput.innerHTML += `
[${timeString}] SYS ERROR: Fallo de bus I2C simulado o servidor desconectado.
`; - } - - // 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 - } -} \ No newline at end of file diff --git a/package.json b/package.json index 83b4c7e..16bbed1 100644 --- a/package.json +++ b/package.json @@ -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": "", diff --git a/sensors/EZOCommand/Makefile b/sensors/EZOCommand/Makefile new file mode 100644 index 0000000..55d4121 --- /dev/null +++ b/sensors/EZOCommand/Makefile @@ -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) diff --git a/sensors/EZOCommand/main.c b/sensors/EZOCommand/main.c new file mode 100644 index 0000000..7fdb9a5 --- /dev/null +++ b/sensors/EZOCommand/main.c @@ -0,0 +1,124 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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; +} diff --git a/sensors/EZODO/Makefile b/sensors/EZODO/Makefile index 04100d8..77029dd 100644 --- a/sensors/EZODO/Makefile +++ b/sensors/EZODO/Makefile @@ -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) @@ -12,4 +12,4 @@ $(TARGET): $(OBJ) $(CC) -o $@ $^ $(CFLAGS) clean: - rm -f *.o $(TARGET) \ No newline at end of file + rm -f *.o $(TARGET) diff --git a/sensors/EZODO/main.c b/sensors/EZODO/main.c index e69de29..0b9a1b8 100644 --- a/sensors/EZODO/main.c +++ b/sensors/EZODO/main.c @@ -0,0 +1,31 @@ +#include +#include +#include +#include +#include + +#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; +} diff --git a/sensors/EZOEC/Makefile b/sensors/EZOEC/Makefile index 04100d8..867935a 100644 --- a/sensors/EZOEC/Makefile +++ b/sensors/EZOEC/Makefile @@ -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) @@ -12,4 +12,4 @@ $(TARGET): $(OBJ) $(CC) -o $@ $^ $(CFLAGS) clean: - rm -f *.o $(TARGET) \ No newline at end of file + rm -f *.o $(TARGET) diff --git a/sensors/EZOEC/main.c b/sensors/EZOEC/main.c index 0601e40..d058f0a 100644 --- a/sensors/EZOEC/main.c +++ b/sensors/EZOEC/main.c @@ -1,5 +1,8 @@ #include #include +#include +#include +#include #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; -} \ No newline at end of file +} diff --git a/sensors/EZOPH/Makefile b/sensors/EZOPH/Makefile index 04100d8..90c3171 100644 --- a/sensors/EZOPH/Makefile +++ b/sensors/EZOPH/Makefile @@ -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) @@ -12,4 +12,4 @@ $(TARGET): $(OBJ) $(CC) -o $@ $^ $(CFLAGS) clean: - rm -f *.o $(TARGET) \ No newline at end of file + rm -f *.o $(TARGET) diff --git a/sensors/EZOPH/main.c b/sensors/EZOPH/main.c index f67aa55..75169b2 100644 --- a/sensors/EZOPH/main.c +++ b/sensors/EZOPH/main.c @@ -2,6 +2,7 @@ #include #include #include +#include #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; -} \ No newline at end of file +} diff --git a/sensors/EZORTD/ezortd_daemon.c b/sensors/EZORTD/ezortd_daemon.c index f009835..eb09a58 100644 --- a/sensors/EZORTD/ezortd_daemon.c +++ b/sensors/EZORTD/ezortd_daemon.c @@ -1,6 +1,7 @@ #include #include #include +#include #include #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); } diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 0000000..60b0a2f --- /dev/null +++ b/test/api.test.js @@ -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); + } +}); diff --git a/test/ezo-command-service.test.js b/test/ezo-command-service.test.js new file mode 100644 index 0000000..5a289a5 --- /dev/null +++ b/test/ezo-command-service.test.js @@ -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); +}); diff --git a/test/static.test.js b/test/static.test.js new file mode 100644 index 0000000..baf1c59 --- /dev/null +++ b/test/static.test.js @@ -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(//g) || []).length; + const openSections = (html.match(//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); +});