const fs = require('node:fs'); const fsPromises = require('node:fs/promises'); const path = require('node:path'); const { execFile } = require('node:child_process'); const { promisify } = require('node:util'); const execFileAsync = promisify(execFile); const ROOT_DIRECTORY = path.join(__dirname, '..'); const DATA_DIRECTORY = process.env.DATA_DIRECTORY ? path.resolve(process.env.DATA_DIRECTORY) : path.join(ROOT_DIRECTORY, 'data'); const LOGS_DIRECTORY = process.env.LOGS_DIRECTORY ? path.resolve(process.env.LOGS_DIRECTORY) : path.join(ROOT_DIRECTORY, 'logs'); const RUNTIME_CONFIG_FILE = process.env.RUNTIME_CONFIG_FILE ? path.resolve(process.env.RUNTIME_CONFIG_FILE) : path.join(ROOT_DIRECTORY, 'config', 'runtime.json'); const ALARM_CONFIG_FILE = process.env.ALARM_CONFIG_FILE ? path.resolve(process.env.ALARM_CONFIG_FILE) : path.join(ROOT_DIRECTORY, 'config', 'alarms.json'); const ACQUISITION_HELPER = process.env.EZO_ACQUIRE_HELPER ? path.resolve(process.env.EZO_ACQUIRE_HELPER) : path.join(ROOT_DIRECTORY, 'sensors', 'EZOCommand', 'EZO_ACQUIRE'); const SENSOR_FILES = { temperature: { json: 'EZORTD.json', csv: 'temperature.csv', key: 'temperature' }, ph: { json: 'EZOPH.json', csv: 'ph.csv', key: 'ph' }, do: { json: 'EZODO.json', csv: 'do.csv', key: 'do' }, ec: { json: 'EZOEC.json', csv: 'ec.csv', key: 'ec' } }; const ALLOWED_LOGGING_RATES = new Set([1, 5, 10, 60]); const ALLOWED_RETENTION_DAYS = new Set([0, 7, 30, 90, 365]); const WARNING_MARGIN_RATIO = 0.1; const ALARM_LOG_FILE = 'alarms.csv'; const ALARM_LOG_HEADER = 'timestamp,sensor,state,value,message\n'; const SENSOR_ALIASES = { rtd: 'temperature', temperature: 'temperature', ph: 'ph', do: 'do', ec: 'ec' }; const DEFAULT_ENABLED_SENSORS = Object.keys(SENSOR_FILES); const previousAlarmStates = new Map(); const lastRetentionChecks = new Map(); const RETENTION_CHECK_INTERVAL_MS = 10 * 60 * 1000; function normalizeEnabledSensors(value, fallback = DEFAULT_ENABLED_SENSORS) { const source = Array.isArray(value) ? value : String(value || '').split(','); const sensors = [ ...new Set( source .map((sensor) => SENSOR_ALIASES[String(sensor).trim().toLowerCase()]) .filter(Boolean) ) ]; return sensors.length > 0 ? sensors : [...fallback]; } async function atomicWriteFile(filePath, content) { await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; await fsPromises.writeFile(temporaryPath, content, 'utf8'); await fsPromises.rename(temporaryPath, filePath); } async function readRuntimeConfig() { try { const rawConfig = await fsPromises.readFile(RUNTIME_CONFIG_FILE, 'utf8'); const config = JSON.parse(rawConfig); const rate = Number(config.loggingRateSeconds); const retentionDays = Number(config.historyRetentionDays); const environmentSensors = String(process.env.EZO_ENABLED_SENSORS || '').trim(); return { loggingRateSeconds: ALLOWED_LOGGING_RATES.has(rate) ? rate : 1, historyRetentionDays: ALLOWED_RETENTION_DAYS.has(retentionDays) ? retentionDays : 30, enabledSensors: environmentSensors ? normalizeEnabledSensors(environmentSensors) : normalizeEnabledSensors(config.enabledSensors) }; } catch { return { loggingRateSeconds: 1, historyRetentionDays: 30, enabledSensors: normalizeEnabledSensors(process.env.EZO_ENABLED_SENSORS) }; } } async function writeRuntimeConfig(updates) { const currentConfig = await readRuntimeConfig(); const numericRate = updates.rate === undefined ? currentConfig.loggingRateSeconds : Number(updates.rate); const retentionDays = updates.historyRetentionDays === undefined ? currentConfig.historyRetentionDays : Number(updates.historyRetentionDays); if (!ALLOWED_LOGGING_RATES.has(numericRate)) { const error = new Error('Frecuencia no valida.'); error.status = 400; throw error; } if (!ALLOWED_RETENTION_DAYS.has(retentionDays)) { const error = new Error('Retencion historica no valida.'); error.status = 400; throw error; } const enabledSensors = updates.enabledSensors === undefined ? currentConfig.enabledSensors : normalizeEnabledSensors(updates.enabledSensors, []); if (enabledSensors.length === 0) { const error = new Error('Debe habilitar al menos un sensor.'); error.status = 400; throw error; } const config = { loggingRateSeconds: numericRate, historyRetentionDays: retentionDays, enabledSensors, updatedAt: new Date().toISOString() }; await atomicWriteFile( RUNTIME_CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n` ); return config; } function detectAcquisitionMode() { const requestedMode = String(process.env.EZO_MODE || 'auto').toLowerCase(); const hardwareReady = process.platform === 'linux' && fs.existsSync('/dev/i2c-1') && fs.existsSync(ACQUISITION_HELPER); if (requestedMode === 'hardware' && !hardwareReady) { return { mode: 'unavailable', message: 'Se solicito hardware, pero /dev/i2c-1 o EZO_ACQUIRE no esta disponible.' }; } if (requestedMode === 'hardware' || (requestedMode === 'auto' && hardwareReady)) { return { mode: 'hardware', message: 'Adquisicion I2C real activa.' }; } return { mode: 'demo', message: 'Adquisicion de demostracion activa.' }; } function buildDemoReadings() { return { temperature: Number((25 + Math.random() * 0.1 - 0.05).toFixed(3)), ph: Number((7.2 + Math.random() * 0.04 - 0.02).toFixed(3)), do: Number((8.5 + Math.random() * 0.1 - 0.05).toFixed(3)), ec: Number((1050 + Math.random() * 10 - 5).toFixed(1)) }; } async function readHardwareSensors(enabledSensors) { const helperArguments = ['/dev/i2c-1', ...enabledSensors]; const { stdout } = await execFileAsync( ACQUISITION_HELPER, helperArguments, { timeout: 5000, windowsHide: true } ); const readings = JSON.parse(stdout.trim()); if (!readings || typeof readings !== 'object') { throw new Error('EZO_ACQUIRE devolvio una respuesta invalida.'); } return readings; } async function appendReading(filePath, timestamp, value) { await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); let needsHeader = false; try { const stats = await fsPromises.stat(filePath); needsHeader = stats.size === 0; } catch (error) { if (error.code !== 'ENOENT') throw error; needsHeader = true; } const row = `${timestamp},${value}\n`; await fsPromises.appendFile( filePath, `${needsHeader ? 'timestamp,value\n' : ''}${row}`, 'utf8' ); } function escapeCsvValue(value) { const text = value === null || value === undefined ? '' : String(value); return `"${text.replace(/"/g, '""')}"`; } async function appendCsvRow(filePath, header, values) { await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); let needsHeader = false; try { const stats = await fsPromises.stat(filePath); needsHeader = stats.size === 0; } catch (error) { if (error.code !== 'ENOENT') throw error; needsHeader = true; } const row = `${values.map(escapeCsvValue).join(',')}\n`; await fsPromises.appendFile( filePath, `${needsHeader ? header : ''}${row}`, 'utf8' ); } async function pruneCsvByRetention(filePath, header, retentionDays, now = new Date()) { if (!retentionDays) return false; let csvText = ''; try { csvText = await fsPromises.readFile(filePath, 'utf8'); } catch (error) { if (error.code === 'ENOENT') return false; throw error; } const lines = csvText.split(/\r?\n/).filter(Boolean); if (lines.length <= 1) return false; const cutoffMs = now.getTime() - retentionDays * 24 * 60 * 60 * 1000; const rows = lines.slice(1); const keptRows = rows.filter((line) => { const timestamp = line.split(',', 1)[0].replace(/^"|"$/g, ''); const parsedMs = new Date(timestamp).getTime(); return Number.isNaN(parsedMs) || parsedMs >= cutoffMs; }); if (keptRows.length === rows.length) return false; await atomicWriteFile( filePath, `${header}${keptRows.length > 0 ? `${keptRows.join('\n')}\n` : ''}` ); return true; } async function enforceHistoryRetention(retentionDays, now = new Date()) { if (!retentionDays) return []; const prunedFiles = []; const logFiles = [ ...Object.values(SENSOR_FILES).map((sensor) => ({ file: sensor.csv, header: 'timestamp,value\n' })), { file: ALARM_LOG_FILE, header: ALARM_LOG_HEADER } ]; for (const logFile of logFiles) { const filePath = path.join(LOGS_DIRECTORY, logFile.file); const lastCheck = lastRetentionChecks.get(filePath) || 0; if (now.getTime() - lastCheck < RETENTION_CHECK_INTERVAL_MS) { continue; } lastRetentionChecks.set(filePath, now.getTime()); if (await pruneCsvByRetention(filePath, logFile.header, retentionDays, now)) { prunedFiles.push(logFile.file); } } return prunedFiles; } async function readAlarmThresholds() { try { return JSON.parse(await fsPromises.readFile(ALARM_CONFIG_FILE, 'utf8')); } catch { return {}; } } function isValidThreshold(limits) { return Boolean(limits) && Number.isFinite(Number(limits.min)) && Number.isFinite(Number(limits.max)) && Number(limits.min) < Number(limits.max); } function isNearThreshold(value, limits) { const min = Number(limits.min); const max = Number(limits.max); const warningMargin = (max - min) * WARNING_MARGIN_RATIO; return value <= min + warningMargin || value >= max - warningMargin; } function evaluateAlarmState(sensorId, result, thresholds) { if (result.disabled) { return { state: 'DISABLED', message: 'Sensor deshabilitado por configuracion' }; } if (!result.online) { return { state: 'OFFLINE', message: 'Lectura no disponible' }; } const limits = thresholds[sensorId]; if (!isValidThreshold(limits)) { return { state: 'WARNING', message: 'Limites de alarma no disponibles' }; } if (result.value < Number(limits.min) || result.value > Number(limits.max)) { return { state: 'CRITICAL', message: `Valor fuera de rango (${limits.min} - ${limits.max})` }; } if (isNearThreshold(result.value, limits)) { return { state: 'WARNING', message: `Valor cerca del limite (${limits.min} - ${limits.max})` }; } return { state: 'NORMAL', message: 'Dentro del rango configurado' }; } async function persistAlarmEvents(results, timestamp) { const thresholds = await readAlarmThresholds(); const events = []; for (const result of results) { const evaluation = evaluateAlarmState(result.sensorId, result, thresholds); const previousState = previousAlarmStates.get(result.sensorId) || 'NORMAL'; if (evaluation.state === 'DISABLED') { previousAlarmStates.delete(result.sensorId); continue; } const enteredAlarm = ['WARNING', 'CRITICAL', 'OFFLINE'].includes(evaluation.state) && previousState !== evaluation.state; const recovered = evaluation.state === 'NORMAL' && ['WARNING', 'CRITICAL', 'OFFLINE'].includes(previousState); if (enteredAlarm || recovered) { events.push({ timestamp, sensorId: result.sensorId, state: recovered ? 'RECOVERY' : evaluation.state, value: Number.isFinite(result.value) ? result.value : '', message: recovered ? 'Sensor recuperado a NORMAL' : evaluation.message }); } previousAlarmStates.set(result.sensorId, evaluation.state); } for (const event of events) { await appendCsvRow( path.join(LOGS_DIRECTORY, ALARM_LOG_FILE), ALARM_LOG_HEADER, [ event.timestamp, event.sensorId, event.state, event.value, event.message ] ); } return events; } async function publishReading(sensorId, rawValue, timestamp, mode) { const sensor = SENSOR_FILES[sensorId]; const jsonPath = path.join(DATA_DIRECTORY, sensor.json); const hasReading = rawValue !== null && rawValue !== undefined && rawValue !== ''; const value = hasReading ? Number(rawValue) : NaN; if (!Number.isFinite(value)) { await atomicWriteFile(jsonPath, `${JSON.stringify({ online: false, timestamp, error: 'Lectura no disponible' })}\n`); return false; } await Promise.all([ atomicWriteFile(jsonPath, `${JSON.stringify({ [sensor.key]: value, online: true, timestamp, mode })}\n`), appendReading(path.join(LOGS_DIRECTORY, sensor.csv), timestamp, value) ]); return true; } async function publishDisabled(sensorId, timestamp) { const sensor = SENSOR_FILES[sensorId]; await atomicWriteFile( path.join(DATA_DIRECTORY, sensor.json), `${JSON.stringify({ online: false, disabled: true, timestamp, error: 'Sensor deshabilitado por configuracion' })}\n` ); } async function collectReadings() { const modeInfo = detectAcquisitionMode(); const runtimeConfig = await readRuntimeConfig(); const enabledSensorSet = new Set(runtimeConfig.enabledSensors); if (modeInfo.mode === 'unavailable') { throw new Error(modeInfo.message); } const readings = modeInfo.mode === 'hardware' ? await readHardwareSensors(runtimeConfig.enabledSensors) : buildDemoReadings(); const timestamp = new Date().toISOString(); const results = await Promise.all( Object.keys(SENSOR_FILES).map(async (sensorId) => { if (!enabledSensorSet.has(sensorId)) { await publishDisabled(sensorId, timestamp); return { sensorId, online: false, disabled: true, value: null }; } const rawValue = readings[sensorId]; const value = rawValue === null || rawValue === undefined || rawValue === '' ? NaN : Number(rawValue); const online = await publishReading( sensorId, rawValue, timestamp, modeInfo.mode ); return { sensorId, online, value: Number.isFinite(value) ? value : null, disabled: false }; }) ); const alarmEvents = await persistAlarmEvents(results, timestamp); const prunedFiles = await enforceHistoryRetention( runtimeConfig.historyRetentionDays, new Date(timestamp) ); return { mode: modeInfo.mode, timestamp, results, alarmEvents, prunedFiles, enabledSensors: runtimeConfig.enabledSensors }; } async function publishAllOffline(error) { const timestamp = new Date().toISOString(); const message = error instanceof Error ? error.message : String(error); await Promise.all( Object.values(SENSOR_FILES).map((sensor) => atomicWriteFile( path.join(DATA_DIRECTORY, sensor.json), `${JSON.stringify({ online: false, timestamp, error: message })}\n` ) ) ); } module.exports = { ACQUISITION_HELPER, ALLOWED_LOGGING_RATES, ALLOWED_RETENTION_DAYS, DEFAULT_ENABLED_SENSORS, RUNTIME_CONFIG_FILE, SENSOR_FILES, atomicWriteFile, collectReadings, detectAcquisitionMode, enforceHistoryRetention, normalizeEnabledSensors, publishAllOffline, publishReading, pruneCsvByRetention, readRuntimeConfig, writeRuntimeConfig };