Compare commits

..

2 Commits

Author SHA1 Message Date
EMOTIONS-HUNTER bcbee4e479 Integrar adquisición y despliegue en Raspberry Pi 2 months ago
EMOTIONS-HUNTER 426b9c247b Integrar dashboard y control EZO por I2C
Mejora el monitoreo, alarmas, historiales y exportaciones del dashboard. Agrega consola y calibración Atlas Scientific con transporte I2C real y modo demo, corrige módulos de sensores, incorpora documentación oficial y pruebas automáticas.
2 months ago

3
.gitignore vendored

@ -5,6 +5,7 @@
build/
bin/
obj/
node_modules/
# Archivos de configuración locales
*.code-workspace
@ -15,6 +16,8 @@ obj/
*.bin
*.elf
*.map
sensors/EZOCommand/EZO_COMMAND
sensors/EZOCommand/EZO_ACQUIRE
# Archivos de depuración y logs
*.log

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

@ -0,0 +1,65 @@
# Estado del Proyecto
## Estado actual
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`.
## Arquitectura operativa
| Componente | Responsabilidad |
|---|---|
| `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,219 +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
### 2.2. Capa de Enrutamiento y Proxy
- **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
| Temperatura | EZO-RTD | `0x66` | °C |
| pH | EZO-pH | `0x63` | pH |
| Oxígeno disuelto | EZO-DO | `0x61` | mg/L |
| Conductividad | EZO-EC | `0x64` | µS/cm |
---
El sistema incluye dashboard responsive, históricos Chart.js, alarmas,
exportación CSV/Excel, consola de comandos y panel de calibración.
## 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
│ ├── mock-service.js # Capa de comunicación asíncrona con el backend (fetch/POST)
│ ├── 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
```
---
`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`.
## 4. Instrucciones de Despliegue
### 4.1. Requisitos Previos
- 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:
Ejecute en dos terminales:
```
[MOCK SERVER] Backend Node.js corriendo en http://localhost:3000
```bash
EZO_MODE=demo npm run acquire
EZO_MODE=demo npm start
```
Para ejecución persistente en segundo plano se recomienda el gestor de procesos `pm2`:
Abra `http://localhost:3000/frontend/index.html`.
```bash
npm install -g pm2
pm2 start api/server.js --name fotobiorreactor-api
pm2 save
```
En PowerShell:
### 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;
}
}
```powershell
$env:EZO_MODE = "demo"
npm run acquire
```
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 iniciará automáticamente el ciclo de polling hacia la API y las lecturas en vivo comenzarán a actualizarse con los datos simulados del backend.
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).
### 4.5. Construcción de los Demonios en C (Hardware Real)
## Archivos de datos
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):
- 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`.
```bash
cd sensors/EZORTD
make
Los CSV usan una única nomenclatura y el formato:
```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;
});

@ -0,0 +1,311 @@
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const { promisify } = require('util');
const execFileAsync = promisify(execFile);
const SENSOR_CONFIG = {
rtd: { address: '0x66', name: 'RTD' },
ph: { address: '0x63', name: 'pH' },
do: { address: '0x61', name: 'DO' },
ec: { address: '0x64', name: 'EC' }
};
const DANGEROUS_COMMANDS = new Set(['factory', 'i2c', 'baud', 'sleep']);
const COMMON_COMMANDS = new Set([
'r', 'i', 'status', 'find', 'l', 'plock', 'name', 'cal',
'export', 'import', 't', 'rt', '*ok'
]);
const SENSOR_COMMANDS = {
rtd: new Set(['s', 'd', 'm']),
ph: new Set(['slope', 'phext']),
do: new Set(['s', 'p', 'o']),
ec: new Set(['k', 'tc', 'tds', 'o'])
};
const MOCK_STATE = {
rtd: { calibrationPoints: 0 },
ph: { calibrationPoints: 0 },
do: { calibrationPoints: 0 },
ec: { calibrationPoints: 0, k: 1.0 }
};
function normalizeCommand(rawCommand) {
return String(rawCommand || '').trim().replace(/\s+/g, '');
}
function getBaseCommand(command) {
return command.split(',')[0].toLowerCase();
}
function validateCommand(sensor, command, dangerousConfirmed) {
if (!SENSOR_CONFIG[sensor]) {
throw createHttpError(400, 'Sensor no válido.');
}
if (!command || command.length > 64 || !/^[a-zA-Z0-9*?.+\-,]+$/.test(command)) {
throw createHttpError(400, 'Comando EZO no válido.');
}
const baseCommand = getBaseCommand(command);
const isAllowed = COMMON_COMMANDS.has(baseCommand) ||
SENSOR_COMMANDS[sensor].has(baseCommand) ||
DANGEROUS_COMMANDS.has(baseCommand);
if (!isAllowed) {
throw createHttpError(400, `El comando ${baseCommand} no aplica a ${sensor.toUpperCase()}.`);
}
if (DANGEROUS_COMMANDS.has(baseCommand) && !dangerousConfirmed) {
throw createHttpError(409, 'El comando requiere confirmación explícita.');
}
if (!matchesOfficialSyntax(sensor, command)) {
throw createHttpError(
400,
`La sintaxis "${command}" no coincide con los comandos documentados para ${sensor.toUpperCase()}.`
);
}
}
function matchesOfficialSyntax(sensor, command) {
const normalized = command.toLowerCase();
const commonPatterns = [
/^(r|i|status|find|sleep|factory)$/,
/^l,(0|1|\?)$/,
/^plock,(0|1|\?)$/,
/^name,(|\?|[a-z0-9_.-]{1,16})$/,
/^\*ok,(0|1|\?)$/,
/^export(\,?)?$/,
/^export,\?$/,
/^import,[0-9a-f ]+$/,
/^i2c,([1-9]|[1-9]\d|1[01]\d|12[0-7])$/,
/^baud,(300|1200|2400|9600|19200|38400|57600|115200)$/
];
if (commonPatterns.some((pattern) => pattern.test(normalized))) return true;
const sensorPatterns = {
rtd: [
/^cal,(\?|clear|[-+]?\d+(\.\d+)?)$/,
/^s,(c|k|f|\?)$/,
/^d,(0|1|\?)$/,
/^m,(clear|\?)$/
],
ph: [
/^cal,(\?|clear)$/,
/^cal,(mid|low|high),[-+]?\d+(\.\d+)?$/,
/^slope,\?$/,
/^phext,(0|1|\?)$/,
/^t,(\?|[-+]?\d+(\.\d+)?)$/,
/^rt,[-+]?\d+(\.\d+)?$/
],
do: [
/^cal$/,
/^cal,(0|\?|clear)$/,
/^t,(\?|[-+]?\d+(\.\d+)?)$/,
/^rt,[-+]?\d+(\.\d+)?$/,
/^s,(\?|[-+]?\d+(\.\d+)?(,ppt)?)$/,
/^p,(\?|[-+]?\d+(\.\d+)?)$/,
/^o,\?$/,
/^o,(mg|%),(0|1)$/
],
ec: [
/^cal,(\?|clear|dry|[-+]?\d+(\.\d+)?)$/,
/^cal,(low|high),[-+]?\d+(\.\d+)?$/,
/^k,(\?|[-+]?\d+(\.\d+)?)$/,
/^t,(\?|[-+]?\d+(\.\d+)?)$/,
/^rt,[-+]?\d+(\.\d+)?$/,
/^tc,(\?|[-+]?\d+(\.\d+)?)$/,
/^tds,(\?|[-+]?\d+(\.\d+)?)$/,
/^o,\?$/,
/^o,(ec|tds|s|sg),(0|1)$/
]
};
return sensorPatterns[sensor].some((pattern) => pattern.test(normalized));
}
function getProcessingDelay(command) {
const normalized = command.toLowerCase();
if (normalized === 'r') return 1000;
if (normalized.startsWith('rt,')) return 1000;
if (normalized === 'cal' || normalized === 'cal,0') return 1300;
if (normalized.startsWith('cal,') && !['cal,?', 'cal,clear'].includes(normalized)) {
return normalized.includes('mid') || normalized.includes('low') ||
normalized.includes('high') ? 900 : 600;
}
return 300;
}
function commandExpectsNoResponse(command) {
return ['sleep', 'factory'].includes(getBaseCommand(command)) ||
getBaseCommand(command) === 'i2c' ||
getBaseCommand(command) === 'baud';
}
function detectMode() {
const requestedMode = String(process.env.EZO_MODE || 'auto').toLowerCase();
const helperPath = process.env.EZO_HELPER ||
path.join(__dirname, '..', 'sensors', 'EZOCommand', 'EZO_COMMAND');
const hardwareReady = process.platform === 'linux' &&
fs.existsSync('/dev/i2c-1') &&
fs.existsSync(helperPath);
if (requestedMode === 'hardware' && !hardwareReady) {
return {
mode: 'unavailable',
helperPath,
message: 'Se solicitó hardware, pero /dev/i2c-1 o EZO_COMMAND no está disponible.'
};
}
if (requestedMode === 'hardware' || (requestedMode === 'auto' && hardwareReady)) {
return { mode: 'hardware', helperPath, message: 'Bus I2C real activo.' };
}
return {
mode: 'demo',
helperPath,
message: 'Modo demo activo; no se enviarán comandos al bus I2C.'
};
}
async function executeHardwareCommand(sensor, command, modeInfo) {
const config = SENSOR_CONFIG[sensor];
const args = [
'/dev/i2c-1',
config.address,
String(getProcessingDelay(command)),
command
];
if (commandExpectsNoResponse(command)) {
args.push('--no-response');
}
const { stdout } = await execFileAsync(modeInfo.helperPath, args, {
timeout: getProcessingDelay(command) + 2500,
windowsHide: true
});
const result = JSON.parse(stdout.trim());
if (!result.success) {
throw createHttpError(502, result.error || 'El circuito EZO rechazó el comando.');
}
return result.response || '*OK';
}
async function executeDemoCommand(sensor, command) {
const normalized = command.toLowerCase();
const state = MOCK_STATE[sensor];
await new Promise((resolve) => setTimeout(resolve, Math.min(getProcessingDelay(command), 80)));
if (normalized === 'r') {
const values = {
rtd: (25 + Math.random() * 0.1).toFixed(3),
ph: (7.2 + Math.random() * 0.05).toFixed(3),
do: (8.5 + Math.random() * 0.1).toFixed(2),
ec: (1050 + Math.random() * 5).toFixed(0)
};
return values[sensor];
}
if (normalized === 'i') return `?i,${SENSOR_CONFIG[sensor].name},demo`;
if (normalized === 'status') return '?Status,P,5.000';
if (normalized === 'cal,?') return `?Cal,${state.calibrationPoints}`;
if (normalized === 'cal,clear') {
state.calibrationPoints = 0;
return '*OK';
}
if (normalized.startsWith('cal')) {
updateDemoCalibration(sensor, normalized);
return '*OK';
}
if (normalized === 'slope,?') return '?Slope,98.2,97.8,-1.20';
if (normalized === 'k,?') return `?K,${state.k.toFixed(1)}`;
if (normalized.startsWith('k,')) {
state.k = Number(normalized.split(',')[1]);
return '*OK';
}
if (normalized === 't,?') return '?T,25.0';
if (normalized === 's,?' && sensor === 'do') return '?S,0,µS';
if (normalized === 'p,?') return '?P,101.3';
if (normalized === 'o,?') {
return sensor === 'do' ? '?O,mg,%' : '?O,EC,TDS,S,SG';
}
if (normalized === 'l,?') return '?L,1';
if (normalized === 'plock,?') return '?Plock,1';
if (normalized === 'phext,?') return '?pHext,0';
if (normalized === 's,?' && sensor === 'rtd') return '?S,C';
return '*OK';
}
function updateDemoCalibration(sensor, command) {
if (sensor === 'ph') {
if (command.startsWith('cal,mid,')) MOCK_STATE.ph.calibrationPoints = 1;
if (command.startsWith('cal,low,')) MOCK_STATE.ph.calibrationPoints = 2;
if (command.startsWith('cal,high,')) MOCK_STATE.ph.calibrationPoints = 3;
return;
}
if (sensor === 'do') {
if (command === 'cal,0') MOCK_STATE.do.calibrationPoints = 1;
if (command === 'cal') MOCK_STATE.do.calibrationPoints = 2;
return;
}
if (sensor === 'ec') {
if (command === 'cal,dry') MOCK_STATE.ec.calibrationPoints = 0;
if (command.startsWith('cal,low,')) MOCK_STATE.ec.calibrationPoints = 1;
if (command.startsWith('cal,high,')) MOCK_STATE.ec.calibrationPoints = 2;
if (/^cal,[\d.]+$/.test(command)) MOCK_STATE.ec.calibrationPoints = 1;
return;
}
MOCK_STATE.rtd.calibrationPoints = 1;
}
async function executeEzoCommand(sensor, rawCommand, options = {}) {
const command = normalizeCommand(rawCommand);
validateCommand(sensor, command, options.dangerousConfirmed);
const modeInfo = detectMode();
if (modeInfo.mode === 'unavailable') {
throw createHttpError(503, modeInfo.message);
}
const response = modeInfo.mode === 'hardware'
? await executeHardwareCommand(sensor, command, modeInfo)
: await executeDemoCommand(sensor, command);
return {
success: true,
mode: modeInfo.mode,
sensor: SENSOR_CONFIG[sensor].name,
command,
response
};
}
function createHttpError(status, message) {
const error = new Error(message);
error.status = status;
return error;
}
module.exports = {
SENSOR_CONFIG,
detectMode,
executeEzoCommand,
getProcessingDelay,
matchesOfficialSyntax,
normalizeCommand,
validateCommand
};

@ -1,206 +1,220 @@
// api/server.js
const express = require('express');
const cors = require('cors');
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 = 3000;
const PORT = Number(process.env.PORT || 3000);
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());
app.use('/frontend', express.static(path.join(__dirname, '..', 'frontend')));
app.use('/data', express.static(path.join(__dirname, '..', 'data'), {
etag: false,
maxAge: 0
}));
app.use('/logs', express.static(LOGS_DIRECTORY, {
etag: false,
maxAge: 0
}));
app.use('/config', express.static(path.join(__dirname, '..', 'config'), {
etag: false,
maxAge: 0
}));
app.get('/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'
));
});
// 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);
const data = [];
const now = new Date();
// Generamos 60 registros simulados
for (let i = 60; i >= 0; i--) {
const timestamp = new Date(now.getTime() - i * 1000).toISOString();
// 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') {
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" });
}
}
res.json(data);
app.get('/', (req, res) => {
res.redirect('/frontend/index.html');
});
// Endpoint DEFINITIVO para comandos Atlas Scientific EZO
app.post('/api/sensors/:type/command', async (req, res) => {
const sensorType = req.params.type.toUpperCase();
const rawCommand = req.body.command ? req.body.command.toLowerCase().trim() : '';
app.get('/api/system/ezo', (req, res) => {
const commandMode = detectMode();
const acquisitionMode = detectAcquisitionMode();
console.log(`[COMANDO] RX: '${rawCommand}' para ${sensorType}`);
res.json({
mode: commandMode.mode,
message: commandMode.message,
acquisitionMode: acquisitionMode.mode,
acquisitionMessage: acquisitionMode.message
});
});
// Dividimos el comando por comas para analizar base y parámetros (ej. "cal,mid,7.00" -> ["cal", "mid", "7.00"])
const parts = rawCommand.split(',');
const baseCmd = parts[0];
function parseHistoryCsv(csvText, sensorName) {
return csvText
.split(/\r?\n/)
.slice(1)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const separatorIndex = line.indexOf(',');
let ezoResponse = "";
if (separatorIndex < 0) return null;
const timestamp = line.slice(0, separatorIndex).trim();
const value = Number(line.slice(separatorIndex + 1).trim());
// 1. COMANDOS DE ESTADO Y CONFIGURACIÓN GLOBAL (Comunes a todos los EZO)
if (baseCmd === 'i') {
await delay(300);
ezoResponse = `?I,${sensorType},2.12`;
}
else if (baseCmd === 'status') {
await delay(300);
ezoResponse = `?STATUS,P,5.03`; // P = Power On, 5.03 = Voltaje
}
else if (baseCmd === 'sleep') {
ezoResponse = `[SLEEP MODE ACTIVADO]`;
}
else if (baseCmd === 'factory') {
await delay(800);
ezoResponse = `*OK`;
}
else if (baseCmd === 'find') {
await delay(300);
ezoResponse = `*OK`; // Hace parpadear el LED en blanco
}
else if (baseCmd === 'led') {
await delay(300);
if (parts[1] === '?') ezoResponse = `?LED,1`;
else ezoResponse = `*OK`;
}
else if (baseCmd === 'plock') {
// Protocol Lock (Evita cambios accidentales de I2C a UART)
await delay(300);
if (parts[1] === '?') ezoResponse = `?PLOCK,1`;
else ezoResponse = `*OK`;
}
else if (baseCmd === 'i2c') {
// Cambio de dirección I2C (ej. i2c,100)
await delay(300);
ezoResponse = `*OK`;
if (!timestamp || !Number.isFinite(value)) return null;
return { Timestamp: timestamp, Sensor: sensorName, Valor: value };
})
.filter(Boolean);
}
// 2. COMANDO DE LECTURA PRINCIPAL
else if (baseCmd === 'r') {
await delay(900); // Latencia real de procesamiento químico/eléctrico
if (sensorType === 'RTD') ezoResponse = (25.0 + Math.random() * 0.1).toFixed(3);
if (sensorType === 'PH') ezoResponse = (7.2 + Math.random() * 0.05).toFixed(2);
if (sensorType === 'DO') ezoResponse = (8.5 + Math.random() * 0.1).toFixed(2);
if (sensorType === 'EC') ezoResponse = (1050 + Math.random() * 5).toFixed(0);
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);
}
// 3. SISTEMA DE CALIBRACIÓN UNIVERSAL
else if (baseCmd === 'cal') {
await delay(600);
if (parts[1] === 'clear') {
ezoResponse = `*OK`;
} else if (parts[1] === '?') {
ezoResponse = `?CAL,1`; // Devuelve puntos calibrados
} else {
// Acepta cal,mid,7.00 (pH) | cal,atm (DO) | cal,dry (EC) | cal,t (RTD)
ezoResponse = `*OK`;
}
}
app.get('/api/sensors/:type', async (req, res) => {
const sensorType = req.params.type.toLowerCase();
// 4. COMPENSACIONES AMBIENTALES (Temperatura, Salinidad, Presión)
else if (baseCmd === 't' || baseCmd === 's' || baseCmd === 'p') {
await delay(300);
if (parts[1] === '?') {
const defaultVals = { 't': '25.0', 's': '0.00', 'p': '101.3' };
ezoResponse = `?${baseCmd.toUpperCase()},${defaultVals[baseCmd]}`;
} else {
ezoResponse = `*OK`;
}
if (sensorType !== 'all' && !HISTORY_SENSOR_MAP[sensorType]) {
return res.status(400).json({ error: 'Sensor no válido.' });
}
// 5. COMANDOS ESPECÍFICOS POR SENSOR
else {
await delay(300);
// --- RTD (Temperatura) ---
if (sensorType === 'RTD' && baseCmd === 's') {
// Escala (c=Celsius, k=Kelvin, f=Fahrenheit)
ezoResponse = parts[1] === '?' ? `?S,C` : `*OK`;
try {
if (sensorType === 'all') {
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))
)
);
}
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.'
});
}
});
// --- PH ---
else if (sensorType === 'PH' && baseCmd === 'slope') {
// Estado de salud del vidrio de la sonda
ezoResponse = parts[1] === '?' ? `?Slope,99.7,100.3,-0.89` : `*ER`;
app.post('/api/sensors/:type/command', async (req, res) => {
try {
const result = await executeEzoCommand(
req.params.type.toLowerCase(),
req.body.command,
{ dangerousConfirmed: req.body.dangerousConfirmed === true }
);
res.json(result);
} catch (error) {
res.status(error.status || 500).json({
success: false,
error: error.message
});
}
});
// --- EC (Conductividad) ---
else if (sensorType === 'EC') {
if (baseCmd === 'k') {
// Constante de la sonda (0.1, 1.0, 10)
ezoResponse = parts[1] === '?' ? `?K,1.0` : `*OK`;
} else if (baseCmd === 'tc') {
// Coeficiente de temperatura
ezoResponse = parts[1] === '?' ? `?TC,1.90` : `*OK`;
} else if (baseCmd === 'o') {
// Habilitar/Deshabilitar parámetros de salida (TDS, Salinidad, Gravedad Específica)
ezoResponse = parts[1] === '?' ? `?O,EC,TDS,S,SG` : `*OK`;
} else {
ezoResponse = `*ER`;
}
}
app.get('/api/config/logging', async (req, res) => {
const config = await readRuntimeConfig();
res.json({ success: true, rate: config.loggingRateSeconds });
});
// Si ninguna regla coincide, el comando no existe en el datasheet
else {
ezoResponse = `*ER`;
}
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
});
}
});
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'
)
)
);
console.log('[HISTORY] Archivos CSV truncados.');
res.json({
success: true,
sensor: sensorType,
command: rawCommand,
response: ezoResponse
message: 'Archivos de registro truncados correctamente.'
});
} catch (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.'
});
// --- RUTAS DE GESTIÓN DE ALMACENAMIENTO ---
// Variable global para simular la configuración de escritura del demonio C++
let loggingRateSeconds = 1;
app.post('/api/config/logging', (req, res) => {
loggingRateSeconds = req.body.rate || 1;
console.log(`[SISTEMA] Demonio de escritura configurado a: 1 registro cada ${loggingRateSeconds}s`);
res.json({ success: true, rate: loggingRateSeconds });
}
});
app.post('/api/history/clear', (req, res) => {
console.log(`[SISTEMA] Purgado de base de datos histórico solicitado por el usuario.`);
// En el hardware real, aquí se ejecutaría 'fs.unlink()' o se truncarían los archivos CSV en /logs/
res.json({ success: true, message: "Archivos de registro truncados correctamente." });
if (require.main === module) {
app.listen(PORT, HOST, () => {
console.log(`[API] Backend Node.js activo en http://${HOST}:${PORT}`);
});
}
app.listen(PORT, () => {
console.log(`[MOCK SERVER] Backend Node.js corriendo en http://localhost:${PORT}`);
});
module.exports = {
app,
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,130 @@
# Comandos y calibracion Atlas Scientific EZO
Esta implementacion sigue los datasheets oficiales vigentes consultados para:
- EZO-pH, datasheet V6.1, revision 02/2024.
- EZO-DO, datasheet V5.8, revision 03/2025.
- EZO-EC, datasheet V5.5.
- EZO-RTD, datasheet V3.7, revision 10/2024.
## Transporte I2C
Los comandos son cadenas ASCII sin retorno de carro. Despues de escribir el
comando se espera el tiempo de procesamiento y se solicita la respuesta.
El primer byte de una respuesta I2C es:
| Codigo | Significado |
|---|---|
| `1` | Solicitud procesada correctamente |
| `2` | Error de sintaxis |
| `254` | Procesando; aun no esta lista |
| `255` | No hay datos |
El ejecutable `sensors/EZOCommand/EZO_COMMAND` implementa este intercambio y
reintenta las respuestas pendientes.
## Calibracion EZO-RTD
Calibracion de un punto:
```text
Cal,<temperatura>
Cal,?
Cal,clear
```
Ejemplo: `Cal,25.00`.
## Calibracion EZO-pH
Orden recomendado:
```text
Cal,mid,7.00
Cal,low,4.00
Cal,high,10.00
```
El punto medio siempre debe realizarse primero. Ejecutar `Cal,mid` sobre una
calibracion existente elimina los otros puntos. El estado se consulta con
`Cal,?` y la salud de la sonda con `Slope,?`.
## Calibracion EZO-DO
Un punto:
```text
Cal
```
Dos puntos, en este orden:
```text
Cal,0
Cal
```
`Cal,0` usa solucion de cero oxigeno. `Cal` usa la sonda estabilizada en aire
atmosferico. Compensaciones disponibles:
```text
T,<grados Celsius>
S,<conductividad en uS/cm>
S,<salinidad>,ppt
P,<presion en kPa>
```
## Calibracion EZO-EC
Primero se configura la constante de la sonda con `K,<valor>` y se realiza la
calibracion en seco:
```text
K,1.0
Cal,dry
```
Calibracion de dos puntos:
```text
Cal,dry
Cal,<valor>
```
Calibracion de tres puntos:
```text
Cal,dry
Cal,low,<valor>
Cal,high,<valor>
```
No se debe usar `Cal,0` en EC. El valor cero corresponde unicamente al paso
`Cal,dry`.
## Modos del backend
- `EZO_MODE=auto`: usa hardware si encuentra `/dev/i2c-1` y `EZO_COMMAND`;
de lo contrario usa demo.
- `EZO_MODE=hardware`: exige bus y ejecutable reales; si faltan, la API devuelve
error en vez de simular.
- `EZO_MODE=demo`: genera respuestas de desarrollo sin acceder al bus.
Para preparar Raspberry Pi:
```bash
make -C sensors/EZOCommand
sudo usermod -aG i2c $USER
EZO_MODE=hardware npm start
```
Es necesario cerrar sesion y volver a entrar despues de agregar el usuario al
grupo `i2c`.
## Fuentes oficiales
- https://files.atlas-scientific.com/pH_EZO_Datasheet.pdf
- https://files.atlas-scientific.com/DO_EZO_Datasheet.pdf
- https://files.atlas-scientific.com/EC_EZO_Datasheet.pdf
- https://files.atlas-scientific.com/EZO_RTD_Datasheet.pdf

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

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

@ -1,10 +1,13 @@
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;
// Diccionario para traducir estados en la UI sin romper las clases CSS
const stateTranslations = {
@ -114,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) {
@ -205,6 +223,13 @@ function renderSystemStatus(results) {
}
async function updateDashboard() {
if (dashboardUpdateInProgress) {
return;
}
dashboardUpdateInProgress = true;
try {
const [results, alarmConfig] = await Promise.all([
Promise.all(sensors.map(readSensor)),
readAlarmConfig()
@ -216,6 +241,9 @@ async function updateDashboard() {
evaluatedResults.forEach(setSensorState);
renderSystemStatus(evaluatedResults);
renderAlarmSummary(evaluatedResults, alarmConfig);
} finally {
dashboardUpdateInProgress = false;
}
}
async function readAlarmConfig() {
@ -599,6 +627,8 @@ function startHistoryPolling() {
}
// Inicialización de renderizado
updateDashboard();
setInterval(updateDashboard, POLLING_INTERVAL_MS);
updateHistoricalTrends();
startHistoryPolling();
@ -626,12 +656,34 @@ window.updateLoggingRate = async function () {
});
if (!response.ok) throw new Error("Fallo en la sincronización con el servidor");
alert(`Configuración de hardware actualizada:\nLos demonios C++ guardarán 1 registro cada ${rate} segundos.`);
const result = await response.json();
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.");
@ -641,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) {
@ -658,33 +710,3 @@ window.clearHistoricalData = async function () {
alert("Error de sistema al intentar purgar los archivos.");
}
};
const chartCards = {
temperature: document.getElementById("chart-card-temperature"),
ph: document.getElementById("chart-card-ph"),
do: document.getElementById("chart-card-do"),
ec: document.getElementById("chart-card-ec")
};
document.querySelectorAll(".chart-filter").forEach((button) => {
button.addEventListener("click", () => {
const selected = button.dataset.chart;
document.querySelectorAll(".chart-filter").forEach((btn) => {
btn.classList.remove("active");
});
button.classList.add("active");
if (selected === "all") {
Object.values(chartCards).forEach((card) => {
if (card) card.style.display = "";
});
return;
}
Object.entries(chartCards).forEach(([key, card]) => {
if (card) card.style.display = key === selected ? "" : "none";
});
});
});

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

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

@ -0,0 +1,350 @@
const calibrationProfiles = {
rtd: {
description: "Calibración de un punto contra una temperatura de referencia.",
note: "Espere a que la lectura se estabilice antes de enviar Cal,<temperatura>.",
groups: [
{
title: "Punto de referencia",
help: "Use una referencia térmica trazable en grados Celsius.",
value: "25.00",
step: "0.01",
button: "Calibrar temperatura",
command(value) { return `Cal,${value}`; }
}
]
},
ph: {
description: "Calibración de uno, dos o tres puntos. El punto medio siempre va primero.",
note: "Orden Atlas: medio (pH 7), bajo (pH 4), alto (pH 10). Cal,mid borra los otros puntos.",
groups: [
{
title: "Punto medio",
help: "Enjuague la sonda, colóquela en buffer pH 7 y espere estabilidad.",
value: "7.00",
step: "0.01",
button: "Calibrar medio",
command(value) { return `Cal,mid,${value}`; }
},
{
title: "Punto bajo",
help: "Después del punto medio, use buffer ácido, normalmente pH 4.",
value: "4.00",
step: "0.01",
button: "Calibrar bajo",
command(value) { return `Cal,low,${value}`; }
},
{
title: "Punto alto",
help: "Después del punto bajo, use buffer básico, normalmente pH 10.",
value: "10.00",
step: "0.01",
button: "Calibrar alto",
command(value) { return `Cal,high,${value}`; }
},
{
title: "Diagnóstico de pendiente",
help: "Muestra pendiente ácida, básica y desplazamiento del punto neutro.",
button: "Consultar pendiente",
command() { return "Slope,?"; }
},
{
title: "Compensación de temperatura",
help: "La compensación es temporal y siempre se expresa en grados Celsius.",
value: "25.0",
step: "0.1",
button: "Aplicar temperatura",
command(value) { return `T,${value}`; },
secondaryButton: "Consultar temperatura",
secondaryCommand: "T,?"
}
]
},
do: {
description: "Calibración atmosférica de un punto o calibración de dos puntos con cero.",
note: "Para dos puntos, Atlas indica calibrar primero cero (Cal,0) y después aire (Cal).",
groups: [
{
title: "Cero oxígeno",
help: "Use solución de cero O₂, elimine burbujas y espere una lectura estable.",
button: "Calibrar cero",
command() { return "Cal,0"; }
},
{
title: "Oxígeno atmosférico",
help: "Deje la sonda expuesta al aire hasta que la lectura se estabilice.",
button: "Calibrar aire",
command() { return "Cal"; }
},
{
title: "Compensaciones",
help: "Temperatura en °C, salinidad en µS/cm y presión atmosférica en kPa.",
fields: [
{ label: "Temperatura", value: "20.0", step: "0.1", command: "T" },
{ label: "Salinidad", value: "0", step: "1", command: "S" },
{ label: "Presión", value: "101.3", step: "0.1", command: "P" }
]
}
]
},
ec: {
description: "Calibración de dos o tres puntos; la calibración en seco siempre va primero.",
note: "Configure primero la constante K de la sonda. Nunca calibre EC a cero con Cal,0.",
groups: [
{
title: "Constante de la sonda",
help: "Valores habituales: K 0.1, K 1.0 o K 10.",
value: "1.0",
step: "0.1",
button: "Configurar K",
command(value) { return `K,${value}`; },
secondaryButton: "Consultar K",
secondaryCommand: "K,?"
},
{
title: "Calibración en seco",
help: "Conecte la sonda completamente seca. Este paso siempre debe ser primero.",
button: "Calibrar seco",
command() { return "Cal,dry"; }
},
{
title: "Segundo punto",
help: "Para calibración de dos puntos use Cal,<valor>, por ejemplo 1413.",
value: "1413",
step: "1",
button: "Calibrar punto único",
command(value) { return `Cal,${value}`; }
},
{
title: "Puntos bajo y alto",
help: "Para tres puntos use primero bajo y finalmente alto.",
fields: [
{ label: "Bajo", value: "12880", step: "1", command: "Cal,low" },
{ label: "Alto", value: "80000", step: "1", command: "Cal,high" }
]
},
{
title: "Compensación de temperatura",
help: "La compensación se expresa en °C y no se conserva al apagar.",
value: "25.0",
step: "0.1",
button: "Aplicar temperatura",
command(value) { return `T,${value}`; },
secondaryButton: "Consultar temperatura",
secondaryCommand: "T,?"
}
]
}
};
async function fetchHistoricalData(sensorType) {
try {
const response = await fetch(`/api/sensors/${sensorType}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("[ERROR] Fallo al conectar con el backend:", error);
return [];
}
}
async function loadEzoTransportStatus() {
const statusElement = document.getElementById("ezo-transport-status");
try {
const response = await fetch("/api/system/ezo", { cache: "no-store" });
const data = await response.json();
statusElement.textContent = data.mode === "hardware"
? "HARDWARE I2C"
: data.mode === "demo" ? "DEMO" : "NO DISPONIBLE";
statusElement.className = `transport-${data.mode}`;
appendTerminalLine(
document.getElementById("terminal-output"),
`[SISTEMA] ${data.message}`,
data.mode === "hardware" ? "#63e6be" : "#ffd166"
);
} catch (error) {
statusElement.textContent = "NO DISPONIBLE";
statusElement.className = "transport-unavailable";
}
}
async function sendCommand(command, options = {}) {
const sensorType = options.sensor ||
document.getElementById("terminal-sensor-select").value;
const terminalOutput = document.getElementById("terminal-output");
const dangerous = ["factory", "i2c", "baud", "sleep"]
.includes(String(command).split(",")[0].toLowerCase());
let dangerousConfirmed = false;
if (dangerous) {
dangerousConfirmed = confirm(
`El comando ${command} puede reiniciar, dormir o cambiar la comunicación del circuito. ¿Desea enviarlo?`
);
if (!dangerousConfirmed) return null;
}
const timeString = new Date().toLocaleTimeString();
appendTerminalLine(terminalOutput, `[${timeString}] TX (${sensorType}): ${command}`, "#ffffff");
try {
const response = await fetch(`/api/sensors/${sensorType}/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command, dangerousConfirmed })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
const mode = data.mode === "hardware" ? "I2C" : "DEMO";
appendTerminalLine(
terminalOutput,
`[${timeString}] RX (${mode}/${sensorType}): ${data.response}`,
data.response === "*ER" ? "#ff6b6b" : "#63e6be"
);
terminalOutput.scrollTop = terminalOutput.scrollHeight;
return data;
} catch (error) {
appendTerminalLine(
terminalOutput,
`[${timeString}] ERROR: ${error.message}`,
"#ff6b6b"
);
return null;
}
}
async function sendCalibrationCommand(command) {
const sensor = document.getElementById("cal-sensor-select").value;
document.getElementById("terminal-sensor-select").value = sensor;
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() {
const sensor = document.getElementById("cal-sensor-select").value;
const confirmed = confirm(
`Se borrarán todos los puntos de calibración del sensor ${sensor.toUpperCase()}. ¿Continuar?`
);
if (confirmed) {
await sendCalibrationCommand("Cal,clear");
}
}
function renderCalibrationPanel() {
const sensor = document.getElementById("cal-sensor-select").value;
const profile = calibrationProfiles[sensor];
const controls = document.getElementById("calibration-controls");
document.getElementById("calibration-description").textContent = profile.description;
document.getElementById("calibration-note").textContent = profile.note;
controls.replaceChildren();
profile.groups.forEach((group, groupIndex) => {
const container = document.createElement("div");
container.className = "calibration-group";
const title = document.createElement("h4");
title.textContent = group.title;
const help = document.createElement("p");
help.textContent = group.help;
container.append(title, help);
if (group.fields) {
group.fields.forEach((field, fieldIndex) => {
container.appendChild(createCalibrationAction(
`${sensor}-${groupIndex}-${fieldIndex}`,
field.label,
field.value,
field.step,
(value) => `${field.command},${value}`
));
});
} else {
container.appendChild(createCalibrationAction(
`${sensor}-${groupIndex}`,
null,
group.value,
group.step,
group.command,
group.button
));
}
if (group.secondaryCommand) {
const secondary = document.createElement("button");
secondary.className = "btn-command";
secondary.textContent = group.secondaryButton;
secondary.addEventListener("click", () =>
sendCalibrationCommand(group.secondaryCommand)
);
container.appendChild(secondary);
}
controls.appendChild(container);
});
}
function createCalibrationAction(id, label, value, step, commandBuilder, buttonText) {
const row = document.createElement("div");
row.className = "calibration-action";
let input = null;
if (value !== undefined) {
input = document.createElement("input");
input.type = "number";
input.id = `cal-${id}`;
input.value = value;
input.step = step || "0.01";
input.setAttribute("aria-label", label || buttonText || "Valor de calibración");
row.appendChild(input);
}
const button = document.createElement("button");
button.className = "btn-command";
button.textContent = buttonText || label;
button.addEventListener("click", async () => {
const command = commandBuilder(input ? input.value : undefined);
if (!command || (input && input.value === "")) return;
await sendCalibrationCommand(command);
});
row.appendChild(button);
return row;
}
function sendCustomCommand() {
const input = document.getElementById("custom-command");
const command = input.value.trim();
if (command) {
sendCommand(command);
input.value = "";
}
}
function appendTerminalLine(container, message, color) {
const line = document.createElement("div");
line.textContent = message;
line.style.color = color;
container.appendChild(line);
}
document.addEventListener("DOMContentLoaded", () => {
loadEzoTransportStatus();
renderCalibrationPanel();
});

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

@ -1,109 +0,0 @@
// Actualización de mock-service.js (que ahora actúa como un api-service real)
async function fetchHistoricalData(sensorType) {
console.log(`[FETCH] Solicitando datos al backend vía Nginx para: ${sensorType}...`);
try {
// Hacemos la petición a la ruta que Nginx está interceptando (/api/...)
const response = await fetch(`/api/sensors/${sensorType}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("[ERROR] Fallo al conectar con el backend:", error);
// Retornar array vacío para evitar que la UI colapse
return [];
}
}
async function sendCommand(command) {
const sensorType = document.getElementById('terminal-sensor-select').value;
const terminalOutput = document.getElementById('terminal-output');
// 1. Imprimir el comando enviado en la consola UI
const timeString = new Date().toLocaleTimeString();
terminalOutput.innerHTML += `<div><span style="color: #fff;">[${timeString}] TX (${sensorType}):</span> ${command}</div>`;
// Hacer scroll automático hacia abajo
terminalOutput.scrollTop = terminalOutput.scrollHeight;
try {
// 2. Hacer la petición POST al backend mediante Nginx
const response = await fetch(`/api/sensors/${sensorType}/command`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
// El cuerpo viaja en formato JSON
body: JSON.stringify({ command: command })
});
if (!response.ok) {
throw new Error(`Error HTTP: ${response.status}`);
}
const data = await response.json();
// 3. Imprimir la respuesta simulada del circuito EZO en la UI
let color = data.response === '*ER' ? '#ff3333' : '#00ffcc';
terminalOutput.innerHTML += `<div><span style="color: #fff;">[${timeString}] RX (${sensorType}):</span> <span style="color: ${color};">${data.response}</span></div>`;
} catch (error) {
console.error("Error enviando comando:", error);
terminalOutput.innerHTML += `<div><span style="color: #ff3333;">[${timeString}] SYS ERROR: Fallo de bus I2C simulado o servidor desconectado.</span></div>`;
}
// Mantener el scroll al final
terminalOutput.scrollTop = terminalOutput.scrollHeight;
}
/**
* Envía un comando de calibración predefinido y sincroniza el select de la consola.
*/
function sendCalibrationCmd(cmd) {
const sensorSelect = document.getElementById('cal-sensor-select').value;
// Sincronizar el select de la terminal para que los logs sean coherentes
document.getElementById('terminal-sensor-select').value = sensorSelect;
// Reutilizar la función de la consola para enviar la petición POST
sendCommand(cmd);
}
/**
* Toma el valor introducido por el usuario y construye el comando (ej. "cal,7.00").
*/
function calibratePoint() {
const sensorSelect = document.getElementById('cal-sensor-select').value;
const calValue = document.getElementById('cal-value').value;
if (!calValue) {
alert("Por favor, ingresa un valor de referencia para calibrar.");
return;
}
// Sincronizar selectores
document.getElementById('terminal-sensor-select').value = sensorSelect;
// Construir el comando Atlas Scientific y enviarlo a la consola
const fullCommand = `cal,${calValue}`;
sendCommand(fullCommand);
// Limpiar el input después de enviar
document.getElementById('cal-value').value = '';
}
function sendCustomCommand() {
const input = document.getElementById('custom-command');
const command = input.value.trim();
if (command !== "") {
sendCommand(command);
input.value = ""; // Limpiar el input después de enviar
}
}

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

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

@ -0,0 +1,14 @@
CC=gcc
CFLAGS=-Wall -Wextra -O2
TARGETS=EZO_COMMAND EZO_ACQUIRE
all: $(TARGETS)
EZO_COMMAND: main.c
$(CC) $(CFLAGS) -o $@ $<
EZO_ACQUIRE: acquire.c
$(CC) $(CFLAGS) -o $@ $<
clean:
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;
}

@ -0,0 +1,124 @@
#include <errno.h>
#include <fcntl.h>
#include <linux/i2c-dev.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/file.h>
#include <sys/ioctl.h>
#include <unistd.h>
static void print_json_error(const char *message)
{
printf("{\"success\":false,\"error\":\"%s\"}\n", message);
}
int main(int argc, char *argv[])
{
if (argc < 5)
{
print_json_error("Uso: EZO_COMMAND bus address delay_ms command [--no-response]");
return 2;
}
const char *bus = argv[1];
int address = (int)strtol(argv[2], NULL, 0);
int delay_ms = atoi(argv[3]);
const char *command = argv[4];
int no_response = argc > 5 && strcmp(argv[5], "--no-response") == 0;
int lock_fd = open("/tmp/photobioreactor-i2c.lock", O_CREAT | O_RDWR, 0660);
if (lock_fd < 0 || flock(lock_fd, LOCK_EX) < 0)
{
print_json_error("No se pudo bloquear el bus I2C");
return 3;
}
int fd = open(bus, O_RDWR);
if (fd < 0)
{
print_json_error("No se pudo abrir el bus I2C");
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 4;
}
if (ioctl(fd, I2C_SLAVE, address) < 0)
{
print_json_error("No se pudo seleccionar el circuito EZO");
close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 5;
}
if (write(fd, command, strlen(command)) < 0)
{
print_json_error("Fallo al escribir el comando EZO");
close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 6;
}
usleep((useconds_t)delay_ms * 1000);
if (no_response)
{
printf("{\"success\":true,\"response\":\"SENT\"}\n");
close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 0;
}
unsigned char response[64] = {0};
int bytes_read = read(fd, response, sizeof(response) - 1);
for (int retry = 0; bytes_read > 0 && response[0] == 254 && retry < 10; retry++)
{
usleep(100000);
memset(response, 0, sizeof(response));
bytes_read = read(fd, response, sizeof(response) - 1);
}
if (bytes_read < 1)
{
print_json_error("El circuito EZO no respondió");
close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 7;
}
if (response[0] != 1)
{
char message[64];
snprintf(message, sizeof(message), "Código de respuesta EZO: %u", response[0]);
print_json_error(message);
close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 8;
}
char *payload = (char *)&response[1];
payload[bytes_read > 1 ? bytes_read - 1 : 0] = '\0';
for (int i = 0; payload[i] != '\0'; i++)
{
if (payload[i] == '"' || payload[i] == '\\')
{
payload[i] = ' ';
}
}
printf("{\"success\":true,\"response\":\"%s\"}\n",
payload[0] == '\0' ? "*OK" : payload);
close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 0;
}

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

@ -0,0 +1,31 @@
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include "ezodo.h"
int main()
{
int fd = open("/dev/i2c-1", O_RDWR);
if (fd < 0)
{
fprintf(stderr, "%s\n", strerror(errno));
return -1;
}
double oxygen;
if (getDO(fd, &oxygen) < 0)
{
close(fd);
return -1;
}
printf("{ \"do\": %.3f }\n", oxygen);
close(fd);
return 0;
}

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

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

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

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

@ -1,13 +1,22 @@
#include <stdio.h>
#include <unistd.h>
#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);
@ -20,12 +29,24 @@ int main()
while (1)
{
double temperature;
int lock_fd = open("/tmp/photobioreactor-i2c.lock", O_CREAT | O_RDWR, 0660);
if (getTemperature(fd, &temperature) == 0)
if (lock_fd >= 0 && flock(lock_fd, LOCK_EX) == 0 &&
getTemperature(fd, &temperature) == 0)
{
FILE *f = fopen(
"/home/cristian/airquality/basic-ui-dashboard/data/EZORTD.json",
"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)
{
@ -36,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)
{
@ -60,6 +79,12 @@ int main()
}
}
if (lock_fd >= 0)
{
flock(lock_fd, LOCK_UN);
close(lock_fd);
}
sleep(1);
}

@ -0,0 +1,161 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const 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;
test.before(async () => {
await new Promise((resolve) => {
server = app.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
baseUrl = `http://127.0.0.1:${address.port}`;
});
test.after(async () => {
await new Promise((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
});
fs.rmSync(temporaryRoot, { recursive: true, force: true });
});
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, 2);
assert.ok(data[0].Timestamp);
assert.ok(data[0].Sensor);
assert.equal(typeof data[0].Valor, 'number');
});
test('serves the dashboard in local development mode', async () => {
const response = await fetch(`${baseUrl}/`);
const html = await response.text();
assert.equal(response.status, 200);
assert.match(html, /Panel del Fotobiorreactor/);
});
test('validates sensor commands', async () => {
const invalidResponse = await fetch(`${baseUrl}/api/sensors/invalid/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: 'r' })
});
assert.equal(invalidResponse.status, 400);
const validResponse = await fetch(`${baseUrl}/api/sensors/ph/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: 'r' })
});
const validData = await validResponse.json();
assert.equal(validResponse.status, 200);
assert.equal(validData.success, true);
assert.equal(validData.mode, 'demo');
});
test('rejects undocumented calibration syntax', async () => {
const response = await fetch(`${baseUrl}/api/sensors/do/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: 'Cal,atm' })
});
const data = await response.json();
assert.equal(response.status, 400);
assert.match(data.error, /documentados/);
});
test('persists logging rates used by acquisition', async () => {
const invalidResponse = await fetch(`${baseUrl}/api/config/logging`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rate: 3 })
});
assert.equal(invalidResponse.status, 400);
const validResponse = await fetch(`${baseUrl}/api/config/logging`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rate: 5 })
});
const validData = await validResponse.json();
assert.equal(validResponse.status, 200);
assert.equal(validData.rate, 5);
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 () => {
for (const name of Object.keys(HISTORY_FILES)) {
fs.writeFileSync(path.join(temporaryLogs, `${name}.csv`), 'old,data\n1,2\n');
}
const response = await fetch(`${baseUrl}/api/history/clear`, {
method: 'POST'
});
assert.equal(response.status, 200);
for (const [name, header] of Object.entries(HISTORY_FILES)) {
const content = fs.readFileSync(
path.join(temporaryLogs, `${name}.csv`),
'utf8'
);
assert.equal(content, header);
}
});

@ -0,0 +1,36 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const {
getProcessingDelay,
matchesOfficialSyntax
} = require('../api/ezo-command-service');
test('accepts official calibration commands by sensor', () => {
assert.equal(matchesOfficialSyntax('rtd', 'Cal,25.00'), true);
assert.equal(matchesOfficialSyntax('ph', 'Cal,mid,7.00'), true);
assert.equal(matchesOfficialSyntax('ph', 'Cal,low,4.00'), true);
assert.equal(matchesOfficialSyntax('ph', 'Cal,high,10.00'), true);
assert.equal(matchesOfficialSyntax('do', 'Cal'), true);
assert.equal(matchesOfficialSyntax('do', 'Cal,0'), true);
assert.equal(matchesOfficialSyntax('ec', 'Cal,dry'), true);
assert.equal(matchesOfficialSyntax('ec', 'Cal,1413'), true);
assert.equal(matchesOfficialSyntax('ec', 'Cal,low,12880'), true);
assert.equal(matchesOfficialSyntax('ec', 'Cal,high,80000'), true);
});
test('rejects commands assigned to the wrong sensor', () => {
assert.equal(matchesOfficialSyntax('do', 'Cal,atm'), false);
assert.equal(matchesOfficialSyntax('rtd', 'Cal,mid,7'), false);
assert.equal(matchesOfficialSyntax('ph', 'Cal,dry'), false);
assert.equal(matchesOfficialSyntax('rtd', 'T,25'), false);
});
test('uses Atlas processing delays for critical commands', () => {
assert.equal(getProcessingDelay('R'), 1000);
assert.equal(getProcessingDelay('Cal'), 1300);
assert.equal(getProcessingDelay('Cal,0'), 1300);
assert.equal(getProcessingDelay('Cal,mid,7'), 900);
assert.equal(getProcessingDelay('Cal,dry'), 600);
assert.equal(getProcessingDelay('Status'), 300);
});

@ -0,0 +1,64 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.join(__dirname, '..');
function read(relativePath) {
return fs.readFileSync(path.join(root, relativePath), 'utf8');
}
test('dashboard starts live polling and exposes required controls', () => {
const html = read('frontend/index.html');
const dashboard = read('frontend/dashboard.js');
assert.match(html, /id="history-interval"/);
assert.match(dashboard, /updateDashboard\(\);/);
assert.match(
dashboard,
/setInterval\(updateDashboard,\s*POLLING_INTERVAL_MS\)/
);
});
test('dashboard HTML has balanced structural containers', () => {
const html = read('frontend/index.html');
const openDivs = (html.match(/<div\b/g) || []).length;
const closeDivs = (html.match(/<\/div>/g) || []).length;
const openSections = (html.match(/<section\b/g) || []).length;
const closeSections = (html.match(/<\/section>/g) || []).length;
assert.equal(openDivs, closeDivs);
assert.equal(openSections, closeSections);
});
test('terminal output does not append untrusted HTML', () => {
const service = read('frontend/ezo-service.js');
assert.doesNotMatch(service, /terminalOutput\.innerHTML/);
assert.match(service, /line\.textContent = message/);
});
test('sensor Makefiles reference their real implementation objects', () => {
assert.match(read('sensors/EZOPH/Makefile'), /OBJ=main\.o ezoph\.o/);
assert.match(read('sensors/EZODO/Makefile'), /OBJ=main\.o ezodo\.o/);
assert.match(read('sensors/EZOEC/Makefile'), /OBJ=main\.o ezoec\.o/);
assert.ok(read('sensors/EZODO/main.c').trim().length > 0);
});
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