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
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
|
|
};
|