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.

431 lines
12 KiB
JavaScript

const express = require('express');
const cors = require('cors');
const fs = require('node:fs/promises');
const path = require('node:path');
const {
detectMode,
executeEzoCommand
} = require('./ezo-command-service');
const {
detectAcquisitionMode,
readRuntimeConfig,
writeRuntimeConfig
} = require('./acquisition-service');
const app = express();
const PORT = Number(process.env.PORT || 3000);
const HOST = process.env.HOST || '127.0.0.1';
const API_AUTH_TOKEN = process.env.API_AUTH_TOKEN || '';
const LOGS_DIRECTORY = process.env.LOGS_DIRECTORY
? path.resolve(process.env.LOGS_DIRECTORY)
: path.join(__dirname, '..', 'logs');
const ALARM_CONFIG_FILE = process.env.ALARM_CONFIG_FILE
? path.resolve(process.env.ALARM_CONFIG_FILE)
: path.join(__dirname, '..', 'config', 'alarms.json');
const HISTORY_FILES = {
temperature: 'timestamp,value\n',
ph: 'timestamp,value\n',
do: 'timestamp,value\n',
ec: 'timestamp,value\n',
alarms: 'timestamp,sensor,state,value,message\n'
};
const HISTORY_SENSOR_MAP = {
rtd: { file: 'temperature.csv', name: 'RTD' },
ph: { file: 'ph.csv', name: 'PH' },
do: { file: 'do.csv', name: 'DO' },
ec: { file: 'ec.csv', name: 'EC' }
};
const HISTORY_SENSOR_IDS = {
temperature: 'rtd',
ph: 'ph',
do: 'do',
ec: 'ec'
};
const ALARM_SENSOR_IDS = ['temperature', 'ph', 'do', 'ec'];
function parseAlarmCsv(csvText) {
return csvText
.split(/\r?\n/)
.slice(1)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const columns = parseCsvLine(line);
if (columns.length < 5) return null;
return {
timestamp: columns[0],
sensor: columns[1],
state: columns[2],
value: columns[3] === '' ? null : Number(columns[3]),
message: columns[4]
};
})
.filter(Boolean);
}
function parseCsvLine(line) {
const columns = [];
let current = '';
let quoted = false;
for (let index = 0; index < line.length; index++) {
const character = line[index];
const nextCharacter = line[index + 1];
if (character === '"' && quoted && nextCharacter === '"') {
current += '"';
index++;
} else if (character === '"') {
quoted = !quoted;
} else if (character === ',' && !quoted) {
columns.push(current);
current = '';
} else {
current += character;
}
}
columns.push(current);
return columns;
}
async function readAlarmConfigFile() {
const rawConfig = await fs.readFile(ALARM_CONFIG_FILE, 'utf8');
return JSON.parse(rawConfig);
}
function validateAlarmConfig(rawConfig) {
if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
throw Object.assign(new Error('Configuracion de alarmas invalida.'), { status: 400 });
}
const normalizedConfig = {};
for (const sensorId of ALARM_SENSOR_IDS) {
const limits = rawConfig[sensorId];
const min = Number(limits && limits.min);
const max = Number(limits && limits.max);
if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max) {
throw Object.assign(
new Error(`Umbrales invalidos para ${sensorId}.`),
{ status: 400 }
);
}
normalizedConfig[sensorId] = { min, max };
}
return normalizedConfig;
}
async function writeAlarmConfigFile(rawConfig) {
const config = validateAlarmConfig(rawConfig);
await fs.mkdir(path.dirname(ALARM_CONFIG_FILE), { recursive: true });
await fs.writeFile(
ALARM_CONFIG_FILE,
`${JSON.stringify(config, null, 4)}\n`,
'utf8'
);
return config;
}
app.use(cors());
app.use(express.json());
app.use('/frontend', express.static(path.join(__dirname, '..', 'frontend')));
app.use('/data', express.static(path.join(__dirname, '..', 'data'), {
etag: false,
maxAge: 0
}));
app.use('/logs', express.static(LOGS_DIRECTORY, {
etag: false,
maxAge: 0
}));
app.use('/config', express.static(path.join(__dirname, '..', 'config'), {
etag: false,
maxAge: 0
}));
app.get('/vendor/chart.js', (req, res) => {
res.sendFile(path.join(
__dirname,
'..',
'node_modules',
'chart.js',
'dist',
'chart.umd.js'
));
});
app.get('/vendor/xlsx.js', (req, res) => {
res.sendFile(path.join(
__dirname,
'..',
'node_modules',
'xlsx',
'dist',
'xlsx.full.min.js'
));
});
app.get('/', (req, res) => {
res.redirect('/frontend/index.html');
});
function requireApiToken(req, res, next) {
if (!API_AUTH_TOKEN) {
return next();
}
const providedToken = req.get('X-API-Token') || '';
if (providedToken === API_AUTH_TOKEN) {
return next();
}
return res.status(401).json({
success: false,
error: 'Token de API requerido.'
});
}
app.get('/api/system/ezo', (req, res) => {
const commandMode = detectMode();
const acquisitionMode = detectAcquisitionMode();
res.json({
mode: commandMode.mode,
message: commandMode.message,
acquisitionMode: acquisitionMode.mode,
acquisitionMessage: acquisitionMode.message
});
});
function parseHistoryCsv(csvText, sensorName) {
return csvText
.split(/\r?\n/)
.slice(1)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const separatorIndex = line.indexOf(',');
if (separatorIndex < 0) return null;
const timestamp = line.slice(0, separatorIndex).trim();
const value = Number(line.slice(separatorIndex + 1).trim());
if (!timestamp || !Number.isFinite(value)) return null;
return { Timestamp: timestamp, Sensor: sensorName, Valor: value };
})
.filter(Boolean);
}
async function readSensorHistory(sensorType) {
const sensor = HISTORY_SENSOR_MAP[sensorType];
const csvText = await fs.readFile(
path.join(LOGS_DIRECTORY, sensor.file),
'utf8'
);
return parseHistoryCsv(csvText, sensor.name);
}
app.get('/api/sensors/:type', async (req, res) => {
const sensorType = req.params.type.toLowerCase();
if (sensorType !== 'all' && !HISTORY_SENSOR_MAP[sensorType]) {
return res.status(400).json({ error: 'Sensor no válido.' });
}
try {
if (sensorType === 'all') {
const runtimeConfig = await readRuntimeConfig();
const enabledHistorySensors = runtimeConfig.enabledSensors
.map((sensorId) => HISTORY_SENSOR_IDS[sensorId])
.filter(Boolean);
const histories = await Promise.all(
enabledHistorySensors.map(readSensorHistory)
);
return res.json(
histories.flat().sort((left, right) =>
String(left.Timestamp).localeCompare(String(right.Timestamp))
)
);
}
return res.json(await readSensorHistory(sensorType));
} catch (error) {
if (error.code === 'ENOENT') return res.json([]);
console.error('[HISTORY] No se pudo leer el historial:', error);
return res.status(500).json({
error: 'No se pudo leer el historial.'
});
}
});
app.get('/api/alarms', async (req, res) => {
try {
const csvText = await fs.readFile(
path.join(LOGS_DIRECTORY, 'alarms.csv'),
'utf8'
);
res.json(parseAlarmCsv(csvText));
} catch (error) {
if (error.code === 'ENOENT') return res.json([]);
console.error('[ALARMS] No se pudo leer la bitacora:', error);
return res.status(500).json({
error: 'No se pudo leer la bitacora de alarmas.'
});
}
});
app.post('/api/sensors/:type/command', requireApiToken, async (req, res) => {
try {
const result = await executeEzoCommand(
req.params.type.toLowerCase(),
req.body.command,
{ dangerousConfirmed: req.body.dangerousConfirmed === true }
);
res.json(result);
} catch (error) {
res.status(error.status || 500).json({
success: false,
error: error.message
});
}
});
app.get('/api/config/logging', async (req, res) => {
const config = await readRuntimeConfig();
res.json({
success: true,
rate: config.loggingRateSeconds,
historyRetentionDays: config.historyRetentionDays,
enabledSensors: config.enabledSensors
});
});
app.get('/api/config/alarms', async (req, res) => {
try {
res.json({
success: true,
thresholds: await readAlarmConfigFile()
});
} catch (error) {
console.error('[CONFIG] No se pudieron leer alarmas:', error);
res.status(500).json({
success: false,
error: 'No se pudieron leer los umbrales de alarma.'
});
}
});
app.post('/api/config/alarms', requireApiToken, async (req, res) => {
try {
const thresholds = await writeAlarmConfigFile(req.body.thresholds || req.body);
console.log('[CONFIG] Umbrales de alarma actualizados.');
res.json({
success: true,
thresholds,
message: 'Umbrales de alarma actualizados.'
});
} catch (error) {
res.status(error.status || 500).json({
success: false,
error: error.message
});
}
});
app.post('/api/config/logging', requireApiToken, async (req, res) => {
try {
const config = await writeRuntimeConfig({ rate: req.body.rate });
console.log(
`[CONFIG] Frecuencia de adquisición: ${config.loggingRateSeconds}s`
);
res.json({
success: true,
rate: config.loggingRateSeconds,
message: 'La frecuencia será aplicada por el recolector en el siguiente ciclo.'
});
} catch (error) {
res.status(error.status || 500).json({
success: false,
error: error.message
});
}
});
app.get('/api/config/runtime', async (req, res) => {
const config = await readRuntimeConfig();
res.json({
success: true,
rate: config.loggingRateSeconds,
historyRetentionDays: config.historyRetentionDays,
enabledSensors: config.enabledSensors,
availableSensors: Object.keys(HISTORY_SENSOR_IDS)
});
});
app.post('/api/config/runtime', requireApiToken, async (req, res) => {
try {
const config = await writeRuntimeConfig({
rate: req.body.rate,
historyRetentionDays: req.body.historyRetentionDays,
enabledSensors: req.body.enabledSensors
});
console.log(
`[CONFIG] Sensores activos: ${config.enabledSensors.join(', ')}`
);
res.json({
success: true,
rate: config.loggingRateSeconds,
historyRetentionDays: config.historyRetentionDays,
enabledSensors: config.enabledSensors,
message: 'La configuracion sera aplicada por el recolector en el siguiente ciclo.'
});
} catch (error) {
res.status(error.status || 500).json({
success: false,
error: error.message
});
}
});
app.post('/api/history/clear', requireApiToken, async (req, res) => {
try {
await fs.mkdir(LOGS_DIRECTORY, { recursive: true });
await Promise.all(
Object.entries(HISTORY_FILES).map(([name, header]) =>
fs.writeFile(
path.join(LOGS_DIRECTORY, `${name}.csv`),
header,
'utf8'
)
)
);
console.log('[HISTORY] Archivos CSV truncados.');
res.json({
success: true,
message: 'Archivos de registro truncados correctamente.'
});
} catch (error) {
console.error('[HISTORY] No se pudieron truncar los historiales:', error);
res.status(500).json({
success: false,
error: 'No se pudieron borrar los archivos históricos.'
});
}
});
if (require.main === module) {
app.listen(PORT, HOST, () => {
console.log(`[API] Backend Node.js activo en http://${HOST}:${PORT}`);
});
}
module.exports = {
app,
HISTORY_FILES,
parseHistoryCsv
};