Integrar adquisición y despliegue en Raspberry Pi
parent
426b9c247b
commit
bcbee4e479
@ -1,234 +1,101 @@
|
||||
# Sistema de Monitoreo de Parámetros Fisicoquímicos para Fotobiorreactor
|
||||
# Photobioreactor Dashboard
|
||||
|
||||
**Plataforma de hardware:** Raspberry Pi 4 Model B
|
||||
**Estado del proyecto:** Desarrollo activo — Integración de hardware en curso (Fase 9)
|
||||
**Pila tecnológica:** C, Node.js, Express, Vanilla JavaScript, Nginx
|
||||
**Protocolo de comunicación físico:** I²C (Inter-Integrated Circuit), 100 kHz — 400 kHz
|
||||
Sistema de monitoreo para Raspberry Pi y circuitos Atlas Scientific EZO:
|
||||
|
||||
---
|
||||
|
||||
## 1. Resumen Ejecutivo
|
||||
|
||||
Este repositorio documenta la arquitectura de software y las especificaciones de diseño de hardware de un sistema de adquisición de datos (DAQ) en tiempo real orientado a fotobiorreactores de escala de laboratorio. El proyecto se inició como un sistema de medición de temperatura de precisión basado en el módulo **EZO-RTD™** de Atlas Scientific y una sonda de platino PT1000; sin embargo, ha evolucionado en una plataforma integral de monitoreo que gestiona de forma simultánea cuatro parámetros fisicoquímicos críticos para el control de cultivos fotosintéticos.
|
||||
|
||||
Los cuatro parámetros monitoreados y los módulos OEM asociados son los siguientes:
|
||||
|
||||
| Parámetro | Módulo EZO | Dirección I²C | Unidad de medida |
|
||||
| Variable | Circuito | Dirección | Unidad |
|
||||
|---|---|---|---|
|
||||
| Temperatura | EZO-RTD™ (ISCCB-2) | `0x66` | °C |
|
||||
| Potencial de Hidrógeno | EZO-pH™ | `0x63` | pH |
|
||||
| Oxígeno Disuelto | EZO-DO™ | `0x61` | mg/L |
|
||||
| Conductividad Eléctrica | EZO-EC™ | `0x64` | µS/cm |
|
||||
|
||||
La arquitectura del sistema implementa una topología de red distribuida localmente, separando de forma explícita las responsabilidades en cuatro capas: presentación (frontend), enrutamiento (Nginx), lógica de negocio y API (Node.js/Express) y adquisición de datos en hardware (demonios en C). Este diseño por capas garantiza la extensibilidad del sistema y permite la sustitución o incorporación de nuevos módulos sensores sin alterar la lógica de presentación.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pila Tecnológica
|
||||
|
||||
### 2.1. Capa de Presentación (Frontend)
|
||||
|
||||
- **Lenguaje:** Vanilla JavaScript ES6+, HTML5, CSS3 puro
|
||||
- **Biblioteca de visualización:** Chart.js (entregada vía CDN)
|
||||
- **Biblioteca de exportación tabular:** SheetJS (`xlsx.full.min.js`, vía CDN)
|
||||
- **Ciclo de actualización de lecturas en vivo:** 1000 ms (intervalo de *polling*)
|
||||
- **Ciclo de actualización de tendencias históricas:** 10 000 ms
|
||||
- **Idioma de la interfaz:** Español neutro
|
||||
| Temperatura | EZO-RTD | `0x66` | °C |
|
||||
| pH | EZO-pH | `0x63` | pH |
|
||||
| Oxígeno disuelto | EZO-DO | `0x61` | mg/L |
|
||||
| Conductividad | EZO-EC | `0x64` | µS/cm |
|
||||
|
||||
### 2.2. Capa de Enrutamiento y Proxy
|
||||
El sistema incluye dashboard responsive, históricos Chart.js, alarmas,
|
||||
exportación CSV/Excel, consola de comandos y panel de calibración.
|
||||
|
||||
- **Servidor:** Nginx
|
||||
- **Puerto de entrada:** `8888`
|
||||
- **Función:** Entrega de archivos estáticos del frontend y proxy inverso transparente hacia el puerto `3000` para el prefijo `/api/`
|
||||
|
||||
### 2.3. Capa de Lógica de Negocio y API
|
||||
|
||||
- **Entorno de ejecución:** Node.js (≥ v18)
|
||||
- **Framework HTTP:** Express v5
|
||||
- **Puerto de escucha:** `3000`
|
||||
- **Dependencias de producción:** `express ^5.2.1`, `cors ^2.8.6`
|
||||
|
||||
### 2.4. Capa de Adquisición de Datos (Hardware)
|
||||
|
||||
- **Lenguaje:** C (estándar C99)
|
||||
- **Interfaz de hardware:** Bus I²C del sistema operativo Linux mediante `/dev/i2c-1`
|
||||
- **Encabezados del sistema utilizados:** `<linux/i2c-dev.h>`, `<sys/ioctl.h>`
|
||||
- **Sistema de construcción:** GNU Make
|
||||
|
||||
---
|
||||
|
||||
## 3. Estructura del Directorio Fuente
|
||||
## Arquitectura
|
||||
|
||||
```text
|
||||
/
|
||||
├── api/
|
||||
│ └── server.js # Servidor Express: endpoints REST y parser léxico EZO
|
||||
│
|
||||
├── config/
|
||||
│ ├── alarms.json # Umbrales operativos configurables por variable
|
||||
│ └── sensors.json # Metadatos declarativos de los módulos EZO (dirección I²C, habilitación)
|
||||
│
|
||||
├── data/
|
||||
│ ├── EZORTD.json # Vector de estado actual: temperatura (escrito por el demonio C)
|
||||
│ ├── EZOPH.json # Vector de estado actual: pH
|
||||
│ ├── EZODO.json # Vector de estado actual: oxígeno disuelto
|
||||
│ └── EZOEC.json # Vector de estado actual: conductividad eléctrica
|
||||
│
|
||||
├── frontend/
|
||||
│ ├── index.html # Punto de entrada del dashboard de monitoreo
|
||||
│ ├── dashboard.css # Hoja de estilos responsiva del sistema
|
||||
│ ├── dashboard.js # Lógica principal: polling, evaluación de alarmas, gráficas
|
||||
│ ├── ezo-service.js # Consola EZO y panel de calibración por sensor
|
||||
│ ├── export-service.js # Serialización de datos históricos a formato CSV
|
||||
│ └── export-excel.js # Generación de reportes multipagina en formato XLSX
|
||||
│
|
||||
├── logs/
|
||||
│ ├── temperature.csv # Historial persistente de temperatura (escrito por el demonio)
|
||||
│ ├── ph.csv # Historial persistente de pH
|
||||
│ ├── do.csv # Historial persistente de oxígeno disuelto
|
||||
│ └── ec.csv # Historial persistente de conductividad eléctrica
|
||||
│
|
||||
├── sensors/
|
||||
│ ├── EZORTD/
|
||||
│ │ ├── ezortd.h # API pública: constante de dirección I²C y firma de getTemperature()
|
||||
│ │ ├── ezortd.c # Implementación del protocolo I²C para el EZO-RTD
|
||||
│ │ ├── main.c # Ejecutable de lectura única (one-shot), salida JSON a stdout
|
||||
│ │ ├── ezortd_daemon.c # Demonio de lectura continua: escribe JSON y appends CSV
|
||||
│ │ └── Makefile # Sistema de construcción del módulo RTD
|
||||
│ │
|
||||
│ ├── EZOPH/
|
||||
│ │ ├── ezoph.h # API pública del módulo pH
|
||||
│ │ ├── ezoph.c # Implementación del protocolo I²C para el EZO-pH
|
||||
│ │ ├── main.c # Ejecutable de lectura única
|
||||
│ │ └── Makefile
|
||||
│ │
|
||||
│ ├── EZODO/
|
||||
│ │ ├── ezodo.h # API pública del módulo DO
|
||||
│ │ ├── ezodo.c # Implementación del protocolo I²C para el EZO-DO
|
||||
│ │ ├── main.c # Ejecutable de lectura única
|
||||
│ │ └── Makefile
|
||||
│ │
|
||||
│ └── EZOEC/
|
||||
│ ├── ezoec.h # API pública del módulo EC
|
||||
│ ├── ezoec.c # Implementación del protocolo I²C para el EZO-EC
|
||||
│ ├── main.c # Ejecutable de lectura única
|
||||
│ └── Makefile
|
||||
│
|
||||
├── package.json # Manifiesto de dependencias Node.js
|
||||
├── package-lock.json # Árbol de dependencias resuelto y bloqueado
|
||||
├── README.md # Este documento
|
||||
├── ARCHITECTURE.md # Especificación técnica de la arquitectura del sistema
|
||||
└── PROJECT_STATUS.md # Estado de hitos, riesgos y fases pendientes
|
||||
EZO por I2C
|
||||
|
|
||||
EZO_ACQUIRE / EZO_COMMAND
|
||||
|
|
||||
Recolector Node ---------> data/*.json
|
||||
| logs/*.csv
|
||||
|
|
||||
API Express <------------ Dashboard
|
||||
^
|
||||
|
|
||||
Nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Instrucciones de Despliegue
|
||||
|
||||
### 4.1. Requisitos Previos
|
||||
`EZO_ACQUIRE` obtiene las cuatro lecturas en un ciclo agrupado.
|
||||
`EZO_COMMAND` ejecuta comandos interactivos y calibraciones. Ambos comparten el
|
||||
bloqueo `/tmp/photobioreactor-i2c.lock`.
|
||||
|
||||
- Node.js v18 o superior instalado en el sistema anfitrión
|
||||
- Nginx instalado (`sudo apt install nginx` en sistemas Debian/Ubuntu)
|
||||
- Acceso al directorio raíz del repositorio
|
||||
|
||||
### 4.2. Inicialización del Servidor de Backend (Node.js)
|
||||
|
||||
Desde el directorio raíz del repositorio, instalar las dependencias de producción y levantar el servidor:
|
||||
## Desarrollo sin sensores
|
||||
|
||||
```bash
|
||||
npm install
|
||||
node api/server.js
|
||||
npm ci
|
||||
npm test
|
||||
```
|
||||
|
||||
El servidor quedará escuchando en `http://localhost:3000`. Verificar el inicio exitoso con el mensaje:
|
||||
|
||||
```
|
||||
[MOCK SERVER] Backend Node.js corriendo en http://localhost:3000
|
||||
```
|
||||
Ejecute en dos terminales:
|
||||
|
||||
Para desarrollo local, abrir directamente:
|
||||
|
||||
```text
|
||||
http://localhost:3000/frontend/index.html
|
||||
```bash
|
||||
EZO_MODE=demo npm run acquire
|
||||
EZO_MODE=demo npm start
|
||||
```
|
||||
|
||||
Express sirve las carpetas necesarias para el dashboard. Nginx sigue siendo la
|
||||
opcion recomendada para el despliegue final en Raspberry Pi.
|
||||
Abra `http://localhost:3000/frontend/index.html`.
|
||||
|
||||
Para ejecución persistente en segundo plano se recomienda el gestor de procesos `pm2`:
|
||||
En PowerShell:
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
pm2 start api/server.js --name fotobiorreactor-api
|
||||
pm2 save
|
||||
```powershell
|
||||
$env:EZO_MODE = "demo"
|
||||
npm run acquire
|
||||
```
|
||||
|
||||
### 4.3. Configuración del Proxy Inverso Nginx
|
||||
|
||||
Crear o editar el bloque de servidor activo de Nginx. En sistemas Debian-based, el archivo de configuración canónico es `/etc/nginx/sites-available/fotobiorreactor`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 8888;
|
||||
server_name localhost;
|
||||
|
||||
# Raíz del contenido estático: directorio raíz del repositorio
|
||||
root /ruta/absoluta/al/repositorio;
|
||||
index frontend/index.html;
|
||||
|
||||
# Entrega de archivos estáticos del frontend
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# Proxy inverso transparente hacia el backend Node.js
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Habilitar el sitio y recargar el servicio:
|
||||
Y en una segunda terminal:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/fotobiorreactor /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```powershell
|
||||
$env:EZO_MODE = "demo"
|
||||
npm start
|
||||
```
|
||||
|
||||
### 4.4. Acceso al Cliente
|
||||
## Raspberry Pi
|
||||
|
||||
Abrir un navegador web y dirigirse a:
|
||||
El despliegue de producción usa `EZO_MODE=hardware`, systemd y Nginx:
|
||||
|
||||
```
|
||||
http://localhost:8888/frontend/index.html
|
||||
```bash
|
||||
chmod +x scripts/install-raspberry-pi.sh
|
||||
sudo ./scripts/install-raspberry-pi.sh
|
||||
```
|
||||
|
||||
El dashboard inicia automáticamente el ciclo de polling de los archivos JSON
|
||||
ubicados en `data/`. La API se usa por separado para la consola de comandos
|
||||
simulada, las exportaciones simuladas y las operaciones administrativas.
|
||||
La guía de preparación, detección I2C, calibración y prueba integral está en
|
||||
[`docs/RASPBERRY_PI_DEPLOYMENT.md`](docs/RASPBERRY_PI_DEPLOYMENT.md).
|
||||
|
||||
La consola EZO detecta automaticamente el transporte disponible. En Raspberry
|
||||
Pi puede enviar comandos reales por I2C mediante `sensors/EZOCommand/EZO_COMMAND`;
|
||||
en otros equipos permanece en modo demo. Consulte `docs/EZO_COMMANDS.md`.
|
||||
## Archivos de datos
|
||||
|
||||
### 4.5. Construcción de los Demonios en C (Hardware Real)
|
||||
- Lecturas actuales: `data/EZORTD.json`, `EZOPH.json`, `EZODO.json`,
|
||||
`EZOEC.json`.
|
||||
- Históricos: `logs/temperature.csv`, `ph.csv`, `do.csv`, `ec.csv`.
|
||||
- Umbrales: `config/alarms.json`.
|
||||
- Frecuencia de adquisición: `config/runtime.json`.
|
||||
|
||||
Para compilar los controladores de hardware en la Raspberry Pi, ejecutar el siguiente procedimiento por módulo sensor (se ilustra con el módulo RTD):
|
||||
Los CSV usan una única nomenclatura y el formato:
|
||||
|
||||
```bash
|
||||
cd sensors/EZORTD
|
||||
make
|
||||
```csv
|
||||
timestamp,value
|
||||
2026-06-22T12:00:00.000Z,25.123
|
||||
```
|
||||
|
||||
Esto generará el ejecutable `EZORTD`. Antes de compilar el demonio continuo (`ezortd_daemon.c`), actualizar las rutas absolutas codificadas en el código fuente para que correspondan al directorio de despliegue real en la Raspberry Pi.
|
||||
## Documentación
|
||||
|
||||
**Nota:** El bus I²C debe estar habilitado en la Raspberry Pi mediante `sudo raspi-config` → *Interface Options* → *I2C* → *Enable*. Se recomienda agregar el usuario de ejecución al grupo `i2c` para evitar la ejecución con privilegios de superusuario:
|
||||
- Estado actual: [`PROJECT_STATUS.md`](PROJECT_STATUS.md)
|
||||
- Comandos y calibración: [`docs/EZO_COMMANDS.md`](docs/EZO_COMMANDS.md)
|
||||
- Despliegue: [`docs/RASPBERRY_PI_DEPLOYMENT.md`](docs/RASPBERRY_PI_DEPLOYMENT.md)
|
||||
- Diseño extendido: [`ARCHITECTURE.md`](ARCHITECTURE.md)
|
||||
|
||||
```bash
|
||||
sudo usermod -aG i2c $USER
|
||||
```
|
||||
No se utiliza un driver personalizado del kernel. Linux ya proporciona la capa
|
||||
I2C mediante `/dev/i2c-1`; el protocolo ASCII, la adquisición y la calibración
|
||||
se mantienen en espacio de usuario para facilitar mantenimiento y diagnóstico.
|
||||
|
||||
@ -0,0 +1,222 @@
|
||||
const fs = require('node:fs');
|
||||
const fsPromises = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { execFile } = require('node:child_process');
|
||||
const { promisify } = require('node:util');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const ROOT_DIRECTORY = path.join(__dirname, '..');
|
||||
const DATA_DIRECTORY = process.env.DATA_DIRECTORY
|
||||
? path.resolve(process.env.DATA_DIRECTORY)
|
||||
: path.join(ROOT_DIRECTORY, 'data');
|
||||
const LOGS_DIRECTORY = process.env.LOGS_DIRECTORY
|
||||
? path.resolve(process.env.LOGS_DIRECTORY)
|
||||
: path.join(ROOT_DIRECTORY, 'logs');
|
||||
const RUNTIME_CONFIG_FILE = process.env.RUNTIME_CONFIG_FILE
|
||||
? path.resolve(process.env.RUNTIME_CONFIG_FILE)
|
||||
: path.join(ROOT_DIRECTORY, 'config', 'runtime.json');
|
||||
const ACQUISITION_HELPER = process.env.EZO_ACQUIRE_HELPER
|
||||
? path.resolve(process.env.EZO_ACQUIRE_HELPER)
|
||||
: path.join(ROOT_DIRECTORY, 'sensors', 'EZOCommand', 'EZO_ACQUIRE');
|
||||
|
||||
const SENSOR_FILES = {
|
||||
temperature: {
|
||||
json: 'EZORTD.json',
|
||||
csv: 'temperature.csv',
|
||||
key: 'temperature'
|
||||
},
|
||||
ph: { json: 'EZOPH.json', csv: 'ph.csv', key: 'ph' },
|
||||
do: { json: 'EZODO.json', csv: 'do.csv', key: 'do' },
|
||||
ec: { json: 'EZOEC.json', csv: 'ec.csv', key: 'ec' }
|
||||
};
|
||||
|
||||
const ALLOWED_LOGGING_RATES = new Set([1, 5, 10, 60]);
|
||||
|
||||
async function atomicWriteFile(filePath, content) {
|
||||
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
await fsPromises.writeFile(temporaryPath, content, 'utf8');
|
||||
await fsPromises.rename(temporaryPath, filePath);
|
||||
}
|
||||
|
||||
async function readRuntimeConfig() {
|
||||
try {
|
||||
const rawConfig = await fsPromises.readFile(RUNTIME_CONFIG_FILE, 'utf8');
|
||||
const config = JSON.parse(rawConfig);
|
||||
const rate = Number(config.loggingRateSeconds);
|
||||
|
||||
return {
|
||||
loggingRateSeconds: ALLOWED_LOGGING_RATES.has(rate) ? rate : 1
|
||||
};
|
||||
} catch {
|
||||
return { loggingRateSeconds: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRuntimeConfig(rate) {
|
||||
const numericRate = Number(rate);
|
||||
|
||||
if (!ALLOWED_LOGGING_RATES.has(numericRate)) {
|
||||
const error = new Error('Frecuencia no válida.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const config = {
|
||||
loggingRateSeconds: numericRate,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
await atomicWriteFile(
|
||||
RUNTIME_CONFIG_FILE,
|
||||
`${JSON.stringify(config, null, 2)}\n`
|
||||
);
|
||||
return config;
|
||||
}
|
||||
|
||||
function detectAcquisitionMode() {
|
||||
const requestedMode = String(process.env.EZO_MODE || 'auto').toLowerCase();
|
||||
const hardwareReady = process.platform === 'linux' &&
|
||||
fs.existsSync('/dev/i2c-1') &&
|
||||
fs.existsSync(ACQUISITION_HELPER);
|
||||
|
||||
if (requestedMode === 'hardware' && !hardwareReady) {
|
||||
return {
|
||||
mode: 'unavailable',
|
||||
message: 'Se solicitó hardware, pero /dev/i2c-1 o EZO_ACQUIRE no está disponible.'
|
||||
};
|
||||
}
|
||||
|
||||
if (requestedMode === 'hardware' || (requestedMode === 'auto' && hardwareReady)) {
|
||||
return { mode: 'hardware', message: 'Adquisición I2C real activa.' };
|
||||
}
|
||||
|
||||
return { mode: 'demo', message: 'Adquisición de demostración activa.' };
|
||||
}
|
||||
|
||||
function buildDemoReadings() {
|
||||
return {
|
||||
temperature: Number((25 + Math.random() * 0.1 - 0.05).toFixed(3)),
|
||||
ph: Number((7.2 + Math.random() * 0.04 - 0.02).toFixed(3)),
|
||||
do: Number((8.5 + Math.random() * 0.1 - 0.05).toFixed(3)),
|
||||
ec: Number((1050 + Math.random() * 10 - 5).toFixed(1))
|
||||
};
|
||||
}
|
||||
|
||||
async function readHardwareSensors() {
|
||||
const { stdout } = await execFileAsync(
|
||||
ACQUISITION_HELPER,
|
||||
['/dev/i2c-1'],
|
||||
{ timeout: 5000, windowsHide: true }
|
||||
);
|
||||
const readings = JSON.parse(stdout.trim());
|
||||
|
||||
if (!readings || typeof readings !== 'object') {
|
||||
throw new Error('EZO_ACQUIRE devolvió una respuesta inválida.');
|
||||
}
|
||||
|
||||
return readings;
|
||||
}
|
||||
|
||||
async function appendReading(filePath, timestamp, value) {
|
||||
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
let needsHeader = false;
|
||||
|
||||
try {
|
||||
const stats = await fsPromises.stat(filePath);
|
||||
needsHeader = stats.size === 0;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
needsHeader = true;
|
||||
}
|
||||
|
||||
const row = `${timestamp},${value}\n`;
|
||||
await fsPromises.appendFile(
|
||||
filePath,
|
||||
`${needsHeader ? 'timestamp,value\n' : ''}${row}`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
async function publishReading(sensorId, rawValue, timestamp, mode) {
|
||||
const sensor = SENSOR_FILES[sensorId];
|
||||
const value = Number(rawValue);
|
||||
const jsonPath = path.join(DATA_DIRECTORY, sensor.json);
|
||||
|
||||
if (!Number.isFinite(value)) {
|
||||
await atomicWriteFile(jsonPath, `${JSON.stringify({
|
||||
online: false,
|
||||
timestamp,
|
||||
error: 'Lectura no disponible'
|
||||
})}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
atomicWriteFile(jsonPath, `${JSON.stringify({
|
||||
[sensor.key]: value,
|
||||
online: true,
|
||||
timestamp,
|
||||
mode
|
||||
})}\n`),
|
||||
appendReading(path.join(LOGS_DIRECTORY, sensor.csv), timestamp, value)
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function collectReadings() {
|
||||
const modeInfo = detectAcquisitionMode();
|
||||
|
||||
if (modeInfo.mode === 'unavailable') {
|
||||
throw new Error(modeInfo.message);
|
||||
}
|
||||
|
||||
const readings = modeInfo.mode === 'hardware'
|
||||
? await readHardwareSensors()
|
||||
: buildDemoReadings();
|
||||
const timestamp = new Date().toISOString();
|
||||
const results = await Promise.all(
|
||||
Object.keys(SENSOR_FILES).map(async (sensorId) => ({
|
||||
sensorId,
|
||||
online: await publishReading(
|
||||
sensorId,
|
||||
readings[sensorId],
|
||||
timestamp,
|
||||
modeInfo.mode
|
||||
)
|
||||
}))
|
||||
);
|
||||
|
||||
return { mode: modeInfo.mode, timestamp, results };
|
||||
}
|
||||
|
||||
async function publishAllOffline(error) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
await Promise.all(
|
||||
Object.values(SENSOR_FILES).map((sensor) =>
|
||||
atomicWriteFile(
|
||||
path.join(DATA_DIRECTORY, sensor.json),
|
||||
`${JSON.stringify({
|
||||
online: false,
|
||||
timestamp,
|
||||
error: message
|
||||
})}\n`
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACQUISITION_HELPER,
|
||||
ALLOWED_LOGGING_RATES,
|
||||
RUNTIME_CONFIG_FILE,
|
||||
SENSOR_FILES,
|
||||
atomicWriteFile,
|
||||
collectReadings,
|
||||
detectAcquisitionMode,
|
||||
publishAllOffline,
|
||||
readRuntimeConfig,
|
||||
writeRuntimeConfig
|
||||
};
|
||||
@ -0,0 +1,46 @@
|
||||
const {
|
||||
collectReadings,
|
||||
publishAllOffline,
|
||||
readRuntimeConfig
|
||||
} = require('./acquisition-service');
|
||||
|
||||
let stopping = false;
|
||||
|
||||
function wait(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('[ACQUISITION] Recolector iniciado.');
|
||||
|
||||
while (!stopping) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const result = await collectReadings();
|
||||
const onlineCount = result.results.filter((item) => item.online).length;
|
||||
console.log(
|
||||
`[ACQUISITION] ${result.timestamp} ${result.mode}: ${onlineCount}/4 sensores.`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[ACQUISITION] ${error.message}`);
|
||||
await publishAllOffline(error);
|
||||
}
|
||||
|
||||
const config = await readRuntimeConfig();
|
||||
const elapsed = Date.now() - startedAt;
|
||||
await wait(Math.max(100, config.loggingRateSeconds * 1000 - elapsed));
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopping = true;
|
||||
}
|
||||
|
||||
process.on('SIGINT', stop);
|
||||
process.on('SIGTERM', stop);
|
||||
|
||||
run().catch((error) => {
|
||||
console.error('[ACQUISITION] Error fatal:', error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@ -0,0 +1,3 @@
|
||||
{
|
||||
"loggingRateSeconds": 1
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
root /opt/photobioreactor;
|
||||
index frontend/index.html;
|
||||
|
||||
location = / {
|
||||
return 302 /frontend/index.html;
|
||||
}
|
||||
|
||||
location /frontend/ {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /data/ {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /logs/ {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /config/ {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /vendor/chart.js {
|
||||
alias /opt/photobioreactor/node_modules/chart.js/dist/chart.umd.js;
|
||||
}
|
||||
|
||||
location = /vendor/xlsx.js {
|
||||
alias /opt/photobioreactor/node_modules/xlsx/dist/xlsx.full.min.js;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Photobioreactor Atlas EZO Acquisition
|
||||
After=dev-i2c\x2d1.device
|
||||
Wants=dev-i2c\x2d1.device
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=photobioreactor
|
||||
Group=photobioreactor
|
||||
SupplementaryGroups=i2c
|
||||
WorkingDirectory=/opt/photobioreactor
|
||||
EnvironmentFile=/etc/default/photobioreactor
|
||||
ExecStart=/usr/bin/node api/acquisition.js
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Photobioreactor Dashboard API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=photobioreactor
|
||||
Group=photobioreactor
|
||||
SupplementaryGroups=i2c
|
||||
WorkingDirectory=/opt/photobioreactor
|
||||
EnvironmentFile=/etc/default/photobioreactor
|
||||
ExecStart=/usr/bin/node api/server.js
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -0,0 +1,4 @@
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
HOST=127.0.0.1
|
||||
EZO_MODE=hardware
|
||||
@ -0,0 +1,116 @@
|
||||
# Despliegue y validación en Raspberry Pi
|
||||
|
||||
## Prueba sin hardware
|
||||
|
||||
En una computadora de desarrollo:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm test
|
||||
```
|
||||
|
||||
Para ejecutar el sistema completo en demo, use dos terminales:
|
||||
|
||||
```bash
|
||||
EZO_MODE=demo npm run acquire
|
||||
EZO_MODE=demo npm start
|
||||
```
|
||||
|
||||
Abra `http://localhost:3000/frontend/index.html`. El recolector genera JSON y
|
||||
CSV de prueba; la consola y el panel de calibración muestran transporte DEMO.
|
||||
|
||||
## Preparación del bus I2C
|
||||
|
||||
En Raspberry Pi OS:
|
||||
|
||||
```bash
|
||||
sudo raspi-config
|
||||
sudo reboot
|
||||
ls -l /dev/i2c-1
|
||||
sudo apt install i2c-tools
|
||||
i2cdetect -y 1
|
||||
```
|
||||
|
||||
El mapa esperado es:
|
||||
|
||||
| Circuito | Dirección |
|
||||
|---|---|
|
||||
| EZO-DO | `0x61` |
|
||||
| EZO-pH | `0x63` |
|
||||
| EZO-EC | `0x64` |
|
||||
| EZO-RTD | `0x66` |
|
||||
|
||||
No continúe con calibraciones si falta una dirección, aparece una dirección
|
||||
inesperada o el barrido del bus es inestable.
|
||||
|
||||
## Instalación
|
||||
|
||||
Desde el repositorio clonado:
|
||||
|
||||
```bash
|
||||
chmod +x scripts/install-raspberry-pi.sh
|
||||
sudo ./scripts/install-raspberry-pi.sh
|
||||
```
|
||||
|
||||
El instalador copia la aplicación a `/opt/photobioreactor`, instala
|
||||
dependencias, compila `EZO_COMMAND` y `EZO_ACQUIRE`, crea el usuario de servicio,
|
||||
activa los dos servicios systemd y configura Nginx en el puerto 80.
|
||||
|
||||
Validación:
|
||||
|
||||
```bash
|
||||
systemctl status photobioreactor-api
|
||||
systemctl status photobioreactor-acquisition
|
||||
journalctl -u photobioreactor-acquisition -f
|
||||
curl http://127.0.0.1:3000/api/system/ezo
|
||||
```
|
||||
|
||||
La respuesta de producción debe informar `hardware` tanto para comandos como
|
||||
para adquisición. `EZO_MODE=hardware` evita que una instalación incompleta
|
||||
caiga silenciosamente a valores simulados.
|
||||
|
||||
La API escucha únicamente en `127.0.0.1` y se publica mediante Nginx. El
|
||||
dashboard todavía no implementa autenticación; despliegue esta versión solo en
|
||||
una red local confiable y no exponga el puerto 80 directamente a Internet.
|
||||
|
||||
## Validación previa a calibración
|
||||
|
||||
Desde la consola web, pruebe individualmente:
|
||||
|
||||
```text
|
||||
i
|
||||
Status
|
||||
R
|
||||
Cal,?
|
||||
```
|
||||
|
||||
Compruebe que cada lectura coincide con el medio físico y que los archivos de
|
||||
`data/` cambian. El helper agrupado envía `R` a los cuatro circuitos, espera una
|
||||
sola ventana de conversión y recoge las respuestas bajo el mismo bloqueo usado
|
||||
por la consola.
|
||||
|
||||
## Calibración
|
||||
|
||||
Realice cada procedimiento con soluciones de referencia vigentes y espere la
|
||||
estabilización de la sonda. El panel consulta `Cal,?` automáticamente después
|
||||
de cada comando de calibración exitoso.
|
||||
|
||||
- RTD: `Cal,<temperatura>`.
|
||||
- pH: `Cal,mid,7.00`, `Cal,low,4.00`, `Cal,high,10.00`.
|
||||
- DO de dos puntos: `Cal,0` y después `Cal`; configure antes las compensaciones.
|
||||
- EC: configure `K`, ejecute `Cal,dry` y después uno o dos puntos húmedos.
|
||||
|
||||
Consulte `docs/EZO_COMMANDS.md` para restricciones y comandos de diagnóstico.
|
||||
|
||||
## Prueba integral
|
||||
|
||||
1. Confirme lecturas, históricos, exportaciones y alarmas.
|
||||
2. Desconecte un sensor: debe aparecer `DESCONECTADO` mientras los demás siguen.
|
||||
3. Detenga el recolector: las lecturas deben pasar a `DESCONECTADO` al superar
|
||||
la tolerancia calculada desde la frecuencia configurada.
|
||||
4. Reinicie la Raspberry y confirme el arranque automático.
|
||||
5. Mantenga el sistema varias horas y revise `journalctl`, tamaño de CSV,
|
||||
estabilidad de valores y recuperación después de comandos de consola.
|
||||
|
||||
Las pruebas físicas y la exactitud metrológica no pueden certificarse fuera de
|
||||
la Raspberry Pi con las sondas y soluciones reales conectadas.
|
||||
@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ${EUID} -ne 0 ]]; then
|
||||
echo "Ejecute este script con sudo." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PROJECT_SOURCE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PROJECT_TARGET="/opt/photobioreactor"
|
||||
SERVICE_USER="photobioreactor"
|
||||
|
||||
apt-get update
|
||||
apt-get install -y nodejs npm nginx i2c-tools build-essential
|
||||
|
||||
if ! id "${SERVICE_USER}" >/dev/null 2>&1; then
|
||||
useradd --system --create-home --groups i2c "${SERVICE_USER}"
|
||||
fi
|
||||
|
||||
install -d -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${PROJECT_TARGET}"
|
||||
cp -a "${PROJECT_SOURCE}/." "${PROJECT_TARGET}/"
|
||||
rm -rf "${PROJECT_TARGET}/node_modules"
|
||||
|
||||
cd "${PROJECT_TARGET}"
|
||||
npm ci --omit=dev
|
||||
make -C sensors/EZOCommand
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${PROJECT_TARGET}"
|
||||
|
||||
install -m 0644 deployment/photobioreactor.env /etc/default/photobioreactor
|
||||
install -m 0644 deployment/photobioreactor-api.service /etc/systemd/system/
|
||||
install -m 0644 deployment/photobioreactor-acquisition.service /etc/systemd/system/
|
||||
install -m 0644 deployment/nginx-photobioreactor.conf \
|
||||
/etc/nginx/sites-available/photobioreactor
|
||||
ln -sfn /etc/nginx/sites-available/photobioreactor \
|
||||
/etc/nginx/sites-enabled/photobioreactor
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
systemctl daemon-reload
|
||||
nginx -t
|
||||
systemctl enable --now photobioreactor-api
|
||||
systemctl enable --now photobioreactor-acquisition
|
||||
systemctl reload nginx
|
||||
|
||||
echo "Instalación terminada. Revise: systemctl status photobioreactor-*"
|
||||
@ -1,9 +1,14 @@
|
||||
CC=gcc
|
||||
CFLAGS=-Wall -Wextra -O2
|
||||
TARGET=EZO_COMMAND
|
||||
TARGETS=EZO_COMMAND EZO_ACQUIRE
|
||||
|
||||
$(TARGET): main.c
|
||||
$(CC) $(CFLAGS) -o $(TARGET) main.c
|
||||
all: $(TARGETS)
|
||||
|
||||
EZO_COMMAND: main.c
|
||||
$(CC) $(CFLAGS) -o $@ $<
|
||||
|
||||
EZO_ACQUIRE: acquire.c
|
||||
$(CC) $(CFLAGS) -o $@ $<
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET)
|
||||
rm -f $(TARGETS)
|
||||
|
||||
@ -0,0 +1,154 @@
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <linux/i2c-dev.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
typedef struct
|
||||
{
|
||||
const char *name;
|
||||
int address;
|
||||
double value;
|
||||
int valid;
|
||||
} sensor_t;
|
||||
|
||||
static int select_sensor(int fd, int address)
|
||||
{
|
||||
return ioctl(fd, I2C_SLAVE, address);
|
||||
}
|
||||
|
||||
static int request_reading(int fd, sensor_t *sensor)
|
||||
{
|
||||
const char command = 'R';
|
||||
|
||||
if (select_sensor(fd, sensor->address) < 0)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (write(fd, &command, 1) != 1)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
sensor->valid = -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int receive_reading(int fd, sensor_t *sensor)
|
||||
{
|
||||
unsigned char response[64] = {0};
|
||||
|
||||
if (select_sensor(fd, sensor->address) < 0)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
int bytes_read = read(fd, response, sizeof(response) - 1);
|
||||
|
||||
for (int retry = 0;
|
||||
bytes_read > 0 && response[0] == 254 && retry < 10;
|
||||
retry++)
|
||||
{
|
||||
usleep(100000);
|
||||
memset(response, 0, sizeof(response));
|
||||
bytes_read = read(fd, response, sizeof(response) - 1);
|
||||
}
|
||||
|
||||
if (bytes_read < 2 || response[0] != 1)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
response[bytes_read] = '\0';
|
||||
char *end = NULL;
|
||||
errno = 0;
|
||||
double value = strtod((char *)&response[1], &end);
|
||||
|
||||
if (errno != 0 || end == (char *)&response[1])
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
sensor->value = value;
|
||||
sensor->valid = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
if (argc != 2)
|
||||
{
|
||||
fprintf(stderr, "Uso: EZO_ACQUIRE /dev/i2c-1\n");
|
||||
return 2;
|
||||
}
|
||||
|
||||
sensor_t sensors[] = {
|
||||
{"temperature", 0x66, 0.0, 0},
|
||||
{"ph", 0x63, 0.0, 0},
|
||||
{"do", 0x61, 0.0, 0},
|
||||
{"ec", 0x64, 0.0, 0}};
|
||||
const size_t sensor_count = sizeof(sensors) / sizeof(sensors[0]);
|
||||
int lock_fd = open(
|
||||
"/tmp/photobioreactor-i2c.lock",
|
||||
O_CREAT | O_RDWR,
|
||||
0660);
|
||||
|
||||
if (lock_fd < 0 || flock(lock_fd, LOCK_EX) < 0)
|
||||
{
|
||||
fprintf(stderr, "No se pudo bloquear el bus I2C\n");
|
||||
return 3;
|
||||
}
|
||||
|
||||
int fd = open(argv[1], O_RDWR);
|
||||
|
||||
if (fd < 0)
|
||||
{
|
||||
fprintf(stderr, "No se pudo abrir %s\n", argv[1]);
|
||||
flock(lock_fd, LOCK_UN);
|
||||
close(lock_fd);
|
||||
return 4;
|
||||
}
|
||||
|
||||
for (size_t index = 0; index < sensor_count; index++)
|
||||
{
|
||||
request_reading(fd, &sensors[index]);
|
||||
}
|
||||
|
||||
usleep(1000000);
|
||||
|
||||
for (size_t index = 0; index < sensor_count; index++)
|
||||
{
|
||||
if (sensors[index].valid == -1)
|
||||
{
|
||||
receive_reading(fd, &sensors[index]);
|
||||
}
|
||||
}
|
||||
|
||||
printf("{");
|
||||
for (size_t index = 0; index < sensor_count; index++)
|
||||
{
|
||||
printf(
|
||||
"%s\"%s\":",
|
||||
index == 0 ? "" : ",",
|
||||
sensors[index].name);
|
||||
if (sensors[index].valid == 1)
|
||||
{
|
||||
printf("%.6f", sensors[index].value);
|
||||
}
|
||||
else
|
||||
{
|
||||
printf("null");
|
||||
}
|
||||
}
|
||||
printf("}\n");
|
||||
|
||||
close(fd);
|
||||
flock(lock_fd, LOCK_UN);
|
||||
close(lock_fd);
|
||||
return 0;
|
||||
}
|
||||
Loading…
Reference in New Issue