Integrar adquisición y despliegue en Raspberry Pi

main
EMOTIONS-HUNTER 1 month ago
parent 426b9c247b
commit bcbee4e479

5
.gitignore vendored

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

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

@ -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:** `<linux/i2c-dev.h>`, `<sys/ioctl.h>`
- **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.

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

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

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

@ -0,0 +1,3 @@
{
"loggingRateSeconds": 1
}

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

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

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

@ -0,0 +1,4 @@
NODE_ENV=production
PORT=3000
HOST=127.0.0.1
EZO_MODE=hardware

@ -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,<temperatura>`.
- 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.

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

@ -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() {

@ -80,7 +80,7 @@
<div class="status-grid">
<div class="status-item">
<span class="status-label">Intervalo de muestreo</span>
<strong>1 segundo</strong>
<strong id="sampling-interval">1 segundo</strong>
</div>
<div class="status-item">
<span class="status-label">Fuente de datos</span>
@ -294,8 +294,8 @@
</main>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.sheetjs.com/xlsx-latest/package/dist/xlsx.full.min.js"></script>
<script src="/vendor/chart.js"></script>
<script src="/vendor/xlsx.js"></script>
<script src="dashboard.js"></script>
<script src="ezo-service.js"></script>
<script src="export-service.js"></script>

34
package-lock.json generated

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

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

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

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

@ -0,0 +1,154 @@
#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>
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;
}

@ -3,12 +3,20 @@
#include <fcntl.h>
#include <sys/file.h>
#include <time.h>
#include <stdlib.h>
#include <limits.h>
#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)
{

@ -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 () => {

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

Loading…
Cancel
Save