Compare commits

..

No commits in common. 'e372c6bfceca39f90aee5989647e449cad69e071' and 'bdc8c2f5ade6bd0b6309c63698291c2f150369cc' have entirely different histories.

@ -1,329 +1,325 @@
# Arquitectura Del Sistema # Especificación de Arquitectura del Sistema
## Sistema de Monitoreo de Parámetros Fisicoquímicos para Fotobiorreactor
## Objetivo
El sistema mide variables fisicoquimicas de un fotobiorreactor usando una
Raspberry Pi y circuitos Atlas Scientific EZO por I2C. La Raspberry ejecuta una
API, un recolector de datos, Nginx y un dashboard web.
La arquitectura evita un driver personalizado de kernel. El acceso al hardware
se realiza desde espacio de usuario mediante `/dev/i2c-1`.
## Vista General
```text
Sondas fisicas
|
Circuitos Atlas Scientific EZO
|
Bus I2C de Raspberry Pi (/dev/i2c-1)
|
+------------------------------+
| Helpers C |
| - EZO_ACQUIRE |
| - EZO_COMMAND |
+------------------------------+
|
+------------------------------+
| Backend Node.js |
| - API Express |
| - Recolector continuo |
| - Alarmas |
| - Notificaciones |
+------------------------------+
|
+------------------------------+
| Archivos locales |
| - data/*.json |
| - logs/*.csv |
| - config/*.json |
+------------------------------+
|
+------------------------------+
| Nginx + Dashboard web |
+------------------------------+
```
## Capas **Revisión:** 2.0
**Estado:** Vigente
### 1. Hardware ---
Sensores principales: ## 1. Visión General del Sistema
| Variable | Circuito | Direccion | El sistema implementa una arquitectura de cuatro capas desacopladas que operan de forma asíncrona sobre una red local. El flujo de datos se inicia en el hardware físico del sensor (bus I²C) y se propaga hasta el navegador web del operador mediante una cadena de transformaciones bien definidas. En la fase actual de desarrollo, la capa de hardware es emulada por un servidor de simulación (*mock*) Node.js que reproduce fielmente las latencias y los formatos de respuesta de los módulos Atlas Scientific EZO.
|---|---|---:|
| Temperatura | EZO-RTD | `0x66` |
| pH | EZO-pH | `0x63` |
| Oxigeno disuelto | EZO-DO | `0x61` |
| Conductividad | EZO-EC | `0x64` |
Los sensores deben estar en modo I2C. Si un sensor esta en UART no aparecera en ### 1.1. Diagrama de Capas
`i2cdetect`.
### 2. Helpers C ```
┌──────────────────────────────────────────────────────────────────────┐
│ CAPA 4 — PRESENTACIÓN │
│ Navegador web (HTML5, CSS3, Vanilla JS ES6+, Chart.js, SheetJS) │
│ Polling cada 1000 ms → GET /data/*.json │
│ Envío de comandos → POST /api/sensors/:type/command │
├──────────────────────────────────────────────────────────────────────┤
│ CAPA 3 — ENRUTAMIENTO Y PROXY │
│ Nginx, puerto 8888 │
│ - Entrega de archivos estáticos del frontend (HTML, CSS, JS) │
│ - Proxy inverso transparente: /api/* → http://127.0.0.1:3000 │
├──────────────────────────────────────────────────────────────────────┤
│ CAPA 2 — LÓGICA DE NEGOCIO Y API │
│ Node.js + Express, puerto 3000 │
│ - GET /api/sensors/:type → historial simulado para exportación │
│ - POST /api/sensors/:type/command → parser léxico EZO completo │
├──────────────────────────────────────────────────────────────────────┤
│ CAPA 1 — ADQUISICIÓN DE DATOS (HARDWARE / SIMULADO) │
│ Demonios C nativos en Raspberry Pi (producción futura) │
│ Lectura por I²C: /dev/i2c-1 @ 100 kHz │
│ Módulos: EZO-RTD (0x66), EZO-pH (0x63), EZO-DO (0x61), EZO-EC (0x64)│
└──────────────────────────────────────────────────────────────────────┘
```
Ubicacion: ---
```text ## 2. Topología de Red Local y Flujo de Datos Asíncrono
sensors/EZOCommand/
```
Binarios: ### 2.1. Topología
```text El sistema opera exclusivamente en una red de área local (LAN). Un único nodo Raspberry Pi actúa como servidor de todos los servicios. Los clientes son navegadores web en la misma red. No existe comunicación hacia redes externas en la configuración de producción.
EZO_ACQUIRE
EZO_COMMAND
```
`EZO_ACQUIRE` toma lecturas agrupadas. En lugar de esperar un segundo por cada ```
sensor, envia `R` a los sensores habilitados, espera una sola ventana de [Navegador del operador]
conversion y luego recoge respuestas. Esto reduce tiempo de ciclo y carga del
bus. │ HTTP, puerto 8888
[Nginx — Proxy Inverso]
│ │
│ Estático │ /api/* → proxy
▼ ▼
[frontend/] [Node.js Express — puerto 3000]
│ Simula protocolo I²C EZO (fase actual)
│ ─────────────────────────────────────
│ Producción futura: lectura real de
│ archivos JSON escritos por demonios C
[data/EZORTD.json]
[data/EZOPH.json ] ← escritos por demonios C
[data/EZODO.json ] (Capa 1 / hardware)
[data/EZOEC.json ]
```
`EZO_COMMAND` envia un comando a un circuito especifico. Se usa para: ### 2.2. Flujo de Datos Asíncrono — Lectura en Vivo
- `i` ```
- `Status` 1. El navegador ejecuta `setInterval(updateDashboard, 1000)`.
- `R` 2. `dashboard.js` lee `data/EZORTD.json`, `data/EZOPH.json`, `data/EZODO.json` y `data/EZOEC.json`.
- `Cal,?` 3. En paralelo, lee los umbrales desde `config/alarms.json`.
- Calibraciones 4. El navegador valida cada valor, actualiza el DOM y evalúa alarmas.
- Compensaciones 5. `renderAlarmSummary()` actualiza el resumen de riesgo global.
- Cambios de configuracion EZO 6. Las gráficas leen los CSV de `logs/` con un intervalo configurable.
La API Express no participa actualmente en la lectura en vivo. Se utiliza para
comandos EZO reales o de demostracion, exportaciones con datos simulados y
operaciones de administracion del historial.
```
Ambos usan: ### 2.3. Flujo de Datos Asíncrono — Comando EZO (Consola de Hardware)
```text ```
/tmp/photobioreactor-i2c.lock 1. El operador selecciona el sensor y emite un comando desde la interfaz
2. ezo-service.js ejecuta: POST /api/sensors/:type/command
Body: { "command": "cal,mid,7.00" }
3. Nginx reenvía la petición al puerto 3000
4. El parser léxico de Express descompone el comando por comas:
parts = ["cal", "mid", "7.00"], baseCmd = "cal"
5. Se aplica la latencia de procesamiento químico correspondiente (600 ms para CAL)
6. El servidor responde: { "success": true, "sensor": "PH", "response": "*OK" }
7. ezo-service.js imprime el intercambio TX/RX en el terminal virtual de la UI
``` ```
Ese lock evita acceso simultaneo al bus I2C. ---
### 3. Recolector ## 3. Especificación de la API REST
Archivo: ### 3.1. Endpoint de Telemetría
```text **`GET /api/sensors/:type`**
api/acquisition.js
``` Parámetros de ruta:
Responsabilidades: | Parámetro | Valores válidos | Descripción |
|---|---|---|
| `type` | `rtd`, `ph`, `do`, `ec`, `all` | Identificador del módulo sensor |
- Leer configuracion de runtime. Latencia simulada: 400 ms (emula el tiempo de procesamiento I²C más la conversión del ADC interno del EZO).
- Ejecutar adquisicion en modo demo o hardware.
- Publicar JSON actual por sensor.
- Agregar filas CSV.
- Evaluar alarmas.
- Despachar notificaciones.
- Aplicar retencion historica.
Servicio: Respuesta exitosa — tipo específico (HTTP 200):
```text ```json
photobioreactor-acquisition.service [
{
"Timestamp": "2025-05-30T14:22:01.000Z",
"Sensor": "RTD",
"Valor": "25.04"
},
...
]
``` ```
### 4. API Respuesta exitosa — tipo `all` (HTTP 200):
```json
[
{
"Timestamp": "2025-05-30T14:22:01.000Z",
"Temperatura_C": "25.04",
"pH": "7.21",
"DO_mgL": "8.52",
"EC_uS": "1048"
},
...
]
```
Archivo: Respuesta de error — tipo no reconocido (HTTP 400):
```text ```json
api/server.js { "error": "Sensor no válido" }
``` ```
Responsabilidades: ### 3.2. Endpoint de Comandos EZO
- Servir endpoints de configuracion. **`POST /api/sensors/:type/command`**
- Exponer historicos para exportacion.
- Ejecutar comandos EZO.
- Guardar umbrales.
- Guardar notificaciones.
- Limpiar historicos.
- Servir bibliotecas locales `Chart.js` y `SheetJS`.
Servicio: Cuerpo de la petición (`Content-Type: application/json`):
```text ```json
photobioreactor-api.service { "command": "<cadena_de_comando_EZO>" }
``` ```
### 5. Frontend Respuesta exitosa (HTTP 200):
Ubicacion: ```json
{
```text "success": true,
frontend/ "sensor": "PH",
"command": "cal,mid,7.00",
"response": "*OK"
}
``` ```
Archivos principales: ### 3.3. Tabla de Comandos EZO Soportados por el Parser Léxico
| Archivo | Uso | | Comando base | Aplica a | Latencia simulada | Respuesta representativa |
|---|---| |---|---|---|---|
| `index.html` | Estructura del dashboard | | `i` | Todos | 300 ms | `?I,RTD,2.12` |
| `dashboard.css` | Diseno responsive | | `status` | Todos | 300 ms | `?STATUS,P,5.03` |
| `dashboard.js` | Lecturas, graficas, alarmas y configuracion | | `r` | Todos | 900 ms | `25.047` / `7.22` / `8.51` / `1052` |
| `ezo-service.js` | Consola y calibracion EZO | | `cal` | Todos | 600 ms | `*OK` / `?CAL,1` |
| `export-service.js` | Exportacion CSV | | `sleep` | Todos | 0 ms | `[SLEEP MODE ACTIVADO]` |
| `export-excel.js` | Exportacion Excel | | `factory` | Todos | 800 ms | `*OK` |
| `find` | Todos | 300 ms | `*OK` |
El dashboard lee JSON cada segundo y actualiza tarjetas, estados y resumen de | `led` | Todos | 300 ms | `?LED,1` / `*OK` |
alarmas. | `plock` | Todos | 300 ms | `?PLOCK,1` / `*OK` |
| `i2c` | Todos | 300 ms | `*OK` |
Las graficas se actualizan cada 10 segundos por defecto y muestran solo el dia | `t`, `s`, `p` | Todos | 300 ms | `?T,25.0` / `*OK` |
actual. Los CSV conservan el historico segun la retencion configurada. | `slope` | PH | 300 ms | `?Slope,99.7,100.3,-0.89` |
| `k` | EC | 300 ms | `?K,1.0` / `*OK` |
### 6. Nginx | `tc` | EC | 300 ms | `?TC,1.90` / `*OK` |
| `o` | EC | 300 ms | `?O,EC,TDS,S,SG` / `*OK` |
Nginx sirve el frontend y redirige `/api/` hacia Express. En produccion la API | Desconocido | Todos | 300 ms | `*ER` |
escucha en `127.0.0.1`; Nginx es la entrada HTTP para navegadores de la red
local.
## Flujo De Lectura
```text
1. systemd inicia photobioreactor-acquisition.
2. El recolector lee config/runtime.json.
3. El recolector ejecuta EZO_ACQUIRE con los sensores habilitados.
4. EZO_ACQUIRE toma el lock I2C.
5. EZO_ACQUIRE consulta los EZO por /dev/i2c-1.
6. El recolector escribe data/*.json.
7. El recolector agrega filas a logs/*.csv.
8. El dashboard lee data/*.json cada segundo.
9. El dashboard actualiza tarjetas, alarmas y graficas.
```
## Flujo De Comandos Y Calibracion ---
```text ## 4. Especificación Técnica de la Capa de Hardware
1. El operador usa la consola o el panel de calibracion.
2. El frontend envia POST /api/sensors/:type/command.
3. La API valida token y comando.
4. La API ejecuta EZO_COMMAND.
5. EZO_COMMAND toma el lock I2C.
6. EZO_COMMAND envia el comando al circuito.
7. La API devuelve la respuesta real o demo.
8. La interfaz muestra TX/RX y, si aplica, consulta Cal,?.
```
Para comandos manuales desde terminal, detenga primero el recolector: ### 4.1. Protocolo de Comunicación I²C con Módulos EZO
```bash Todos los módulos EZO de Atlas Scientific implementan el protocolo I²C con la siguiente secuencia de operación para una lectura (comando `R`):
sudo systemctl stop photobioreactor-acquisition
sudo rm -f /tmp/photobioreactor-i2c.lock ```
1. ioctl(fd, I2C_SLAVE, ADDR) — seleccionar dirección del esclavo
2. write(fd, "R", 1) — emitir el comando de lectura (ASCII 0x52)
3. usleep(1000000) — esperar 1000 ms para conversión analógica interna
4. read(fd, response, 32) — leer la respuesta del módulo
5. Verificar response[0] == 0x01 — código de estado: 0x01 indica éxito
6. atof(&response[1]) — parsear el float ASCII desde el byte 1 en adelante
``` ```
## Archivos De Datos Tabla de códigos de estado en `response[0]`:
Lectura actual: | Código | Significado |
|---|---|
| `0x01` | Éxito — dato válido disponible |
| `0x02` | Error de sintaxis en el comando |
| `0xFE` | Pendiente — conversión no completada |
| `0xFF` | Sin datos disponibles |
```text ### 4.2. Mapa de Direcciones I²C del Bus
data/EZORTD.json
data/EZOPH.json
data/EZODO.json
data/EZOEC.json
```
Historicos: | Módulo | Dirección I²C | Constante en código fuente |
|---|---|---|
| EZO-RTD | `0x66` | `EZORTD_I2C_ADDR` |
| EZO-pH | `0x63` | `EZOPH_I2C_ADDR` |
| EZO-DO | `0x61` | `EZODO_I2C_ADDR` |
| EZO-EC | `0x64` | `EZOEC_I2C_ADDR` |
```text Ninguna dirección colisiona en el espacio de 7 bits del protocolo I²C estándar. El bus opera en modo maestro único (*single-master*), lo que elimina la necesidad de arbitraje.
logs/temperature.csv
logs/ph.csv
logs/do.csv
logs/ec.csv
```
Alarmas: ---
```text ## 5. Arquitectura de Hardware Propuesta — Fase 9: Shield PCB con Aislamiento Galvánico
logs/alarms.csv
```
Configuracion: ### 5.1. Justificación Técnica del Aislamiento Galvánico
```text La operación de sensores electroquímicos en un medio líquido conductor presenta una condición de riesgo inherente: la existencia de **corrientes parásitas de bucle de masa** (*ground loop currents*). Este fenómeno se produce cuando dos o más sensores sumergidos en el mismo líquido establecen caminos de retorno de corriente a través del propio medio líquido, creando diferencias de potencial espurias entre sus masas de referencia.
config/runtime.json
config/alarms.json
config/sensors.json
config/notifications.json
```
En Raspberry instalada, la aplicacion vive en: Las consecuencias operativas de esta condición son las siguientes:
```text **Para el módulo EZO-pH:** La sonda de pH opera mediante la detección de una diferencia de potencial electroquímico (típicamente entre 414 mV y +414 mV para el rango de 0 a 14 pH) generada en el electrodo de vidrio. Una corriente parásita que atraviese el medio líquido introduce un potencial de interferencia directamente sumado a esta señal de alta impedancia, produciendo lecturas de pH sistemáticamente desplazadas y no reproducibles.
/opt/photobioreactor
```
Las notificaciones de produccion viven en: **Para el módulo EZO-EC:** La medición de conductividad eléctrica se realiza mediante la inyección de una señal de corriente alterna de frecuencia controlada a través de electrodos de acero inoxidable o platino sumergidos. La presencia de corrientes parásitas de corriente continua procedentes de otros sistemas altera la conductividad aparente del medio, introduciendo errores proporcionales a la magnitud de la corriente de interferencia.
```text **Para el módulo EZO-DO:** Aunque la sonda de oxígeno disuelto es en términos eléctricos menos sensible que el electrodo de pH, las corrientes parásitas pueden inducir polarización electrolítica en los electrodos de platino de las sondas galvánicas, degradando irreversiblemente la membrana de politetrafluoroetileno (PTFE) y alterando la cinética de reducción del oxígeno.
/etc/photobioreactor/notifications.json
```
## Estados De Sensor ### 5.2. Solución de Diseño: Aisladores Digitales I²C y Convertidores DC-DC Aislados
| Estado | Significado | La arquitectura de la PCB propuesta para la Fase 9 implementa una barrera galvánica completa en cada canal de sensor mediante dos componentes:
|---|---|
| `NORMAL` | Valor valido dentro del rango | **Aislador digital bidireccional I²C — Texas Instruments ISO1540:**
| `WARNING` | Valor cerca de un limite |
| `CRITICAL` | Valor fuera de rango | El ISO1540 es un aislador de capacitancia de silicio que implementa los canales SDA y SCL del bus I²C de forma completamente aislada, con una rigidez dieléctrica de 2500 V RMS. El dispositivo detecta el estado lógico de cada línea en el lado primario (Raspberry Pi) y reproduce la señal en el lado secundario (módulo EZO) sin conexión eléctrica directa. Sus características operativas relevantes son:
| `OFFLINE` | Sensor habilitado sin lectura valida |
| `DISABLED` | Sensor deshabilitado por configuracion |
## Seguridad - Velocidad máxima de transferencia: 1 Mbps (compatible con el modo *Fast-mode Plus* de I²C)
- Corriente de cortocircuito de salida: ±4 mA (compatible con los pull-ups del bus)
- Tiempo de propagación: < 15 ns
- Consumo en standby: < 1 mA por canal
La API protege acciones criticas con `API_AUTH_TOKEN` si esta configurado. **Convertidor DC-DC aislado — Mornsun B0303S-1W (o equivalente):**
Acciones protegidas: El ISO1540 requiere dos dominios de alimentación físicamente separados: VCC1 (lado Raspberry Pi, 3.3 V) y VCC2 (lado módulo EZO). Si ambos dominios comparten la misma referencia de tierra, la barrera galvánica del aislador digital resulta inoperante, ya que el bucle de masa se cierra a través del plano de tierra compartido de la PCB.
- Comandos EZO. El B0303S-1W es un convertidor DC-DC de 1 W, entrada 3.3 V, salida 3.3 V, con aislamiento galvánico de 1500 V DC entre sus terminales de entrada y salida. Su interposición entre el plano de tierra de la Raspberry Pi y el plano de tierra de cada módulo EZO garantiza que no exista ningún camino eléctrico directo entre ambos dominios, eliminando efectivamente el bucle de masa.
- Calibraciones.
- Cambios de umbrales.
- Cambios de runtime.
- Cambios de notificaciones.
- Borrado de historicos.
El dashboard envia el token como: ### 5.3. Esquema Conceptual del Aislamiento por Canal
```text ```
X-API-Token Dominio Raspberry Pi (GND_PI) Dominio Sensor (GND_EZO)
───────────────────────────── ────────────────────────
3.3V ──┬─────────────────────────────── VCC_in (B0303S) ──► VCC_out → VCC_EZO
│ │ │
│ [BARRERA 1500V] │
│ GND_EZO (flotante respecto a GND_PI)
├── SDA_PI ──► ISO1540 ──────────────────────────────────► SDA_EZO
│ [BARRERA 2500V RMS]
└── SCL_PI ──► ISO1540 ──────────────────────────────────► SCL_EZO
``` ```
## Almacenamiento Este esquema se replica de forma independiente para cada uno de los cuatro módulos EZO, garantizando el aislamiento no solo respecto a la Raspberry Pi sino también entre los sensores entre sí, lo que elimina por completo los caminos de corriente parásita a través del medio líquido del fotobiorreactor.
El crecimiento principal viene de los CSV historicos. Los JSON se sobrescriben. ---
Peor caso operativo: ## 6. Sistema de Evaluación de Alarmas
```text ### 6.1. Lógica de Evaluación
4 sensores
1 lectura por segundo
30 dias
```
Resultado aproximado: El módulo `evaluateSensorAlarm()` de `dashboard.js` implementa una máquina de estados de cuatro niveles para cada sensor, evaluada en cada ciclo de actualización (1000 ms):
```text ```
350 a 375 MB por mes Estado OFFLINE: JSON faltante, HTTP error, valor no numérico
Estado CRITICAL: valor < limits.min OR valor > limits.max
Estado WARNING: valor dentro del rango, pero dentro del 10% de los límites
Estado NORMAL: valor dentro del rango, fuera de la banda de advertencia
``` ```
Ver detalles en: La banda de advertencia se calcula como:
```text
docs/STORAGE_ESTIMATE.md
``` ```
margen_advertencia = (limits.max - limits.min) × WARNING_MARGIN_RATIO
WARNING si: valor ≤ (limits.min + margen_advertencia) OR
valor ≥ (limits.max - margen_advertencia)
```
donde `WARNING_MARGIN_RATIO = 0.10` (configurable en `dashboard.js`).
### 6.2. Umbrales Operativos Configurados
Definidos en `config/alarms.json`:
| Variable | Mínimo | Máximo | Unidad |
|---|---|---|---|
| Temperatura | 20.0 | 30.0 | °C |
| pH | 6.8 | 7.5 | pH |
| Oxígeno Disuelto | 4.0 | 12.0 | mg/L |
| Conductividad Eléctrica | 500 | 2500 | µS/cm |
---
## 7. Módulo de Exportación de Datos
## Razon Para No Usar Driver De Kernel ### 7.1. Exportación CSV
El protocolo Atlas EZO es ASCII sobre I2C. No requiere temporizacion de kernel La función `handleExport(sensorType)` en `export-service.js` recupera el historial mediante `fetchHistoricalData()`, convierte el arreglo de objetos JSON a texto CSV mediante `convertToCSV()` y fuerza la descarga del archivo en el navegador mediante un enlace temporal con `URL.createObjectURL()`.
ni procesamiento en tiempo real estricto. Mantenerlo en espacio de usuario
permite:
- Diagnostico con comandos simples. ### 7.2. Exportación XLSX Multipagina
- Cambios rapidos de calibracion.
- Menor riesgo de bloquear el sistema.
- Despliegue mas sencillo.
- Compatibilidad directa con Raspberry Pi OS.
Un driver de kernel solo tendria sentido si existiera una necesidad medible que La función `handleExportExcel()` en `export-excel.js` itera sobre los cuatro sensores, recupera los datos de cada uno, crea una hoja de cálculo independiente con `XLSX.utils.json_to_sheet()` y las consolida en un único libro de trabajo (`Workbook`) mediante `XLSX.utils.book_append_sheet()`. El archivo binario `.xlsx` se descarga mediante `XLSX.writeFile()`.
no pueda resolverse con `/dev/i2c-1`, por ejemplo latencias estrictas,
integracion profunda con subsistemas Linux o requerimientos industriales
especificos.

@ -1,198 +1,113 @@
# Estado del Proyecto # Estado del Proyecto
## Resumen ## Estado actual
El proyecto implementa un dashboard web para monitoreo de fotobiorreactor con
Raspberry Pi y circuitos Atlas Scientific EZO. La aplicacion puede trabajar en
modo demo para desarrollo sin hardware y en modo hardware usando `/dev/i2c-1`.
## Estado Actual
Implementado:
- Dashboard responsive.
- Lecturas en vivo desde `data/*.json`.
- Adquisicion automatica con `api/acquisition.js`.
- Helper C `EZO_ACQUIRE` para lecturas agrupadas.
- Helper C `EZO_COMMAND` para comandos y calibracion.
- Sintaxis, tiempos y secuencias de calibracion auditados contra datasheets Atlas.
- Historial CSV por sensor.
- Graficas con Chart.js.
- Exportacion CSV y Excel con SheetJS.
- Alarmas configurables.
- Bitacora de alarmas en `logs/alarms.csv`.
- Notificaciones por webhook y Telegram.
- Configuracion de sensores habilitados.
- Retencion historica configurable.
- Servicios `systemd` para API y adquisicion.
- Configuracion Nginx.
- Instalador para Raspberry Pi.
## Componentes Principales
| Componente | Responsabilidad | El dashboard integra monitoreo en vivo, alarmas configurables, tendencias,
|---|---| exportación CSV/Excel, consola EZO y calibración. La adquisición puede operar en
| `frontend/` | Dashboard, graficas, controles, exportaciones y consola EZO | modo demo o en Raspberry Pi mediante `/dev/i2c-1`.
| `api/server.js` | API HTTP, configuracion, historicos, comandos y seguridad |
| `api/acquisition.js` | Proceso continuo de adquisicion |
| `api/acquisition-service.js` | Lectura, escritura atomica, alarmas y retencion |
| `api/notification-service.js` | Webhook y Telegram |
| `sensors/EZOCommand/EZO_ACQUIRE` | Lectura agrupada de sensores EZO |
| `sensors/EZOCommand/EZO_COMMAND` | Comandos individuales EZO |
| `config/*.json` | Configuracion operativa |
| `deployment/` | Servicios systemd, Nginx y variables de entorno |
| `scripts/install-raspberry-pi.sh` | Instalacion automatizada en Raspberry Pi |
## Sensores
| Sensor | Direccion esperada | Estado en software |
|---|---:|---|
| EZO-RTD | `0x66` | Implementado |
| EZO-pH | `0x63` | Implementado |
| EZO-DO | `0x61` | Implementado |
| EZO-EC | `0x64` | Implementado |
El sistema tambien puede detectar otros EZO en el bus, por ejemplo ORP, pero el
dashboard actual solo modela RTD, pH, DO y EC.
## Modos De Operacion
| Modo | Uso |
|---|---|
| `EZO_MODE=demo` | Desarrollo sin Raspberry ni sensores |
| `EZO_MODE=auto` | Usa hardware si esta disponible; si no, demo |
| `EZO_MODE=hardware` | Produccion estricta; no simula datos |
En Raspberry Pi de produccion se usa `EZO_MODE=hardware`.
## Datos
Lecturas actuales:
- `data/EZORTD.json`
- `data/EZOPH.json`
- `data/EZODO.json`
- `data/EZOEC.json`
Historicos:
- `logs/temperature.csv`
- `logs/ph.csv`
- `logs/do.csv`
- `logs/ec.csv`
Formato:
```csv
timestamp,value
2026-07-07T17:26:13.459Z,25.132
```
Las graficas muestran el dia actual. Los CSV conservan el periodo configurado
por `historyRetentionDays`.
## Alarmas
Los umbrales viven en:
```text
config/alarms.json
```
Estados:
- `NORMAL`
- `WARNING`
- `CRITICAL`
- `OFFLINE`
- `DISABLED`
Los eventos se guardan en: ## Arquitectura operativa
```text | Componente | Responsabilidad |
logs/alarms.csv |---|---|
``` | `api/server.js` | API, históricos, configuración y comandos EZO |
| `api/acquisition.js` | Ciclo continuo de adquisición |
Los sensores deshabilitados no generan alarma ni riesgo global. | `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.
## Configuracion parcial de sensores
- `config/runtime.json` guarda `enabledSensors`.
- Los sensores deshabilitados se muestran como `DESHABILITADO`.
- Un sensor deshabilitado no cuenta como `OFFLINE`, alarma ni riesgo global.
## Seguridad operativa
- `API_AUTH_TOKEN` protege endpoints criticos cuando esta configurado.
- El dashboard envia el token como `X-API-Token` desde almacenamiento local del
navegador.
- La API compara tokens en tiempo constante y emite headers defensivos basicos.
- Los POST criticos usan rate limit configurable en memoria.
- Sin `API_AUTH_TOKEN`, el modo desarrollo permanece sin autenticacion.
## Bitacora de alarmas
- `logs/alarms.csv` registra transiciones a `WARNING`, `CRITICAL`, `OFFLINE` y
recuperaciones `RECOVERY`.
- `GET /api/alarms` expone los eventos para dashboard y futuras notificaciones.
- Los sensores `DISABLED` no generan eventos de alarma.
## Umbrales configurables
- `GET /api/config/alarms` expone los limites actuales.
- `POST /api/config/alarms` valida y persiste cambios en `config/alarms.json`.
- El dashboard permite editar min/max por sensor; guardar requiere token si
`API_AUTH_TOKEN` esta activo.
## Retencion historica
- `historyRetentionDays` en `config/runtime.json` controla poda automatica de
CSV.
- Valores permitidos: 7, 30, 90, 365 o 0 para retencion indefinida.
- La poda conserva los nombres actuales de archivos para no romper graficas ni
exportaciones.
- Las graficas filtran visualmente el dia actual; los CSV mantienen todos los
registros disponibles dentro de la retencion configurada.
## Notificaciones ## Notificaciones
Configuracion: - `config/notifications.json` permite activar webhook y Telegram.
- `GET /api/config/notifications` devuelve configuracion redactada.
```text - `POST /api/config/notifications` valida y persiste canales con token.
config/notifications.json - El recolector despacha notificaciones desde eventos de `logs/alarms.csv`.
``` - `docs/TELEGRAM_BOT_SETUP.md` documenta creacion del bot, obtencion de
`chatId`, configuracion, pruebas y diagnostico.
En produccion:
```text
/etc/photobioreactor/notifications.json
```
Canales:
- Webhook HTTP.
- Telegram Bot API.
El backend redacta secretos cuando envia configuracion al navegador.
## Seguridad
Implementado:
- `API_AUTH_TOKEN` para endpoints criticos.
- Header `X-API-Token`.
- Comparacion de token en tiempo constante.
- Rate limit para acciones criticas.
- Headers HTTP defensivos basicos.
- API escuchando en `127.0.0.1` detras de Nginx.
Pendiente si se expone fuera de LAN:
- HTTPS.
- Autenticacion de usuario completa.
- Politica de acceso por VPN o red privada.
## Validacion
Validado en desarrollo:
- Pruebas automatizadas Node. ## Modos
- Modo demo.
- Dashboard por HTTP local.
- Chart.js y SheetJS locales.
- Comandos EZO simulados.
- Pruebas de sintaxis oficial por sensor y tiempos de procesamiento.
- Preservacion de bloques hexadecimales para exportar/importar calibracion.
- Configuracion de alarmas y notificaciones.
Validacion en Raspberry: - `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.
- I2C mediante `/dev/i2c-1`. ## Verificación realizada
- Servicios `systemd`.
- Lecturas reales parciales cuando los sensores estan conectados.
La calibracion metrologica y la estabilidad final deben verificarse siempre con - 16 pruebas automáticas aprobadas.
sondas reales, soluciones de referencia y operacion continua. - 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.
## Trabajo Pendiente Recomendado 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.
- Prueba continua de 8 a 24 horas en Raspberry. ## Trabajo pendiente en hardware
- Validacion con los cuatro sensores conectados.
- Calibracion real documentada con fecha y soluciones usadas.
- Confirmar notificaciones Telegram en la red final.
- Evaluar integracion futura de ORP si se decide usar ese sensor.
- Mejorar exportaciones para seleccionar rango: dia actual, ultimas 24 horas o
todo el historico.
## Documentos Relacionados 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.
- `README.md` Consulte `docs/RASPBERRY_PI_DEPLOYMENT.md` para el procedimiento completo.
- `docs/README.md`
- `docs/QUICK_START.md`
- `docs/RASPBERRY_PI_DEPLOYMENT.md`
- `docs/EZO_COMMANDS.md`
- `docs/TROUBLESHOOTING.md`
- `docs/STORAGE_ESTIMATE.md`
- `docs/TELEGRAM_BOT_SETUP.md`
- `ARCHITECTURE.md`

@ -1,103 +1,52 @@
# Photobioreactor Dashboard # Photobioreactor Dashboard
Dashboard web para monitorear un fotobiorreactor con Raspberry Pi y circuitos Sistema de monitoreo para Raspberry Pi y circuitos Atlas Scientific EZO:
Atlas Scientific EZO por I2C.
El sistema ya contempla: | Variable | Circuito | Dirección | Unidad |
|---|---|---|---|
| Temperatura | EZO-RTD | `0x66` | °C |
| pH | EZO-pH | `0x63` | pH |
| Oxígeno disuelto | EZO-DO | `0x61` | mg/L |
| Conductividad | EZO-EC | `0x64` | µS/cm |
- Lecturas en vivo de temperatura, pH, oxigeno disuelto y conductividad. El sistema incluye dashboard responsive, históricos Chart.js, alarmas,
- Historial CSV por sensor. exportación CSV/Excel, consola de comandos y panel de calibración.
- Graficas Chart.js.
- Alarmas configurables.
- Exportacion CSV y Excel.
- Consola de comandos EZO.
- Panel de calibracion.
- Notificaciones por webhook y Telegram.
- Despliegue permanente con `systemd` y Nginx en Raspberry Pi.
## Lectura Rapida ## Arquitectura
Si es la primera vez que usas el proyecto, sigue este orden:
1. [Inicio rapido](docs/QUICK_START.md)
2. [Despliegue en Raspberry Pi](docs/RASPBERRY_PI_DEPLOYMENT.md)
3. [Comandos y calibracion EZO](docs/EZO_COMMANDS.md)
4. [Diagnostico de problemas](docs/TROUBLESHOOTING.md)
Si ya conoces el sistema y necesitas detalle tecnico:
- [Arquitectura](ARCHITECTURE.md)
- [Estado del proyecto](PROJECT_STATUS.md)
- [Calculo de almacenamiento](docs/STORAGE_ESTIMATE.md)
- [Telegram](docs/TELEGRAM_BOT_SETUP.md)
## Sensores Esperados
| Variable | Circuito | Direccion I2C | Archivo JSON | CSV historico |
|---|---|---:|---|---|
| Temperatura | EZO-RTD | `0x66` | `data/EZORTD.json` | `logs/temperature.csv` |
| pH | EZO-pH | `0x63` | `data/EZOPH.json` | `logs/ph.csv` |
| Oxigeno disuelto | EZO-DO | `0x61` | `data/EZODO.json` | `logs/do.csv` |
| Conductividad | EZO-EC | `0x64` | `data/EZOEC.json` | `logs/ec.csv` |
El proyecto puede operar con sensores parciales. Un sensor deshabilitado se
muestra como `DESHABILITADO` y no cuenta como alarma. Un sensor habilitado pero
no disponible se muestra como `OFFLINE`.
## Arquitectura Corta
```text ```text
Sensores EZO I2C EZO por I2C
| |
| /dev/i2c-1
v
EZO_ACQUIRE / EZO_COMMAND EZO_ACQUIRE / EZO_COMMAND
| |
v Recolector Node ---------> data/*.json
Recolector Node.js ---> data/*.json
| logs/*.csv | logs/*.csv
v |
API Express <------ Dashboard web API Express <------------ Dashboard
^ ^
| |
Nginx Nginx
``` ```
`EZO_ACQUIRE` toma lecturas agrupadas de los sensores habilitados. `EZO_ACQUIRE` obtiene las cuatro lecturas en un ciclo agrupado.
`EZO_COMMAND` envia comandos individuales, incluyendo diagnostico y calibracion. `EZO_COMMAND` ejecuta comandos interactivos y calibraciones. Ambos comparten el
Ambos usan el mismo bloqueo: bloqueo `/tmp/photobioreactor-i2c.lock`.
```text
/tmp/photobioreactor-i2c.lock
```
Ese bloqueo evita que una lectura automatica y un comando manual usen el bus I2C
al mismo tiempo.
## Prueba Sin Sensores
En una computadora de desarrollo: ## Desarrollo sin sensores
```bash ```bash
npm ci npm ci
npm test npm test
``` ```
Modo demo en dos terminales: Ejecute en dos terminales:
```bash ```bash
EZO_MODE=demo npm run acquire EZO_MODE=demo npm run acquire
```
```bash
EZO_MODE=demo npm start EZO_MODE=demo npm start
``` ```
Abrir: Abra `http://localhost:3000/frontend/index.html`.
```text
http://localhost:3000/frontend/index.html
```
En PowerShell: En PowerShell:
@ -106,6 +55,8 @@ $env:EZO_MODE = "demo"
npm run acquire npm run acquire
``` ```
Y en una segunda terminal:
```powershell ```powershell
$env:EZO_MODE = "demo" $env:EZO_MODE = "demo"
npm start npm start
@ -113,154 +64,104 @@ npm start
## Raspberry Pi ## Raspberry Pi
Instalacion de produccion desde el repo clonado: El despliegue de producción usa `EZO_MODE=hardware`, systemd y Nginx:
```bash ```bash
chmod +x scripts/install-raspberry-pi.sh chmod +x scripts/install-raspberry-pi.sh
sudo ./scripts/install-raspberry-pi.sh sudo ./scripts/install-raspberry-pi.sh
``` ```
El instalador: 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).
- Copia la aplicacion a `/opt/photobioreactor`. ## Sensores conectados parcialmente
- Instala dependencias.
- Compila `EZO_COMMAND` y `EZO_ACQUIRE`.
- Crea el usuario `photobioreactor`.
- Configura servicios `systemd`.
- Configura Nginx.
- Fuerza `EZO_MODE=hardware` para produccion.
Servicios principales: Para pruebas con solo EZO-RTD conectado, limite la adquisicion al sensor de
temperatura desde el panel de configuracion o en `config/runtime.json`:
```bash
systemctl status photobioreactor-api --no-pager
systemctl status photobioreactor-acquisition --no-pager
systemctl status nginx --no-pager
```
Dashboard:
```text
http://IP_DE_LA_RASPBERRY/frontend/index.html
```
## Configuracion Operativa
Archivo principal de operacion:
```text
/opt/photobioreactor/config/runtime.json
```
Ejemplo con tres sensores conectados:
```json
{
"loggingRateSeconds": 5,
"historyRetentionDays": 30,
"enabledSensors": [
"temperature",
"ph",
"ec"
]
}
```
Ejemplo con los cuatro sensores:
```json ```json
{ {
"loggingRateSeconds": 5, "loggingRateSeconds": 1,
"historyRetentionDays": 30, "enabledSensors": ["temperature"]
"enabledSensors": [
"temperature",
"ph",
"do",
"ec"
]
} }
``` ```
Despues de cambiar esta configuracion: Tambien puede forzarlo temporalmente por entorno:
```bash ```bash
sudo systemctl restart photobioreactor-acquisition EZO_MODE=hardware EZO_ENABLED_SENSORS=temperature npm run acquire
``` ```
## Datos Guardados En produccion puede dejar `EZO_ENABLED_SENSORS=temperature` en
`/etc/default/photobioreactor` como override. Cuando esten conectados los cuatro
circuitos, deje la variable vacia y habilite RTD, pH, DO y EC desde el panel.
Los JSON en `data/` se sobrescriben; no crecen de forma indefinida. ## Seguridad operativa
Los CSV en `logs/` si crecen hasta el limite definido por
`historyRetentionDays`.
Formato CSV: En produccion configure `API_AUTH_TOKEN` en `/etc/default/photobioreactor` para
proteger comandos EZO, calibracion, cambios de configuracion y borrado de
historicos. El dashboard incluye un campo "Token API" que guarda el valor solo
en el navegador local y lo envia como header `X-API-Token`.
La API aplica comparacion de token en tiempo constante y headers defensivos
basicos (`nosniff`, `DENY`, `same-origin`, `no-store`).
Los POST criticos aplican rate limit en memoria mediante
`API_RATE_LIMIT_WINDOW_MS` y `API_RATE_LIMIT_MAX`.
```csv ## Bitacora de alarmas
timestamp,value
2026-07-07T17:26:13.459Z,25.132
```
En el peor caso operativo: El recolector registra transiciones de alarma en `logs/alarms.csv` y la API las
expone en `GET /api/alarms`. Se registran entradas a `WARNING`, `CRITICAL` y
`OFFLINE`, ademas de recuperaciones a `NORMAL`.
```text ## Retencion historica
4 sensores
1 lectura por segundo
30 dias
```
el sistema guarda aproximadamente: `config/runtime.json` define `historyRetentionDays`. El valor puede ser 7, 30,
90, 365 o 0 para conservar indefinidamente. El recolector poda filas antiguas de
CSV sin cambiar los nombres que usa el dashboard.
```text Las graficas del dashboard muestran solo los datos del dia actual para evitar
350 a 375 MB por mes saturacion visual. Los CSV conservan el periodo completo configurado por
``` retencion y siguen disponibles para exportacion.
Ver el calculo completo en [docs/STORAGE_ESTIMATE.md](docs/STORAGE_ESTIMATE.md). ## Umbrales de alarma
## Exportaciones Los limites se guardan en `config/alarms.json`, se consultan con
`GET /api/config/alarms` y pueden editarse desde el dashboard. Guardar cambios
requiere `API_AUTH_TOKEN` cuando esta configurado.
Las graficas del dashboard muestran solo el dia actual para evitar saturacion ## Notificaciones
visual. Las exportaciones CSV y Excel leen el historico disponible en los CSV.
Por eso un Excel puede pesar mucho mas que lo que se ve en pantalla.
## Seguridad `config/notifications.json` define canales de webhook y Telegram. El recolector
envia notificaciones cuando una alarma persistente alcanza `minSeverity`. La API
expone configuracion redactada en `GET /api/config/notifications`; guardar
requiere token y nunca devuelve secretos sin redaccion.
En produccion configure `API_AUTH_TOKEN` en: La configuracion completa del bot, `chatId`, pruebas y diagnostico esta en
[`docs/TELEGRAM_BOT_SETUP.md`](docs/TELEGRAM_BOT_SETUP.md).
```text ## Archivos de datos
/etc/default/photobioreactor
```
Ese token protege: - 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`.
- Comandos EZO. Los CSV usan una única nomenclatura y el formato:
- Calibraciones.
- Cambios de configuracion.
- Borrado de historicos.
- Configuracion de notificaciones.
El dashboard guarda el token solo en el navegador local y lo envia como header: ```csv
timestamp,value
```text 2026-06-22T12:00:00.000Z,25.123
X-API-Token
``` ```
No exponga el dashboard directamente a Internet. Use una red local confiable o ## Documentación
una VPN.
## Documentacion
- [Inicio rapido](docs/QUICK_START.md)
- [Indice de documentacion](docs/README.md)
- [Despliegue en Raspberry Pi](docs/RASPBERRY_PI_DEPLOYMENT.md)
- [Comandos y calibracion EZO](docs/EZO_COMMANDS.md)
- [Diagnostico de problemas](docs/TROUBLESHOOTING.md)
- [Calculo de almacenamiento](docs/STORAGE_ESTIMATE.md)
- [Telegram](docs/TELEGRAM_BOT_SETUP.md)
- [Arquitectura](ARCHITECTURE.md)
- [Estado del proyecto](PROJECT_STATUS.md)
## Nota Sobre Drivers Del Kernel - 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)
- Telegram: [`docs/TELEGRAM_BOT_SETUP.md`](docs/TELEGRAM_BOT_SETUP.md)
- Diseño extendido: [`ARCHITECTURE.md`](ARCHITECTURE.md)
No se usa un driver personalizado del kernel. Linux ya expone el bus I2C como No se utiliza un driver personalizado del kernel. Linux ya proporciona la capa
`/dev/i2c-1`. Mantener el protocolo Atlas EZO en espacio de usuario simplifica I2C mediante `/dev/i2c-1`; el protocolo ASCII, la adquisición y la calibración
diagnostico, mantenimiento, calibracion y despliegue. se mantienen en espacio de usuario para facilitar mantenimiento y diagnóstico.

@ -12,7 +12,7 @@ const SENSOR_CONFIG = {
ec: { address: '0x64', name: 'EC' } ec: { address: '0x64', name: 'EC' }
}; };
const DANGEROUS_COMMANDS = new Set(['factory', 'i2c', 'baud', 'sleep', 'import']); const DANGEROUS_COMMANDS = new Set(['factory', 'i2c', 'baud', 'sleep']);
const COMMON_COMMANDS = new Set([ const COMMON_COMMANDS = new Set([
'r', 'i', 'status', 'find', 'l', 'plock', 'name', 'cal', 'r', 'i', 'status', 'find', 'l', 'plock', 'name', 'cal',
'export', 'import', 't', 'rt', '*ok' 'export', 'import', 't', 'rt', '*ok'
@ -32,15 +32,7 @@ const MOCK_STATE = {
}; };
function normalizeCommand(rawCommand) { function normalizeCommand(rawCommand) {
const command = String(rawCommand || '').trim(); return String(rawCommand || '').trim().replace(/\s+/g, '');
if (/^import\s*,/i.test(command)) {
return command
.replace(/^import\s*,\s*/i, 'Import,')
.replace(/\s+/g, ' ');
}
return command.replace(/\s+/g, '');
} }
function getBaseCommand(command) { function getBaseCommand(command) {
@ -52,7 +44,7 @@ function validateCommand(sensor, command, dangerousConfirmed) {
throw createHttpError(400, 'Sensor no válido.'); throw createHttpError(400, 'Sensor no válido.');
} }
if (!command || command.length > 63 || !/^[a-zA-Z0-9*?.+\-, ]+$/.test(command)) { if (!command || command.length > 64 || !/^[a-zA-Z0-9*?.+\-,]+$/.test(command)) {
throw createHttpError(400, 'Comando EZO no válido.'); throw createHttpError(400, 'Comando EZO no válido.');
} }
@ -65,7 +57,7 @@ function validateCommand(sensor, command, dangerousConfirmed) {
throw createHttpError(400, `El comando ${baseCommand} no aplica a ${sensor.toUpperCase()}.`); throw createHttpError(400, `El comando ${baseCommand} no aplica a ${sensor.toUpperCase()}.`);
} }
if (isDangerousCommand(command) && !dangerousConfirmed) { if (DANGEROUS_COMMANDS.has(baseCommand) && !dangerousConfirmed) {
throw createHttpError(409, 'El comando requiere confirmación explícita.'); throw createHttpError(409, 'El comando requiere confirmación explícita.');
} }
@ -85,9 +77,9 @@ function matchesOfficialSyntax(sensor, command) {
/^plock,(0|1|\?)$/, /^plock,(0|1|\?)$/,
/^name,(|\?|[a-z0-9_.-]{1,16})$/, /^name,(|\?|[a-z0-9_.-]{1,16})$/,
/^\*ok,(0|1|\?)$/, /^\*ok,(0|1|\?)$/,
/^export$/, /^export(\,?)?$/,
/^export,\?$/, /^export,\?$/,
/^import,[0-9a-f]{2}( [0-9a-f]{2})*$/, /^import,[0-9a-f ]+$/,
/^i2c,([1-9]|[1-9]\d|1[01]\d|12[0-7])$/, /^i2c,([1-9]|[1-9]\d|1[01]\d|12[0-7])$/,
/^baud,(300|1200|2400|9600|19200|38400|57600|115200)$/ /^baud,(300|1200|2400|9600|19200|38400|57600|115200)$/
]; ];
@ -98,8 +90,8 @@ function matchesOfficialSyntax(sensor, command) {
rtd: [ rtd: [
/^cal,(\?|clear|[-+]?\d+(\.\d+)?)$/, /^cal,(\?|clear|[-+]?\d+(\.\d+)?)$/,
/^s,(c|k|f|\?)$/, /^s,(c|k|f|\?)$/,
/^d,(0|\?|[1-9]\d{0,3}|[12]\d{4}|3[01]\d{3}|32000)$/, /^d,(0|1|\?)$/,
/^m(,(clear|\?))?$/ /^m,(clear|\?)$/
], ],
ph: [ ph: [
/^cal,(\?|clear)$/, /^cal,(\?|clear)$/,
@ -132,46 +124,22 @@ function matchesOfficialSyntax(sensor, command) {
] ]
}; };
if (!sensorPatterns[sensor].some((pattern) => pattern.test(normalized))) return false; return sensorPatterns[sensor].some((pattern) => pattern.test(normalized));
if (sensor === 'ec' && normalized.startsWith('k,') && normalized !== 'k,?') {
const value = Number(normalized.split(',')[1]);
return value >= 0.01 && value <= 10.2;
} }
if (sensor === 'ec' && normalized.startsWith('tds,') && normalized !== 'tds,?') { function getProcessingDelay(command) {
const value = Number(normalized.split(',')[1]);
return value >= 0.01 && value <= 1.0;
}
return true;
}
function getProcessingDelay(sensorOrCommand, rawCommand) {
const sensor = rawCommand === undefined ? null : sensorOrCommand;
const command = rawCommand === undefined ? sensorOrCommand : rawCommand;
const normalized = command.toLowerCase(); const normalized = command.toLowerCase();
if (normalized === 'r') { if (normalized === 'r') return 1000;
return { rtd: 600, ph: 900, do: 600, ec: 600 }[sensor] || 1000; if (normalized.startsWith('rt,')) return 1000;
}
if (normalized.startsWith('rt,')) return 900;
if (normalized === 'cal' || normalized === 'cal,0') return 1300; if (normalized === 'cal' || normalized === 'cal,0') return 1300;
if (normalized.startsWith('cal,') && !['cal,?', 'cal,clear'].includes(normalized)) { if (normalized.startsWith('cal,') && !['cal,?', 'cal,clear'].includes(normalized)) {
const legacyPhCommand = !sensor && /cal,(mid|low|high),/.test(normalized); return normalized.includes('mid') || normalized.includes('low') ||
return sensor === 'ph' || legacyPhCommand ? 900 : 600; normalized.includes('high') ? 900 : 600;
} }
if (sensor === 'ec' && normalized.startsWith('k,') && normalized !== 'k,?') return 600;
return 300; return 300;
} }
function isDangerousCommand(command) {
const normalized = String(command || '').toLowerCase();
return DANGEROUS_COMMANDS.has(getBaseCommand(normalized)) ||
normalized === 'cal,clear' ||
normalized === 'm,clear';
}
function commandExpectsNoResponse(command) { function commandExpectsNoResponse(command) {
return ['sleep', 'factory'].includes(getBaseCommand(command)) || return ['sleep', 'factory'].includes(getBaseCommand(command)) ||
getBaseCommand(command) === 'i2c' || getBaseCommand(command) === 'i2c' ||
@ -207,11 +175,10 @@ function detectMode() {
async function executeHardwareCommand(sensor, command, modeInfo) { async function executeHardwareCommand(sensor, command, modeInfo) {
const config = SENSOR_CONFIG[sensor]; const config = SENSOR_CONFIG[sensor];
const processingDelay = getProcessingDelay(sensor, command);
const args = [ const args = [
'/dev/i2c-1', '/dev/i2c-1',
config.address, config.address,
String(processingDelay), String(getProcessingDelay(command)),
command command
]; ];
@ -220,7 +187,7 @@ async function executeHardwareCommand(sensor, command, modeInfo) {
} }
const { stdout } = await execFileAsync(modeInfo.helperPath, args, { const { stdout } = await execFileAsync(modeInfo.helperPath, args, {
timeout: processingDelay + 2500, timeout: getProcessingDelay(command) + 2500,
windowsHide: true windowsHide: true
}); });
const result = JSON.parse(stdout.trim()); const result = JSON.parse(stdout.trim());
@ -235,9 +202,7 @@ async function executeHardwareCommand(sensor, command, modeInfo) {
async function executeDemoCommand(sensor, command) { async function executeDemoCommand(sensor, command) {
const normalized = command.toLowerCase(); const normalized = command.toLowerCase();
const state = MOCK_STATE[sensor]; const state = MOCK_STATE[sensor];
await new Promise((resolve) => await new Promise((resolve) => setTimeout(resolve, Math.min(getProcessingDelay(command), 80)));
setTimeout(resolve, Math.min(getProcessingDelay(sensor, command), 80))
);
if (normalized === 'r') { if (normalized === 'r') {
const values = { const values = {
@ -340,7 +305,6 @@ module.exports = {
detectMode, detectMode,
executeEzoCommand, executeEzoCommand,
getProcessingDelay, getProcessingDelay,
isDangerousCommand,
matchesOfficialSyntax, matchesOfficialSyntax,
normalizeCommand, normalizeCommand,
validateCommand validateCommand

@ -1,203 +1,98 @@
# Comandos y calibración Atlas Scientific EZO # Comandos y calibracion Atlas Scientific EZO
Esta guía cubre los cuatro circuitos utilizados por el dashboard. La sintaxis, Esta implementacion sigue los datasheets oficiales vigentes consultados para:
los tiempos y el orden de calibración se verificaron contra los datasheets
oficiales indicados al final del documento.
## Sensores del proyecto - 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.
| Variable | Circuito | Dirección I2C | Lectura | ## Transporte I2C
|---|---|---:|---:|
| Temperatura | EZO-RTD | `0x66` | 600 ms |
| pH | EZO-pH | `0x63` | 900 ms |
| Oxígeno disuelto | EZO-DO | `0x61` | 600 ms |
| Conductividad | EZO-EC | `0x64` | 600 ms |
## Uso desde el dashboard 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.
La consola web envía los comandos por la API y el helper `EZO_COMMAND`. El El primer byte de una respuesta I2C es:
bloqueo compartido de `/tmp/photobioreactor-i2c.lock` evita que una lectura y
un comando usen el bus al mismo tiempo. No es necesario abrir una terminal en
la Raspberry para consultar, compensar o calibrar.
Los comandos que borran datos, importan calibraciones, reinician el circuito, | Codigo | Significado |
cambian la dirección o cambian el protocolo exigen confirmación. La consola |---|---|
acepta únicamente sintaxis documentada para el sensor seleccionado. | `1` | Solicitud procesada correctamente |
Después de cada comando de calibración enviado desde el panel se ejecuta
automáticamente `Cal,?` para mostrar el número real de puntos guardados.
## Protocolo I2C
La primera posición de cada respuesta es un código Atlas:
| Código | Significado |
|---:|---|
| `1` | Comando procesado correctamente |
| `2` | Error de sintaxis | | `2` | Error de sintaxis |
| `254` | El circuito sigue procesando | | `254` | Procesando; aun no esta lista |
| `255` | No hay datos disponibles | | `255` | No hay datos |
`EZO_COMMAND` espera el tiempo oficial y vuelve a consultar si recibe `254`.
Los comandos `Sleep`, `Factory`, `I2C,n` y `Baud,n` no entregan respuesta.
## Diagnóstico básico
Desde la consola web seleccione el sensor y use:
```text
i
Status
R
Cal,?
```
Respuestas esperadas:
```text El ejecutable `sensors/EZOCommand/EZO_COMMAND` implementa este intercambio y
?i,pH,2.17 reintenta las respuestas pendientes.
?Status,P,3.83
6.982
?Cal,3
```
`Cal,0` significa cero puntos guardados. El significado de los demás números ## Calibracion EZO-RTD
depende del sensor y del esquema de calibración.
## Calibración EZO-RTD Calibracion de un punto:
El RTD usa una calibración de un punto contra una referencia conocida:
```text ```text
Cal,25.00 Cal,<temperatura>
Cal,? Cal,?
Cal,clear
``` ```
Espere a que la sonda y la referencia alcancen equilibrio térmico antes de Ejemplo: `Cal,25.00`.
enviar `Cal,<temperatura>`. Atlas permite cualquier temperatura válida. El
comando tarda 600 ms. Para borrar el punto use `Cal,clear`.
Comandos adicionales exclusivos del RTD:
```text
S,c salida en Celsius
S,k salida en Kelvin
S,f salida en Fahrenheit
S,? consultar escala
D,n registrar cada n x 10 segundos; n entre 1 y 32000
D,0 desactivar registrador interno
D,? consultar intervalo
M recuperar la siguiente lectura guardada
M,? consultar última posición guardada
M,clear borrar memoria interna
```
El historial principal del proyecto se guarda en CSV; el registrador interno
del RTD es independiente y normalmente puede permanecer desactivado.
## Calibración EZO-pH ## Calibracion EZO-pH
Compruebe primero que la sonda cambia de lectura. Una sonda bloqueada de forma Orden recomendado:
permanente en 0, 7 o 14 no debe calibrarse. Enjuague la sonda entre soluciones,
no regrese solución usada al frasco y observe lecturas hasta que se estabilicen,
normalmente durante 1 a 2 minutos.
Orden Atlas para tres puntos:
```text ```text
Cal,mid,7.00 Cal,mid,7.00
Cal,low,4.00 Cal,low,4.00
Cal,high,10.00 Cal,high,10.00
Cal,?
Slope,?
```
El punto medio siempre va primero. Enviar de nuevo `Cal,mid,n` elimina los
puntos bajo y alto existentes. Cada punto tarda 900 ms. `Slope,?` informa la
pendiente ácida, la pendiente básica y el desplazamiento del punto neutro;
sirve para revisar calibración y salud de la sonda.
Compensación temporal de temperatura:
```text
T,25.0
T,?
RT,25.0
``` ```
`T,n` no se conserva al cortar energía. `RT,n` aplica la temperatura y toma una El punto medio siempre debe realizarse primero. Ejecutar `Cal,mid` sobre una
lectura en la misma operación. calibracion existente elimina los otros puntos. El estado se consulta con
`Cal,?` y la salud de la sonda con `Slope,?`.
## Calibración EZO-DO
Para calibrar mantenga primero los valores predeterminados: temperatura 20 °C, ## Calibracion EZO-DO
salinidad 0 y presión 101.3 kPa. Atlas indica calibrar primero y aplicar las
compensaciones reales después.
Calibración de un punto: Un punto:
```text ```text
Cal Cal
``` ```
Calibración de dos puntos, en este orden: Dos puntos, en este orden:
```text ```text
Cal,0 Cal,0
Cal Cal
Cal,?
``` ```
Para `Cal,0`, coloque la sonda en solución de cero oxígeno, agite para retirar `Cal,0` usa solucion de cero oxigeno. `Cal` usa la sonda estabilizada en aire
burbujas y observe lecturas hasta estabilizar. Una sonda con electrolito nuevo atmosferico. Compensaciones disponibles:
puede tardar varias horas en llegar a cero. Después exponga la sonda al aire;
normalmente estabiliza en 5 a 30 segundos. Ambos comandos tardan 1300 ms.
Compensaciones, aplicadas después de calibrar:
```text ```text
T,<grados Celsius> T,<grados Celsius>
T,? S,<conductividad en uS/cm>
S,<conductividad en µS/cm>
S,<salinidad>,ppt S,<salinidad>,ppt
S,? P,<presion en kPa>
P,<presión en kPa>
P,?
RT,<grados Celsius>
``` ```
La compensación de salinidad es irrelevante por debajo de 2500 µS/cm. Los ## Calibracion EZO-EC
valores de temperatura, salinidad y presión cambian la lectura calculada, no
los puntos guardados en la sonda.
## Calibración EZO-EC Primero se configura la constante de la sonda con `K,<valor>` y se realiza la
calibracion en seco:
Configure primero la constante real de la sonda. El firmware actual admite
valores de K 0.01 a K 10.2:
```text ```text
K,1.0 K,1.0
K,?
```
No cambie la compensación predeterminada de 25 °C durante la calibración. Si la
solución está 5 °C o más alejada de 25 °C, use el valor que indique la tabla de
temperatura del frasco, manteniendo `T,25` en el circuito.
La calibración en seco siempre es obligatoria, aunque la lectura ya marque 0:
```text
Cal,dry Cal,dry
``` ```
Dos puntos, para precisión en una banda estrecha: Calibracion de dos puntos:
```text ```text
Cal,dry Cal,dry
Cal,<valor> Cal,<valor>
``` ```
Tres puntos, para un rango amplio: Calibracion de tres puntos:
```text ```text
Cal,dry Cal,dry
@ -205,89 +100,31 @@ Cal,low,<valor>
Cal,high,<valor> Cal,high,<valor>
``` ```
Valores recomendados por Atlas: No se debe usar `Cal,0` en EC. El valor cero corresponde unicamente al paso
`Cal,dry`.
| Sonda | Bajo | Alto |
|---|---:|---:|
| K 0.1 | 84 µS/cm | 1413 µS/cm |
| K 1.0 | 12880 µS/cm | 80000 µS/cm |
| K 10 | 12880 µS/cm | 150000 µS/cm |
Use recipientes limpios, retire burbujas de la zona sensible, espere estabilidad
y enjuague antes del punto alto. Todos los comandos de calibración EC tardan
600 ms. Nunca use `Cal,0`; el cero de EC se establece con `Cal,dry`.
Después de calibrar puede aplicar compensación temporal:
```text
T,<grados Celsius>
T,?
RT,<grados Celsius>
```
Otros comandos EC admitidos son `TDS,n`, `TDS,?`, `O,?` y
`O,<EC|TDS|S|SG>,<0|1>`. El factor TDS válido está entre 0.01 y 1.00.
## Comandos comunes admitidos ## Modos del backend
```text - `EZO_MODE=auto`: usa hardware si encuentra `/dev/i2c-1` y `EZO_COMMAND`;
L,0 | L,1 | L,? de lo contrario usa demo.
Find - `EZO_MODE=hardware`: exige bus y ejecutable reales; si faltan, la API devuelve
Name,<nombre> | Name, | Name,? error en vez de simular.
i - `EZO_MODE=demo`: genera respuestas de desarrollo sin acceder al bus.
Status
Plock,0 | Plock,1 | Plock,?
*OK,0 | *OK,1 | *OK,?
Export,? | Export
Import,<bytes hexadecimales separados por espacios>
Sleep
Factory
I2C,<1-127>
Baud,<300|1200|2400|9600|19200|38400|57600|115200>
```
`Export` se envía repetidamente hasta recibir `*DONE`. Cada bloque exportado se Para preparar Raspberry Pi:
importa preservando los espacios entre bytes, por ejemplo:
```text
Import,59 6F 75 20 61 72
```
No importe datos de un tipo de circuito distinto ni interrumpa la secuencia;
una importación incorrecta es rechazada y puede reiniciar el EZO.
## Uso manual en Raspberry Pi
La consola web es la vía normal. Para diagnóstico manual detenga adquisición:
```bash
sudo systemctl stop photobioreactor-acquisition
sudo rm -f /tmp/photobioreactor-i2c.lock
```
Formato:
```bash ```bash
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND <bus> <dirección> <delay_ms> <comando> [--no-response] make -C sensors/EZOCommand
sudo usermod -aG i2c $USER
EZO_MODE=hardware npm start
``` ```
Ejemplos: Es necesario cerrar sesion y volver a entrar despues de agregar el usuario al
grupo `i2c`.
```bash
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 300 i
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 900 R
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 900 Cal,mid,7.00
```
Reinicie el servicio al terminar:
```bash
sudo systemctl start photobioreactor-acquisition
```
## Fuentes oficiales ## Fuentes oficiales
- [EZO-RTD Datasheet](https://files.atlas-scientific.com/EZO_RTD_Datasheet.pdf), versión 3.7, revisión 10/24. - https://files.atlas-scientific.com/pH_EZO_Datasheet.pdf
- [EZO-pH Datasheet](https://files.atlas-scientific.com/pH_EZO_Datasheet.pdf), versión 6.1, revisión 2/24. - https://files.atlas-scientific.com/DO_EZO_Datasheet.pdf
- [EZO-DO Datasheet](https://files.atlas-scientific.com/DO_EZO_Datasheet.pdf), versión 5.8, revisión 3/25. - https://files.atlas-scientific.com/EC_EZO_Datasheet.pdf
- [EZO-EC Datasheet](https://files.atlas-scientific.com/EC_EZO_Datasheet.pdf), versión 6.6, revisión 3/26. - https://files.atlas-scientific.com/EZO_RTD_Datasheet.pdf

@ -1,163 +0,0 @@
# Inicio Rapido
Esta guia es para levantar el proyecto sin entrar todavia en todos los detalles
tecnicos.
## Que Necesitas
Para desarrollo sin sensores:
- Una computadora con Node.js.
- El repositorio clonado.
Para Raspberry Pi:
- Raspberry Pi con Raspberry Pi OS.
- I2C habilitado.
- Modulos Atlas Scientific EZO en modo I2C.
- Red local para abrir el dashboard.
## Probar Sin Sensores
Instale dependencias:
```bash
npm ci
```
Ejecute pruebas:
```bash
npm test
```
Abra dos terminales.
Terminal 1:
```bash
EZO_MODE=demo npm run acquire
```
Terminal 2:
```bash
EZO_MODE=demo npm start
```
Abra:
```text
http://localhost:3000/frontend/index.html
```
En Windows PowerShell:
```powershell
$env:EZO_MODE = "demo"
npm run acquire
```
```powershell
$env:EZO_MODE = "demo"
npm start
```
## Instalar En Raspberry Pi
Clone el repositorio:
```bash
git clone ssh://git@gitea.itmorelia.com:222/Verano-Delfin-2026/bioreactor-multiparametric-daq-shield.git
cd bioreactor-multiparametric-daq-shield
```
Ejecute el instalador:
```bash
chmod +x scripts/install-raspberry-pi.sh
sudo ./scripts/install-raspberry-pi.sh
```
Abra el dashboard desde otra computadora:
```text
http://IP_DE_LA_RASPBERRY/frontend/index.html
```
## Verificar Que Esta Corriendo
```bash
systemctl status photobioreactor-api --no-pager
systemctl status photobioreactor-acquisition --no-pager
systemctl status nginx --no-pager
```
Si todo esta bien, los servicios deben aparecer como `active (running)`.
## Ver Sensores En I2C
```bash
i2cdetect -y 1
```
Mapa esperado:
| Direccion | Sensor |
|---:|---|
| `0x61` | EZO-DO |
| `0x63` | EZO-pH |
| `0x64` | EZO-EC |
| `0x66` | EZO-RTD |
Si falta un sensor, revise cableado, alimentacion y modo I2C.
## Habilitar Solo Sensores Conectados
Edite:
```bash
sudo nano /opt/photobioreactor/config/runtime.json
```
Ejemplo con RTD, pH y EC:
```json
{
"loggingRateSeconds": 5,
"historyRetentionDays": 30,
"enabledSensors": [
"temperature",
"ph",
"ec"
]
}
```
Reinicie adquisicion:
```bash
sudo systemctl restart photobioreactor-acquisition
```
## Revisar Datos
```bash
cat /opt/photobioreactor/data/EZORTD.json
cat /opt/photobioreactor/data/EZOPH.json
cat /opt/photobioreactor/data/EZOEC.json
```
```bash
tail /opt/photobioreactor/logs/temperature.csv
tail /opt/photobioreactor/logs/ph.csv
tail /opt/photobioreactor/logs/ec.csv
```
## Si Algo Falla
Lea:
```text
docs/TROUBLESHOOTING.md
```

@ -1,303 +1,116 @@
# Despliegue En Raspberry Pi # Despliegue y validación en Raspberry Pi
Guia para instalar, validar y operar el dashboard en Raspberry Pi. ## Prueba sin hardware
## 1. Preparar Raspberry Pi OS En una computadora de desarrollo:
Actualice el sistema:
```bash
sudo apt update
sudo apt upgrade -y
```
Instale herramientas base:
```bash ```bash
sudo apt install -y git build-essential i2c-tools nginx nodejs npm npm ci
npm test
``` ```
Habilite I2C: Para ejecutar el sistema completo en demo, use dos terminales:
```bash ```bash
sudo raspi-config EZO_MODE=demo npm run acquire
EZO_MODE=demo npm start
``` ```
Ruta: 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.
```text ## Preparación del bus I2C
Interface Options -> I2C -> Enable
```
Reinicie: En Raspberry Pi OS:
```bash ```bash
sudo raspi-config
sudo reboot sudo reboot
```
## 2. Confirmar I2C
```bash
ls -l /dev/i2c-1 ls -l /dev/i2c-1
sudo apt install i2c-tools
i2cdetect -y 1 i2cdetect -y 1
``` ```
Mapa esperado: El mapa esperado es:
| Sensor | Direccion | | Circuito | Dirección |
|---|---:| |---|---|
| EZO-DO | `0x61` | | EZO-DO | `0x61` |
| EZO-pH | `0x63` | | EZO-pH | `0x63` |
| EZO-EC | `0x64` | | EZO-EC | `0x64` |
| EZO-RTD | `0x66` | | EZO-RTD | `0x66` |
Si aparece `0x62` y responde `?I,ORP,...`, ese modulo es ORP, no DO. No continúe con calibraciones si falta una dirección, aparece una dirección
inesperada o el barrido del bus es inestable.
No calibre sensores hasta confirmar que la comunicacion I2C es estable. ## Instalación
## 3. Clonar El Repositorio Desde el repositorio clonado:
```bash
git clone ssh://git@gitea.itmorelia.com:222/Verano-Delfin-2026/bioreactor-multiparametric-daq-shield.git
cd bioreactor-multiparametric-daq-shield
```
## 4. Instalar El Proyecto
```bash ```bash
chmod +x scripts/install-raspberry-pi.sh chmod +x scripts/install-raspberry-pi.sh
sudo ./scripts/install-raspberry-pi.sh sudo ./scripts/install-raspberry-pi.sh
``` ```
El instalador: El instalador copia la aplicación a `/opt/photobioreactor`, instala
dependencias, compila `EZO_COMMAND` y `EZO_ACQUIRE`, crea el usuario de servicio,
- Copia el proyecto a `/opt/photobioreactor`. activa los dos servicios systemd y configura Nginx en el puerto 80.
- Ejecuta instalacion de dependencias.
- Compila `EZO_COMMAND` y `EZO_ACQUIRE`.
- Crea usuario `photobioreactor`.
- Agrega permisos al grupo `i2c`.
- Configura `/etc/default/photobioreactor`.
- Crea servicios `systemd`.
- Configura Nginx.
## 5. Verificar Servicios
```bash
systemctl status photobioreactor-api --no-pager
systemctl status photobioreactor-acquisition --no-pager
systemctl status nginx --no-pager
```
Ver modo: Validación:
```bash ```bash
systemctl status photobioreactor-api
systemctl status photobioreactor-acquisition
journalctl -u photobioreactor-acquisition -f
curl http://127.0.0.1:3000/api/system/ezo curl http://127.0.0.1:3000/api/system/ezo
``` ```
En produccion debe indicar `hardware`. La respuesta de producción debe informar `hardware` tanto para comandos como
para adquisición. `EZO_MODE=hardware` evita que una instalación incompleta
## 6. Abrir Dashboard caiga silenciosamente a valores simulados.
Desde una computadora en la misma red:
```text
http://IP_DE_LA_RASPBERRY/frontend/index.html
```
Para saber la IP:
```bash
hostname -I
```
## 7. Configurar Sensores Habilitados
Edite:
```bash
sudo nano /opt/photobioreactor/config/runtime.json
```
Tres sensores conectados:
```json
{
"loggingRateSeconds": 5,
"historyRetentionDays": 30,
"enabledSensors": [
"temperature",
"ph",
"ec"
]
}
```
Cuatro sensores:
```json
{
"loggingRateSeconds": 5,
"historyRetentionDays": 30,
"enabledSensors": [
"temperature",
"ph",
"do",
"ec"
]
}
```
Reinicie adquisicion:
```bash
sudo systemctl restart photobioreactor-acquisition
```
## 8. Validar Sensores Individualmente
Antes de ejecutar comandos manuales, detenga el recolector:
```bash
sudo systemctl stop photobioreactor-acquisition
sudo rm -f /tmp/photobioreactor-i2c.lock
```
Identificar pH:
```bash
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 300 i
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 300 Status
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 1000 R
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 300 Cal,?
```
Cambie la direccion segun el sensor: 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.
| Sensor | Direccion | ## Validación previa a calibración
|---|---:|
| DO | `0x61` |
| pH | `0x63` |
| EC | `0x64` |
| RTD | `0x66` |
Reinicie adquisicion despues: Desde la consola web, pruebe individualmente:
```bash
sudo systemctl start photobioreactor-acquisition
```
## 9. Revisar Datos
```bash
cat /opt/photobioreactor/data/EZORTD.json
cat /opt/photobioreactor/data/EZOPH.json
cat /opt/photobioreactor/data/EZODO.json
cat /opt/photobioreactor/data/EZOEC.json
```
```bash
tail /opt/photobioreactor/logs/temperature.csv
tail /opt/photobioreactor/logs/ph.csv
tail /opt/photobioreactor/logs/do.csv
tail /opt/photobioreactor/logs/ec.csv
```
## 10. Prueba De Estabilidad
Arranque limpio:
```bash
sudo systemctl stop photobioreactor-acquisition
sudo rm -f /tmp/photobioreactor-i2c.lock
sudo systemctl start photobioreactor-acquisition
```
Revise logs:
```bash
journalctl -u photobioreactor-acquisition --since "10 minutes ago" -l --no-pager
```
Monitoreo en vivo:
```bash
journalctl -u photobioreactor-acquisition -f
```
Salir:
```text ```text
Ctrl + C i
Status
R
Cal,?
``` ```
Recomendacion de tiempo: 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
| Duracion | Uso | sola ventana de conversión y recoge las respuestas bajo el mismo bloqueo usado
|---|---| por la consola.
| 15 minutos | Prueba rapida |
| 1 hora | Prueba inicial seria |
| 4 horas | Estabilidad de bus y alimentacion |
| 8 a 12 horas | Prueba fuerte de laboratorio |
| 24 horas | Validacion previa a operacion continua |
## 11. Calibracion
Use soluciones de referencia vigentes y espere estabilizacion fisica de cada ## Calibración
sonda.
Resumen: 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>`. - RTD: `Cal,<temperatura>`.
- pH: `Cal,mid,7.00`, luego `Cal,low,4.00`, luego `Cal,high,10.00`. - pH: `Cal,mid,7.00`, `Cal,low,4.00`, `Cal,high,10.00`.
- DO: `Cal,0` y luego `Cal`. - DO de dos puntos: `Cal,0` y después `Cal`; configure antes las compensaciones.
- EC: `K,<valor>`, `Cal,dry`, luego puntos humedos. - EC: configure `K`, ejecute `Cal,dry` y después uno o dos puntos húmedos.
Detalle completo: Consulte `docs/EZO_COMMANDS.md` para restricciones y comandos de diagnóstico.
```text ## Prueba integral
docs/EZO_COMMANDS.md
```
## 12. Seguridad Minima
Edite:
```bash
sudo nano /etc/default/photobioreactor
```
Configure:
```text
API_AUTH_TOKEN=un_token_largo_y_privado
```
Reinicie API:
```bash
sudo systemctl restart photobioreactor-api
```
No exponga el dashboard directamente a Internet.
## 13. Apagado Seguro
Para detener adquisicion:
```bash
sudo systemctl stop photobioreactor-acquisition
```
Para apagar Raspberry: 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.
```bash Las pruebas físicas y la exactitud metrológica no pueden certificarse fuera de
sudo shutdown now la Raspberry Pi con las sondas y soluciones reales conectadas.
```
## 14. Diagnostico
Si algo falla, consulte:
```text
docs/TROUBLESHOOTING.md
```

@ -1,48 +0,0 @@
# Documentacion
Indice rapido de documentos del proyecto.
## Para Empezar
| Documento | Para que sirve |
|---|---|
| [Inicio rapido](QUICK_START.md) | Levantar el proyecto sin entrar en detalle tecnico |
| [Despliegue en Raspberry Pi](RASPBERRY_PI_DEPLOYMENT.md) | Instalar, activar servicios y validar hardware |
| [Diagnostico](TROUBLESHOOTING.md) | Resolver fallas comunes de I2C, servicios y sensores |
## Operacion
| Documento | Para que sirve |
|---|---|
| [Comandos EZO](EZO_COMMANDS.md) | Identificacion, lectura, diagnostico y calibracion |
| [Telegram](TELEGRAM_BOT_SETUP.md) | Crear bot, obtener chatId y activar alertas |
| [Almacenamiento](STORAGE_ESTIMATE.md) | Calcular uso de microSD y crecimiento de historicos |
## Referencia Tecnica
| Documento | Para que sirve |
|---|---|
| [Arquitectura](../ARCHITECTURE.md) | Capas, flujo de datos, servicios y archivos |
| [Estado del proyecto](../PROJECT_STATUS.md) | Funciones implementadas y trabajo pendiente |
| [README principal](../README.md) | Entrada general al proyecto |
## Ruta Recomendada De Lectura
Para alguien nuevo:
```text
README.md
docs/QUICK_START.md
docs/RASPBERRY_PI_DEPLOYMENT.md
docs/TROUBLESHOOTING.md
```
Para alguien tecnico:
```text
ARCHITECTURE.md
PROJECT_STATUS.md
docs/EZO_COMMANDS.md
docs/STORAGE_ESTIMATE.md
docs/TELEGRAM_BOT_SETUP.md
```

@ -1,180 +0,0 @@
# Calculo De Almacenamiento
Este documento estima cuanto espacio ocupan los datos guardados por el sistema.
## Que Archivos Crecen
Crecen principalmente:
```text
logs/temperature.csv
logs/ph.csv
logs/do.csv
logs/ec.csv
logs/alarms.csv
```
No crecen de forma acumulativa:
```text
data/EZORTD.json
data/EZOPH.json
data/EZODO.json
data/EZOEC.json
```
Los JSON se sobrescriben en cada ciclo.
## Formato De Una Lectura
Cada fila CSV usa:
```csv
timestamp,value
2026-07-07T17:26:13.459Z,25.132
```
Tamano aproximado por fila:
```text
24 bytes timestamp ISO
1 byte coma
6 a 10 bytes valor
1 byte salto de linea
```
Rango practico:
```text
32 a 36 bytes por lectura
```
Para calculos conservadores se usa:
```text
36 bytes por lectura
```
## Peor Caso Operativo
Supuestos:
```text
4 sensores
1 lectura por segundo
30 dias
36 bytes por fila
```
Lecturas por sensor al mes:
```text
30 * 24 * 60 * 60 = 2,592,000 lecturas
```
Espacio por sensor:
```text
2,592,000 * 36 = 93,312,000 bytes
```
Resultado:
```text
93.31 MB por sensor por mes
88.99 MiB por sensor por mes
```
Cuatro sensores:
```text
93.31 MB * 4 = 373.25 MB por mes
```
Resultado practico:
```text
350 a 375 MB por mes
```
## Tabla Por Sensor
Con 1 lectura por segundo:
| Periodo | Por sensor |
|---|---:|
| 1 dia | ~3.11 MB |
| 30 dias | ~93.31 MB |
| 1 ano | ~1.14 GB |
## Tabla Del Sistema Completo
Con 4 sensores:
| Frecuencia | Datos por mes |
|---|---:|
| 1 segundo | ~350 a 375 MB |
| 5 segundos | ~70 MB |
| 10 segundos | ~35 MB |
| 60 segundos | ~6 MB |
Con 3 sensores:
| Frecuencia | Datos por mes |
|---|---:|
| 1 segundo | ~264 a 280 MB |
| 5 segundos | ~50 a 53 MB |
| 10 segundos | ~25 a 27 MB |
| 60 segundos | ~4 a 5 MB |
## Tiempo Estimado Por MicroSD
Estimacion teorica solo para datos, sin borrar historicos:
| MicroSD | Tiempo aproximado con 4 sensores a 1 Hz |
|---|---:|
| 16 GB | ~3.5 anos |
| 32 GB | ~7 anos |
| 64 GB | ~14 anos |
| 128 GB | ~28 anos |
Estimacion conservadora, reservando espacio para Raspberry Pi OS, paquetes,
logs del sistema y margen libre:
| MicroSD | Espacio comodo para datos | Tiempo comodo |
|---|---:|---:|
| 16 GB | ~6 GB | ~16 meses |
| 32 GB | ~18 GB | ~4 anos |
| 64 GB | ~43 GB | ~9.5 anos |
| 128 GB | ~94 GB | ~21 anos |
## Retencion Historica
El sistema puede podar historicos con:
```json
{
"historyRetentionDays": 30
}
```
Con 30 dias de retencion, el uso de CSV no crece indefinidamente. En el peor
caso de 4 sensores a 1 Hz se estabiliza alrededor de:
```text
350 a 375 MB
```
## Excel No Cuenta Como Almacenamiento Continuo
Los reportes Excel pueden pesar mucho porque `.xlsx` almacena celdas, hojas y
metadatos. Un dia con 4 sensores a 1 Hz puede tener:
```text
345,600 filas
mas de 1,000,000 de celdas si hay 3 columnas
```
Por eso un Excel diario puede pesar decenas de MB. Ese peso solo cuenta si se
descarga o guarda el reporte; no es parte del almacenamiento continuo del
sistema.

@ -1,248 +0,0 @@
# Diagnostico De Problemas
Guia practica para resolver fallas comunes en Raspberry Pi, sensores EZO y
dashboard.
## Ver Estado General
```bash
systemctl status photobioreactor-api --no-pager
systemctl status photobioreactor-acquisition --no-pager
systemctl status nginx --no-pager
```
Logs recientes:
```bash
journalctl -u photobioreactor-api --since "10 minutes ago" -l --no-pager
journalctl -u photobioreactor-acquisition --since "10 minutes ago" -l --no-pager
```
## El Dashboard No Abre
1. Confirme IP de la Raspberry:
```bash
hostname -I
```
2. Confirme Nginx:
```bash
systemctl status nginx --no-pager
```
3. Pruebe localmente:
```bash
curl http://127.0.0.1/frontend/index.html
curl http://127.0.0.1/api/system/ezo
```
4. Desde otra computadora abra:
```text
http://IP_DE_LA_RASPBERRY/frontend/index.html
```
## No Aparece Un Sensor En `i2cdetect`
Detenga adquisicion:
```bash
sudo systemctl stop photobioreactor-acquisition
sudo rm -f /tmp/photobioreactor-i2c.lock
```
Revise bus:
```bash
i2cdetect -y 1
```
Direcciones esperadas:
| Sensor | Direccion |
|---|---:|
| DO | `0x61` |
| pH | `0x63` |
| EC | `0x64` |
| RTD | `0x66` |
Si no aparece:
- Confirme alimentacion.
- Confirme GND comun si no hay aislamiento galvanico.
- Confirme SDA y SCL.
- Confirme que el circuito EZO esta en modo I2C.
- Revise que no haya una direccion cambiada.
## Identificar Que Sensor Hay En Una Direccion
```bash
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 300 i
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 300 Status
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 1000 R
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 300 Cal,?
```
Cambie `0x63` por la direccion que quiera probar.
Ejemplos de identificacion:
```text
?I,pH,2.17
?I,EC,2.14
?I,RTD,2.12
?I,DO,2.XX
?I,ORP,2.14
```
Si aparece `?I,ORP,...`, ese modulo no es DO.
## Error: No Se Pudo Bloquear El Bus I2C
Significa que el helper no pudo tomar:
```text
/tmp/photobioreactor-i2c.lock
```
Solucion rapida:
```bash
sudo systemctl stop photobioreactor-acquisition
sudo rm -f /tmp/photobioreactor-i2c.lock
sudo systemctl start photobioreactor-acquisition
```
Si sigue fallando:
```bash
ls -ld /tmp
id photobioreactor
ls -l /dev/i2c-1
groups photobioreactor
ps -eo pid,user,stat,cmd | grep -E "EZO_ACQUIRE|EZO_COMMAND|acquisition.js" | grep -v grep
```
Prueba directa como usuario de servicio:
```bash
sudo -u photobioreactor /opt/photobioreactor/sensors/EZOCommand/EZO_ACQUIRE /dev/i2c-1 temperature ph ec
```
Si esta prueba responde JSON, el bus y permisos estan bien.
## Error: EZO_COMMAND No Existe
Si esta en `~` o en otra carpeta, esta ruta puede fallar:
```bash
./sensors/EZOCommand/EZO_COMMAND
```
Use la ruta instalada:
```bash
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND
```
O compile desde el repositorio:
```bash
make -C sensors/EZOCommand
```
## El Servicio Dice 1/1, 2/2 O 3/3 Sensores
Eso significa que solo estan habilitados esos sensores en:
```text
/opt/photobioreactor/config/runtime.json
```
Ejemplo con tres sensores:
```json
{
"loggingRateSeconds": 5,
"historyRetentionDays": 30,
"enabledSensors": [
"temperature",
"ph",
"ec"
]
}
```
Despues de editar:
```bash
sudo systemctl restart photobioreactor-acquisition
```
## SSH Se Vuelve Inestable
Posibles causas:
- Alimentacion insuficiente.
- Bus I2C bloqueado.
- Sensor mal cableado.
- Frecuencia de adquisicion muy agresiva durante pruebas.
- Raspberry reiniciandose.
Revise:
```bash
dmesg | tail -n 80
journalctl -u photobioreactor-acquisition --since "30 minutes ago" -p warning --no-pager
vcgencmd get_throttled
```
Si `vcgencmd get_throttled` no devuelve `throttled=0x0`, hay indicios de
problemas de alimentacion o temperatura.
## Excel Pesa Mucho
Las graficas muestran solo el dia actual, pero Excel exporta el historico
disponible en CSV.
Con 4 sensores a 1 lectura por segundo:
```text
86,400 filas por sensor por dia
345,600 filas totales por dia
1,036,800 celdas en Excel si son 3 columnas
```
Por eso un Excel diario puede pesar decenas de MB. El CSV es mas eficiente.
## Prueba De Estabilidad
Para dejarlo corriendo:
```bash
sudo systemctl stop photobioreactor-acquisition
sudo rm -f /tmp/photobioreactor-i2c.lock
sudo systemctl start photobioreactor-acquisition
```
Revisar errores:
```bash
journalctl -u photobioreactor-acquisition --since "1 hour ago" -p warning --no-pager
journalctl -u photobioreactor-api --since "1 hour ago" -p warning --no-pager
```
Revisar crecimiento de CSV:
```bash
wc -l /opt/photobioreactor/logs/temperature.csv
wc -l /opt/photobioreactor/logs/ph.csv
wc -l /opt/photobioreactor/logs/ec.csv
wc -l /opt/photobioreactor/logs/do.csv
```
Con `loggingRateSeconds` en `5`, cada sensor debe sumar aproximadamente 720
filas por hora.

@ -61,7 +61,7 @@ const calibrationProfiles = {
}, },
do: { do: {
description: "Calibración atmosférica de un punto o calibración de dos puntos con cero.", description: "Calibración atmosférica de un punto o calibración de dos puntos con cero.",
note: "Calibre con T=20 °C, S=0 y P=101.3 kPa; aplique compensaciones reales después.", note: "Para dos puntos, Atlas indica calibrar primero cero (Cal,0) y después aire (Cal).",
groups: [ groups: [
{ {
title: "Cero oxígeno", title: "Cero oxígeno",
@ -79,20 +79,20 @@ const calibrationProfiles = {
title: "Compensaciones", title: "Compensaciones",
help: "Temperatura en °C, salinidad en µS/cm y presión atmosférica en kPa.", help: "Temperatura en °C, salinidad en µS/cm y presión atmosférica en kPa.",
fields: [ fields: [
{ label: "Temperatura", value: "20.0", step: "0.1", command: "T", query: "T,?" }, { label: "Temperatura", value: "20.0", step: "0.1", command: "T" },
{ label: "Salinidad", value: "0", step: "1", command: "S", query: "S,?" }, { label: "Salinidad", value: "0", step: "1", command: "S" },
{ label: "Presión", value: "101.3", step: "0.1", command: "P", query: "P,?" } { label: "Presión", value: "101.3", step: "0.1", command: "P" }
] ]
} }
] ]
}, },
ec: { ec: {
description: "Calibración de dos o tres puntos; la calibración en seco siempre va primero.", description: "Calibración de dos o tres puntos; la calibración en seco siempre va primero.",
note: "Configure K y mantenga T=25 °C durante toda la calibración. Nunca use Cal,0.", note: "Configure primero la constante K de la sonda. Nunca calibre EC a cero con Cal,0.",
groups: [ groups: [
{ {
title: "Constante de la sonda", title: "Constante de la sonda",
help: "Use el valor real de la sonda, entre K 0.01 y K 10.2.", help: "Valores habituales: K 0.1, K 1.0 o K 10.",
value: "1.0", value: "1.0",
step: "0.1", step: "0.1",
button: "Configurar K", button: "Configurar K",
@ -108,8 +108,8 @@ const calibrationProfiles = {
}, },
{ {
title: "Segundo punto", title: "Segundo punto",
help: "Para K 1.0 Atlas recomienda Cal,12880 después de Cal,dry.", help: "Para calibración de dos puntos use Cal,<valor>, por ejemplo 1413.",
value: "12880", value: "1413",
step: "1", step: "1",
button: "Calibrar punto único", button: "Calibrar punto único",
command(value) { return `Cal,${value}`; } command(value) { return `Cal,${value}`; }
@ -124,7 +124,7 @@ const calibrationProfiles = {
}, },
{ {
title: "Compensación de temperatura", title: "Compensación de temperatura",
help: "Aplíquela sólo después de calibrar; durante la calibración debe permanecer en 25 °C.", help: "La compensación se expresa en °C y no se conserva al apagar.",
value: "25.0", value: "25.0",
step: "0.1", step: "0.1",
button: "Aplicar temperatura", button: "Aplicar temperatura",
@ -176,16 +176,13 @@ async function sendCommand(command, options = {}) {
const sensorType = options.sensor || const sensorType = options.sensor ||
document.getElementById("terminal-sensor-select").value; document.getElementById("terminal-sensor-select").value;
const terminalOutput = document.getElementById("terminal-output"); const terminalOutput = document.getElementById("terminal-output");
const normalizedCommand = String(command).trim().toLowerCase(); const dangerous = ["factory", "i2c", "baud", "sleep"]
const dangerous = ["factory", "i2c", "baud", "sleep", "import"] .includes(String(command).split(",")[0].toLowerCase());
.includes(normalizedCommand.split(",")[0]) || let dangerousConfirmed = false;
normalizedCommand === "cal,clear" ||
normalizedCommand === "m,clear";
let dangerousConfirmed = options.dangerousConfirmed === true;
if (dangerous && !dangerousConfirmed) { if (dangerous) {
dangerousConfirmed = confirm( dangerousConfirmed = confirm(
`El comando ${command} modifica o borra configuración sensible del circuito. ¿Desea enviarlo?` `El comando ${command} puede reiniciar, dormir o cambiar la comunicación del circuito. ¿Desea enviarlo?`
); );
if (!dangerousConfirmed) return null; if (!dangerousConfirmed) return null;
} }
@ -223,10 +220,10 @@ async function sendCommand(command, options = {}) {
} }
} }
async function sendCalibrationCommand(command, options = {}) { async function sendCalibrationCommand(command) {
const sensor = document.getElementById("cal-sensor-select").value; const sensor = document.getElementById("cal-sensor-select").value;
document.getElementById("terminal-sensor-select").value = sensor; document.getElementById("terminal-sensor-select").value = sensor;
const result = await sendCommand(command, { sensor, ...options }); const result = await sendCommand(command, { sensor });
const normalized = String(command).toLowerCase(); const normalized = String(command).toLowerCase();
if ( if (
@ -246,7 +243,7 @@ async function clearCalibration() {
`Se borrarán todos los puntos de calibración del sensor ${sensor.toUpperCase()}. ¿Continuar?` `Se borrarán todos los puntos de calibración del sensor ${sensor.toUpperCase()}. ¿Continuar?`
); );
if (confirmed) { if (confirmed) {
await sendCalibrationCommand("Cal,clear", { dangerousConfirmed: true }); await sendCalibrationCommand("Cal,clear");
} }
} }
@ -270,25 +267,13 @@ function renderCalibrationPanel() {
if (group.fields) { if (group.fields) {
group.fields.forEach((field, fieldIndex) => { group.fields.forEach((field, fieldIndex) => {
const action = createCalibrationAction( container.appendChild(createCalibrationAction(
`${sensor}-${groupIndex}-${fieldIndex}`, `${sensor}-${groupIndex}-${fieldIndex}`,
field.label, field.label,
field.value, field.value,
field.step, field.step,
(value) => `${field.command},${value}` (value) => `${field.command},${value}`
); ));
if (field.query) {
const queryButton = document.createElement("button");
queryButton.className = "btn-command";
queryButton.textContent = "Consultar";
queryButton.addEventListener("click", () =>
sendCalibrationCommand(field.query)
);
action.appendChild(queryButton);
}
container.appendChild(action);
}); });
} else { } else {
container.appendChild(createCalibrationAction( container.appendChild(createCalibrationAction(

@ -1,3 +1,4 @@
#include <errno.h>
#include <fcntl.h> #include <fcntl.h>
#include <linux/i2c-dev.h> #include <linux/i2c-dev.h>
#include <stdio.h> #include <stdio.h>
@ -12,61 +13,24 @@ static void print_json_error(const char *message)
printf("{\"success\":false,\"error\":\"%s\"}\n", message); printf("{\"success\":false,\"error\":\"%s\"}\n", message);
} }
static void close_bus(int fd, int lock_fd)
{
if (fd >= 0)
{
close(fd);
}
flock(lock_fd, LOCK_UN);
close(lock_fd);
}
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
if (argc < 5 || argc > 6 || if (argc < 5)
(argc == 6 && strcmp(argv[5], "--no-response") != 0))
{ {
print_json_error("Uso: EZO_COMMAND bus address delay_ms command [--no-response]"); print_json_error("Uso: EZO_COMMAND bus address delay_ms command [--no-response]");
return 2; return 2;
} }
const char *bus = argv[1]; const char *bus = argv[1];
char *address_end = NULL; int address = (int)strtol(argv[2], NULL, 0);
char *delay_end = NULL; int delay_ms = atoi(argv[3]);
long address_value = strtol(argv[2], &address_end, 0);
long delay_value = strtol(argv[3], &delay_end, 10);
const char *command = argv[4]; const char *command = argv[4];
size_t command_length = strlen(command); int no_response = argc > 5 && strcmp(argv[5], "--no-response") == 0;
int no_response = argc == 6;
if (*argv[2] == '\0' || *address_end != '\0' || address_value < 1 || address_value > 127)
{
print_json_error("Direccion I2C no valida (1-127)");
return 2;
}
if (*argv[3] == '\0' || *delay_end != '\0' || delay_value < 0 || delay_value > 60000)
{
print_json_error("Delay no valido (0-60000 ms)");
return 2;
}
if (command_length == 0 || command_length > 63)
{
print_json_error("Comando EZO no valido (1-63 bytes)");
return 2;
}
int lock_fd = open("/tmp/photobioreactor-i2c.lock", O_CREAT | O_RDWR, 0660); int lock_fd = open("/tmp/photobioreactor-i2c.lock", O_CREAT | O_RDWR, 0660);
if (lock_fd < 0 || flock(lock_fd, LOCK_EX) < 0) if (lock_fd < 0 || flock(lock_fd, LOCK_EX) < 0)
{ {
print_json_error("No se pudo bloquear el bus I2C"); print_json_error("No se pudo bloquear el bus I2C");
if (lock_fd >= 0)
{
close(lock_fd);
}
return 3; return 3;
} }
@ -75,30 +39,37 @@ int main(int argc, char *argv[])
if (fd < 0) if (fd < 0)
{ {
print_json_error("No se pudo abrir el bus I2C"); print_json_error("No se pudo abrir el bus I2C");
close_bus(fd, lock_fd); flock(lock_fd, LOCK_UN);
close(lock_fd);
return 4; return 4;
} }
if (ioctl(fd, I2C_SLAVE, (int)address_value) < 0) if (ioctl(fd, I2C_SLAVE, address) < 0)
{ {
print_json_error("No se pudo seleccionar el circuito EZO"); print_json_error("No se pudo seleccionar el circuito EZO");
close_bus(fd, lock_fd); close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 5; return 5;
} }
if (write(fd, command, command_length) != (ssize_t)command_length) if (write(fd, command, strlen(command)) < 0)
{ {
print_json_error("Fallo al escribir el comando EZO"); print_json_error("Fallo al escribir el comando EZO");
close_bus(fd, lock_fd); close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 6; return 6;
} }
usleep((useconds_t)delay_value * 1000); usleep((useconds_t)delay_ms * 1000);
if (no_response) if (no_response)
{ {
printf("{\"success\":true,\"response\":\"SENT\"}\n"); printf("{\"success\":true,\"response\":\"SENT\"}\n");
close_bus(fd, lock_fd); close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 0; return 0;
} }
@ -114,37 +85,40 @@ int main(int argc, char *argv[])
if (bytes_read < 1) if (bytes_read < 1)
{ {
print_json_error("El circuito EZO no respondio"); print_json_error("El circuito EZO no respondió");
close_bus(fd, lock_fd); close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 7; return 7;
} }
if (response[0] != 1) if (response[0] != 1)
{ {
char message[64]; char message[64];
const char *detail = response[0] == 2 ? "error de sintaxis" : snprintf(message, sizeof(message), "Código de respuesta EZO: %u", response[0]);
response[0] == 254 ? "procesando, timeout" :
response[0] == 255 ? "sin datos" : "desconocido";
snprintf(message, sizeof(message), "Codigo EZO %u: %s", response[0], detail);
print_json_error(message); print_json_error(message);
close_bus(fd, lock_fd); close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 8; return 8;
} }
char *payload = (char *)&response[1]; char *payload = (char *)&response[1];
payload[bytes_read > 1 ? bytes_read - 1 : 0] = '\0'; payload[bytes_read > 1 ? bytes_read - 1 : 0] = '\0';
for (int index = 0; payload[index] != '\0'; index++) for (int i = 0; payload[i] != '\0'; i++)
{ {
if (payload[index] == '"' || payload[index] == '\\') if (payload[i] == '"' || payload[i] == '\\')
{ {
payload[index] = ' '; payload[i] = ' ';
} }
} }
printf("{\"success\":true,\"response\":\"%s\"}\n", printf("{\"success\":true,\"response\":\"%s\"}\n",
payload[0] == '\0' ? "*OK" : payload); payload[0] == '\0' ? "*OK" : payload);
close_bus(fd, lock_fd); close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 0; return 0;
} }

@ -3,9 +3,7 @@ const test = require('node:test');
const { const {
getProcessingDelay, getProcessingDelay,
isDangerousCommand, matchesOfficialSyntax
matchesOfficialSyntax,
normalizeCommand
} = require('../api/ezo-command-service'); } = require('../api/ezo-command-service');
test('accepts official calibration commands by sensor', () => { test('accepts official calibration commands by sensor', () => {
@ -26,44 +24,13 @@ test('rejects commands assigned to the wrong sensor', () => {
assert.equal(matchesOfficialSyntax('rtd', 'Cal,mid,7'), false); assert.equal(matchesOfficialSyntax('rtd', 'Cal,mid,7'), false);
assert.equal(matchesOfficialSyntax('ph', 'Cal,dry'), false); assert.equal(matchesOfficialSyntax('ph', 'Cal,dry'), false);
assert.equal(matchesOfficialSyntax('rtd', 'T,25'), false); assert.equal(matchesOfficialSyntax('rtd', 'T,25'), false);
assert.equal(matchesOfficialSyntax('ec', 'K,0'), false);
assert.equal(matchesOfficialSyntax('ec', 'K,10.3'), false);
assert.equal(matchesOfficialSyntax('ec', 'TDS,0'), false);
assert.equal(matchesOfficialSyntax('ec', 'TDS,1.01'), false);
}); });
test('uses Atlas processing delays for critical commands', () => { test('uses Atlas processing delays for critical commands', () => {
assert.equal(getProcessingDelay('rtd', 'R'), 600); assert.equal(getProcessingDelay('R'), 1000);
assert.equal(getProcessingDelay('ph', 'R'), 900); assert.equal(getProcessingDelay('Cal'), 1300);
assert.equal(getProcessingDelay('do', 'R'), 600); assert.equal(getProcessingDelay('Cal,0'), 1300);
assert.equal(getProcessingDelay('ec', 'R'), 600); assert.equal(getProcessingDelay('Cal,mid,7'), 900);
assert.equal(getProcessingDelay('do', 'Cal'), 1300); assert.equal(getProcessingDelay('Cal,dry'), 600);
assert.equal(getProcessingDelay('do', 'Cal,0'), 1300); assert.equal(getProcessingDelay('Status'), 300);
assert.equal(getProcessingDelay('ph', 'Cal,mid,7'), 900);
assert.equal(getProcessingDelay('ec', 'Cal,low,12880'), 600);
assert.equal(getProcessingDelay('ec', 'K,1.0'), 600);
assert.equal(getProcessingDelay('rtd', 'Status'), 300);
});
test('accepts RTD data logger and memory commands from the datasheet', () => {
assert.equal(matchesOfficialSyntax('rtd', 'D,1'), true);
assert.equal(matchesOfficialSyntax('rtd', 'D,32000'), true);
assert.equal(matchesOfficialSyntax('rtd', 'D,32001'), false);
assert.equal(matchesOfficialSyntax('rtd', 'M'), true);
assert.equal(matchesOfficialSyntax('rtd', 'M,?'), true);
assert.equal(matchesOfficialSyntax('rtd', 'M,clear'), true);
});
test('preserves the hexadecimal spacing required by Import', () => {
const command = normalizeCommand(' Import, 59 6F 75 20 61 72 ');
assert.equal(command, 'Import,59 6F 75 20 61 72');
assert.equal(matchesOfficialSyntax('ph', command), true);
assert.equal(matchesOfficialSyntax('ph', 'Export,'), false);
});
test('requires confirmation for destructive calibration and memory commands', () => {
assert.equal(isDangerousCommand('Cal,clear'), true);
assert.equal(isDangerousCommand('M,clear'), true);
assert.equal(isDangerousCommand('Import,59 6F'), true);
assert.equal(isDangerousCommand('Cal,mid,7'), false);
}); });

Loading…
Cancel
Save