Compare commits
2 Commits
0ba3e59fb6
...
bcbee4e479
| Author | SHA1 | Date |
|---|---|---|
|
|
bcbee4e479 | 2 months ago |
|
|
426b9c247b | 2 months ago |
@ -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));
|
||||
app.get('/', (req, res) => {
|
||||
res.redirect('/frontend/index.html');
|
||||
});
|
||||
|
||||
// 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}`);
|
||||
app.get('/api/system/ezo', (req, res) => {
|
||||
const commandMode = detectMode();
|
||||
const acquisitionMode = detectAcquisitionMode();
|
||||
|
||||
// Simulación de latencia I2C y procesamiento (400ms)
|
||||
await delay(400);
|
||||
res.json({
|
||||
mode: commandMode.mode,
|
||||
message: commandMode.message,
|
||||
acquisitionMode: acquisitionMode.mode,
|
||||
acquisitionMessage: acquisitionMode.message
|
||||
});
|
||||
});
|
||||
|
||||
const data = [];
|
||||
const now = new Date();
|
||||
function parseHistoryCsv(csvText, sensorName) {
|
||||
return csvText
|
||||
.split(/\r?\n/)
|
||||
.slice(1)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const separatorIndex = line.indexOf(',');
|
||||
|
||||
if (separatorIndex < 0) return null;
|
||||
const timestamp = line.slice(0, separatorIndex).trim();
|
||||
const value = Number(line.slice(separatorIndex + 1).trim());
|
||||
|
||||
if (!timestamp || !Number.isFinite(value)) return null;
|
||||
return { Timestamp: timestamp, Sensor: sensorName, Valor: value };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function readSensorHistory(sensorType) {
|
||||
const sensor = HISTORY_SENSOR_MAP[sensorType];
|
||||
const csvText = await fs.readFile(
|
||||
path.join(LOGS_DIRECTORY, sensor.file),
|
||||
'utf8'
|
||||
);
|
||||
return parseHistoryCsv(csvText, sensor.name);
|
||||
}
|
||||
|
||||
// Generamos 60 registros simulados
|
||||
for (let i = 60; i >= 0; i--) {
|
||||
const timestamp = new Date(now.getTime() - i * 1000).toISOString();
|
||||
app.get('/api/sensors/:type', async (req, res) => {
|
||||
const sensorType = req.params.type.toLowerCase();
|
||||
|
||||
// Simulación de valores con ruido térmico/químico
|
||||
const mockValues = {
|
||||
rtd: (25.0 + (Math.random() * 0.1 - 0.05)).toFixed(2),
|
||||
ph: (7.2 + (Math.random() * 0.04 - 0.02)).toFixed(2),
|
||||
do: (8.5 + (Math.random() * 0.1 - 0.05)).toFixed(2),
|
||||
ec: (1050 + (Math.random() * 10 - 5)).toFixed(0)
|
||||
};
|
||||
if (sensorType !== 'all' && !HISTORY_SENSOR_MAP[sensorType]) {
|
||||
return res.status(400).json({ error: 'Sensor no válido.' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (sensorType === 'all') {
|
||||
data.push({
|
||||
Timestamp: timestamp,
|
||||
Temperatura_C: mockValues.rtd,
|
||||
pH: mockValues.ph,
|
||||
DO_mgL: mockValues.do,
|
||||
EC_uS: mockValues.ec
|
||||
});
|
||||
} else if (mockValues[sensorType]) {
|
||||
data.push({
|
||||
Timestamp: timestamp,
|
||||
Sensor: sensorType.toUpperCase(),
|
||||
Valor: mockValues[sensorType]
|
||||
});
|
||||
} else {
|
||||
return res.status(400).json({ error: "Sensor no válido" });
|
||||
const histories = await Promise.all(
|
||||
Object.keys(HISTORY_SENSOR_MAP).map(readSensorHistory)
|
||||
);
|
||||
return res.json(
|
||||
histories.flat().sort((left, right) =>
|
||||
String(left.Timestamp).localeCompare(String(right.Timestamp))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(data);
|
||||
return res.json(await readSensorHistory(sensorType));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return res.json([]);
|
||||
console.error('[HISTORY] No se pudo leer el historial:', error);
|
||||
return res.status(500).json({
|
||||
error: 'No se pudo leer el historial.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 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() : '';
|
||||
|
||||
console.log(`[COMANDO] RX: '${rawCommand}' para ${sensorType}`);
|
||||
|
||||
// Dividimos el comando por comas para analizar base y parámetros (ej. "cal,mid,7.00" -> ["cal", "mid", "7.00"])
|
||||
const parts = rawCommand.split(',');
|
||||
const baseCmd = parts[0];
|
||||
|
||||
let ezoResponse = "";
|
||||
|
||||
// 1. COMANDOS DE ESTADO Y CONFIGURACIÓN GLOBAL (Comunes a todos los EZO)
|
||||
if (baseCmd === 'i') {
|
||||
await delay(300);
|
||||
ezoResponse = `?I,${sensorType},2.12`;
|
||||
}
|
||||
else if (baseCmd === 'status') {
|
||||
await delay(300);
|
||||
ezoResponse = `?STATUS,P,5.03`; // P = Power On, 5.03 = Voltaje
|
||||
}
|
||||
else if (baseCmd === 'sleep') {
|
||||
ezoResponse = `[SLEEP MODE ACTIVADO]`;
|
||||
}
|
||||
else if (baseCmd === 'factory') {
|
||||
await delay(800);
|
||||
ezoResponse = `*OK`;
|
||||
}
|
||||
else if (baseCmd === 'find') {
|
||||
await delay(300);
|
||||
ezoResponse = `*OK`; // Hace parpadear el LED en blanco
|
||||
}
|
||||
else if (baseCmd === 'led') {
|
||||
await delay(300);
|
||||
if (parts[1] === '?') ezoResponse = `?LED,1`;
|
||||
else ezoResponse = `*OK`;
|
||||
}
|
||||
else if (baseCmd === 'plock') {
|
||||
// Protocol Lock (Evita cambios accidentales de I2C a UART)
|
||||
await delay(300);
|
||||
if (parts[1] === '?') ezoResponse = `?PLOCK,1`;
|
||||
else ezoResponse = `*OK`;
|
||||
}
|
||||
else if (baseCmd === 'i2c') {
|
||||
// Cambio de dirección I2C (ej. i2c,100)
|
||||
await delay(300);
|
||||
ezoResponse = `*OK`;
|
||||
}
|
||||
|
||||
// 2. COMANDO DE LECTURA PRINCIPAL
|
||||
else if (baseCmd === 'r') {
|
||||
await delay(900); // Latencia real de procesamiento químico/eléctrico
|
||||
if (sensorType === 'RTD') ezoResponse = (25.0 + Math.random() * 0.1).toFixed(3);
|
||||
if (sensorType === 'PH') ezoResponse = (7.2 + Math.random() * 0.05).toFixed(2);
|
||||
if (sensorType === 'DO') ezoResponse = (8.5 + Math.random() * 0.1).toFixed(2);
|
||||
if (sensorType === 'EC') ezoResponse = (1050 + Math.random() * 5).toFixed(0);
|
||||
}
|
||||
|
||||
// 3. SISTEMA DE CALIBRACIÓN UNIVERSAL
|
||||
else if (baseCmd === 'cal') {
|
||||
await delay(600);
|
||||
if (parts[1] === 'clear') {
|
||||
ezoResponse = `*OK`;
|
||||
} else if (parts[1] === '?') {
|
||||
ezoResponse = `?CAL,1`; // Devuelve puntos calibrados
|
||||
} else {
|
||||
// Acepta cal,mid,7.00 (pH) | cal,atm (DO) | cal,dry (EC) | cal,t (RTD)
|
||||
ezoResponse = `*OK`;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. COMPENSACIONES AMBIENTALES (Temperatura, Salinidad, Presión)
|
||||
else if (baseCmd === 't' || baseCmd === 's' || baseCmd === 'p') {
|
||||
await delay(300);
|
||||
if (parts[1] === '?') {
|
||||
const defaultVals = { 't': '25.0', 's': '0.00', 'p': '101.3' };
|
||||
ezoResponse = `?${baseCmd.toUpperCase()},${defaultVals[baseCmd]}`;
|
||||
} else {
|
||||
ezoResponse = `*OK`;
|
||||
}
|
||||
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
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 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`;
|
||||
}
|
||||
|
||||
// --- 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`;
|
||||
}
|
||||
|
||||
// --- 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
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
sensor: sensorType,
|
||||
command: rawCommand,
|
||||
response: ezoResponse
|
||||
});
|
||||
});
|
||||
|
||||
// --- RUTAS DE GESTIÓN DE ALMACENAMIENTO ---
|
||||
|
||||
// Variable global para simular la configuración de escritura del demonio C++
|
||||
let loggingRateSeconds = 1;
|
||||
|
||||
app.post('/api/config/logging', (req, res) => {
|
||||
loggingRateSeconds = req.body.rate || 1;
|
||||
console.log(`[SISTEMA] Demonio de escritura configurado a: 1 registro cada ${loggingRateSeconds}s`);
|
||||
res.json({ success: true, rate: loggingRateSeconds });
|
||||
app.post('/api/history/clear', async (req, res) => {
|
||||
try {
|
||||
await 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,
|
||||
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.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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.
|
||||
@ -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();
|
||||
});
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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…
Reference in New Issue