Pulir comandos y calibracion Atlas EZO

main
EMOTIONS-HUNTER 1 week ago
parent 10c53dd8b8
commit e372c6bfce

@ -15,6 +15,7 @@ Implementado:
- Adquisicion automatica con `api/acquisition.js`. - Adquisicion automatica con `api/acquisition.js`.
- Helper C `EZO_ACQUIRE` para lecturas agrupadas. - Helper C `EZO_ACQUIRE` para lecturas agrupadas.
- Helper C `EZO_COMMAND` para comandos y calibracion. - Helper C `EZO_COMMAND` para comandos y calibracion.
- Sintaxis, tiempos y secuencias de calibracion auditados contra datasheets Atlas.
- Historial CSV por sensor. - Historial CSV por sensor.
- Graficas con Chart.js. - Graficas con Chart.js.
- Exportacion CSV y Excel con SheetJS. - Exportacion CSV y Excel con SheetJS.
@ -161,6 +162,8 @@ Validado en desarrollo:
- Dashboard por HTTP local. - Dashboard por HTTP local.
- Chart.js y SheetJS locales. - Chart.js y SheetJS locales.
- Comandos EZO simulados. - 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. - Configuracion de alarmas y notificaciones.
Validacion en Raspberry: Validacion en Raspberry:

