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]); 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); return { loggingRateSeconds: ALLOWED_LOGGING_RATES.has(rate) ? rate : 1 }; } catch { return { loggingRateSeconds: 1 }; } } async function writeRuntimeConfig(rate) { const numericRate = Number(rate); if (!ALLOWED_LOGGING_RATES.has(numericRate)) { const error = new Error('Frecuencia no válida.'); error.status = 400; throw error; } const config = { loggingRateSeconds: numericRate, 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 solicitó hardware, pero /dev/i2c-1 o EZO_ACQUIRE no está disponible.' }; } if (requestedMode === 'hardware' || (requestedMode === 'auto' && hardwareReady)) { return { mode: 'hardware', message: 'Adquisición I2C real activa.' }; } return { mode: 'demo', message: 'Adquisición de demostración 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() { const { stdout } = await execFileAsync( ACQUISITION_HELPER, ['/dev/i2c-1'], { timeout: 5000, windowsHide: true } ); const readings = JSON.parse(stdout.trim()); if (!readings || typeof readings !== 'object') { throw new Error('EZO_ACQUIRE devolvió una respuesta inválida.'); } 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 collectReadings() { const modeInfo = detectAcquisitionMode(); if (modeInfo.mode === 'unavailable') { throw new Error(modeInfo.message); } const readings = modeInfo.mode === 'hardware' ? await readHardwareSensors() : buildDemoReadings(); const timestamp = new Date().toISOString(); const results = await Promise.all( Object.keys(SENSOR_FILES).map(async (sensorId) => ({ sensorId, online: await publishReading( sensorId, readings[sensorId], timestamp, modeInfo.mode ) })) ); return { mode: modeInfo.mode, timestamp, results }; } 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, RUNTIME_CONFIG_FILE, SENSOR_FILES, atomicWriteFile, collectReadings, detectAcquisitionMode, publishAllOffline, publishReading, readRuntimeConfig, writeRuntimeConfig };