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.
227 lines
6.7 KiB
JavaScript
227 lines
6.7 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 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
|
|
};
|