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 │ │ CAPA 4 — PRESENTACIÓN │
│ Navegador web (HTML5, CSS3, Vanilla JS ES6+, Chart.js, SheetJS) │ │ 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 │ │ Envío de comandos → POST /api/sensors/:type/command │
├──────────────────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────────────────┤
│ CAPA 3 — ENRUTAMIENTO Y PROXY │ │ 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 │ │ CAPA 2 — LÓGICA DE NEGOCIO Y API │
│ Node.js + Express, puerto 3000 │ │ 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 │ │ - POST /api/sensors/:type/command → parser léxico EZO completo │
├──────────────────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────────────────┤
│ CAPA 1 — ADQUISICIÓN DE DATOS (HARDWARE / SIMULADO) │ │ 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 ### 2.2. Flujo de Datos Asíncrono — Lectura en Vivo
``` ```
1. El navegador ejecuta setInterval(updateDashboard, 1000) 1. El navegador ejecuta `setInterval(updateDashboard, 1000)`.
2. dashboard.js llama a fetch('/api/sensors/rtd'), fetch('/api/sensors/ph'), etc. 2. `dashboard.js` lee `data/EZORTD.json`, `data/EZOPH.json`, `data/EZODO.json` y `data/EZOEC.json`.
3. Las peticiones alcanzan Nginx en el puerto 8888 3. En paralelo, lee los umbrales desde `config/alarms.json`.
4. Nginx reenvía /api/* → http://127.0.0.1:3000/api/* 4. El navegador valida cada valor, actualiza el DOM y evalúa alarmas.
5. Express evalúa el parámetro :type y genera 60 registros simulados 5. `renderAlarmSummary()` actualiza el resumen de riesgo global.
6. El servidor responde con un arreglo JSON tras una latencia de 400 ms 6. Las gráficas leen los CSV de `logs/` con un intervalo configurable.
7. dashboard.js parsea la respuesta, actualiza el DOM y evalúa alarmas
8. renderAlarmSummary() actualiza el resumen de riesgo global 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) ### 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 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" } Body: { "command": "cal,mid,7.00" }
3. Nginx reenvía la petición al puerto 3000 3. Nginx reenvía la petición al puerto 3000
4. El parser léxico de Express descompone el comando por comas: 4. El parser léxico de Express descompone el comando por comas:
parts = ["cal", "mid", "7.00"], baseCmd = "cal" parts = ["cal", "mid", "7.00"], baseCmd = "cal"
5. Se aplica la latencia de procesamiento químico correspondiente (600 ms para 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" } 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 ### 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()`. 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()`.

@ -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 │ ├── index.html # Punto de entrada del dashboard de monitoreo
│ ├── dashboard.css # Hoja de estilos responsiva del sistema │ ├── dashboard.css # Hoja de estilos responsiva del sistema
│ ├── dashboard.js # Lógica principal: polling, evaluación de alarmas, gráficas │ ├── 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-service.js # Serialización de datos históricos a formato CSV
│ └── export-excel.js # Generación de reportes multipagina en formato XLSX │ └── 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 [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`: Para ejecución persistente en segundo plano se recomienda el gestor de procesos `pm2`:
```bash ```bash
@ -199,7 +208,13 @@ Abrir un navegador web y dirigirse a:
http://localhost:8888/frontend/index.html 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) ### 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 ```bash
sudo usermod -aG i2c $USER sudo usermod -aG i2c $USER
``` ```

@ -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 // api/server.js
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const fs = require('fs/promises');
const path = require('path');
const {
detectMode,
executeEzoCommand
} = require('./ezo-command-service');
const app = express(); 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(cors());
app.use(express.json()); 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 // Función auxiliar para simular latencia
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
@ -55,152 +95,75 @@ app.get('/api/sensors/:type', async (req, res) => {
res.json(data); 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) => { app.post('/api/sensors/:type/command', async (req, res) => {
const sensorType = req.params.type.toUpperCase(); try {
const rawCommand = req.body.command ? req.body.command.toLowerCase().trim() : ''; const result = await executeEzoCommand(
req.params.type.toLowerCase(),
console.log(`[COMANDO] RX: '${rawCommand}' para ${sensorType}`); req.body.command,
{ dangerousConfirmed: req.body.dangerousConfirmed === true }
// Dividimos el comando por comas para analizar base y parámetros (ej. "cal,mid,7.00" -> ["cal", "mid", "7.00"]) );
const parts = rawCommand.split(','); res.json(result);
const baseCmd = parts[0]; } catch (error) {
res.status(error.status || 500).json({
let ezoResponse = ""; success: false,
error: error.message
// 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`;
}
}
// 5. COMANDOS ESPECÍFICOS POR SENSOR
else {
await delay(300);
// --- RTD (Temperatura) --- // --- RUTAS DE GESTIÓN DE ALMACENAMIENTO ---
if (sensorType === 'RTD' && baseCmd === 's') {
// Escala (c=Celsius, k=Kelvin, f=Fahrenheit)
ezoResponse = parts[1] === '?' ? `?S,C` : `*OK`;
}
// --- PH --- // Variable global para simular la configuración de escritura del demonio C++
else if (sensorType === 'PH' && baseCmd === 'slope') { let loggingRateSeconds = 1;
// Estado de salud del vidrio de la sonda
ezoResponse = parts[1] === '?' ? `?Slope,99.7,100.3,-0.89` : `*ER`;
}
// --- EC (Conductividad) --- app.post('/api/config/logging', (req, res) => {
else if (sensorType === 'EC') { const requestedRate = Number(req.body.rate);
if (baseCmd === 'k') { const allowedRates = new Set([1, 5, 10, 60]);
// 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`;
}
}
// Si ninguna regla coincide, el comando no existe en el datasheet if (!allowedRates.has(requestedRate)) {
else { return res.status(400).json({ success: false, error: 'Frecuencia no válida' });
ezoResponse = `*ER`;
}
} }
loggingRateSeconds = requestedRate;
console.log(`[SIMULACIÓN] Frecuencia configurada a: 1 registro cada ${loggingRateSeconds}s`);
res.json({ res.json({
success: true, success: true,
sensor: sensorType, simulated: true,
command: rawCommand, rate: loggingRateSeconds,
response: ezoResponse message: 'Configuración aplicada únicamente al backend simulado.'
}); });
}); });
// --- RUTAS DE GESTIÓN DE ALMACENAMIENTO --- app.post('/api/history/clear', async (req, res) => {
try {
// Variable global para simular la configuración de escritura del demonio C++ await Promise.all(
let loggingRateSeconds = 1; Object.entries(HISTORY_FILES).map(([name, header]) =>
fs.writeFile(path.join(LOGS_DIRECTORY, `${name}.csv`), header, 'utf8')
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 }); 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) => { if (require.main === module) {
console.log(`[SISTEMA] Purgado de base de datos histórico solicitado por el usuario.`); app.listen(PORT, () => {
// En el hardware real, aquí se ejecutaría 'fs.unlink()' o se truncarían los archivos CSV en /logs/ console.log(`[MOCK SERVER] Backend Node.js corriendo en http://localhost:${PORT}`);
res.json({ success: true, message: "Archivos de registro truncados correctamente." }); });
}); }
app.listen(PORT, () => { module.exports = {
console.log(`[MOCK SERVER] Backend Node.js corriendo en http://localhost:${PORT}`); 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, .metric-card,
.system-panel, .system-panel,
.hardware-panel,
.trends-panel { .trends-panel {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
@ -216,6 +217,7 @@ h1 {
.system-panel, .system-panel,
.alarm-panel, .alarm-panel,
.hardware-panel,
.trends-panel { .trends-panel {
margin-top: 18px; margin-top: 18px;
padding: 20px; padding: 20px;
@ -502,4 +504,192 @@ h1 {
background: var(--accent); background: var(--accent);
color: white; color: white;
border-color: var(--accent); 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 // Variables dinámicas para el control de históricos
let currentHistoryInterval = 10000; let currentHistoryInterval = 10000;
let historyIntervalId = null; let historyIntervalId = null;
let dashboardUpdateInProgress = false;
// Diccionario para traducir estados en la UI sin romper las clases CSS // Diccionario para traducir estados en la UI sin romper las clases CSS
const stateTranslations = { const stateTranslations = {
@ -205,17 +206,27 @@ function renderSystemStatus(results) {
} }
async function updateDashboard() { async function updateDashboard() {
const [results, alarmConfig] = await Promise.all([ if (dashboardUpdateInProgress) {
Promise.all(sensors.map(readSensor)), return;
readAlarmConfig() }
]);
const evaluatedResults = results.map((result) =>
evaluateSensorAlarm(result, alarmConfig.thresholds)
);
evaluatedResults.forEach(setSensorState); dashboardUpdateInProgress = true;
renderSystemStatus(evaluatedResults);
renderAlarmSummary(evaluatedResults, alarmConfig); 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() { async function readAlarmConfig() {
@ -599,6 +610,8 @@ function startHistoryPolling() {
} }
// Inicialización de renderizado // Inicialización de renderizado
updateDashboard();
setInterval(updateDashboard, POLLING_INTERVAL_MS);
updateHistoricalTrends(); updateHistoricalTrends();
startHistoryPolling(); startHistoryPolling();
@ -626,7 +639,12 @@ window.updateLoggingRate = async function () {
}); });
if (!response.ok) throw new Error("Fallo en la sincronización con el servidor"); 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) { } catch (error) {
console.error("Error configurando la tasa de registro:", 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."); 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";
});
});
});

@ -19,6 +19,10 @@ async function handleExportExcel() {
for (const sensor of sensors) { for (const sensor of sensors) {
// Reutilizamos el mock de la Fase 4 // Reutilizamos el mock de la Fase 4
const data = await fetchHistoricalData(sensor.id); 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 // SheetJS convierte automáticamente un array de objetos JSON a una hoja de cálculo
const worksheet = XLSX.utils.json_to_sheet(data); const worksheet = XLSX.utils.json_to_sheet(data);
@ -45,4 +49,4 @@ async function handleExportExcel() {
console.error("[ERROR] Fallo al exportar el Excel:", error); console.error("[ERROR] Fallo al exportar el Excel:", error);
alert("Hubo un error al generar el Excel. Revisa la consola."); alert("Hubo un error al generar el Excel. Revisa la consola.");
} }
} }

@ -5,22 +5,25 @@
*/ */
function convertToCSV(objArray) { function convertToCSV(objArray) {
const array = typeof objArray !== 'object' ? JSON.parse(objArray) : 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 if (!Array.isArray(array) || array.length === 0) {
for (let i = 0; i < array.length; i++) { throw new Error('No hay datos históricos disponibles para exportar.');
let line = '';
for (let index in array[i]) {
if (line != '') line += ',';
line += array[i][index];
}
str += line + '\r\n';
} }
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); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url);
console.log(`[EXITO] Archivo CSV descargado para ${sensorType}`); 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); console.error("[ERROR] Fallo al exportar el CSV:", error);
alert("Hubo un error al generar el archivo. Revisa la consola."); alert("Hubo un error al generar el archivo. Revisa la consola.");
} }
} }

