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`.
- 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.
@ -161,6 +162,8 @@ Validado en desarrollo:
- 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:

@ -12,7 +12,7 @@ const SENSOR_CONFIG = {
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([
'r', 'i', 'status', 'find', 'l', 'plock', 'name', 'cal',
'export', 'import', 't', 'rt', '*ok'
@ -32,7 +32,15 @@ const MOCK_STATE = {
};
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) {
@ -44,7 +52,7 @@ function validateCommand(sensor, command, dangerousConfirmed) {
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.');
}
@ -57,7 +65,7 @@ function validateCommand(sensor, command, dangerousConfirmed) {
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.');
}
@ -77,9 +85,9 @@ function matchesOfficialSyntax(sensor, command) {
/^plock,(0|1|\?)$/,
/^name,(|\?|[a-z0-9_.-]{1,16})$/,
/^\*ok,(0|1|\?)$/,
/^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])$/,
/^baud,(300|1200|2400|9600|19200|38400|57600|115200)$/
];
@ -90,8 +98,8 @@ function matchesOfficialSyntax(sensor, command) {
rtd: [
/^cal,(\?|clear|[-+]?\d+(\.\d+)?)$/,
/^s,(c|k|f|\?)$/,
/^d,(0|1|\?)$/,
/^m,(clear|\?)$/
/^d,(0|\?|[1-9]\d{0,3}|[12]\d{4}|3[01]\d{3}|32000)$/,
/^m(,(clear|\?))?$/
],
ph: [
/^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;
}
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(command) {
function getProcessingDelay(sensorOrCommand, rawCommand) {
const sensor = rawCommand === undefined ? null : sensorOrCommand;
const command = rawCommand === undefined ? sensorOrCommand : rawCommand;
const normalized = command.toLowerCase();
if (normalized === 'r') return 1000;
if (normalized.startsWith('rt,')) return 1000;
if (normalized === 'r') {
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.startsWith('cal,') && !['cal,?', 'cal,clear'].includes(normalized)) {
return normalized.includes('mid') || normalized.includes('low') ||
normalized.includes('high') ? 900 : 600;
const legacyPhCommand = !sensor && /cal,(mid|low|high),/.test(normalized);
return sensor === 'ph' || legacyPhCommand ? 900 : 600;
}
if (sensor === 'ec' && normalized.startsWith('k,') && normalized !== 'k,?') return 600;
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) {
return ['sleep', 'factory'].includes(getBaseCommand(command)) ||
getBaseCommand(command) === 'i2c' ||
@ -175,10 +207,11 @@ function detectMode() {
async function executeHardwareCommand(sensor, command, modeInfo) {
const config = SENSOR_CONFIG[sensor];
const processingDelay = getProcessingDelay(sensor, command);
const args = [
'/dev/i2c-1',
config.address,
String(getProcessingDelay(command)),
String(processingDelay),
command
];
@ -187,7 +220,7 @@ async function executeHardwareCommand(sensor, command, modeInfo) {
}
const { stdout } = await execFileAsync(modeInfo.helperPath, args, {
timeout: getProcessingDelay(command) + 2500,
timeout: processingDelay + 2500,
windowsHide: true
});
const result = JSON.parse(stdout.trim());
@ -202,7 +235,9 @@ async function executeHardwareCommand(sensor, command, modeInfo) {
async function executeDemoCommand(sensor, command) {
const normalized = command.toLowerCase();
const state = MOCK_STATE[sensor];
await new Promise((resolve) => setTimeout(resolve, Math.min(getProcessingDelay(command), 80)));
await new Promise((resolve) =>
setTimeout(resolve, Math.min(getProcessingDelay(sensor, command), 80))
);
if (normalized === 'r') {
const values = {
@ -305,6 +340,7 @@ module.exports = {
detectMode,
executeEzoCommand,
getProcessingDelay,
isDangerousCommand,
matchesOfficialSyntax,
normalizeCommand,
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
usados en el fotobiorreactor.
Esta guía cubre los cuatro circuitos utilizados por el dashboard. La sintaxis,
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 |
|---|---|---:|
| Temperatura | EZO-RTD | `0x66` |
| pH | EZO-pH | `0x63` |
| Oxigeno disuelto | EZO-DO | `0x61` |
| Conductividad | EZO-EC | `0x64` |
| Variable | Circuito | Dirección I2C | Lectura |
|---|---|---:|---:|
| 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 |
## Transporte I2C
## Uso desde el dashboard
Los EZO reciben comandos ASCII. En I2C, la primera posicion de la respuesta es
un codigo de estado:
La consola web envía los comandos por la API y el helper `EZO_COMMAND`. El
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 |
|---:|---|
| `1` | Solicitud procesada correctamente |
| `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
Los comandos que borran datos, importan calibraciones, reinician el circuito,
cambian la dirección o cambian el protocolo exigen confirmación. La consola
acepta únicamente sintaxis documentada para el sensor seleccionado.
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
sudo systemctl stop photobioreactor-acquisition
sudo rm -f /tmp/photobioreactor-i2c.lock
```
## Protocolo I2C
Use la ruta instalada:
La primera posición de cada respuesta es un código Atlas:
```bash
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 300 i
```
| Código | Significado |
|---:|---|
| `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
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND <bus> <direccion> <delay_ms> <comando> [--no-response]
```
## Diagnóstico básico
Ejemplo:
Desde la consola web seleccione el sensor y use:
```bash
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x63 1000 R
```text
i
Status
R
Cal,?
```
## Comandos De Diagnostico
Respuestas esperadas:
Para cada sensor:
```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,?
```text
?i,pH,2.17
?Status,P,3.83
6.982
?Cal,3
```
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:
```text
Cal,<temperatura>
```
Ejemplo:
El RTD usa una calibración de un punto contra una referencia conocida:
```text
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
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
Cal,clear
```
## Calibración EZO-pH
## 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
Cal,mid,7.00
Cal,low,4.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.
- Ejecutar `Cal,mid` sobre una calibracion existente elimina los otros puntos.
- Consulte estado con `Cal,?`.
- Consulte salud de sonda con `Slope,?`.
## Calibración EZO-DO
## 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
Cal
```
Calibracion de dos puntos:
Calibración de dos puntos, en este orden:
```text
Cal,0
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.
2. `Cal` con sonda estabilizada en aire atmosferico.
Compensaciones disponibles:
Compensaciones, aplicadas después de calibrar:
```text
T,<grados Celsius>
S,<conductividad en uS/cm>
T,?
S,<conductividad en µS/cm>
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
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
Cal,dry
```
Calibracion de dos puntos:
Dos puntos, para precisión en una banda estrecha:
```text
Cal,dry
Cal,<valor>
```
Calibracion de tres puntos:
Tres puntos, para un rango amplio:
```text
Cal,dry
@ -168,50 +205,89 @@ Cal,low,<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
T,<grados Celsius>
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
/opt/photobioreactor/sensors/EZOCommand/EZO_COMMAND /dev/i2c-1 0x62 300 I2C,97 --no-response
`Export` se envía repetidamente hasta recibir `*DONE`. Cada bloque exportado se
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
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
EZO_MODE=demo
EZO_MODE=auto
EZO_MODE=hardware
Ejemplos:
```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
```
En produccion use:
Reinicie el servicio al terminar:
```text
EZO_MODE=hardware
```bash
sudo systemctl start photobioreactor-acquisition
```
## Fuentes Oficiales
## Fuentes oficiales
- https://files.atlas-scientific.com/pH_EZO_Datasheet.pdf
- https://files.atlas-scientific.com/DO_EZO_Datasheet.pdf
- https://files.atlas-scientific.com/EC_EZO_Datasheet.pdf
- https://files.atlas-scientific.com/EZO_RTD_Datasheet.pdf
- [EZO-RTD Datasheet](https://files.atlas-scientific.com/EZO_RTD_Datasheet.pdf), versión 3.7, revisión 10/24.
- [EZO-pH Datasheet](https://files.atlas-scientific.com/pH_EZO_Datasheet.pdf), versión 6.1, revisión 2/24.
- [EZO-DO Datasheet](https://files.atlas-scientific.com/DO_EZO_Datasheet.pdf), versión 5.8, revisión 3/25.
- [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: {
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: [
{
title: "Cero oxígeno",
@ -79,20 +79,20 @@ const calibrationProfiles = {
title: "Compensaciones",
help: "Temperatura en °C, salinidad en µS/cm y presión atmosférica en kPa.",
fields: [
{ label: "Temperatura", value: "20.0", step: "0.1", command: "T" },
{ label: "Salinidad", value: "0", step: "1", command: "S" },
{ label: "Presión", value: "101.3", step: "0.1", command: "P" }
{ label: "Temperatura", value: "20.0", step: "0.1", command: "T", query: "T,?" },
{ label: "Salinidad", value: "0", step: "1", command: "S", query: "S,?" },
{ label: "Presión", value: "101.3", step: "0.1", command: "P", query: "P,?" }
]
}
]
},
ec: {
description: "Calibración de dos o tres puntos; la calibración en seco siempre va primero.",
note: "Configure primero la constante K de la sonda. Nunca calibre EC a cero con Cal,0.",
note: "Configure K y mantenga T=25 °C durante toda la calibración. Nunca use Cal,0.",
groups: [
{
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",
step: "0.1",
button: "Configurar K",
@ -108,8 +108,8 @@ const calibrationProfiles = {
},
{
title: "Segundo punto",
help: "Para calibración de dos puntos use Cal,<valor>, por ejemplo 1413.",
value: "1413",
help: "Para K 1.0 Atlas recomienda Cal,12880 después de Cal,dry.",
value: "12880",
step: "1",
button: "Calibrar punto único",
command(value) { return `Cal,${value}`; }
@ -124,7 +124,7 @@ const calibrationProfiles = {
},
{
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",
step: "0.1",
button: "Aplicar temperatura",
@ -176,13 +176,16 @@ async function sendCommand(command, options = {}) {
const sensorType = options.sensor ||
document.getElementById("terminal-sensor-select").value;
const terminalOutput = document.getElementById("terminal-output");
const dangerous = ["factory", "i2c", "baud", "sleep"]
.includes(String(command).split(",")[0].toLowerCase());
let dangerousConfirmed = false;
const normalizedCommand = String(command).trim().toLowerCase();
const dangerous = ["factory", "i2c", "baud", "sleep", "import"]
.includes(normalizedCommand.split(",")[0]) ||
normalizedCommand === "cal,clear" ||
normalizedCommand === "m,clear";
let dangerousConfirmed = options.dangerousConfirmed === true;
if (dangerous) {
if (dangerous && !dangerousConfirmed) {
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;
}
@ -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;
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();
if (
@ -243,7 +246,7 @@ async function clearCalibration() {
`Se borrarán todos los puntos de calibración del sensor ${sensor.toUpperCase()}. ¿Continuar?`
);
if (confirmed) {
await sendCalibrationCommand("Cal,clear");
await sendCalibrationCommand("Cal,clear", { dangerousConfirmed: true });
}
}
@ -267,13 +270,25 @@ function renderCalibrationPanel() {
if (group.fields) {
group.fields.forEach((field, fieldIndex) => {
container.appendChild(createCalibrationAction(
const action = createCalibrationAction(
`${sensor}-${groupIndex}-${fieldIndex}`,
field.label,
field.value,
field.step,
(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 {
container.appendChild(createCalibrationAction(

@ -1,4 +1,3 @@
#include <errno.h>
#include <fcntl.h>
#include <linux/i2c-dev.h>
#include <stdio.h>
@ -13,24 +12,61 @@ static void print_json_error(const char *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[])
{
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]");
return 2;
}
const char *bus = argv[1];
int address = (int)strtol(argv[2], NULL, 0);
int delay_ms = atoi(argv[3]);
char *address_end = NULL;
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];
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);
if (lock_fd < 0 || flock(lock_fd, LOCK_EX) < 0)
{
print_json_error("No se pudo bloquear el bus I2C");
if (lock_fd >= 0)
{
close(lock_fd);
}
return 3;
}
@ -39,37 +75,30 @@ int main(int argc, char *argv[])
if (fd < 0)
{
print_json_error("No se pudo abrir el bus I2C");
flock(lock_fd, LOCK_UN);
close(lock_fd);
close_bus(fd, lock_fd);
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");
close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
close_bus(fd, lock_fd);
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");
close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
close_bus(fd, lock_fd);
return 6;
}
usleep((useconds_t)delay_ms * 1000);
usleep((useconds_t)delay_value * 1000);
if (no_response)
{
printf("{\"success\":true,\"response\":\"SENT\"}\n");
close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
close_bus(fd, lock_fd);
return 0;
}
@ -85,40 +114,37 @@ int main(int argc, char *argv[])
if (bytes_read < 1)
{
print_json_error("El circuito EZO no respondió");
close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
print_json_error("El circuito EZO no respondio");
close_bus(fd, lock_fd);
return 7;
}
if (response[0] != 1)
{
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);
close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
close_bus(fd, lock_fd);
return 8;
}
char *payload = (char *)&response[1];
payload[bytes_read > 1 ? bytes_read - 1 : 0] = '\0';
for (int i = 0; payload[i] != '\0'; i++)
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",
payload[0] == '\0' ? "*OK" : payload);
close(fd);
flock(lock_fd, LOCK_UN);
close(lock_fd);
close_bus(fd, lock_fd);
return 0;
}

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