You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

312 lines
9.7 KiB
JavaScript

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']);
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) {
return String(rawCommand || '').trim().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 > 64 || !/^[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 (DANGEROUS_COMMANDS.has(baseCommand) && !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 ]+$/,
/^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|\?)$/,
/^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)$/
]
};
return sensorPatterns[sensor].some((pattern) => pattern.test(normalized));
}
function getProcessingDelay(command) {
const normalized = command.toLowerCase();
if (normalized === 'r') return 1000;
if (normalized.startsWith('rt,')) return 1000;
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;
}
return 300;
}
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 args = [
'/dev/i2c-1',
config.address,
String(getProcessingDelay(command)),
command
];
if (commandExpectsNoResponse(command)) {
args.push('--no-response');
}
const { stdout } = await execFileAsync(modeInfo.helperPath, args, {
timeout: getProcessingDelay(command) + 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(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,
matchesOfficialSyntax,
normalizeCommand,
validateCommand
};