@ -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 id="temperature-value">--</span>
<span class="metric-unit">&deg;C</span> <span class="metric-unit">&deg;C</span>
</div> </div>
<p class="metric-source">EZO-RTD &middot; API Local</p> <p class="metric-source">EZO-RTD &middot; data/EZORTD.json</p>
</article> </article>
<article class="metric-card" id="card-ph"> <article class="metric-card" id="card-ph">
@ -43,7 +43,7 @@
<span id="ph-value">--</span> <span id="ph-value">--</span>
<span class="metric-unit">pH</span> <span class="metric-unit">pH</span>
</div> </div>
<p class="metric-source">EZO-pH &middot; API Local</p> <p class="metric-source">EZO-pH &middot; data/EZOPH.json</p>
</article> </article>
<article class="metric-card" id="card-do"> <article class="metric-card" id="card-do">
@ -55,7 +55,7 @@
<span id="do-value">--</span> <span id="do-value">--</span>
<span class="metric-unit">mg/L</span> <span class="metric-unit">mg/L</span>
</div> </div>
<p class="metric-source">EZO-DO &middot; API Local</p> <p class="metric-source">EZO-DO &middot; data/EZODO.json</p>
</article> </article>
<article class="metric-card" id="card-ec"> <article class="metric-card" id="card-ec">
@ -67,7 +67,7 @@
<span id="ec-value">--</span> <span id="ec-value">--</span>
<span class="metric-unit">&micro;S/cm</span> <span class="metric-unit">&micro;S/cm</span>
</div> </div>
<p class="metric-source">EZO-EC &middot; API Local</p> <p class="metric-source">EZO-EC &middot; data/EZOEC.json</p>
</article> </article>
</section> </section>
@ -84,7 +84,7 @@
</div> </div>
<div class="status-item"> <div class="status-item">
<span class="status-label">Fuente de datos</span> <span class="status-label">Fuente de datos</span>
<strong>API REST Simulada</strong> <strong>Archivos JSON locales</strong>
</div> </div>
<div class="status-item"> <div class="status-item">
<span class="status-label">Sensores activos</span> <span class="status-label">Sensores activos</span>
@ -132,6 +132,7 @@
<section class="trends-panel" aria-label="Tendencias históricas"> <section class="trends-panel" aria-label="Tendencias históricas">
<div class="panel-heading"> <div class="panel-heading">
<h2>Tendencias Históricas</h2> <h2>Tendencias Históricas</h2>
<p>Actualización: <span id="history-interval">10 segundos</span></p>
</div> </div>
<div class="card config-card" style="margin-bottom: 20px; padding: 20px; border-left: 4px solid #9c27b0;"> <div class="card config-card" style="margin-bottom: 20px; padding: 20px; border-left: 4px solid #9c27b0;">
<h3>Configuración de Almacenamiento</h3> <h3>Configuración de Almacenamiento</h3>
@ -188,7 +189,7 @@
<article class="chart-card"> <article class="chart-card">
<div class="chart-heading"> <div class="chart-heading">
<h3>Temperatura vs Tiempo</h3> <h3>Temperatura vs Tiempo</h3>
<span>API/Historial</span> <span>logs/temperature.csv</span>
</div> </div>
<div class="chart-frame"> <div class="chart-frame">
<canvas id="temperature-chart"></canvas> <canvas id="temperature-chart"></canvas>
@ -199,7 +200,7 @@
<article class="chart-card"> <article class="chart-card">
<div class="chart-heading"> <div class="chart-heading">
<h3>pH vs Tiempo</h3> <h3>pH vs Tiempo</h3>
<span>API/Historial</span> <span>logs/ph.csv</span>
</div> </div>
<div class="chart-frame"> <div class="chart-frame">
<canvas id="ph-chart"></canvas> <canvas id="ph-chart"></canvas>
@ -210,7 +211,7 @@
<article class="chart-card"> <article class="chart-card">
<div class="chart-heading"> <div class="chart-heading">
<h3>Oxígeno Disuelto vs Tiempo</h3> <h3>Oxígeno Disuelto vs Tiempo</h3>
<span>API/Historial</span> <span>logs/do.csv</span>
</div> </div>
<div class="chart-frame"> <div class="chart-frame">
<canvas id="do-chart"></canvas> <canvas id="do-chart"></canvas>
@ -221,7 +222,7 @@
<article class="chart-card"> <article class="chart-card">
<div class="chart-heading"> <div class="chart-heading">
<h3>Conductividad vs Tiempo</h3> <h3>Conductividad vs Tiempo</h3>
<span>API/Historial</span> <span>logs/ec.csv</span>
</div> </div>
<div class="chart-frame"> <div class="chart-frame">
<canvas id="ec-chart"></canvas> <canvas id="ec-chart"></canvas>
@ -231,89 +232,74 @@
</div> </div>
</section> </section>
<div class="card terminal-card" style="margin-top: 20px; padding: 20px; border-left: 4px solid #00ffcc;"> <section class="hardware-panel" aria-label="Control de circuitos EZO">
<div class="card terminal-card" style="margin-top: 20px; padding: 20px; border-left: 4px solid #00ffcc;"> <div class="panel-heading">
<h3>Consola de Hardware (Atlas Scientific EZO)</h3> <h2>Control Atlas Scientific EZO</h2>
<p>Transporte: <strong id="ezo-transport-status">comprobando...</strong></p>
<div style="display: flex; gap: 10px; margin-bottom: 15px; align-items: center; flex-wrap: wrap;">
<select id="terminal-sensor-select" style="padding: 5px;">
<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>
</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>
</div> </div>
<div class="card calibration-card" style="margin-top: 20px; padding: 20px; border-left: 4px solid #ffcc00;"> <div class="hardware-grid">
<h3>Panel de Calibración</h3> <div class="card terminal-card">
<h3>Consola de Comandos</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;"> <div class="control-row">
<div> <select id="terminal-sensor-select">
<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;">
<option value="rtd">Temperatura (RTD)</option> <option value="rtd">Temperatura (RTD)</option>
<option value="ph">Sensor de pH</option> <option value="ph">Sensor de pH</option>
<option value="do">Oxígeno Disuelto (DO)</option> <option value="do">Oxígeno Disuelto (DO)</option>
<option value="ec">Conductividad (EC)</option> <option value="ec">Conductividad (EC)</option>
</select> </select>
<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 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 style="margin-bottom: 15px;"> <div class="card calibration-card">
<label style="display: block; margin-bottom: 5px;">Comandos Globales de Calibración:</label> <div class="calibration-heading">
<button class="btn-command" onclick="sendCalibrationCmd('cal,?')" <div>
style="padding: 5px 10px; margin-right: 5px; cursor: pointer;">Verificar Estado <h3>Panel de Calibración</h3>
(?)</button> <p id="calibration-description"></p>
<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>
<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> </div>
<div style="background-color: #f5f5f5; padding: 15px; border-radius: 4px;"> <div class="calibration-toolbar">
<h4 style="margin-top: 0; color: #333;">Calibración Multipunto</h4> <button class="btn-command" onclick="sendCalibrationCommand('Cal,?')">
<p style="font-size: 0.9em; color: #666;">Ingresa el valor de referencia físico (ej. solución Ver estado
pH, o temperatura del agua).</p> </button>
<button class="btn-command danger" onclick="clearCalibration()">
<div style="display: flex; gap: 10px; align-items: center;"> Borrar calibración
<input type="number" id="cal-value" placeholder="Ej: 7.00" step="0.01" </button>
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> </div>
<div id="calibration-controls"></div>
<p class="calibration-note" id="calibration-note"></p>
</div> </div>
</div> </div>
</section>
</main> </main>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <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="https://cdn.sheetjs.com/xlsx-latest/package/dist/xlsx.full.min.js"></script>
<script src="dashboard.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-service.js"></script>
<script src="export-excel.js"></script> <script src="export-excel.js"></script>
</body> </body>
</html> </html>

