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 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 SENSOR_ALIASES = { rtd: 'temperature', temperature: 'temperature', ph: 'ph', do: 'do', ec: 'ec' }; const DEFAULT_ENABLED_SENSORS = Object.keys(SENSOR_FILES); 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 environmentSensors = String(process.env.EZO_ENABLED_SENSORS || '').trim(); return { loggingRateSeconds: ALLOWED_LOGGING_RATES.has(rate) ? rate : 1, enabledSensors: environmentSensors ? normalizeEnabledSensors(environmentSensors) : normalizeEnabledSensors(config.enabledSensors) }; } catch { return { loggingRateSeconds: 1, 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); if (!ALLOWED_LOGGING_RATES.has(numericRate)) { const error = new Error('Frecuencia 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, 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' ); } 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 }; } return { sensorId, online: await publishReading( sensorId, readings[sensorId], timestamp, modeInfo.mode ), disabled: false }; }) ); return { mode: modeInfo.mode, timestamp, results, 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, DEFAULT_ENABLED_SENSORS, RUNTIME_CONFIG_FILE, SENSOR_FILES, atomicWriteFile, collectReadings, detectAcquisitionMode, normalizeEnabledSensors, publishAllOffline, publishReading, readRuntimeConfig, writeRuntimeConfig };