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.

223 lines
6.6 KiB
JavaScript

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 value = Number(rawValue);
const jsonPath = path.join(DATA_DIRECTORY, sensor.json);
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,
readRuntimeConfig,
writeRuntimeConfig
};