@ -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.", "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", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "start": "node api/server.js",
"test": "node --test"
}, },
"keywords": [], "keywords": [],
"author": "", "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 CC=gcc
CFLAGS=-I. CFLAGS=-I.
OBJ=main.o sensor.o OBJ=main.o ezodo.o
TARGET=sensor TARGET=EZODO
%.o: %.c %.o: %.c
$(CC) -c -o $@ $< $(CFLAGS) $(CC) -c -o $@ $< $(CFLAGS)
@ -12,4 +12,4 @@ $(TARGET): $(OBJ)
$(CC) -o $@ $^ $(CFLAGS) $(CC) -o $@ $^ $(CFLAGS)
clean: clean:
rm -f *.o $(TARGET) rm -f *.o $(TARGET)

@ -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 CC=gcc
CFLAGS=-I. CFLAGS=-I.
OBJ=main.o sensor.o OBJ=main.o ezoec.o
TARGET=sensor TARGET=EZOEC
%.o: %.c %.o: %.c
$(CC) -c -o $@ $< $(CFLAGS) $(CC) -c -o $@ $< $(CFLAGS)
@ -12,4 +12,4 @@ $(TARGET): $(OBJ)
$(CC) -o $@ $^ $(CFLAGS) $(CC) -o $@ $^ $(CFLAGS)
clean: clean:
rm -f *.o $(TARGET) rm -f *.o $(TARGET)

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

@ -1,9 +1,9 @@
CC=gcc CC=gcc
CFLAGS=-I. CFLAGS=-I.
OBJ=main.o sensor.o OBJ=main.o ezoph.o
TARGET=sensor TARGET=EZOPH
%.o: %.c %.o: %.c
$(CC) -c -o $@ $< $(CFLAGS) $(CC) -c -o $@ $< $(CFLAGS)
@ -12,4 +12,4 @@ $(TARGET): $(OBJ)
$(CC) -o $@ $^ $(CFLAGS) $(CC) -o $@ $^ $(CFLAGS)
clean: clean:
rm -f *.o $(TARGET) rm -f *.o $(TARGET)

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

@ -1,6 +1,7 @@
#include <stdio.h> #include <stdio.h>
#include <unistd.h> #include <unistd.h>
#include <fcntl.h> #include <fcntl.h>
#include <sys/file.h>
#include <time.h> #include <time.h>
#include "ezortd.h" #include "ezortd.h"
@ -20,8 +21,10 @@ int main()
while (1) while (1)
{ {
double temperature; 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( FILE *f = fopen(
"/home/cristian/airquality/basic-ui-dashboard/data/EZORTD.json", "/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); 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