From e372c6bfceca39f90aee5989647e449cad69e071 Mon Sep 17 00:00:00 2001 From: EMOTIONS-HUNTER Date: Fri, 17 Jul 2026 09:09:04 -0600 Subject: [PATCH] Pulir comandos y calibracion Atlas EZO --- PROJECT_STATUS.md | 3 + api/ezo-command-service.js | 70 ++++++-- docs/EZO_COMMANDS.md | 292 +++++++++++++++++++------------ frontend/ezo-service.js | 53 ++++-- sensors/EZOCommand/main.c | 92 ++++++---- test/ezo-command-service.test.js | 47 ++++- 6 files changed, 373 insertions(+), 184 deletions(-) diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 932ecca..f049302 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -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: diff --git a/api/ezo-command-service.js b/api/ezo-command-service.js index 3261970..456ee3f 100644 --- a/api/ezo-command-service.js +++ b/api/ezo-command-service.js @@ -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 diff --git a/docs/EZO_COMMANDS.md b/docs/EZO_COMMANDS.md index 3b45a3a..0b0ce24 100644 --- a/docs/EZO_COMMANDS.md +++ b/docs/EZO_COMMANDS.md @@ -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 [--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, -``` - -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,`. 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, -S, +T,? +S, S,,ppt -P, +S,? +P, +P,? +RT, ``` -## 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, ``` -Calibracion de tres puntos: +Tres puntos, para un rango amplio: ```text Cal,dry @@ -168,50 +205,89 @@ Cal,low, Cal,high, ``` -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, T,? +RT, ``` -## Cambiar Direccion I2C +Otros comandos EC admitidos son `TDS,n`, `TDS,?`, `O,?` y +`O,,<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, | Name, | Name,? +i +Status +Plock,0 | Plock,1 | Plock,? +*OK,0 | *OK,1 | *OK,? +Export,? | Export +Import, +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 [--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. diff --git a/frontend/ezo-service.js b/frontend/ezo-service.js index 9a85617..b45214d 100644 --- a/frontend/ezo-service.js +++ b/frontend/ezo-service.js @@ -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,, 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( diff --git a/sensors/EZOCommand/main.c b/sensors/EZOCommand/main.c index 7fdb9a5..b8ad6b0 100644 --- a/sensors/EZOCommand/main.c +++ b/sensors/EZOCommand/main.c @@ -1,4 +1,3 @@ -#include #include #include #include @@ -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; } diff --git a/test/ezo-command-service.test.js b/test/ezo-command-service.test.js index 5a289a5..4e1f69c 100644 --- a/test/ezo-command-service.test.js +++ b/test/ezo-command-service.test.js @@ -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); });