@ -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']); const DANGEROUS_COMMANDS = new Set(['factory', 'i2c', 'baud', 'sleep', 'import']);
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,7 +32,15 @@ const MOCK_STATE = {
}; };
function normalizeCommand(rawCommand) { function normalizeCommand(rawCommand) {
return String(rawCommand || '').trim().replace(/\s+/g, ''); const command = String(rawCommand || '').trim();
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) {
@ -44,7 +52,7 @@ function validateCommand(sensor, command, dangerousConfirmed) {
throw createHttpError(400, 'Sensor no válido.'); throw createHttpError(400, 'Sensor no válido.');
} }
if (!command || command.length > 64 || !/^[a-zA-Z0-9*?.+\-,]+$/.test(command)) { if (!command || command.length > 63 || !/^[a-zA-Z0-9*?.+\-, ]+$/.test(command)) {
throw createHttpError(400, 'Comando EZO no válido.'); throw createHttpError(400, 'Comando EZO no válido.');
} }
@ -57,7 +65,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 (DANGEROUS_COMMANDS.has(baseCommand) && !dangerousConfirmed) { if (isDangerousCommand(command) && !dangerousConfirmed) {
throw createHttpError(409, 'El comando requiere confirmación explícita.'); throw createHttpError(409, 'El comando requiere confirmación explícita.');
} }
@ -77,9 +85,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 ]+$/, /^import,[0-9a-f]{2}( [0-9a-f]{2})*$/,
/^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)$/
]; ];
@ -90,8 +98,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|\?)$/, /^d,(0|\?|[1-9]\d{0,3}|[12]\d{4}|3[01]\d{3}|32000)$/,
/^m,(clear|\?)$/ /^m(,(clear|\?))?$/
], ],
ph: [ ph: [
/^cal,(\?|clear)$/, /^cal,(\?|clear)$/,
@ -124,22 +132,46 @@ function matchesOfficialSyntax(sensor, command) {
] ]
}; };
return sensorPatterns[sensor].some((pattern) => pattern.test(normalized)); if (!sensorPatterns[sensor].some((pattern) => pattern.test(normalized))) return false;
if (sensor === 'ec' && normalized.startsWith('k,') && normalized !== 'k,?') {
const value = Number(normalized.split(',')[1]);
return value >= 0.01 && value <= 10.2;
} }
function getProcessingDelay(command) { if (sensor === 'ec' && normalized.startsWith('tds,') && normalized !== 'tds,?') {
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') return 1000; if (normalized === 'r') {
if (normalized.startsWith('rt,')) return 1000; return { rtd: 600, ph: 900, do: 600, ec: 600 }[sensor] || 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)) {
return normalized.includes('mid') || normalized.includes('low') || const legacyPhCommand = !sensor && /cal,(mid|low|high),/.test(normalized);
normalized.includes('high') ? 900 : 600; return sensor === 'ph' || legacyPhCommand ? 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' ||
@ -175,10 +207,11 @@ 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(getProcessingDelay(command)), String(processingDelay),
command command
]; ];
@ -187,7 +220,7 @@ async function executeHardwareCommand(sensor, command, modeInfo) {
} }
const { stdout } = await execFileAsync(modeInfo.helperPath, args, { const { stdout } = await execFileAsync(modeInfo.helperPath, args, {
timeout: getProcessingDelay(command) + 2500, timeout: processingDelay + 2500,
windowsHide: true windowsHide: true
}); });
const result = JSON.parse(stdout.trim()); const result = JSON.parse(stdout.trim());
@ -202,7 +235,9 @@ 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) => setTimeout(resolve, Math.min(getProcessingDelay(command), 80))); await new Promise((resolve) =>
setTimeout(resolve, Math.min(getProcessingDelay(sensor, command), 80))
);
if (normalized === 'r') { if (normalized === 'r') {
const values = { const values = {
@ -305,6 +340,7 @@ module.exports = {
detectMode, detectMode,
executeEzoCommand, executeEzoCommand,
getProcessingDelay, getProcessingDelay,
isDangerousCommand,
matchesOfficialSyntax, matchesOfficialSyntax,
normalizeCommand, normalizeCommand,
validateCommand validateCommand

@ -1,166 +1,203 @@
# Comandos Y Calibracion Atlas Scientific EZO # Comandos y calibración Atlas Scientific EZO
Guia para diagnostico, lectura y calibracion de circuitos Atlas Scientific EZO Esta guía cubre los cuatro circuitos utilizados por el dashboard. La sintaxis,
usados en el fotobiorreactor. los tiempos y el orden de calibración se verificaron contra los datasheets
oficiales indicados al final del documento.
## Sensores Del Proyecto ## Sensores del proyecto
| Sensor | Circuito | Direccion esperada | | Variable | Circuito | Dirección I2C | Lectura |
|---|---|---:| |---|---|---:|---:|
| Temperatura | EZO-RTD | `0x66` | | Temperatura | EZO-RTD | `0x66` | 600 ms |
| pH | EZO-pH | `0x63` | | pH | EZO-pH | `0x63` | 900 ms |
| Oxigeno disuelto | EZO-DO | `0x61` | | Oxígeno disuelto | EZO-DO | `0x61` | 600 ms |
| Conductividad | EZO-EC | `0x64` | | Conductividad | EZO-EC | `0x64` | 600 ms |
## Transporte I2C ## Uso desde el dashboard
Los EZO reciben comandos ASCII. En I2C, la primera posicion de la respuesta es La consola web envía los comandos por la API y el helper `EZO_COMMAND`. El
un codigo de estado: 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.
| Codigo | Significado | Los comandos que borran datos, importan calibraciones, reinician el circuito,
|---:|---| cambian la dirección o cambian el protocolo exigen confirmación. La consola
| `1` | Solicitud procesada correctamente | acepta únicamente sintaxis documentada para el sensor seleccionado.
| `2` | Error de sintaxis |
| `254` | Procesando; respuesta aun no lista |
| `255` | Sin datos disponibles |
El helper `EZO_COMMAND` escribe el comando, espera el tiempo indicado y lee la
respuesta. Tambien reintenta si el circuito responde `254`.
## Antes De Enviar Comandos Manuales
Detenga el recolector para evitar conflicto con el bus: 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.
```bash ## Protocolo I2C
sudo systemctl stop photobioreactor-acquisition
sudo rm -f /tmp/photobioreactor-i2c.lock
```
Use la ruta instalada: La primera posición de cada respuesta es un código Atlas:
```bash | Código | Significado |
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 300 i |---:|---|
``` | `1` | Comando procesado correctamente |
| `2` | Error de sintaxis |
| `254` | El circuito sigue procesando |
| `255` | No hay datos disponibles |
Formato general: `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.
```bash ## Diagnóstico básico
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND <bus> <direccion> <delay_ms> <comando> [--no-response]
```
Ejemplo: Desde la consola web seleccione el sensor y use:
```bash ```text
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 1000 R i
Status
R
Cal,?
``` ```
## Comandos De Diagnostico Respuestas esperadas:
Para cada sensor: ```text
?i,pH,2.17
```bash ?Status,P,3.83
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 300 i 6.982
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 300 Status ?Cal,3
/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 correspondiente. `Cal,0` significa cero puntos guardados. El significado de los demás números
depende del sensor y del esquema de calibración.
## Calibracion EZO-RTD ## Calibración EZO-RTD
Calibracion de un punto: El RTD usa una calibración de un punto contra una referencia conocida:
```text
Cal,<temperatura>
```
Ejemplo:
```text ```text
Cal,25.00 Cal,25.00
Cal,?
``` ```
Consultar calibracion: Espere a que la sonda y la referencia alcancen equilibrio térmico antes de
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 ```text
Cal,? 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
``` ```
Limpiar calibracion: El historial principal del proyecto se guarda en CSV; el registrador interno
del RTD es independiente y normalmente puede permanecer desactivado.
```text ## Calibración EZO-pH
Cal,clear
```
## Calibracion EZO-pH Compruebe primero que la sonda cambia de lectura. Una sonda bloqueada de forma
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 recomendado: 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
``` ```
Notas: `T,n` no se conserva al cortar energía. `RT,n` aplica la temperatura y toma una
lectura en la misma operación.
- El punto medio debe hacerse primero. ## Calibración EZO-DO
- Ejecutar `Cal,mid` sobre una calibracion existente elimina los otros puntos.
- Consulte estado con `Cal,?`.
- Consulte salud de sonda con `Slope,?`.
## Calibracion EZO-DO Para calibrar mantenga primero los valores predeterminados: temperatura 20 °C,
salinidad 0 y presión 101.3 kPa. Atlas indica calibrar primero y aplicar las
compensaciones reales después.
Calibracion de un punto en aire: Calibración de un punto:
```text ```text
Cal Cal
``` ```
Calibracion de dos puntos: Calibración de dos puntos, en este orden:
```text ```text
Cal,0 Cal,0
Cal Cal
Cal,?
``` ```
Orden: Para `Cal,0`, coloque la sonda en solución de cero oxígeno, agite para retirar
burbujas y observe lecturas hasta estabilizar. Una sonda con electrolito nuevo
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.
1. `Cal,0` en solucion de cero oxigeno. Compensaciones, aplicadas después de calibrar:
2. `Cal` con sonda estabilizada en aire atmosferico.
Compensaciones disponibles:
```text ```text
T,<grados Celsius> T,<grados Celsius>
S,<conductividad en uS/cm> T,?
S,<conductividad en µS/cm>
S,<salinidad>,ppt S,<salinidad>,ppt
P,<presion en kPa> S,?
P,<presión en kPa>
P,?
RT,<grados Celsius>
``` ```
## Calibracion EZO-EC La compensación de salinidad es irrelevante por debajo de 2500 µS/cm. Los
valores de temperatura, salinidad y presión cambian la lectura calculada, no
los puntos guardados en la sonda.
Primero configure constante de celda: ## Calibración EZO-EC
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,?
``` ```
Calibracion en seco: 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 ```text
Cal,dry Cal,dry
``` ```
Calibracion de dos puntos: Dos puntos, para precisión en una banda estrecha:
```text ```text
Cal,dry Cal,dry
Cal,<valor> Cal,<valor>
``` ```
Calibracion de tres puntos: Tres puntos, para un rango amplio:
```text ```text
Cal,dry Cal,dry
@ -168,50 +205,89 @@ Cal,low,<valor>
Cal,high,<valor> Cal,high,<valor>
``` ```
No use `Cal,0` en EC. En EC, el punto cero corresponde a `Cal,dry`. Valores recomendados por Atlas:
Compensacion de temperatura: | 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 ```text
T,<grados Celsius> T,<grados Celsius>
T,? T,?
RT,<grados Celsius>
``` ```
## Cambiar Direccion I2C 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.
Use esto solo si sabe que la nueva direccion esta libre. ## Comandos comunes admitidos
Ejemplo: cambiar a `0x61`. En decimal `0x61` es `97`. ```text
L,0 | L,1 | L,?
Find
Name,<nombre> | Name, | Name,?
i
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>
```
```bash `Export` se envía repetidamente hasta recibir `*DONE`. Cada bloque exportado se
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x62 300 I2C,97 --no-response importa preservando los espacios entre bytes, por ejemplo:
```text
Import,59 6F 75 20 61 72
``` ```
Luego reinicie energia del modulo o reinicie la Raspberry y confirme: 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 ```bash
i2cdetect -y 1 sudo systemctl stop photobioreactor-acquisition
sudo rm -f /tmp/photobioreactor-i2c.lock
``` ```
No cambie direcciones sin identificar primero el circuito con `i`. Formato:
## Modo Demo Y Modo Hardware ```bash
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND <bus> <dirección> <delay_ms> <comando> [--no-response]
```
```text Ejemplos:
EZO_MODE=demo
EZO_MODE=auto ```bash
EZO_MODE=hardware /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
``` ```
En produccion use: Reinicie el servicio al terminar:
```text ```bash
EZO_MODE=hardware sudo systemctl start photobioreactor-acquisition
``` ```
## Fuentes Oficiales ## Fuentes oficiales
- https://files.atlas-scientific.com/pH_EZO_Datasheet.pdf - [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/DO_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/EC_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/EZO_RTD_Datasheet.pdf - [EZO-EC Datasheet](https://files.atlas-scientific.com/EC_EZO_Datasheet.pdf), versión 6.6, revisión 3/26.

@ -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: "Para dos puntos, Atlas indica calibrar primero cero (Cal,0) y después aire (Cal).", note: "Calibre con T=20 °C, S=0 y P=101.3 kPa; aplique compensaciones reales después.",
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" }, { label: "Temperatura", value: "20.0", step: "0.1", command: "T", query: "T,?" },
{ label: "Salinidad", value: "0", step: "1", command: "S" }, { label: "Salinidad", value: "0", step: "1", command: "S", query: "S,?" },
{ label: "Presión", value: "101.3", step: "0.1", command: "P" } { label: "Presión", value: "101.3", step: "0.1", command: "P", query: "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 primero la constante K de la sonda. Nunca calibre EC a cero con Cal,0.", note: "Configure K y mantenga T=25 °C durante toda la calibración. Nunca use Cal,0.",
groups: [ groups: [
{ {
title: "Constante de la sonda", title: "Constante de la sonda",
help: "Valores habituales: K 0.1, K 1.0 o K 10.", help: "Use el valor real de la sonda, entre K 0.01 y K 10.2.",
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 calibración de dos puntos use Cal,<valor>, por ejemplo 1413.", help: "Para K 1.0 Atlas recomienda Cal,12880 después de Cal,dry.",
value: "1413", value: "12880",
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: "La compensación se expresa en °C y no se conserva al apagar.", help: "Aplíquela sólo después de calibrar; durante la calibración debe permanecer en 25 °C.",
value: "25.0", value: "25.0",
step: "0.1", step: "0.1",
button: "Aplicar temperatura", button: "Aplicar temperatura",
@ -176,13 +176,16 @@ 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 dangerous = ["factory", "i2c", "baud", "sleep"] const normalizedCommand = String(command).trim().toLowerCase();
.includes(String(command).split(",")[0].toLowerCase()); const dangerous = ["factory", "i2c", "baud", "sleep", "import"]
let dangerousConfirmed = false; .includes(normalizedCommand.split(",")[0]) ||
normalizedCommand === "cal,clear" ||
normalizedCommand === "m,clear";
let dangerousConfirmed = options.dangerousConfirmed === true;
if (dangerous) { if (dangerous && !dangerousConfirmed) {
dangerousConfirmed = confirm( dangerousConfirmed = confirm(
`El comando ${command} puede reiniciar, dormir o cambiar la comunicación del circuito. ¿Desea enviarlo?` `El comando ${command} modifica o borra configuración sensible del circuito. ¿Desea enviarlo?`
); );
if (!dangerousConfirmed) return null; if (!dangerousConfirmed) return null;
} }
@ -220,10 +223,10 @@ async function sendCommand(command, options = {}) {
} }
} }
async function sendCalibrationCommand(command) { async function sendCalibrationCommand(command, options = {}) {
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 }); const result = await sendCommand(command, { sensor, ...options });
const normalized = String(command).toLowerCase(); const normalized = String(command).toLowerCase();
if ( if (
@ -243,7 +246,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"); await sendCalibrationCommand("Cal,clear", { dangerousConfirmed: true });
} }
} }
@ -267,13 +270,25 @@ function renderCalibrationPanel() {
if (group.fields) { if (group.fields) {
group.fields.forEach((field, fieldIndex) => { group.fields.forEach((field, fieldIndex) => {
container.appendChild(createCalibrationAction( const action = 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,4 +1,3 @@
#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>
@ -13,24 +12,61 @@ 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) if (argc < 5 || argc > 6 ||
(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];
int address = (int)strtol(argv[2], NULL, 0); char *address_end = NULL;
int delay_ms = atoi(argv[3]); char *delay_end = NULL;
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];
int no_response = argc > 5 && strcmp(argv[5], "--no-response") == 0; size_t command_length = strlen(command);
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;
} }
@ -39,37 +75,30 @@ 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");
flock(lock_fd, LOCK_UN); close_bus(fd, lock_fd);
close(lock_fd);
return 4; return 4;
} }
if (ioctl(fd, I2C_SLAVE, address) < 0) if (ioctl(fd, I2C_SLAVE, (int)address_value) < 0)
{ {
print_json_error("No se pudo seleccionar el circuito EZO"); print_json_error("No se pudo seleccionar el circuito EZO");
close(fd); close_bus(fd, lock_fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 5; return 5;
} }
if (write(fd, command, strlen(command)) < 0) if (write(fd, command, command_length) != (ssize_t)command_length)
{ {
print_json_error("Fallo al escribir el comando EZO"); print_json_error("Fallo al escribir el comando EZO");
close(fd); close_bus(fd, lock_fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 6; return 6;
} }
usleep((useconds_t)delay_ms * 1000); usleep((useconds_t)delay_value * 1000);
if (no_response) if (no_response)
{ {
printf("{\"success\":true,\"response\":\"SENT\"}\n"); printf("{\"success\":true,\"response\":\"SENT\"}\n");
close(fd); close_bus(fd, lock_fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 0; return 0;
} }
@ -85,40 +114,37 @@ int main(int argc, char *argv[])
if (bytes_read < 1) if (bytes_read < 1)
{ {
print_json_error("El circuito EZO no respondió"); print_json_error("El circuito EZO no respondio");
close(fd); close_bus(fd, lock_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];
snprintf(message, sizeof(message), "Código de respuesta EZO: %u", response[0]); const char *detail = response[0] == 2 ? "error de sintaxis" :
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(fd); close_bus(fd, lock_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 i = 0; payload[i] != '\0'; i++) for (int index = 0; payload[index] != '\0'; index++)
{ {
if (payload[i] == '"' || payload[i] == '\\') if (payload[index] == '"' || payload[index] == '\\')
{ {
payload[i] = ' '; payload[index] = ' ';
} }
} }
printf("{\"success\":true,\"response\":\"%s\"}\n", printf("{\"success\":true,\"response\":\"%s\"}\n",
payload[0] == '\0' ? "*OK" : payload); payload[0] == '\0' ? "*OK" : payload);
close(fd); close_bus(fd, lock_fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
return 0; return 0;
} }

@ -3,7 +3,9 @@ const test = require('node:test');
const { const {
getProcessingDelay, getProcessingDelay,
matchesOfficialSyntax isDangerousCommand,
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', () => {
@ -24,13 +26,44 @@ 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('R'), 1000); assert.equal(getProcessingDelay('rtd', 'R'), 600);
assert.equal(getProcessingDelay('Cal'), 1300); assert.equal(getProcessingDelay('ph', 'R'), 900);
assert.equal(getProcessingDelay('Cal,0'), 1300); assert.equal(getProcessingDelay('do', 'R'), 600);
assert.equal(getProcessingDelay('Cal,mid,7'), 900); assert.equal(getProcessingDelay('ec', 'R'), 600);
assert.equal(getProcessingDelay('Cal,dry'), 600); assert.equal(getProcessingDelay('do', 'Cal'), 1300);
assert.equal(getProcessingDelay('Status'), 300); assert.equal(getProcessingDelay('do', 'Cal,0'), 1300);
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