const fs = require('fs'); const path = require('path'); const { execFile } = require('child_process'); const { promisify } = require('util'); const execFileAsync = promisify(execFile); const SENSOR_CONFIG = { rtd: { address: '0x66', name: 'RTD' }, ph: { address: '0x63', name: 'pH' }, do: { address: '0x61', name: 'DO' }, ec: { address: '0x64', name: 'EC' } }; 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' ]); const SENSOR_COMMANDS = { rtd: new Set(['s', 'd', 'm']), ph: new Set(['slope', 'phext']), do: new Set(['s', 'p', 'o']), ec: new Set(['k', 'tc', 'tds', 'o']) }; const MOCK_STATE = { rtd: { calibrationPoints: 0 }, ph: { calibrationPoints: 0 }, do: { calibrationPoints: 0 }, ec: { calibrationPoints: 0, k: 1.0 } }; function normalizeCommand(rawCommand) { 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) { return command.split(',')[0].toLowerCase(); } function validateCommand(sensor, command, dangerousConfirmed) { if (!SENSOR_CONFIG[sensor]) { throw createHttpError(400, 'Sensor no válido.'); } if (!command || command.length > 63 || !/^[a-zA-Z0-9*?.+\-, ]+$/.test(command)) { throw createHttpError(400, 'Comando EZO no válido.'); } const baseCommand = getBaseCommand(command); const isAllowed = COMMON_COMMANDS.has(baseCommand) || SENSOR_COMMANDS[sensor].has(baseCommand) || DANGEROUS_COMMANDS.has(baseCommand); if (!isAllowed) { throw createHttpError(400, `El comando ${baseCommand} no aplica a ${sensor.toUpperCase()}.`); } if (isDangerousCommand(command) && !dangerousConfirmed) { throw createHttpError(409, 'El comando requiere confirmación explícita.'); } if (!matchesOfficialSyntax(sensor, command)) { throw createHttpError( 400, `La sintaxis "${command}" no coincide con los comandos documentados para ${sensor.toUpperCase()}.` ); } } function matchesOfficialSyntax(sensor, command) { const normalized = command.toLowerCase(); const commonPatterns = [ /^(r|i|status|find|sleep|factory)$/, /^l,(0|1|\?)$/, /^plock,(0|1|\?)$/, /^name,(|\?|[a-z0-9_.-]{1,16})$/, /^\*ok,(0|1|\?)$/, /^export$/, /^export,\?$/, /^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)$/ ]; if (commonPatterns.some((pattern) => pattern.test(normalized))) return true; const sensorPatterns = { rtd: [ /^cal,(\?|clear|[-+]?\d+(\.\d+)?)$/, /^s,(c|k|f|\?)$/, /^d,(0|\?|[1-9]\d{0,3}|[12]\d{4}|3[01]\d{3}|32000)$/, /^m(,(clear|\?))?$/ ], ph: [ /^cal,(\?|clear)$/, /^cal,(mid|low|high),[-+]?\d+(\.\d+)?$/, /^slope,\?$/, /^phext,(0|1|\?)$/, /^t,(\?|[-+]?\d+(\.\d+)?)$/, /^rt,[-+]?\d+(\.\d+)?$/ ], do: [ /^cal$/, /^cal,(0|\?|clear)$/, /^t,(\?|[-+]?\d+(\.\d+)?)$/, /^rt,[-+]?\d+(\.\d+)?$/, /^s,(\?|[-+]?\d+(\.\d+)?(,ppt)?)$/, /^p,(\?|[-+]?\d+(\.\d+)?)$/, /^o,\?$/, /^o,(mg|%),(0|1)$/ ], ec: [ /^cal,(\?|clear|dry|[-+]?\d+(\.\d+)?)$/, /^cal,(low|high),[-+]?\d+(\.\d+)?$/, /^k,(\?|[-+]?\d+(\.\d+)?)$/, /^t,(\?|[-+]?\d+(\.\d+)?)$/, /^rt,[-+]?\d+(\.\d+)?$/, /^tc,(\?|[-+]?\d+(\.\d+)?)$/, /^tds,(\?|[-+]?\d+(\.\d+)?)$/, /^o,\?$/, /^o,(ec|tds|s|sg),(0|1)$/ ] }; 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(sensorOrCommand, rawCommand) { const sensor = rawCommand === undefined ? null : sensorOrCommand; const command = rawCommand === undefined ? sensorOrCommand : rawCommand; const normalized = command.toLowerCase(); 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)) { 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' || getBaseCommand(command) === 'baud'; } function detectMode() { const requestedMode = String(process.env.EZO_MODE || 'auto').toLowerCase(); const helperPath = process.env.EZO_HELPER || path.join(__dirname, '..', 'sensors', 'EZOCommand', 'EZO_COMMAND'); const hardwareReady = process.platform === 'linux' && fs.existsSync('/dev/i2c-1') && fs.existsSync(helperPath); if (requestedMode === 'hardware' && !hardwareReady) { return { mode: 'unavailable', helperPath, message: 'Se solicitó hardware, pero /dev/i2c-1 o EZO_COMMAND no está disponible.' }; } if (requestedMode === 'hardware' || (requestedMode === 'auto' && hardwareReady)) { return { mode: 'hardware', helperPath, message: 'Bus I2C real activo.' }; } return { mode: 'demo', helperPath, message: 'Modo demo activo; no se enviarán comandos al bus I2C.' }; } async function executeHardwareCommand(sensor, command, modeInfo) { const config = SENSOR_CONFIG[sensor]; const processingDelay = getProcessingDelay(sensor, command); const args = [ '/dev/i2c-1', config.address, String(processingDelay), command ]; if (commandExpectsNoResponse(command)) { args.push('--no-response'); } const { stdout } = await execFileAsync(modeInfo.helperPath, args, { timeout: processingDelay + 2500, windowsHide: true }); const result = JSON.parse(stdout.trim()); if (!result.success) { throw createHttpError(502, result.error || 'El circuito EZO rechazó el comando.'); } return result.response || '*OK'; } async function executeDemoCommand(sensor, command) { const normalized = command.toLowerCase(); const state = MOCK_STATE[sensor]; await new Promise((resolve) => setTimeout(resolve, Math.min(getProcessingDelay(sensor, command), 80)) ); if (normalized === 'r') { const values = { rtd: (25 + Math.random() * 0.1).toFixed(3), ph: (7.2 + Math.random() * 0.05).toFixed(3), do: (8.5 + Math.random() * 0.1).toFixed(2), ec: (1050 + Math.random() * 5).toFixed(0) }; return values[sensor]; } if (normalized === 'i') return `?i,${SENSOR_CONFIG[sensor].name},demo`; if (normalized === 'status') return '?Status,P,5.000'; if (normalized === 'cal,?') return `?Cal,${state.calibrationPoints}`; if (normalized === 'cal,clear') { state.calibrationPoints = 0; return '*OK'; } if (normalized.startsWith('cal')) { updateDemoCalibration(sensor, normalized); return '*OK'; } if (normalized === 'slope,?') return '?Slope,98.2,97.8,-1.20'; if (normalized === 'k,?') return `?K,${state.k.toFixed(1)}`; if (normalized.startsWith('k,')) { state.k = Number(normalized.split(',')[1]); return '*OK'; } if (normalized === 't,?') return '?T,25.0'; if (normalized === 's,?' && sensor === 'do') return '?S,0,µS'; if (normalized === 'p,?') return '?P,101.3'; if (normalized === 'o,?') { return sensor === 'do' ? '?O,mg,%' : '?O,EC,TDS,S,SG'; } if (normalized === 'l,?') return '?L,1'; if (normalized === 'plock,?') return '?Plock,1'; if (normalized === 'phext,?') return '?pHext,0'; if (normalized === 's,?' && sensor === 'rtd') return '?S,C'; return '*OK'; } function updateDemoCalibration(sensor, command) { if (sensor === 'ph') { if (command.startsWith('cal,mid,')) MOCK_STATE.ph.calibrationPoints = 1; if (command.startsWith('cal,low,')) MOCK_STATE.ph.calibrationPoints = 2; if (command.startsWith('cal,high,')) MOCK_STATE.ph.calibrationPoints = 3; return; } if (sensor === 'do') { if (command === 'cal,0') MOCK_STATE.do.calibrationPoints = 1; if (command === 'cal') MOCK_STATE.do.calibrationPoints = 2; return; } if (sensor === 'ec') { if (command === 'cal,dry') MOCK_STATE.ec.calibrationPoints = 0; if (command.startsWith('cal,low,')) MOCK_STATE.ec.calibrationPoints = 1; if (command.startsWith('cal,high,')) MOCK_STATE.ec.calibrationPoints = 2; if (/^cal,[\d.]+$/.test(command)) MOCK_STATE.ec.calibrationPoints = 1; return; } MOCK_STATE.rtd.calibrationPoints = 1; } async function executeEzoCommand(sensor, rawCommand, options = {}) { const command = normalizeCommand(rawCommand); validateCommand(sensor, command, options.dangerousConfirmed); const modeInfo = detectMode(); if (modeInfo.mode === 'unavailable') { throw createHttpError(503, modeInfo.message); } const response = modeInfo.mode === 'hardware' ? await executeHardwareCommand(sensor, command, modeInfo) : await executeDemoCommand(sensor, command); return { success: true, mode: modeInfo.mode, sensor: SENSOR_CONFIG[sensor].name, command, response }; } function createHttpError(status, message) { const error = new Error(message); error.status = status; return error; } module.exports = { SENSOR_CONFIG, detectMode, executeEzoCommand, getProcessingDelay, isDangerousCommand, matchesOfficialSyntax, normalizeCommand, validateCommand };