diff --git a/.gitignore b/.gitignore index 013bd77..dc75359 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ build/ bin/ obj/ +node_modules/ # Archivos de configuración locales *.code-workspace @@ -15,7 +16,9 @@ obj/ *.bin *.elf *.map +sensors/EZOCommand/EZO_COMMAND +sensors/EZOCommand/EZO_ACQUIRE # Archivos de depuración y logs *.log -*.tmp \ No newline at end of file +*.tmp diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 9f939bc..73a4746 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -2,76 +2,64 @@ ## 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. +El dashboard integra monitoreo en vivo, alarmas configurables, tendencias, +exportación CSV/Excel, consola EZO y calibración. La adquisición puede operar en +modo demo o en Raspberry Pi mediante `/dev/i2c-1`. -## Funciones operativas +## Arquitectura operativa -- 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 | +| Componente | Responsabilidad | |---|---| -| 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. +| `api/server.js` | API, históricos, configuración y comandos EZO | +| `api/acquisition.js` | Ciclo continuo de adquisición | +| `api/acquisition-service.js` | Escritura atómica de JSON/CSV y configuración | +| `sensors/EZOCommand/EZO_ACQUIRE` | Lectura agrupada de los cuatro EZO | +| `sensors/EZOCommand/EZO_COMMAND` | Comandos y calibración de un circuito | +| `frontend/` | Dashboard, gráficas, alarmas, exportación y consola | + +Los dos helpers C usan `/tmp/photobioreactor-i2c.lock`. El recolector inicia la +conversión de los cuatro sensores antes de esperar, por lo que comparte una sola +ventana de procesamiento en vez de bloquear un segundo por sensor. + +## Funciones implementadas + +- Lecturas de RTD, pH, DO y EC con publicación en `data/*.json`. +- Históricos normalizados como `logs/temperature.csv`, `ph.csv`, `do.csv` y + `ec.csv`, todos con formato `timestamp,value`. +- Escritura atómica de JSON y estado `online: false` ante fallos globales o + lecturas individuales inválidas. +- Detección de datos obsoletos con tolerancia proporcional a la frecuencia. +- Frecuencia persistente de 1, 5, 10 o 60 segundos en `config/runtime.json`. +- Exportaciones alimentadas por CSV reales, sin generación aleatoria en la API. +- Verificación automática `Cal,?` después de una calibración enviada desde web. +- Chart.js 4.5.1 y SheetJS 0.20.3 instalados localmente. +- Servicios systemd, configuración Nginx e instalador para Raspberry Pi. + +## Modos + +- `EZO_MODE=demo`: adquisición y comandos simulados. +- `EZO_MODE=auto`: hardware cuando existen el bus y los helpers; demo en otro + caso. +- `EZO_MODE=hardware`: producción estricta; un despliegue incompleto falla y no + genera valores simulados. + +## Verificación realizada + +- 16 pruebas automáticas aprobadas. +- Validación sintáctica de todos los JavaScript modificados. +- Dashboard, Chart.js, SheetJS y comando pH comprobados por HTTP en modo demo. +- `npm audit --omit=dev`: cero vulnerabilidades conocidas. + +La compilación ARM, el bus I2C, la estabilidad de las sondas y las calibraciones +metrológicas solo pueden verificarse en la Raspberry Pi con hardware real. + +## Trabajo pendiente en hardware + +1. Ejecutar `i2cdetect -y 1` y confirmar `0x61`, `0x63`, `0x64` y `0x66`. +2. Compilar ambos helpers con `make -C sensors/EZOCommand`. +3. Validar `i`, `Status`, `R` y `Cal,?` individualmente. +4. Realizar las calibraciones con soluciones de referencia. +5. Probar desconexiones, reinicio automático y operación continua de varias + horas. + +Consulte `docs/RASPBERRY_PI_DEPLOYMENT.md` para el procedimiento completo. diff --git a/README.md b/README.md index ded8aab..7ad1b12 100644 --- a/README.md +++ b/README.md @@ -1,234 +1,101 @@ -# Sistema de Monitoreo de Parámetros Fisicoquímicos para Fotobiorreactor +# Photobioreactor Dashboard -**Plataforma de hardware:** Raspberry Pi 4 Model B -**Estado del proyecto:** Desarrollo activo — Integración de hardware en curso (Fase 9) -**Pila tecnológica:** C, Node.js, Express, Vanilla JavaScript, Nginx -**Protocolo de comunicación físico:** I²C (Inter-Integrated Circuit), 100 kHz — 400 kHz +Sistema de monitoreo para Raspberry Pi y circuitos Atlas Scientific EZO: ---- - -## 1. Resumen Ejecutivo - -Este repositorio documenta la arquitectura de software y las especificaciones de diseño de hardware de un sistema de adquisición de datos (DAQ) en tiempo real orientado a fotobiorreactores de escala de laboratorio. El proyecto se inició como un sistema de medición de temperatura de precisión basado en el módulo **EZO-RTD™** de Atlas Scientific y una sonda de platino PT1000; sin embargo, ha evolucionado en una plataforma integral de monitoreo que gestiona de forma simultánea cuatro parámetros fisicoquímicos críticos para el control de cultivos fotosintéticos. - -Los cuatro parámetros monitoreados y los módulos OEM asociados son los siguientes: - -| Parámetro | Módulo EZO | Dirección I²C | Unidad de medida | +| Variable | Circuito | Dirección | Unidad | |---|---|---|---| -| Temperatura | EZO-RTD™ (ISCCB-2) | `0x66` | °C | -| Potencial de Hidrógeno | EZO-pH™ | `0x63` | pH | -| Oxígeno Disuelto | EZO-DO™ | `0x61` | mg/L | -| Conductividad Eléctrica | EZO-EC™ | `0x64` | µS/cm | - -La arquitectura del sistema implementa una topología de red distribuida localmente, separando de forma explícita las responsabilidades en cuatro capas: presentación (frontend), enrutamiento (Nginx), lógica de negocio y API (Node.js/Express) y adquisición de datos en hardware (demonios en C). Este diseño por capas garantiza la extensibilidad del sistema y permite la sustitución o incorporación de nuevos módulos sensores sin alterar la lógica de presentación. - ---- - -## 2. Pila Tecnológica - -### 2.1. Capa de Presentación (Frontend) - -- **Lenguaje:** Vanilla JavaScript ES6+, HTML5, CSS3 puro -- **Biblioteca de visualización:** Chart.js (entregada vía CDN) -- **Biblioteca de exportación tabular:** SheetJS (`xlsx.full.min.js`, vía CDN) -- **Ciclo de actualización de lecturas en vivo:** 1000 ms (intervalo de *polling*) -- **Ciclo de actualización de tendencias históricas:** 10 000 ms -- **Idioma de la interfaz:** Español neutro +| Temperatura | EZO-RTD | `0x66` | °C | +| pH | EZO-pH | `0x63` | pH | +| Oxígeno disuelto | EZO-DO | `0x61` | mg/L | +| Conductividad | EZO-EC | `0x64` | µS/cm | -### 2.2. Capa de Enrutamiento y Proxy +El sistema incluye dashboard responsive, históricos Chart.js, alarmas, +exportación CSV/Excel, consola de comandos y panel de calibración. -- **Servidor:** Nginx -- **Puerto de entrada:** `8888` -- **Función:** Entrega de archivos estáticos del frontend y proxy inverso transparente hacia el puerto `3000` para el prefijo `/api/` - -### 2.3. Capa de Lógica de Negocio y API - -- **Entorno de ejecución:** Node.js (≥ v18) -- **Framework HTTP:** Express v5 -- **Puerto de escucha:** `3000` -- **Dependencias de producción:** `express ^5.2.1`, `cors ^2.8.6` - -### 2.4. Capa de Adquisición de Datos (Hardware) - -- **Lenguaje:** C (estándar C99) -- **Interfaz de hardware:** Bus I²C del sistema operativo Linux mediante `/dev/i2c-1` -- **Encabezados del sistema utilizados:** ``, `` -- **Sistema de construcción:** GNU Make - ---- - -## 3. Estructura del Directorio Fuente +## Arquitectura ```text -/ -├── api/ -│ └── server.js # Servidor Express: endpoints REST y parser léxico EZO -│ -├── config/ -│ ├── alarms.json # Umbrales operativos configurables por variable -│ └── sensors.json # Metadatos declarativos de los módulos EZO (dirección I²C, habilitación) -│ -├── data/ -│ ├── EZORTD.json # Vector de estado actual: temperatura (escrito por el demonio C) -│ ├── EZOPH.json # Vector de estado actual: pH -│ ├── EZODO.json # Vector de estado actual: oxígeno disuelto -│ └── EZOEC.json # Vector de estado actual: conductividad eléctrica -│ -├── frontend/ -│ ├── 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 -│ ├── 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 -│ -├── logs/ -│ ├── temperature.csv # Historial persistente de temperatura (escrito por el demonio) -│ ├── ph.csv # Historial persistente de pH -│ ├── do.csv # Historial persistente de oxígeno disuelto -│ └── ec.csv # Historial persistente de conductividad eléctrica -│ -├── sensors/ -│ ├── EZORTD/ -│ │ ├── ezortd.h # API pública: constante de dirección I²C y firma de getTemperature() -│ │ ├── ezortd.c # Implementación del protocolo I²C para el EZO-RTD -│ │ ├── main.c # Ejecutable de lectura única (one-shot), salida JSON a stdout -│ │ ├── ezortd_daemon.c # Demonio de lectura continua: escribe JSON y appends CSV -│ │ └── Makefile # Sistema de construcción del módulo RTD -│ │ -│ ├── EZOPH/ -│ │ ├── ezoph.h # API pública del módulo pH -│ │ ├── ezoph.c # Implementación del protocolo I²C para el EZO-pH -│ │ ├── main.c # Ejecutable de lectura única -│ │ └── Makefile -│ │ -│ ├── EZODO/ -│ │ ├── ezodo.h # API pública del módulo DO -│ │ ├── ezodo.c # Implementación del protocolo I²C para el EZO-DO -│ │ ├── main.c # Ejecutable de lectura única -│ │ └── Makefile -│ │ -│ └── EZOEC/ -│ ├── ezoec.h # API pública del módulo EC -│ ├── ezoec.c # Implementación del protocolo I²C para el EZO-EC -│ ├── main.c # Ejecutable de lectura única -│ └── Makefile -│ -├── package.json # Manifiesto de dependencias Node.js -├── package-lock.json # Árbol de dependencias resuelto y bloqueado -├── README.md # Este documento -├── ARCHITECTURE.md # Especificación técnica de la arquitectura del sistema -└── PROJECT_STATUS.md # Estado de hitos, riesgos y fases pendientes +EZO por I2C + | +EZO_ACQUIRE / EZO_COMMAND + | +Recolector Node ---------> data/*.json + | logs/*.csv + | +API Express <------------ Dashboard + ^ + | +Nginx ``` ---- - -## 4. Instrucciones de Despliegue - -### 4.1. Requisitos Previos +`EZO_ACQUIRE` obtiene las cuatro lecturas en un ciclo agrupado. +`EZO_COMMAND` ejecuta comandos interactivos y calibraciones. Ambos comparten el +bloqueo `/tmp/photobioreactor-i2c.lock`. -- Node.js v18 o superior instalado en el sistema anfitrión -- Nginx instalado (`sudo apt install nginx` en sistemas Debian/Ubuntu) -- Acceso al directorio raíz del repositorio - -### 4.2. Inicialización del Servidor de Backend (Node.js) - -Desde el directorio raíz del repositorio, instalar las dependencias de producción y levantar el servidor: +## Desarrollo sin sensores ```bash -npm install -node api/server.js +npm ci +npm test ``` -El servidor quedará escuchando en `http://localhost:3000`. Verificar el inicio exitoso con el mensaje: - -``` -[MOCK SERVER] Backend Node.js corriendo en http://localhost:3000 -``` +Ejecute en dos terminales: -Para desarrollo local, abrir directamente: - -```text -http://localhost:3000/frontend/index.html +```bash +EZO_MODE=demo npm run acquire +EZO_MODE=demo npm start ``` -Express sirve las carpetas necesarias para el dashboard. Nginx sigue siendo la -opcion recomendada para el despliegue final en Raspberry Pi. +Abra `http://localhost:3000/frontend/index.html`. -Para ejecución persistente en segundo plano se recomienda el gestor de procesos `pm2`: +En PowerShell: -```bash -npm install -g pm2 -pm2 start api/server.js --name fotobiorreactor-api -pm2 save +```powershell +$env:EZO_MODE = "demo" +npm run acquire ``` -### 4.3. Configuración del Proxy Inverso Nginx - -Crear o editar el bloque de servidor activo de Nginx. En sistemas Debian-based, el archivo de configuración canónico es `/etc/nginx/sites-available/fotobiorreactor`: - -```nginx -server { - listen 8888; - server_name localhost; - - # Raíz del contenido estático: directorio raíz del repositorio - root /ruta/absoluta/al/repositorio; - index frontend/index.html; - - # Entrega de archivos estáticos del frontend - location / { - try_files $uri $uri/ =404; - } - - # Proxy inverso transparente hacia el backend Node.js - location /api/ { - proxy_pass http://127.0.0.1:3000; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } -} -``` - -Habilitar el sitio y recargar el servicio: +Y en una segunda terminal: -```bash -sudo ln -s /etc/nginx/sites-available/fotobiorreactor /etc/nginx/sites-enabled/ -sudo nginx -t -sudo systemctl reload nginx +```powershell +$env:EZO_MODE = "demo" +npm start ``` -### 4.4. Acceso al Cliente +## Raspberry Pi -Abrir un navegador web y dirigirse a: +El despliegue de producción usa `EZO_MODE=hardware`, systemd y Nginx: -``` -http://localhost:8888/frontend/index.html +```bash +chmod +x scripts/install-raspberry-pi.sh +sudo ./scripts/install-raspberry-pi.sh ``` -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 guía de preparación, detección I2C, calibración y prueba integral está en +[`docs/RASPBERRY_PI_DEPLOYMENT.md`](docs/RASPBERRY_PI_DEPLOYMENT.md). -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`. +## Archivos de datos -### 4.5. Construcción de los Demonios en C (Hardware Real) +- Lecturas actuales: `data/EZORTD.json`, `EZOPH.json`, `EZODO.json`, + `EZOEC.json`. +- Históricos: `logs/temperature.csv`, `ph.csv`, `do.csv`, `ec.csv`. +- Umbrales: `config/alarms.json`. +- Frecuencia de adquisición: `config/runtime.json`. -Para compilar los controladores de hardware en la Raspberry Pi, ejecutar el siguiente procedimiento por módulo sensor (se ilustra con el módulo RTD): +Los CSV usan una única nomenclatura y el formato: -```bash -cd sensors/EZORTD -make +```csv +timestamp,value +2026-06-22T12:00:00.000Z,25.123 ``` -Esto generará el ejecutable `EZORTD`. Antes de compilar el demonio continuo (`ezortd_daemon.c`), actualizar las rutas absolutas codificadas en el código fuente para que correspondan al directorio de despliegue real en la Raspberry Pi. +## Documentación -**Nota:** El bus I²C debe estar habilitado en la Raspberry Pi mediante `sudo raspi-config` → *Interface Options* → *I2C* → *Enable*. Se recomienda agregar el usuario de ejecución al grupo `i2c` para evitar la ejecución con privilegios de superusuario: +- Estado actual: [`PROJECT_STATUS.md`](PROJECT_STATUS.md) +- Comandos y calibración: [`docs/EZO_COMMANDS.md`](docs/EZO_COMMANDS.md) +- Despliegue: [`docs/RASPBERRY_PI_DEPLOYMENT.md`](docs/RASPBERRY_PI_DEPLOYMENT.md) +- Diseño extendido: [`ARCHITECTURE.md`](ARCHITECTURE.md) -```bash -sudo usermod -aG i2c $USER -``` +No se utiliza un driver personalizado del kernel. Linux ya proporciona la capa +I2C mediante `/dev/i2c-1`; el protocolo ASCII, la adquisición y la calibración +se mantienen en espacio de usuario para facilitar mantenimiento y diagnóstico. diff --git a/api/acquisition-service.js b/api/acquisition-service.js new file mode 100644 index 0000000..b90df43 --- /dev/null +++ b/api/acquisition-service.js @@ -0,0 +1,222 @@ +const fs = require('node:fs'); +const fsPromises = require('node:fs/promises'); +const path = require('node:path'); +const { execFile } = require('node:child_process'); +const { promisify } = require('node:util'); + +const execFileAsync = promisify(execFile); + +const ROOT_DIRECTORY = path.join(__dirname, '..'); +const DATA_DIRECTORY = process.env.DATA_DIRECTORY + ? path.resolve(process.env.DATA_DIRECTORY) + : path.join(ROOT_DIRECTORY, 'data'); +const LOGS_DIRECTORY = process.env.LOGS_DIRECTORY + ? path.resolve(process.env.LOGS_DIRECTORY) + : path.join(ROOT_DIRECTORY, 'logs'); +const RUNTIME_CONFIG_FILE = process.env.RUNTIME_CONFIG_FILE + ? path.resolve(process.env.RUNTIME_CONFIG_FILE) + : path.join(ROOT_DIRECTORY, 'config', 'runtime.json'); +const ACQUISITION_HELPER = process.env.EZO_ACQUIRE_HELPER + ? path.resolve(process.env.EZO_ACQUIRE_HELPER) + : path.join(ROOT_DIRECTORY, 'sensors', 'EZOCommand', 'EZO_ACQUIRE'); + +const SENSOR_FILES = { + temperature: { + json: 'EZORTD.json', + csv: 'temperature.csv', + key: 'temperature' + }, + ph: { json: 'EZOPH.json', csv: 'ph.csv', key: 'ph' }, + do: { json: 'EZODO.json', csv: 'do.csv', key: 'do' }, + ec: { json: 'EZOEC.json', csv: 'ec.csv', key: 'ec' } +}; + +const ALLOWED_LOGGING_RATES = new Set([1, 5, 10, 60]); + +async function atomicWriteFile(filePath, content) { + await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); + const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + await fsPromises.writeFile(temporaryPath, content, 'utf8'); + await fsPromises.rename(temporaryPath, filePath); +} + +async function readRuntimeConfig() { + try { + const rawConfig = await fsPromises.readFile(RUNTIME_CONFIG_FILE, 'utf8'); + const config = JSON.parse(rawConfig); + const rate = Number(config.loggingRateSeconds); + + return { + loggingRateSeconds: ALLOWED_LOGGING_RATES.has(rate) ? rate : 1 + }; + } catch { + return { loggingRateSeconds: 1 }; + } +} + +async function writeRuntimeConfig(rate) { + const numericRate = Number(rate); + + if (!ALLOWED_LOGGING_RATES.has(numericRate)) { + const error = new Error('Frecuencia no válida.'); + error.status = 400; + throw error; + } + + const config = { + loggingRateSeconds: numericRate, + updatedAt: new Date().toISOString() + }; + await atomicWriteFile( + RUNTIME_CONFIG_FILE, + `${JSON.stringify(config, null, 2)}\n` + ); + return config; +} + +function detectAcquisitionMode() { + const requestedMode = String(process.env.EZO_MODE || 'auto').toLowerCase(); + const hardwareReady = process.platform === 'linux' && + fs.existsSync('/dev/i2c-1') && + fs.existsSync(ACQUISITION_HELPER); + + if (requestedMode === 'hardware' && !hardwareReady) { + return { + mode: 'unavailable', + message: 'Se solicitó hardware, pero /dev/i2c-1 o EZO_ACQUIRE no está disponible.' + }; + } + + if (requestedMode === 'hardware' || (requestedMode === 'auto' && hardwareReady)) { + return { mode: 'hardware', message: 'Adquisición I2C real activa.' }; + } + + return { mode: 'demo', message: 'Adquisición de demostración activa.' }; +} + +function buildDemoReadings() { + return { + temperature: Number((25 + Math.random() * 0.1 - 0.05).toFixed(3)), + ph: Number((7.2 + Math.random() * 0.04 - 0.02).toFixed(3)), + do: Number((8.5 + Math.random() * 0.1 - 0.05).toFixed(3)), + ec: Number((1050 + Math.random() * 10 - 5).toFixed(1)) + }; +} + +async function readHardwareSensors() { + const { stdout } = await execFileAsync( + ACQUISITION_HELPER, + ['/dev/i2c-1'], + { timeout: 5000, windowsHide: true } + ); + const readings = JSON.parse(stdout.trim()); + + if (!readings || typeof readings !== 'object') { + throw new Error('EZO_ACQUIRE devolvió una respuesta inválida.'); + } + + return readings; +} + +async function appendReading(filePath, timestamp, value) { + await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); + let needsHeader = false; + + try { + const stats = await fsPromises.stat(filePath); + needsHeader = stats.size === 0; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + needsHeader = true; + } + + const row = `${timestamp},${value}\n`; + await fsPromises.appendFile( + filePath, + `${needsHeader ? 'timestamp,value\n' : ''}${row}`, + 'utf8' + ); +} + +async function publishReading(sensorId, rawValue, timestamp, mode) { + const sensor = SENSOR_FILES[sensorId]; + const value = Number(rawValue); + const jsonPath = path.join(DATA_DIRECTORY, sensor.json); + + if (!Number.isFinite(value)) { + await atomicWriteFile(jsonPath, `${JSON.stringify({ + online: false, + timestamp, + error: 'Lectura no disponible' + })}\n`); + return false; + } + + await Promise.all([ + atomicWriteFile(jsonPath, `${JSON.stringify({ + [sensor.key]: value, + online: true, + timestamp, + mode + })}\n`), + appendReading(path.join(LOGS_DIRECTORY, sensor.csv), timestamp, value) + ]); + return true; +} + +async function collectReadings() { + const modeInfo = detectAcquisitionMode(); + + if (modeInfo.mode === 'unavailable') { + throw new Error(modeInfo.message); + } + + const readings = modeInfo.mode === 'hardware' + ? await readHardwareSensors() + : buildDemoReadings(); + const timestamp = new Date().toISOString(); + const results = await Promise.all( + Object.keys(SENSOR_FILES).map(async (sensorId) => ({ + sensorId, + online: await publishReading( + sensorId, + readings[sensorId], + timestamp, + modeInfo.mode + ) + })) + ); + + return { mode: modeInfo.mode, timestamp, results }; +} + +async function publishAllOffline(error) { + const timestamp = new Date().toISOString(); + const message = error instanceof Error ? error.message : String(error); + + await Promise.all( + Object.values(SENSOR_FILES).map((sensor) => + atomicWriteFile( + path.join(DATA_DIRECTORY, sensor.json), + `${JSON.stringify({ + online: false, + timestamp, + error: message + })}\n` + ) + ) + ); +} + +module.exports = { + ACQUISITION_HELPER, + ALLOWED_LOGGING_RATES, + RUNTIME_CONFIG_FILE, + SENSOR_FILES, + atomicWriteFile, + collectReadings, + detectAcquisitionMode, + publishAllOffline, + readRuntimeConfig, + writeRuntimeConfig +}; diff --git a/api/acquisition.js b/api/acquisition.js new file mode 100644 index 0000000..46389fe --- /dev/null +++ b/api/acquisition.js @@ -0,0 +1,46 @@ +const { + collectReadings, + publishAllOffline, + readRuntimeConfig +} = require('./acquisition-service'); + +let stopping = false; + +function wait(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function run() { + console.log('[ACQUISITION] Recolector iniciado.'); + + while (!stopping) { + const startedAt = Date.now(); + + try { + const result = await collectReadings(); + const onlineCount = result.results.filter((item) => item.online).length; + console.log( + `[ACQUISITION] ${result.timestamp} ${result.mode}: ${onlineCount}/4 sensores.` + ); + } catch (error) { + console.error(`[ACQUISITION] ${error.message}`); + await publishAllOffline(error); + } + + const config = await readRuntimeConfig(); + const elapsed = Date.now() - startedAt; + await wait(Math.max(100, config.loggingRateSeconds * 1000 - elapsed)); + } +} + +function stop() { + stopping = true; +} + +process.on('SIGINT', stop); +process.on('SIGTERM', stop); + +run().catch((error) => { + console.error('[ACQUISITION] Error fatal:', error); + process.exitCode = 1; +}); diff --git a/api/server.js b/api/server.js index 42f0949..dd27a31 100644 --- a/api/server.js +++ b/api/server.js @@ -1,23 +1,35 @@ -// api/server.js const express = require('express'); const cors = require('cors'); -const fs = require('fs/promises'); -const path = require('path'); +const fs = require('node:fs/promises'); +const path = require('node:path'); const { detectMode, executeEzoCommand } = require('./ezo-command-service'); +const { + detectAcquisitionMode, + readRuntimeConfig, + writeRuntimeConfig +} = require('./acquisition-service'); + const app = express(); 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 HOST = process.env.HOST || '127.0.0.1'; const LOGS_DIRECTORY = process.env.LOGS_DIRECTORY ? path.resolve(process.env.LOGS_DIRECTORY) : path.join(__dirname, '..', 'logs'); +const HISTORY_FILES = { + temperature: 'timestamp,value\n', + ph: 'timestamp,value\n', + do: 'timestamp,value\n', + ec: 'timestamp,value\n' +}; +const HISTORY_SENSOR_MAP = { + rtd: { file: 'temperature.csv', name: 'RTD' }, + ph: { file: 'ph.csv', name: 'PH' }, + do: { file: 'do.csv', name: 'DO' }, + ec: { file: 'ec.csv', name: 'EC' } +}; app.use(cors()); app.use(express.json()); @@ -34,68 +46,100 @@ app.use('/config', express.static(path.join(__dirname, '..', 'config'), { etag: false, maxAge: 0 })); +app.get('/vendor/chart.js', (req, res) => { + res.sendFile(path.join( + __dirname, + '..', + 'node_modules', + 'chart.js', + 'dist', + 'chart.umd.js' + )); +}); +app.get('/vendor/xlsx.js', (req, res) => { + res.sendFile(path.join( + __dirname, + '..', + 'node_modules', + 'xlsx', + 'dist', + 'xlsx.full.min.js' + )); +}); app.get('/', (req, res) => { res.redirect('/frontend/index.html'); }); app.get('/api/system/ezo', (req, res) => { - const mode = detectMode(); + const commandMode = detectMode(); + const acquisitionMode = detectAcquisitionMode(); + res.json({ - mode: mode.mode, - message: mode.message + mode: commandMode.mode, + message: commandMode.message, + acquisitionMode: acquisitionMode.mode, + acquisitionMessage: acquisitionMode.message }); }); -// Función auxiliar para simular latencia -const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); - -// Endpoint principal para obtener datos de los sensores (MOCK) -app.get('/api/sensors/:type', async (req, res) => { - const sensorType = req.params.type; - console.log(`[GET] Petición recibida para sensor: ${sensorType}`); - - // Simulación de latencia I2C y procesamiento (400ms) - await delay(400); +function parseHistoryCsv(csvText, sensorName) { + return csvText + .split(/\r?\n/) + .slice(1) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const separatorIndex = line.indexOf(','); + + if (separatorIndex < 0) return null; + const timestamp = line.slice(0, separatorIndex).trim(); + const value = Number(line.slice(separatorIndex + 1).trim()); + + if (!timestamp || !Number.isFinite(value)) return null; + return { Timestamp: timestamp, Sensor: sensorName, Valor: value }; + }) + .filter(Boolean); +} - const data = []; - const now = new Date(); +async function readSensorHistory(sensorType) { + const sensor = HISTORY_SENSOR_MAP[sensorType]; + const csvText = await fs.readFile( + path.join(LOGS_DIRECTORY, sensor.file), + 'utf8' + ); + return parseHistoryCsv(csvText, sensor.name); +} - // Generamos 60 registros simulados - for (let i = 60; i >= 0; i--) { - const timestamp = new Date(now.getTime() - i * 1000).toISOString(); +app.get('/api/sensors/:type', async (req, res) => { + const sensorType = req.params.type.toLowerCase(); - // Simulación de valores con ruido térmico/químico - const mockValues = { - rtd: (25.0 + (Math.random() * 0.1 - 0.05)).toFixed(2), - ph: (7.2 + (Math.random() * 0.04 - 0.02)).toFixed(2), - do: (8.5 + (Math.random() * 0.1 - 0.05)).toFixed(2), - ec: (1050 + (Math.random() * 10 - 5)).toFixed(0) - }; + if (sensorType !== 'all' && !HISTORY_SENSOR_MAP[sensorType]) { + return res.status(400).json({ error: 'Sensor no válido.' }); + } + try { if (sensorType === 'all') { - data.push({ - Timestamp: timestamp, - Temperatura_C: mockValues.rtd, - pH: mockValues.ph, - DO_mgL: mockValues.do, - EC_uS: mockValues.ec - }); - } else if (mockValues[sensorType]) { - data.push({ - Timestamp: timestamp, - Sensor: sensorType.toUpperCase(), - Valor: mockValues[sensorType] - }); - } else { - return res.status(400).json({ error: "Sensor no válido" }); + const histories = await Promise.all( + Object.keys(HISTORY_SENSOR_MAP).map(readSensorHistory) + ); + return res.json( + histories.flat().sort((left, right) => + String(left.Timestamp).localeCompare(String(right.Timestamp)) + ) + ); } - } - res.json(data); + return res.json(await readSensorHistory(sensorType)); + } catch (error) { + if (error.code === 'ENOENT') return res.json([]); + console.error('[HISTORY] No se pudo leer el historial:', error); + return res.status(500).json({ + error: 'No se pudo leer el historial.' + }); + } }); -// Consola EZO: usa I2C real en Raspberry Pi y modo demo en otros equipos. app.post('/api/sensors/:type/command', async (req, res) => { try { const result = await executeEzoCommand( @@ -112,44 +156,50 @@ app.post('/api/sensors/:type/command', async (req, res) => { } }); -// --- 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) => { - const requestedRate = Number(req.body.rate); - const allowedRates = new Set([1, 5, 10, 60]); +app.get('/api/config/logging', async (req, res) => { + const config = await readRuntimeConfig(); + res.json({ success: true, rate: config.loggingRateSeconds }); +}); - if (!allowedRates.has(requestedRate)) { - return res.status(400).json({ success: false, error: 'Frecuencia no válida' }); +app.post('/api/config/logging', async (req, res) => { + try { + const config = await writeRuntimeConfig(req.body.rate); + console.log( + `[CONFIG] Frecuencia de adquisición: ${config.loggingRateSeconds}s` + ); + res.json({ + success: true, + rate: config.loggingRateSeconds, + message: 'La frecuencia será aplicada por el recolector en el siguiente ciclo.' + }); + } catch (error) { + res.status(error.status || 500).json({ + success: false, + error: error.message + }); } - - loggingRateSeconds = requestedRate; - console.log(`[SIMULACIÓN] Frecuencia configurada a: 1 registro cada ${loggingRateSeconds}s`); - res.json({ - success: true, - simulated: true, - rate: loggingRateSeconds, - message: 'Configuración aplicada únicamente al backend simulado.' - }); }); app.post('/api/history/clear', async (req, res) => { try { + await fs.mkdir(LOGS_DIRECTORY, { recursive: true }); await Promise.all( Object.entries(HISTORY_FILES).map(([name, header]) => - fs.writeFile(path.join(LOGS_DIRECTORY, `${name}.csv`), header, 'utf8') + fs.writeFile( + path.join(LOGS_DIRECTORY, `${name}.csv`), + header, + 'utf8' + ) ) ); - console.log('[SISTEMA] Archivos CSV históricos truncados.'); + console.log('[HISTORY] Archivos CSV truncados.'); res.json({ success: true, message: 'Archivos de registro truncados correctamente.' }); } catch (error) { - console.error('[ERROR] No se pudieron truncar los historiales:', error); + console.error('[HISTORY] No se pudieron truncar los historiales:', error); res.status(500).json({ success: false, error: 'No se pudieron borrar los archivos históricos.' @@ -158,12 +208,13 @@ app.post('/api/history/clear', async (req, res) => { }); if (require.main === module) { - app.listen(PORT, () => { - console.log(`[MOCK SERVER] Backend Node.js corriendo en http://localhost:${PORT}`); + app.listen(PORT, HOST, () => { + console.log(`[API] Backend Node.js activo en http://${HOST}:${PORT}`); }); } module.exports = { app, - HISTORY_FILES + HISTORY_FILES, + parseHistoryCsv }; diff --git a/config/runtime.json b/config/runtime.json new file mode 100644 index 0000000..e136fcb --- /dev/null +++ b/config/runtime.json @@ -0,0 +1,3 @@ +{ + "loggingRateSeconds": 1 +} diff --git a/deployment/nginx-photobioreactor.conf b/deployment/nginx-photobioreactor.conf new file mode 100644 index 0000000..355a029 --- /dev/null +++ b/deployment/nginx-photobioreactor.conf @@ -0,0 +1,47 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + root /opt/photobioreactor; + index frontend/index.html; + + location = / { + return 302 /frontend/index.html; + } + + location /frontend/ { + try_files $uri =404; + } + + location /data/ { + add_header Cache-Control "no-store"; + try_files $uri =404; + } + + location /logs/ { + add_header Cache-Control "no-store"; + try_files $uri =404; + } + + location /config/ { + add_header Cache-Control "no-store"; + try_files $uri =404; + } + + location = /vendor/chart.js { + alias /opt/photobioreactor/node_modules/chart.js/dist/chart.umd.js; + } + + location = /vendor/xlsx.js { + alias /opt/photobioreactor/node_modules/xlsx/dist/xlsx.full.min.js; + } + + location /api/ { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} diff --git a/deployment/photobioreactor-acquisition.service b/deployment/photobioreactor-acquisition.service new file mode 100644 index 0000000..708bcb3 --- /dev/null +++ b/deployment/photobioreactor-acquisition.service @@ -0,0 +1,19 @@ +[Unit] +Description=Photobioreactor Atlas EZO Acquisition +After=dev-i2c\x2d1.device +Wants=dev-i2c\x2d1.device + +[Service] +Type=simple +User=photobioreactor +Group=photobioreactor +SupplementaryGroups=i2c +WorkingDirectory=/opt/photobioreactor +EnvironmentFile=/etc/default/photobioreactor +ExecStart=/usr/bin/node api/acquisition.js +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target diff --git a/deployment/photobioreactor-api.service b/deployment/photobioreactor-api.service new file mode 100644 index 0000000..b96c6ca --- /dev/null +++ b/deployment/photobioreactor-api.service @@ -0,0 +1,18 @@ +[Unit] +Description=Photobioreactor Dashboard API +After=network.target + +[Service] +Type=simple +User=photobioreactor +Group=photobioreactor +SupplementaryGroups=i2c +WorkingDirectory=/opt/photobioreactor +EnvironmentFile=/etc/default/photobioreactor +ExecStart=/usr/bin/node api/server.js +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target diff --git a/deployment/photobioreactor.env b/deployment/photobioreactor.env new file mode 100644 index 0000000..9a66a20 --- /dev/null +++ b/deployment/photobioreactor.env @@ -0,0 +1,4 @@ +NODE_ENV=production +PORT=3000 +HOST=127.0.0.1 +EZO_MODE=hardware diff --git a/docs/RASPBERRY_PI_DEPLOYMENT.md b/docs/RASPBERRY_PI_DEPLOYMENT.md new file mode 100644 index 0000000..b8a111a --- /dev/null +++ b/docs/RASPBERRY_PI_DEPLOYMENT.md @@ -0,0 +1,116 @@ +# Despliegue y validación en Raspberry Pi + +## Prueba sin hardware + +En una computadora de desarrollo: + +```bash +npm ci +npm test +``` + +Para ejecutar el sistema completo en demo, use dos terminales: + +```bash +EZO_MODE=demo npm run acquire +EZO_MODE=demo npm start +``` + +Abra `http://localhost:3000/frontend/index.html`. El recolector genera JSON y +CSV de prueba; la consola y el panel de calibración muestran transporte DEMO. + +## Preparación del bus I2C + +En Raspberry Pi OS: + +```bash +sudo raspi-config +sudo reboot +ls -l /dev/i2c-1 +sudo apt install i2c-tools +i2cdetect -y 1 +``` + +El mapa esperado es: + +| Circuito | Dirección | +|---|---| +| EZO-DO | `0x61` | +| EZO-pH | `0x63` | +| EZO-EC | `0x64` | +| EZO-RTD | `0x66` | + +No continúe con calibraciones si falta una dirección, aparece una dirección +inesperada o el barrido del bus es inestable. + +## Instalación + +Desde el repositorio clonado: + +```bash +chmod +x scripts/install-raspberry-pi.sh +sudo ./scripts/install-raspberry-pi.sh +``` + +El instalador copia la aplicación a `/opt/photobioreactor`, instala +dependencias, compila `EZO_COMMAND` y `EZO_ACQUIRE`, crea el usuario de servicio, +activa los dos servicios systemd y configura Nginx en el puerto 80. + +Validación: + +```bash +systemctl status photobioreactor-api +systemctl status photobioreactor-acquisition +journalctl -u photobioreactor-acquisition -f +curl http://127.0.0.1:3000/api/system/ezo +``` + +La respuesta de producción debe informar `hardware` tanto para comandos como +para adquisición. `EZO_MODE=hardware` evita que una instalación incompleta +caiga silenciosamente a valores simulados. + +La API escucha únicamente en `127.0.0.1` y se publica mediante Nginx. El +dashboard todavía no implementa autenticación; despliegue esta versión solo en +una red local confiable y no exponga el puerto 80 directamente a Internet. + +## Validación previa a calibración + +Desde la consola web, pruebe individualmente: + +```text +i +Status +R +Cal,? +``` + +Compruebe que cada lectura coincide con el medio físico y que los archivos de +`data/` cambian. El helper agrupado envía `R` a los cuatro circuitos, espera una +sola ventana de conversión y recoge las respuestas bajo el mismo bloqueo usado +por la consola. + +## Calibración + +Realice cada procedimiento con soluciones de referencia vigentes y espere la +estabilización de la sonda. El panel consulta `Cal,?` automáticamente después +de cada comando de calibración exitoso. + +- RTD: `Cal,`. +- pH: `Cal,mid,7.00`, `Cal,low,4.00`, `Cal,high,10.00`. +- DO de dos puntos: `Cal,0` y después `Cal`; configure antes las compensaciones. +- EC: configure `K`, ejecute `Cal,dry` y después uno o dos puntos húmedos. + +Consulte `docs/EZO_COMMANDS.md` para restricciones y comandos de diagnóstico. + +## Prueba integral + +1. Confirme lecturas, históricos, exportaciones y alarmas. +2. Desconecte un sensor: debe aparecer `DESCONECTADO` mientras los demás siguen. +3. Detenga el recolector: las lecturas deben pasar a `DESCONECTADO` al superar + la tolerancia calculada desde la frecuencia configurada. +4. Reinicie la Raspberry y confirme el arranque automático. +5. Mantenga el sistema varias horas y revise `journalctl`, tamaño de CSV, + estabilidad de valores y recuperación después de comandos de consola. + +Las pruebas físicas y la exactitud metrológica no pueden certificarse fuera de +la Raspberry Pi con las sondas y soluciones reales conectadas. diff --git a/frontend/dashboard.js b/frontend/dashboard.js index ccd2abe..e5f4a38 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1,9 +1,11 @@ const POLLING_INTERVAL_MS = 1000; const ALARM_CONFIG_FILE = "../config/alarms.json"; const WARNING_MARGIN_RATIO = 0.1; +const MIN_READING_MAX_AGE_MS = 15000; // Variables dinámicas para el control de históricos let currentHistoryInterval = 10000; +let currentLoggingRateSeconds = 1; let historyIntervalId = null; let dashboardUpdateInProgress = false; @@ -115,15 +117,30 @@ async function readSensor(sensor) { const data = await response.json(); const rawValue = Number(data[sensor.key]); + const readingTimestamp = data.timestamp + ? new Date(data.timestamp).getTime() + : null; - if (!Number.isFinite(rawValue)) { + if (data.online === false || !Number.isFinite(rawValue)) { throw new Error(`Invalid value for ${sensor.key}`); } + if ( + Number.isFinite(readingTimestamp) && + Date.now() - readingTimestamp > + Math.max( + MIN_READING_MAX_AGE_MS, + currentLoggingRateSeconds * 2500 + ) + ) { + throw new Error(`Stale value for ${sensor.key}`); + } + return { ...sensor, online: true, numericValue: rawValue, + timestamp: data.timestamp || null, value: rawValue.toFixed(sensor.decimals) }; } catch (error) { @@ -641,15 +658,32 @@ window.updateLoggingRate = async function () { if (!response.ok) throw new Error("Fallo en la sincronización con el servidor"); 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." - ); + currentLoggingRateSeconds = result.rate; + document.getElementById("sampling-interval").textContent = + `${result.rate} ${result.rate === 1 ? "segundo" : "segundos"}`; + alert(`Frecuencia de adquisición actualizada a ${result.rate} segundos.`); } catch (error) { console.error("Error configurando la tasa de registro:", error); } }; +async function loadLoggingRate() { + try { + const response = await fetch('/api/config/logging', { cache: 'no-store' }); + const result = await response.json(); + + if (!response.ok) return; + currentLoggingRateSeconds = result.rate; + document.getElementById("data-logging-rate").value = String(result.rate); + document.getElementById("sampling-interval").textContent = + `${result.rate} ${result.rate === 1 ? "segundo" : "segundos"}`; + } catch (error) { + console.warn("No se pudo cargar la frecuencia de adquisición:", error); + } +} + +loadLoggingRate(); + window.clearHistoricalData = async function () { const confirmacion = confirm("ADVERTENCIA CRÍTICA:\n\n¿Confirma el purgado total de los archivos históricos CSV? Esta acción no se puede deshacer y los datos de tendencias se perderán de forma permanente."); @@ -659,7 +693,7 @@ window.clearHistoricalData = async function () { const response = await fetch('/api/history/clear', { method: 'POST' }); if (!response.ok) throw new Error("Fallo en el purgado de archivos"); - // Simulación visual de purgado en la interfaz (Destrucción de datos del Canvas) + // Limpia los datasets existentes sin recrear las gráficas. historicalCharts.forEach(config => { const existingChart = chartInstances.get(config.id); if (existingChart) { diff --git a/frontend/ezo-service.js b/frontend/ezo-service.js index fc0b173..d93ca23 100644 --- a/frontend/ezo-service.js +++ b/frontend/ezo-service.js @@ -223,7 +223,18 @@ async function sendCommand(command, options = {}) { async function sendCalibrationCommand(command) { const sensor = document.getElementById("cal-sensor-select").value; document.getElementById("terminal-sensor-select").value = sensor; - return sendCommand(command, { sensor }); + const result = await sendCommand(command, { sensor }); + const normalized = String(command).toLowerCase(); + + if ( + result && + (normalized === "cal" || normalized.startsWith("cal,")) && + normalized !== "cal,?" + ) { + await sendCommand("Cal,?", { sensor }); + } + + return result; } async function clearCalibration() { diff --git a/frontend/index.html b/frontend/index.html index 72555a0..4787f98 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -80,7 +80,7 @@
Intervalo de muestreo - 1 segundo + 1 segundo
Fuente de datos @@ -294,8 +294,8 @@ - - + + diff --git a/package-lock.json b/package-lock.json index 3448ec3..f36928a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,18 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "chart.js": "^4.5.1", "cors": "^2.8.6", - "express": "^5.2.1" + "express": "^5.2.1", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -88,6 +96,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -866,6 +886,18 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/xlsx": { + "version": "0.20.3", + "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==", + "license": "Apache-2.0", + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } } } } diff --git a/package.json b/package.json index 16bbed1..a910045 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,16 @@ "main": "index.js", "scripts": { "start": "node api/server.js", + "acquire": "node api/acquisition.js", "test": "node --test" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { + "chart.js": "^4.5.1", "cors": "^2.8.6", - "express": "^5.2.1" + "express": "^5.2.1", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" } } diff --git a/scripts/install-raspberry-pi.sh b/scripts/install-raspberry-pi.sh new file mode 100644 index 0000000..a167ea2 --- /dev/null +++ b/scripts/install-raspberry-pi.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "Ejecute este script con sudo." >&2 + exit 1 +fi + +PROJECT_SOURCE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROJECT_TARGET="/opt/photobioreactor" +SERVICE_USER="photobioreactor" + +apt-get update +apt-get install -y nodejs npm nginx i2c-tools build-essential + +if ! id "${SERVICE_USER}" >/dev/null 2>&1; then + useradd --system --create-home --groups i2c "${SERVICE_USER}" +fi + +install -d -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${PROJECT_TARGET}" +cp -a "${PROJECT_SOURCE}/." "${PROJECT_TARGET}/" +rm -rf "${PROJECT_TARGET}/node_modules" + +cd "${PROJECT_TARGET}" +npm ci --omit=dev +make -C sensors/EZOCommand +chown -R "${SERVICE_USER}:${SERVICE_USER}" "${PROJECT_TARGET}" + +install -m 0644 deployment/photobioreactor.env /etc/default/photobioreactor +install -m 0644 deployment/photobioreactor-api.service /etc/systemd/system/ +install -m 0644 deployment/photobioreactor-acquisition.service /etc/systemd/system/ +install -m 0644 deployment/nginx-photobioreactor.conf \ + /etc/nginx/sites-available/photobioreactor +ln -sfn /etc/nginx/sites-available/photobioreactor \ + /etc/nginx/sites-enabled/photobioreactor +rm -f /etc/nginx/sites-enabled/default + +systemctl daemon-reload +nginx -t +systemctl enable --now photobioreactor-api +systemctl enable --now photobioreactor-acquisition +systemctl reload nginx + +echo "Instalación terminada. Revise: systemctl status photobioreactor-*" diff --git a/sensors/EZOCommand/Makefile b/sensors/EZOCommand/Makefile index 55d4121..34374b9 100644 --- a/sensors/EZOCommand/Makefile +++ b/sensors/EZOCommand/Makefile @@ -1,9 +1,14 @@ CC=gcc CFLAGS=-Wall -Wextra -O2 -TARGET=EZO_COMMAND +TARGETS=EZO_COMMAND EZO_ACQUIRE -$(TARGET): main.c - $(CC) $(CFLAGS) -o $(TARGET) main.c +all: $(TARGETS) + +EZO_COMMAND: main.c + $(CC) $(CFLAGS) -o $@ $< + +EZO_ACQUIRE: acquire.c + $(CC) $(CFLAGS) -o $@ $< clean: - rm -f $(TARGET) + rm -f $(TARGETS) diff --git a/sensors/EZOCommand/acquire.c b/sensors/EZOCommand/acquire.c new file mode 100644 index 0000000..7b93862 --- /dev/null +++ b/sensors/EZOCommand/acquire.c @@ -0,0 +1,154 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +typedef struct +{ + const char *name; + int address; + double value; + int valid; +} sensor_t; + +static int select_sensor(int fd, int address) +{ + return ioctl(fd, I2C_SLAVE, address); +} + +static int request_reading(int fd, sensor_t *sensor) +{ + const char command = 'R'; + + if (select_sensor(fd, sensor->address) < 0) + { + return -1; + } + + if (write(fd, &command, 1) != 1) + { + return -1; + } + + sensor->valid = -1; + return 0; +} + +static int receive_reading(int fd, sensor_t *sensor) +{ + unsigned char response[64] = {0}; + + if (select_sensor(fd, sensor->address) < 0) + { + return -1; + } + + 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 < 2 || response[0] != 1) + { + return -1; + } + + response[bytes_read] = '\0'; + char *end = NULL; + errno = 0; + double value = strtod((char *)&response[1], &end); + + if (errno != 0 || end == (char *)&response[1]) + { + return -1; + } + + sensor->value = value; + sensor->valid = 1; + return 0; +} + +int main(int argc, char *argv[]) +{ + if (argc != 2) + { + fprintf(stderr, "Uso: EZO_ACQUIRE /dev/i2c-1\n"); + return 2; + } + + sensor_t sensors[] = { + {"temperature", 0x66, 0.0, 0}, + {"ph", 0x63, 0.0, 0}, + {"do", 0x61, 0.0, 0}, + {"ec", 0x64, 0.0, 0}}; + const size_t sensor_count = sizeof(sensors) / sizeof(sensors[0]); + int lock_fd = open( + "/tmp/photobioreactor-i2c.lock", + O_CREAT | O_RDWR, + 0660); + + if (lock_fd < 0 || flock(lock_fd, LOCK_EX) < 0) + { + fprintf(stderr, "No se pudo bloquear el bus I2C\n"); + return 3; + } + + int fd = open(argv[1], O_RDWR); + + if (fd < 0) + { + fprintf(stderr, "No se pudo abrir %s\n", argv[1]); + flock(lock_fd, LOCK_UN); + close(lock_fd); + return 4; + } + + for (size_t index = 0; index < sensor_count; index++) + { + request_reading(fd, &sensors[index]); + } + + usleep(1000000); + + for (size_t index = 0; index < sensor_count; index++) + { + if (sensors[index].valid == -1) + { + receive_reading(fd, &sensors[index]); + } + } + + printf("{"); + for (size_t index = 0; index < sensor_count; index++) + { + printf( + "%s\"%s\":", + index == 0 ? "" : ",", + sensors[index].name); + if (sensors[index].valid == 1) + { + printf("%.6f", sensors[index].value); + } + else + { + printf("null"); + } + } + printf("}\n"); + + close(fd); + flock(lock_fd, LOCK_UN); + close(lock_fd); + return 0; +} diff --git a/sensors/EZORTD/ezortd_daemon.c b/sensors/EZORTD/ezortd_daemon.c index eb09a58..cbc50ff 100644 --- a/sensors/EZORTD/ezortd_daemon.c +++ b/sensors/EZORTD/ezortd_daemon.c @@ -3,12 +3,20 @@ #include #include #include +#include +#include #include "ezortd.h" int main() { int fd; + const char *project_root = getenv("PHOTOBIOREACTOR_ROOT"); + + if (!project_root) + { + project_root = "."; + } fd = open("/dev/i2c-1", O_RDWR); @@ -26,9 +34,19 @@ int main() 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", - "w"); + char json_path[PATH_MAX]; + char log_path[PATH_MAX]; + snprintf( + json_path, + sizeof(json_path), + "%s/data/EZORTD.json", + project_root); + snprintf( + log_path, + sizeof(log_path), + "%s/logs/temperature.csv", + project_root); + FILE *f = fopen(json_path, "w"); if (f) { @@ -39,9 +57,7 @@ int main() fclose(f); } - FILE *log = fopen( - "/home/cristian/airquality/basic-ui-dashboard/logs/temperature.csv", - "a+"); + FILE *log = fopen(log_path, "a+"); if (log) { diff --git a/test/api.test.js b/test/api.test.js index 60b0a2f..fc02ab2 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -4,10 +4,19 @@ const os = require('node:os'); const path = require('node:path'); const test = require('node:test'); -const temporaryLogs = fs.mkdtempSync(path.join(os.tmpdir(), 'photobioreactor-')); +const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'photobioreactor-')); +const temporaryLogs = path.join(temporaryRoot, 'logs'); +const temporaryData = path.join(temporaryRoot, 'data'); +const temporaryRuntime = path.join(temporaryRoot, 'runtime.json'); +fs.mkdirSync(temporaryLogs, { recursive: true }); +fs.mkdirSync(temporaryData, { recursive: true }); process.env.LOGS_DIRECTORY = temporaryLogs; +process.env.DATA_DIRECTORY = temporaryData; +process.env.RUNTIME_CONFIG_FILE = temporaryRuntime; +process.env.EZO_MODE = 'demo'; const { app, HISTORY_FILES } = require('../api/server'); +const { collectReadings } = require('../api/acquisition-service'); let server; let baseUrl; @@ -25,17 +34,29 @@ test.after(async () => { await new Promise((resolve, reject) => { server.close((error) => error ? reject(error) : resolve()); }); - fs.rmSync(temporaryLogs, { recursive: true, force: true }); + fs.rmSync(temporaryRoot, { recursive: true, force: true }); }); -test('returns simulated history for all sensors', async () => { +test('returns real CSV history for all sensors', async () => { + fs.writeFileSync( + path.join(temporaryLogs, 'temperature.csv'), + 'timestamp,value\n2026-06-22T12:00:00.000Z,25.1\n' + ); + fs.writeFileSync( + path.join(temporaryLogs, 'ph.csv'), + 'timestamp,value\n2026-06-22T12:00:00.000Z,7.2\n' + ); + fs.writeFileSync(path.join(temporaryLogs, 'do.csv'), 'timestamp,value\n'); + fs.writeFileSync(path.join(temporaryLogs, 'ec.csv'), 'timestamp,value\n'); + const response = await fetch(`${baseUrl}/api/sensors/all`); const data = await response.json(); assert.equal(response.status, 200); - assert.equal(data.length, 61); + assert.equal(data.length, 2); assert.ok(data[0].Timestamp); - assert.ok(data[0].Temperatura_C); + assert.ok(data[0].Sensor); + assert.equal(typeof data[0].Valor, 'number'); }); test('serves the dashboard in local development mode', async () => { @@ -78,7 +99,7 @@ test('rejects undocumented calibration syntax', async () => { assert.match(data.error, /documentados/); }); -test('validates simulated logging rates', async () => { +test('persists logging rates used by acquisition', async () => { const invalidResponse = await fetch(`${baseUrl}/api/config/logging`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -95,7 +116,28 @@ test('validates simulated logging rates', async () => { assert.equal(validResponse.status, 200); assert.equal(validData.rate, 5); - assert.equal(validData.simulated, true); + + const readResponse = await fetch(`${baseUrl}/api/config/logging`); + const readData = await readResponse.json(); + assert.equal(readData.rate, 5); + assert.equal(JSON.parse(fs.readFileSync(temporaryRuntime)).loggingRateSeconds, 5); +}); + +test('demo acquisition writes live JSON and CSV files', async () => { + const result = await collectReadings(); + + assert.equal(result.mode, 'demo'); + for (const file of ['EZORTD.json', 'EZOPH.json', 'EZODO.json', 'EZOEC.json']) { + const data = JSON.parse(fs.readFileSync(path.join(temporaryData, file))); + assert.equal(data.online, true); + assert.ok(data.timestamp); + } + for (const file of ['temperature.csv', 'ph.csv', 'do.csv', 'ec.csv']) { + assert.match( + fs.readFileSync(path.join(temporaryLogs, file), 'utf8'), + /^timestamp,value/m + ); + } }); test('clears historical CSV files and preserves headers', async () => { diff --git a/test/static.test.js b/test/static.test.js index baf1c59..209621b 100644 --- a/test/static.test.js +++ b/test/static.test.js @@ -45,3 +45,20 @@ test('sensor Makefiles reference their real implementation objects', () => { assert.match(read('sensors/EZOEC/Makefile'), /OBJ=main\.o ezoec\.o/); assert.ok(read('sensors/EZODO/main.c').trim().length > 0); }); + +test('dashboard uses locally installed chart and spreadsheet libraries', () => { + const html = read('frontend/index.html'); + + assert.match(html, /src="\/vendor\/chart\.js"/); + assert.match(html, /src="\/vendor\/xlsx\.js"/); + assert.doesNotMatch(html, /cdn\.jsdelivr|cdn\.sheetjs/); +}); + +test('hardware acquisition batches all EZO addresses under the shared lock', () => { + const source = read('sensors/EZOCommand/acquire.c'); + + for (const address of ['0x61', '0x63', '0x64', '0x66']) { + assert.match(source, new RegExp(address)); + } + assert.match(source, /photobioreactor-i2c\.lock/); +});