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
|
Sistema de monitoreo para Raspberry Pi y circuitos Atlas Scientific EZO:
|
||||||
**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
|
|
||||||
|
|
||||||
---
|
| Variable | Circuito | Dirección | Unidad |
|
||||||
|
|
||||||
## 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 |
|
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Temperatura | EZO-RTD™ (ISCCB-2) | `0x66` | °C |
|
| Temperatura | EZO-RTD | `0x66` | °C |
|
||||||
| Potencial de Hidrógeno | EZO-pH™ | `0x63` | pH |
|
| pH | EZO-pH | `0x63` | pH |
|
||||||
| Oxígeno Disuelto | EZO-DO™ | `0x61` | mg/L |
|
| Oxígeno disuelto | EZO-DO | `0x61` | mg/L |
|
||||||
| Conductividad Eléctrica | EZO-EC™ | `0x64` | µS/cm |
|
| Conductividad | 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
|
|
||||||
|
|
||||||
---
|
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
|
```text
|
||||||
/
|
EZO por I2C
|
||||||
├── api/
|
|
|
||||||
│ └── server.js # Servidor Express: endpoints REST y parser léxico EZO
|
EZO_ACQUIRE / EZO_COMMAND
|
||||||
│
|
|
|
||||||
├── config/
|
Recolector Node ---------> data/*.json
|
||||||
│ ├── alarms.json # Umbrales operativos configurables por variable
|
| logs/*.csv
|
||||||
│ └── sensors.json # Metadatos declarativos de los módulos EZO (dirección I²C, habilitación)
|
|
|
||||||
│
|
API Express <------------ Dashboard
|
||||||
├── data/
|
^
|
||||||
│ ├── EZORTD.json # Vector de estado actual: temperatura (escrito por el demonio C)
|
|
|
||||||
│ ├── EZOPH.json # Vector de estado actual: pH
|
Nginx
|
||||||
│ ├── 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_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
|
## Desarrollo sin sensores
|
||||||
|
|
||||||
### 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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm ci
|
||||||
node api/server.js
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
El servidor quedará escuchando en `http://localhost:3000`. Verificar el inicio exitoso con el mensaje:
|
Ejecute en dos terminales:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
[MOCK SERVER] Backend Node.js corriendo en http://localhost:3000
|
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
|
En PowerShell:
|
||||||
npm install -g pm2
|
|
||||||
pm2 start api/server.js --name fotobiorreactor-api
|
|
||||||
pm2 save
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3. Configuración del Proxy Inverso Nginx
|
```powershell
|
||||||
|
$env:EZO_MODE = "demo"
|
||||||
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`:
|
npm run acquire
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 8888;
|
|
||||||
server_name localhost;
|
|
||||||
|
|
||||||
# Raíz del contenido estático: directorio raíz del repositorio
|
|
||||||
root /ruta/absoluta/al/repositorio;
|
|
||||||
index frontend/index.html;
|
|
||||||
|
|
||||||
# Entrega de archivos estáticos del frontend
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ =404;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Proxy inverso transparente hacia el backend Node.js
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://127.0.0.1:3000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Habilitar el sitio y recargar el servicio:
|
Y en una segunda terminal:
|
||||||
|
|
||||||
```bash
|
```powershell
|
||||||
sudo ln -s /etc/nginx/sites-available/fotobiorreactor /etc/nginx/sites-enabled/
|
$env:EZO_MODE = "demo"
|
||||||
sudo nginx -t
|
npm start
|
||||||
sudo systemctl reload nginx
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
http://localhost:8888/frontend/index.html
|
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
|
Los CSV usan una única nomenclatura y el formato:
|
||||||
cd sensors/EZORTD
|
|
||||||
make
|
```csv
|
||||||
|
timestamp,value
|
||||||
|
2026-06-22T12:00:00.000Z,25.123
|
||||||
```
|
```
|
||||||
|
|
||||||
Esto generará el ejecutable `EZORTD`. Antes de compilar el demonio continuo (`ezortd_daemon.c`), actualizar las rutas absolutas codificadas en el código fuente para que correspondan al directorio de despliegue real en la Raspberry Pi.
|
## Documentación
|
||||||
|
|
||||||
**Nota:** El bus I²C debe estar habilitado en la Raspberry Pi mediante `sudo raspi-config` → *Interface Options* → *I2C* → *Enable*. Se recomienda agregar el usuario de ejecución al grupo `i2c` para evitar la ejecución con privilegios de superusuario:
|
- Estado actual: [`PROJECT_STATUS.md`](PROJECT_STATUS.md)
|
||||||
|
- Comandos y calibración: [`docs/EZO_COMMANDS.md`](docs/EZO_COMMANDS.md)
|
||||||
|
- Despliegue: [`docs/RASPBERRY_PI_DEPLOYMENT.md`](docs/RASPBERRY_PI_DEPLOYMENT.md)
|
||||||
|
- Diseño extendido: [`ARCHITECTURE.md`](ARCHITECTURE.md)
|
||||||
|
|
||||||
```bash
|
No se utiliza un driver personalizado del kernel. Linux ya proporciona la capa
|
||||||
sudo usermod -aG i2c $USER
|
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 express = require('express');
|
||||||
const cors = require('cors');
|
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 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(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use('/frontend', express.static(path.join(__dirname, '..', 'frontend')));
|
||||||
|
app.use('/data', express.static(path.join(__dirname, '..', 'data'), {
|
||||||
|
etag: false,
|
||||||
|
maxAge: 0
|
||||||
|
}));
|
||||||
|
app.use('/logs', express.static(LOGS_DIRECTORY, {
|
||||||
|
etag: false,
|
||||||
|
maxAge: 0
|
||||||
|
}));
|
||||||
|
app.use('/config', express.static(path.join(__dirname, '..', 'config'), {
|
||||||
|
etag: false,
|
||||||
|
maxAge: 0
|
||||||
|
}));
|
||||||
|
app.get('/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
|
app.get('/', (req, res) => {
|
||||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
res.redirect('/frontend/index.html');
|
||||||
|
});
|
||||||
|
|
||||||
// Endpoint principal para obtener datos de los sensores (MOCK)
|
app.get('/api/system/ezo', (req, res) => {
|
||||||
app.get('/api/sensors/:type', async (req, res) => {
|
const commandMode = detectMode();
|
||||||
const sensorType = req.params.type;
|
const acquisitionMode = detectAcquisitionMode();
|
||||||
console.log(`[GET] Petición recibida para sensor: ${sensorType}`);
|
|
||||||
|
|
||||||
// Simulación de latencia I2C y procesamiento (400ms)
|
res.json({
|
||||||
await delay(400);
|
mode: commandMode.mode,
|
||||||
|
message: commandMode.message,
|
||||||
|
acquisitionMode: acquisitionMode.mode,
|
||||||
|
acquisitionMessage: acquisitionMode.message
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const data = [];
|
function parseHistoryCsv(csvText, sensorName) {
|
||||||
const now = new Date();
|
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
|
app.get('/api/sensors/:type', async (req, res) => {
|
||||||
for (let i = 60; i >= 0; i--) {
|
const sensorType = req.params.type.toLowerCase();
|
||||||
const timestamp = new Date(now.getTime() - i * 1000).toISOString();
|
|
||||||
|
|
||||||
// Simulación de valores con ruido térmico/químico
|
if (sensorType !== 'all' && !HISTORY_SENSOR_MAP[sensorType]) {
|
||||||
const mockValues = {
|
return res.status(400).json({ error: 'Sensor no válido.' });
|
||||||
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)
|
|
||||||
};
|
|
||||||
|
|
||||||
|
try {
|
||||||
if (sensorType === 'all') {
|
if (sensorType === 'all') {
|
||||||
data.push({
|
const histories = await Promise.all(
|
||||||
Timestamp: timestamp,
|
Object.keys(HISTORY_SENSOR_MAP).map(readSensorHistory)
|
||||||
Temperatura_C: mockValues.rtd,
|
);
|
||||||
pH: mockValues.ph,
|
return res.json(
|
||||||
DO_mgL: mockValues.do,
|
histories.flat().sort((left, right) =>
|
||||||
EC_uS: mockValues.ec
|
String(left.Timestamp).localeCompare(String(right.Timestamp))
|
||||||
});
|
)
|
||||||
} else if (mockValues[sensorType]) {
|
);
|
||||||
data.push({
|
|
||||||
Timestamp: timestamp,
|
|
||||||
Sensor: sensorType.toUpperCase(),
|
|
||||||
Valor: mockValues[sensorType]
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return res.status(400).json({ error: "Sensor no válido" });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
res.json(data);
|
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) => {
|
app.post('/api/sensors/:type/command', async (req, res) => {
|
||||||
const sensorType = req.params.type.toUpperCase();
|
try {
|
||||||
const rawCommand = req.body.command ? req.body.command.toLowerCase().trim() : '';
|
const result = await executeEzoCommand(
|
||||||
|
req.params.type.toLowerCase(),
|
||||||
console.log(`[COMANDO] RX: '${rawCommand}' para ${sensorType}`);
|
req.body.command,
|
||||||
|
{ dangerousConfirmed: req.body.dangerousConfirmed === true }
|
||||||
// Dividimos el comando por comas para analizar base y parámetros (ej. "cal,mid,7.00" -> ["cal", "mid", "7.00"])
|
);
|
||||||
const parts = rawCommand.split(',');
|
res.json(result);
|
||||||
const baseCmd = parts[0];
|
} catch (error) {
|
||||||
|
res.status(error.status || 500).json({
|
||||||
let ezoResponse = "";
|
success: false,
|
||||||
|
error: error.message
|
||||||
// 1. COMANDOS DE ESTADO Y CONFIGURACIÓN GLOBAL (Comunes a todos los EZO)
|
});
|
||||||
if (baseCmd === 'i') {
|
|
||||||
await delay(300);
|
|
||||||
ezoResponse = `?I,${sensorType},2.12`;
|
|
||||||
}
|
|
||||||
else if (baseCmd === 'status') {
|
|
||||||
await delay(300);
|
|
||||||
ezoResponse = `?STATUS,P,5.03`; // P = Power On, 5.03 = Voltaje
|
|
||||||
}
|
|
||||||
else if (baseCmd === 'sleep') {
|
|
||||||
ezoResponse = `[SLEEP MODE ACTIVADO]`;
|
|
||||||
}
|
|
||||||
else if (baseCmd === 'factory') {
|
|
||||||
await delay(800);
|
|
||||||
ezoResponse = `*OK`;
|
|
||||||
}
|
|
||||||
else if (baseCmd === 'find') {
|
|
||||||
await delay(300);
|
|
||||||
ezoResponse = `*OK`; // Hace parpadear el LED en blanco
|
|
||||||
}
|
|
||||||
else if (baseCmd === 'led') {
|
|
||||||
await delay(300);
|
|
||||||
if (parts[1] === '?') ezoResponse = `?LED,1`;
|
|
||||||
else ezoResponse = `*OK`;
|
|
||||||
}
|
|
||||||
else if (baseCmd === 'plock') {
|
|
||||||
// Protocol Lock (Evita cambios accidentales de I2C a UART)
|
|
||||||
await delay(300);
|
|
||||||
if (parts[1] === '?') ezoResponse = `?PLOCK,1`;
|
|
||||||
else ezoResponse = `*OK`;
|
|
||||||
}
|
|
||||||
else if (baseCmd === 'i2c') {
|
|
||||||
// Cambio de dirección I2C (ej. i2c,100)
|
|
||||||
await delay(300);
|
|
||||||
ezoResponse = `*OK`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. COMANDO DE LECTURA PRINCIPAL
|
|
||||||
else if (baseCmd === 'r') {
|
|
||||||
await delay(900); // Latencia real de procesamiento químico/eléctrico
|
|
||||||
if (sensorType === 'RTD') ezoResponse = (25.0 + Math.random() * 0.1).toFixed(3);
|
|
||||||
if (sensorType === 'PH') ezoResponse = (7.2 + Math.random() * 0.05).toFixed(2);
|
|
||||||
if (sensorType === 'DO') ezoResponse = (8.5 + Math.random() * 0.1).toFixed(2);
|
|
||||||
if (sensorType === 'EC') ezoResponse = (1050 + Math.random() * 5).toFixed(0);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 3. SISTEMA DE CALIBRACIÓN UNIVERSAL
|
app.get('/api/config/logging', async (req, res) => {
|
||||||
else if (baseCmd === 'cal') {
|
const config = await readRuntimeConfig();
|
||||||
await delay(600);
|
res.json({ success: true, rate: config.loggingRateSeconds });
|
||||||
if (parts[1] === 'clear') {
|
});
|
||||||
ezoResponse = `*OK`;
|
|
||||||
} else if (parts[1] === '?') {
|
|
||||||
ezoResponse = `?CAL,1`; // Devuelve puntos calibrados
|
|
||||||
} else {
|
|
||||||
// Acepta cal,mid,7.00 (pH) | cal,atm (DO) | cal,dry (EC) | cal,t (RTD)
|
|
||||||
ezoResponse = `*OK`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. COMPENSACIONES AMBIENTALES (Temperatura, Salinidad, Presión)
|
|
||||||
else if (baseCmd === 't' || baseCmd === 's' || baseCmd === 'p') {
|
|
||||||
await delay(300);
|
|
||||||
if (parts[1] === '?') {
|
|
||||||
const defaultVals = { 't': '25.0', 's': '0.00', 'p': '101.3' };
|
|
||||||
ezoResponse = `?${baseCmd.toUpperCase()},${defaultVals[baseCmd]}`;
|
|
||||||
} else {
|
|
||||||
ezoResponse = `*OK`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. COMANDOS ESPECÍFICOS POR SENSOR
|
|
||||||
else {
|
|
||||||
await delay(300);
|
|
||||||
|
|
||||||
// --- RTD (Temperatura) ---
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si ninguna regla coincide, el comando no existe en el datasheet
|
app.post('/api/config/logging', async (req, res) => {
|
||||||
else {
|
try {
|
||||||
ezoResponse = `*ER`;
|
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 ---
|
app.post('/api/history/clear', async (req, res) => {
|
||||||
|
try {
|
||||||
// Variable global para simular la configuración de escritura del demonio C++
|
await fs.mkdir(LOGS_DIRECTORY, { recursive: true });
|
||||||
let loggingRateSeconds = 1;
|
await Promise.all(
|
||||||
|
Object.entries(HISTORY_FILES).map(([name, header]) =>
|
||||||
app.post('/api/config/logging', (req, res) => {
|
fs.writeFile(
|
||||||
loggingRateSeconds = req.body.rate || 1;
|
path.join(LOGS_DIRECTORY, `${name}.csv`),
|
||||||
console.log(`[SISTEMA] Demonio de escritura configurado a: 1 registro cada ${loggingRateSeconds}s`);
|
header,
|
||||||
res.json({ success: true, rate: loggingRateSeconds });
|
'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) => {
|
if (require.main === module) {
|
||||||
console.log(`[SISTEMA] Purgado de base de datos histórico solicitado por el usuario.`);
|
app.listen(PORT, HOST, () => {
|
||||||
// En el hardware real, aquí se ejecutaría 'fs.unlink()' o se truncarían los archivos CSV en /logs/
|
console.log(`[API] Backend Node.js activo en http://${HOST}:${PORT}`);
|
||||||
res.json({ success: true, message: "Archivos de registro truncados correctamente." });
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
module.exports = {
|
||||||
console.log(`[MOCK SERVER] Backend Node.js corriendo en http://localhost:${PORT}`);
|
app,
|
||||||
});
|
HISTORY_FILES,
|
||||||
|
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