Agregar alarmas persistentes y configuracion operativa

main
EMOTIONS-HUNTER 1 month ago
parent f94e8864b5
commit 8797cb4990

@ -41,6 +41,35 @@ ventana de procesamiento en vez de bloquear un segundo por sensor.
- Los sensores deshabilitados se muestran como `DESHABILITADO`.
- Un sensor deshabilitado no cuenta como `OFFLINE`, alarma ni riesgo global.
## Seguridad operativa
- `API_AUTH_TOKEN` protege endpoints criticos cuando esta configurado.
- El dashboard envia el token como `X-API-Token` desde almacenamiento local del
navegador.
- Sin `API_AUTH_TOKEN`, el modo desarrollo permanece sin autenticacion.
## Bitacora de alarmas
- `logs/alarms.csv` registra transiciones a `WARNING`, `CRITICAL`, `OFFLINE` y
recuperaciones `RECOVERY`.
- `GET /api/alarms` expone los eventos para dashboard y futuras notificaciones.
- Los sensores `DISABLED` no generan eventos de alarma.
## Umbrales configurables
- `GET /api/config/alarms` expone los limites actuales.
- `POST /api/config/alarms` valida y persiste cambios en `config/alarms.json`.
- El dashboard permite editar min/max por sensor; guardar requiere token si
`API_AUTH_TOKEN` esta activo.
## Retencion historica
- `historyRetentionDays` en `config/runtime.json` controla poda automatica de
CSV.
- Valores permitidos: 7, 30, 90, 365 o 0 para retencion indefinida.
- La poda conserva los nombres actuales de archivos para no romper graficas ni
exportaciones.
## Modos
- `EZO_MODE=demo`: adquisición y comandos simulados.

@ -96,6 +96,31 @@ En produccion puede dejar `EZO_ENABLED_SENSORS=temperature` en
`/etc/default/photobioreactor` como override. Cuando esten conectados los cuatro
circuitos, deje la variable vacia y habilite RTD, pH, DO y EC desde el panel.
## Seguridad operativa
En produccion configure `API_AUTH_TOKEN` en `/etc/default/photobioreactor` para
proteger comandos EZO, calibracion, cambios de configuracion y borrado de
historicos. El dashboard incluye un campo "Token API" que guarda el valor solo
en el navegador local y lo envia como header `X-API-Token`.
## Bitacora de alarmas
El recolector registra transiciones de alarma en `logs/alarms.csv` y la API las
expone en `GET /api/alarms`. Se registran entradas a `WARNING`, `CRITICAL` y
`OFFLINE`, ademas de recuperaciones a `NORMAL`.
## Retencion historica
`config/runtime.json` define `historyRetentionDays`. El valor puede ser 7, 30,
90, 365 o 0 para conservar indefinidamente. El recolector poda filas antiguas de
CSV sin cambiar los nombres que usa el dashboard.
## Umbrales de alarma
Los limites se guardan en `config/alarms.json`, se consultan con
`GET /api/config/alarms` y pueden editarse desde el dashboard. Guardar cambios
requiere `API_AUTH_TOKEN` cuando esta configurado.
## Archivos de datos
- Lecturas actuales: `data/EZORTD.json`, `EZOPH.json`, `EZODO.json`,

@ -16,6 +16,9 @@ const LOGS_DIRECTORY = process.env.LOGS_DIRECTORY
const RUNTIME_CONFIG_FILE = process.env.RUNTIME_CONFIG_FILE
? path.resolve(process.env.RUNTIME_CONFIG_FILE)
: path.join(ROOT_DIRECTORY, 'config', 'runtime.json');
const ALARM_CONFIG_FILE = process.env.ALARM_CONFIG_FILE
? path.resolve(process.env.ALARM_CONFIG_FILE)
: path.join(ROOT_DIRECTORY, 'config', 'alarms.json');
const ACQUISITION_HELPER = process.env.EZO_ACQUIRE_HELPER
? path.resolve(process.env.EZO_ACQUIRE_HELPER)
: path.join(ROOT_DIRECTORY, 'sensors', 'EZOCommand', 'EZO_ACQUIRE');
@ -32,6 +35,10 @@ const SENSOR_FILES = {
};
const ALLOWED_LOGGING_RATES = new Set([1, 5, 10, 60]);
const ALLOWED_RETENTION_DAYS = new Set([0, 7, 30, 90, 365]);
const WARNING_MARGIN_RATIO = 0.1;
const ALARM_LOG_FILE = 'alarms.csv';
const ALARM_LOG_HEADER = 'timestamp,sensor,state,value,message\n';
const SENSOR_ALIASES = {
rtd: 'temperature',
temperature: 'temperature',
@ -40,6 +47,9 @@ const SENSOR_ALIASES = {
ec: 'ec'
};
const DEFAULT_ENABLED_SENSORS = Object.keys(SENSOR_FILES);
const previousAlarmStates = new Map();
const lastRetentionChecks = new Map();
const RETENTION_CHECK_INTERVAL_MS = 10 * 60 * 1000;
function normalizeEnabledSensors(value, fallback = DEFAULT_ENABLED_SENSORS) {
const source = Array.isArray(value)
@ -68,10 +78,14 @@ async function readRuntimeConfig() {
const rawConfig = await fsPromises.readFile(RUNTIME_CONFIG_FILE, 'utf8');
const config = JSON.parse(rawConfig);
const rate = Number(config.loggingRateSeconds);
const retentionDays = Number(config.historyRetentionDays);
const environmentSensors = String(process.env.EZO_ENABLED_SENSORS || '').trim();
return {
loggingRateSeconds: ALLOWED_LOGGING_RATES.has(rate) ? rate : 1,
historyRetentionDays: ALLOWED_RETENTION_DAYS.has(retentionDays)
? retentionDays
: 30,
enabledSensors: environmentSensors
? normalizeEnabledSensors(environmentSensors)
: normalizeEnabledSensors(config.enabledSensors)
@ -79,6 +93,7 @@ async function readRuntimeConfig() {
} catch {
return {
loggingRateSeconds: 1,
historyRetentionDays: 30,
enabledSensors: normalizeEnabledSensors(process.env.EZO_ENABLED_SENSORS)
};
}
@ -89,6 +104,9 @@ async function writeRuntimeConfig(updates) {
const numericRate = updates.rate === undefined
? currentConfig.loggingRateSeconds
: Number(updates.rate);
const retentionDays = updates.historyRetentionDays === undefined
? currentConfig.historyRetentionDays
: Number(updates.historyRetentionDays);
if (!ALLOWED_LOGGING_RATES.has(numericRate)) {
const error = new Error('Frecuencia no valida.');
@ -96,6 +114,12 @@ async function writeRuntimeConfig(updates) {
throw error;
}
if (!ALLOWED_RETENTION_DAYS.has(retentionDays)) {
const error = new Error('Retencion historica no valida.');
error.status = 400;
throw error;
}
const enabledSensors = updates.enabledSensors === undefined
? currentConfig.enabledSensors
: normalizeEnabledSensors(updates.enabledSensors, []);
@ -108,6 +132,7 @@ async function writeRuntimeConfig(updates) {
const config = {
loggingRateSeconds: numericRate,
historyRetentionDays: retentionDays,
enabledSensors,
updatedAt: new Date().toISOString()
};
@ -183,6 +208,210 @@ async function appendReading(filePath, timestamp, value) {
);
}
function escapeCsvValue(value) {
const text = value === null || value === undefined ? '' : String(value);
return `"${text.replace(/"/g, '""')}"`;
}
async function appendCsvRow(filePath, header, values) {
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 = `${values.map(escapeCsvValue).join(',')}\n`;
await fsPromises.appendFile(
filePath,
`${needsHeader ? header : ''}${row}`,
'utf8'
);
}
async function pruneCsvByRetention(filePath, header, retentionDays, now = new Date()) {
if (!retentionDays) return false;
let csvText = '';
try {
csvText = await fsPromises.readFile(filePath, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') return false;
throw error;
}
const lines = csvText.split(/\r?\n/).filter(Boolean);
if (lines.length <= 1) return false;
const cutoffMs = now.getTime() - retentionDays * 24 * 60 * 60 * 1000;
const rows = lines.slice(1);
const keptRows = rows.filter((line) => {
const timestamp = line.split(',', 1)[0].replace(/^"|"$/g, '');
const parsedMs = new Date(timestamp).getTime();
return Number.isNaN(parsedMs) || parsedMs >= cutoffMs;
});
if (keptRows.length === rows.length) return false;
await atomicWriteFile(
filePath,
`${header}${keptRows.length > 0 ? `${keptRows.join('\n')}\n` : ''}`
);
return true;
}
async function enforceHistoryRetention(retentionDays, now = new Date()) {
if (!retentionDays) return [];
const prunedFiles = [];
const logFiles = [
...Object.values(SENSOR_FILES).map((sensor) => ({
file: sensor.csv,
header: 'timestamp,value\n'
})),
{ file: ALARM_LOG_FILE, header: ALARM_LOG_HEADER }
];
for (const logFile of logFiles) {
const filePath = path.join(LOGS_DIRECTORY, logFile.file);
const lastCheck = lastRetentionChecks.get(filePath) || 0;
if (now.getTime() - lastCheck < RETENTION_CHECK_INTERVAL_MS) {
continue;
}
lastRetentionChecks.set(filePath, now.getTime());
if (await pruneCsvByRetention(filePath, logFile.header, retentionDays, now)) {
prunedFiles.push(logFile.file);
}
}
return prunedFiles;
}
async function readAlarmThresholds() {
try {
return JSON.parse(await fsPromises.readFile(ALARM_CONFIG_FILE, 'utf8'));
} catch {
return {};
}
}
function isValidThreshold(limits) {
return Boolean(limits) &&
Number.isFinite(Number(limits.min)) &&
Number.isFinite(Number(limits.max)) &&
Number(limits.min) < Number(limits.max);
}
function isNearThreshold(value, limits) {
const min = Number(limits.min);
const max = Number(limits.max);
const warningMargin = (max - min) * WARNING_MARGIN_RATIO;
return value <= min + warningMargin || value >= max - warningMargin;
}
function evaluateAlarmState(sensorId, result, thresholds) {
if (result.disabled) {
return {
state: 'DISABLED',
message: 'Sensor deshabilitado por configuracion'
};
}
if (!result.online) {
return {
state: 'OFFLINE',
message: 'Lectura no disponible'
};
}
const limits = thresholds[sensorId];
if (!isValidThreshold(limits)) {
return {
state: 'WARNING',
message: 'Limites de alarma no disponibles'
};
}
if (result.value < Number(limits.min) || result.value > Number(limits.max)) {
return {
state: 'CRITICAL',
message: `Valor fuera de rango (${limits.min} - ${limits.max})`
};
}
if (isNearThreshold(result.value, limits)) {
return {
state: 'WARNING',
message: `Valor cerca del limite (${limits.min} - ${limits.max})`
};
}
return {
state: 'NORMAL',
message: 'Dentro del rango configurado'
};
}
async function persistAlarmEvents(results, timestamp) {
const thresholds = await readAlarmThresholds();
const events = [];
for (const result of results) {
const evaluation = evaluateAlarmState(result.sensorId, result, thresholds);
const previousState = previousAlarmStates.get(result.sensorId) || 'NORMAL';
if (evaluation.state === 'DISABLED') {
previousAlarmStates.delete(result.sensorId);
continue;
}
const enteredAlarm = ['WARNING', 'CRITICAL', 'OFFLINE'].includes(evaluation.state) &&
previousState !== evaluation.state;
const recovered = evaluation.state === 'NORMAL' &&
['WARNING', 'CRITICAL', 'OFFLINE'].includes(previousState);
if (enteredAlarm || recovered) {
events.push({
timestamp,
sensorId: result.sensorId,
state: recovered ? 'RECOVERY' : evaluation.state,
value: Number.isFinite(result.value) ? result.value : '',
message: recovered ? 'Sensor recuperado a NORMAL' : evaluation.message
});
}
previousAlarmStates.set(result.sensorId, evaluation.state);
}
for (const event of events) {
await appendCsvRow(
path.join(LOGS_DIRECTORY, ALARM_LOG_FILE),
ALARM_LOG_HEADER,
[
event.timestamp,
event.sensorId,
event.state,
event.value,
event.message
]
);
}
return events;
}
async function publishReading(sensorId, rawValue, timestamp, mode) {
const sensor = SENSOR_FILES[sensorId];
const jsonPath = path.join(DATA_DIRECTORY, sensor.json);
@ -243,26 +472,40 @@ async function collectReadings() {
Object.keys(SENSOR_FILES).map(async (sensorId) => {
if (!enabledSensorSet.has(sensorId)) {
await publishDisabled(sensorId, timestamp);
return { sensorId, online: false, disabled: true };
return { sensorId, online: false, disabled: true, value: null };
}
const rawValue = readings[sensorId];
const value = rawValue === null || rawValue === undefined || rawValue === ''
? NaN
: Number(rawValue);
const online = await publishReading(
sensorId,
rawValue,
timestamp,
modeInfo.mode
);
return {
sensorId,
online: await publishReading(
sensorId,
readings[sensorId],
timestamp,
modeInfo.mode
),
online,
value: Number.isFinite(value) ? value : null,
disabled: false
};
})
);
const alarmEvents = await persistAlarmEvents(results, timestamp);
const prunedFiles = await enforceHistoryRetention(
runtimeConfig.historyRetentionDays,
new Date(timestamp)
);
return {
mode: modeInfo.mode,
timestamp,
results,
alarmEvents,
prunedFiles,
enabledSensors: runtimeConfig.enabledSensors
};
}
@ -288,15 +531,18 @@ async function publishAllOffline(error) {
module.exports = {
ACQUISITION_HELPER,
ALLOWED_LOGGING_RATES,
ALLOWED_RETENTION_DAYS,
DEFAULT_ENABLED_SENSORS,
RUNTIME_CONFIG_FILE,
SENSOR_FILES,
atomicWriteFile,
collectReadings,
detectAcquisitionMode,
enforceHistoryRetention,
normalizeEnabledSensors,
publishAllOffline,
publishReading,
pruneCsvByRetention,
readRuntimeConfig,
writeRuntimeConfig
};

@ -20,8 +20,9 @@ async function run() {
const result = await collectReadings();
const onlineCount = result.results.filter((item) => item.online).length;
const expectedCount = result.enabledSensors.length;
const alarmCount = result.alarmEvents.length;
console.log(
`[ACQUISITION] ${result.timestamp} ${result.mode}: ${onlineCount}/${expectedCount} sensores habilitados.`
`[ACQUISITION] ${result.timestamp} ${result.mode}: ${onlineCount}/${expectedCount} sensores habilitados, ${alarmCount} eventos de alarma.`
);
} catch (error) {
console.error(`[ACQUISITION] ${error.message}`);

@ -15,14 +15,19 @@ const {
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'
ec: 'timestamp,value\n',
alarms: 'timestamp,sensor,state,value,message\n'
};
const HISTORY_SENSOR_MAP = {
rtd: { file: 'temperature.csv', name: 'RTD' },
@ -36,6 +41,95 @@ const HISTORY_SENSOR_IDS = {
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());
@ -77,6 +171,23 @@ 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();
@ -150,7 +261,23 @@ app.get('/api/sensors/:type', async (req, res) => {
}
});
app.post('/api/sensors/:type/command', async (req, res) => {
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(),
@ -171,11 +298,44 @@ app.get('/api/config/logging', async (req, res) => {
res.json({
success: true,
rate: config.loggingRateSeconds,
historyRetentionDays: config.historyRetentionDays,
enabledSensors: config.enabledSensors
});
});
app.post('/api/config/logging', async (req, res) => {
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(
@ -199,15 +359,17 @@ app.get('/api/config/runtime', async (req, res) => {
res.json({
success: true,
rate: config.loggingRateSeconds,
historyRetentionDays: config.historyRetentionDays,
enabledSensors: config.enabledSensors,
availableSensors: Object.keys(HISTORY_FILES)
availableSensors: Object.keys(HISTORY_SENSOR_IDS)
});
});
app.post('/api/config/runtime', async (req, res) => {
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(
@ -216,6 +378,7 @@ app.post('/api/config/runtime', async (req, res) => {
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.'
});
@ -227,7 +390,7 @@ app.post('/api/config/runtime', async (req, res) => {
}
});
app.post('/api/history/clear', async (req, res) => {
app.post('/api/history/clear', requireApiToken, async (req, res) => {
try {
await fs.mkdir(LOGS_DIRECTORY, { recursive: true });
await Promise.all(

@ -1,5 +1,6 @@
{
"loggingRateSeconds": 1,
"historyRetentionDays": 30,
"enabledSensors": [
"temperature"
]

@ -4,3 +4,5 @@ HOST=127.0.0.1
EZO_MODE=hardware
# Deje vacio para consultar todos. Use "temperature" durante pruebas con solo RTD.
EZO_ENABLED_SENSORS=
# Configure un valor secreto para proteger comandos, calibracion y cambios criticos.
API_AUTH_TOKEN=

@ -361,6 +361,67 @@ h1 {
border-left: 4px solid var(--offline-neutral);
}
.alarm-history-title {
margin: 18px 0 8px;
font-size: 1rem;
}
.alarm-threshold-editor {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.alarm-threshold-editor h3 {
margin: 0 0 12px;
font-size: 1rem;
}
.threshold-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.threshold-group {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-soft);
}
.threshold-group strong {
font-size: 0.9rem;
}
.threshold-group label {
display: grid;
gap: 4px;
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
}
.threshold-group input {
width: 100%;
min-height: 34px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
}
.alarm-history-list {
margin-top: 8px;
}
.alarm-history-list li.recovery {
border-left: 4px solid var(--ok);
}
.alarm-empty {
justify-content: center;
text-align: center;
@ -454,7 +515,8 @@ h1 {
@media (max-width: 980px) {
.metrics-grid,
.status-grid,
.sensor-health {
.sensor-health,
.threshold-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@ -479,6 +541,7 @@ h1 {
.metrics-grid,
.status-grid,
.sensor-health,
.threshold-grid,
.charts-grid {
grid-template-columns: 1fr;
}

@ -1,13 +1,14 @@
const POLLING_INTERVAL_MS = 1000;
const ALARM_CONFIG_FILE = "../config/alarms.json";
const WARNING_MARGIN_RATIO = 0.1;
const MIN_READING_MAX_AGE_MS = 15000;
// Variables dinámicas para el control de históricos
let currentHistoryInterval = 10000;
let currentLoggingRateSeconds = 1;
let currentHistoryRetentionDays = 30;
let historyIntervalId = null;
let dashboardUpdateInProgress = false;
let alarmControlsInitialized = false;
// Diccionario para traducir estados en la UI sin romper las clases CSS
const stateTranslations = {
@ -20,6 +21,34 @@ const stateTranslations = {
let currentEnabledSensorIds = new Set(["temperature", "ph", "do", "ec"]);
function getApiHeaders(extraHeaders = {}) {
const token = localStorage.getItem("photobioreactorApiToken") || "";
const headers = { ...extraHeaders };
if (token) {
headers["X-API-Token"] = token;
}
return headers;
}
window.getApiHeaders = getApiHeaders;
window.saveApiToken = function () {
const input = document.getElementById("api-token-input");
const token = input.value.trim();
if (token) {
localStorage.setItem("photobioreactorApiToken", token);
input.value = "";
alert("Token API guardado en este navegador.");
return;
}
localStorage.removeItem("photobioreactorApiToken");
alert("Token API eliminado de este navegador.");
};
const sensors = [
{
id: "temperature",
@ -67,6 +96,7 @@ const sensors = [
const historicalCharts = [
{
id: "temperature",
sensorId: "temperature",
title: "Temperatura vs Tiempo",
file: "../logs/temperature.csv",
valueKey: "temperature",
@ -264,6 +294,7 @@ async function updateDashboard() {
evaluatedResults.forEach(setSensorState);
renderSystemStatus(evaluatedResults);
renderAlarmSummary(evaluatedResults, alarmConfig);
renderAlarmHistory();
} finally {
dashboardUpdateInProgress = false;
}
@ -290,7 +321,7 @@ async function readRuntimeConfig() {
async function readAlarmConfig() {
try {
const response = await fetch(`${ALARM_CONFIG_FILE}?t=${Date.now()}`, {
const response = await fetch('/api/config/alarms', {
cache: "no-store"
});
@ -298,7 +329,10 @@ async function readAlarmConfig() {
throw new Error(`HTTP ${response.status}`);
}
const thresholds = await response.json();
const result = await response.json();
const thresholds = result.thresholds || result;
renderAlarmThresholdControls(thresholds);
return {
loaded: true,
@ -314,6 +348,77 @@ async function readAlarmConfig() {
}
}
function renderAlarmThresholdControls(thresholds) {
const container = document.getElementById("alarm-threshold-controls");
if (!container || alarmControlsInitialized) return;
container.innerHTML = sensors
.map((sensor) => {
const limits = thresholds[sensor.id] || {};
const min = Number.isFinite(Number(limits.min)) ? Number(limits.min) : "";
const max = Number.isFinite(Number(limits.max)) ? Number(limits.max) : "";
return `
<div class="threshold-group">
<strong>${sensor.name}</strong>
<label>
Min
<input type="number" step="0.001" data-threshold-sensor="${sensor.id}" data-threshold-bound="min" value="${min}">
</label>
<label>
Max
<input type="number" step="0.001" data-threshold-sensor="${sensor.id}" data-threshold-bound="max" value="${max}">
</label>
</div>
`})
.join("");
alarmControlsInitialized = true;
}
function collectAlarmThresholdControls() {
const thresholds = {};
sensors.forEach((sensor) => {
const minInput = document.querySelector(`[data-threshold-sensor="${sensor.id}"][data-threshold-bound="min"]`);
const maxInput = document.querySelector(`[data-threshold-sensor="${sensor.id}"][data-threshold-bound="max"]`);
const min = Number(minInput && minInput.value);
const max = Number(maxInput && maxInput.value);
if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max) {
throw new Error(`Umbrales invalidos para ${sensor.name}.`);
}
thresholds[sensor.id] = { min, max };
});
return thresholds;
}
window.saveAlarmThresholds = async function () {
try {
const thresholds = collectAlarmThresholdControls();
const response = await fetch('/api/config/alarms', {
method: 'POST',
headers: getApiHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ thresholds })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || "No se pudieron guardar los umbrales.");
}
alarmControlsInitialized = false;
renderAlarmThresholdControls(result.thresholds);
await updateDashboard();
alert("Umbrales de alarma actualizados.");
} catch (error) {
console.error("Error guardando umbrales:", error);
alert(error.message);
}
};
function evaluateSensorAlarm(sensorResult, thresholds) {
if (sensorResult.disabled) {
return {
@ -457,6 +562,50 @@ function renderAlarmSummary(results, alarmConfig) {
.join("");
}
async function readAlarmHistory() {
try {
const response = await fetch('/api/alarms', { cache: 'no-store' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.warn("No se pudo cargar la bitacora de alarmas:", error);
return [];
}
}
async function renderAlarmHistory() {
const historyList = document.getElementById("alarm-history-list");
if (!historyList) return;
const events = await readAlarmHistory();
const recentEvents = events.slice(-8).reverse();
if (recentEvents.length === 0) {
historyList.innerHTML = '<li class="alarm-empty">Sin eventos registrados</li>';
return;
}
historyList.innerHTML = recentEvents
.map((event) => {
const stateClass = String(event.state || "").toLowerCase();
const timestamp = event.timestamp
? new Date(event.timestamp).toLocaleString()
: "--";
return `
<li class="${stateClass}">
<span>${timestamp} · ${event.sensor}</span>
<span>${event.state}: ${event.message}</span>
</li>
`})
.join("");
}
async function readHistoricalData(chartConfig) {
try {
const response = await fetch(`${chartConfig.file}?t=${Date.now()}`, {
@ -725,7 +874,7 @@ window.updateLoggingRate = async function () {
try {
const response = await fetch('/api/config/logging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: getApiHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ rate })
});
@ -752,6 +901,9 @@ async function loadLoggingRate() {
currentEnabledSensorIds = new Set(result.enabledSensors);
setEnabledSensorControls(result.enabledSensors);
}
currentHistoryRetentionDays = Number(result.historyRetentionDays ?? 30);
document.getElementById("history-retention-days").value =
String(currentHistoryRetentionDays);
document.getElementById("data-logging-rate").value = String(result.rate);
document.getElementById("sampling-interval").textContent =
`${result.rate} ${result.rate === 1 ? "segundo" : "segundos"}`;
@ -760,6 +912,38 @@ async function loadLoggingRate() {
}
}
window.updateHistoryRetention = async function () {
const select = document.getElementById("history-retention-days");
const historyRetentionDays = Number(select.value);
try {
const response = await fetch('/api/config/runtime', {
method: 'POST',
headers: getApiHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
rate: currentLoggingRateSeconds,
historyRetentionDays,
enabledSensors: [...currentEnabledSensorIds]
})
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "No se pudo actualizar la retencion");
currentHistoryRetentionDays = result.historyRetentionDays;
select.value = String(result.historyRetentionDays);
alert(
result.historyRetentionDays === 0
? "Retención histórica desactivada."
: `Retención histórica actualizada a ${result.historyRetentionDays} días.`
);
} catch (error) {
console.error("Error configurando retencion historica:", error);
select.value = String(currentHistoryRetentionDays);
alert("No se pudo actualizar la retención histórica.");
}
};
function getSelectedEnabledSensors() {
return [...document.querySelectorAll('input[name="enabled-sensor"]:checked')]
.map((input) => input.value);
@ -785,9 +969,10 @@ window.updateEnabledSensors = async function () {
try {
const response = await fetch('/api/config/runtime', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: getApiHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
rate: currentLoggingRateSeconds,
historyRetentionDays: currentHistoryRetentionDays,
enabledSensors
})
});
@ -813,7 +998,10 @@ window.clearHistoricalData = async function () {
if (!confirmacion) return;
try {
const response = await fetch('/api/history/clear', { method: 'POST' });
const response = await fetch('/api/history/clear', {
method: 'POST',
headers: getApiHeaders()
});
if (!response.ok) throw new Error("Fallo en el purgado de archivos");
// Limpia los datasets existentes sin recrear las gráficas.

@ -193,7 +193,7 @@ async function sendCommand(command, options = {}) {
try {
const response = await fetch(`/api/sensors/${sensorType}/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: window.getApiHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ command, dangerousConfirmed })
});
const data = await response.json();

@ -127,6 +127,19 @@
<ul class="alarm-list" id="alarm-list" aria-live="polite">
<li class="alarm-empty">Sin alarmas activas</li>
</ul>
<div class="alarm-threshold-editor">
<h3>Umbrales Configurables</h3>
<div class="threshold-grid" id="alarm-threshold-controls"></div>
<button class="btn-command" type="button" onclick="saveAlarmThresholds()">
Guardar umbrales
</button>
</div>
<h3 class="alarm-history-title">Bitácora de Alarmas</h3>
<ul class="alarm-list alarm-history-list" id="alarm-history-list" aria-live="polite">
<li class="alarm-empty">Sin eventos registrados</li>
</ul>
</section>
<section class="trends-panel" aria-label="Tendencias históricas">
@ -162,6 +175,19 @@
</select>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-size: 0.9em; color: #ccc;">Retención
histórica:</label>
<select id="history-retention-days" onchange="updateHistoryRetention()"
style="padding: 8px; background-color: #222; color: white; border: 1px solid #444; border-radius: 4px;">
<option value="7">7 días</option>
<option value="30" selected>30 días</option>
<option value="90">90 días</option>
<option value="365">1 año</option>
<option value="0">Sin límite</option>
</select>
</div>
<fieldset class="sensor-config-group">
<legend>Sensores habilitados</legend>
<label><input type="checkbox" name="enabled-sensor" value="temperature"> RTD</label>
@ -173,6 +199,13 @@
</button>
</fieldset>
<div>
<label style="display: block; margin-bottom: 5px; font-size: 0.9em; color: #ccc;">Token API:</label>
<input id="api-token-input" type="password" autocomplete="off" placeholder="Opcional"
style="padding: 8px; background-color: #222; color: white; border: 1px solid #444; border-radius: 4px;">
<button class="btn-command" type="button" onclick="saveApiToken()">Guardar</button>
</div>
<div style="margin-top: auto;">
<button class="btn-command" onclick="clearHistoricalData()"
style="padding: 8px 15px; background-color: #ff4444; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">

@ -8,15 +8,28 @@ const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'photobioreactor-'))
const temporaryLogs = path.join(temporaryRoot, 'logs');
const temporaryData = path.join(temporaryRoot, 'data');
const temporaryRuntime = path.join(temporaryRoot, 'runtime.json');
const temporaryAlarms = path.join(temporaryRoot, 'alarms.json');
fs.mkdirSync(temporaryLogs, { recursive: true });
fs.mkdirSync(temporaryData, { recursive: true });
fs.writeFileSync(temporaryAlarms, JSON.stringify({
temperature: { min: 20, max: 30 },
ph: { min: 6.8, max: 7.5 },
do: { min: 4, max: 12 },
ec: { min: 500, max: 2500 }
}));
process.env.LOGS_DIRECTORY = temporaryLogs;
process.env.DATA_DIRECTORY = temporaryData;
process.env.RUNTIME_CONFIG_FILE = temporaryRuntime;
process.env.ALARM_CONFIG_FILE = temporaryAlarms;
process.env.EZO_MODE = 'demo';
process.env.API_AUTH_TOKEN = 'test-token';
const { app, HISTORY_FILES } = require('../api/server');
const { collectReadings, publishReading } = require('../api/acquisition-service');
const {
collectReadings,
pruneCsvByRetention,
publishReading
} = require('../api/acquisition-service');
let server;
let baseUrl;
@ -73,7 +86,10 @@ test('all history export respects enabled sensors', async () => {
await fetch(`${baseUrl}/api/config/runtime`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({
rate: 1,
enabledSensors: ['temperature']
@ -96,17 +112,38 @@ test('serves the dashboard in local development mode', async () => {
assert.match(html, /Panel del Fotobiorreactor/);
});
test('protects critical API endpoints when token is configured', async () => {
const response = await fetch(`${baseUrl}/api/config/runtime`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rate: 1,
enabledSensors: ['temperature']
})
});
const data = await response.json();
assert.equal(response.status, 401);
assert.equal(data.success, false);
});
test('validates sensor commands', async () => {
const invalidResponse = await fetch(`${baseUrl}/api/sensors/invalid/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({ command: 'r' })
});
assert.equal(invalidResponse.status, 400);
const validResponse = await fetch(`${baseUrl}/api/sensors/ph/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({ command: 'r' })
});
const validData = await validResponse.json();
@ -119,7 +156,10 @@ test('validates sensor commands', async () => {
test('rejects undocumented calibration syntax', async () => {
const response = await fetch(`${baseUrl}/api/sensors/do/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({ command: 'Cal,atm' })
});
const data = await response.json();
@ -131,14 +171,20 @@ test('rejects undocumented calibration syntax', async () => {
test('persists logging rates used by acquisition', async () => {
const invalidResponse = await fetch(`${baseUrl}/api/config/logging`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({ rate: 3 })
});
assert.equal(invalidResponse.status, 400);
const validResponse = await fetch(`${baseUrl}/api/config/logging`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({ rate: 5 })
});
const validData = await validResponse.json();
@ -155,9 +201,13 @@ test('persists logging rates used by acquisition', async () => {
test('persists enabled sensors used by acquisition', async () => {
const response = await fetch(`${baseUrl}/api/config/runtime`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({
rate: 1,
historyRetentionDays: 30,
enabledSensors: ['temperature']
})
});
@ -169,12 +219,53 @@ test('persists enabled sensors used by acquisition', async () => {
const readResponse = await fetch(`${baseUrl}/api/config/runtime`);
const readData = await readResponse.json();
assert.deepEqual(readData.enabledSensors, ['temperature']);
assert.equal(readData.historyRetentionDays, 30);
});
test('persists history retention configuration', async () => {
const response = await fetch(`${baseUrl}/api/config/runtime`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({
rate: 1,
historyRetentionDays: 7,
enabledSensors: ['temperature']
})
});
const data = await response.json();
assert.equal(response.status, 200);
assert.equal(data.historyRetentionDays, 7);
assert.equal(JSON.parse(fs.readFileSync(temporaryRuntime)).historyRetentionDays, 7);
});
test('rejects invalid history retention configuration', async () => {
const response = await fetch(`${baseUrl}/api/config/runtime`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({
rate: 1,
historyRetentionDays: 13,
enabledSensors: ['temperature']
})
});
assert.equal(response.status, 400);
});
test('rejects empty enabled sensor configuration', async () => {
const response = await fetch(`${baseUrl}/api/config/runtime`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({
rate: 1,
enabledSensors: []
@ -184,10 +275,73 @@ test('rejects empty enabled sensor configuration', async () => {
assert.equal(response.status, 400);
});
test('persists alarm threshold configuration with token protection', async () => {
const unauthorizedResponse = await fetch(`${baseUrl}/api/config/alarms`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
thresholds: {
temperature: { min: 19, max: 29 },
ph: { min: 6.5, max: 7.8 },
do: { min: 3, max: 13 },
ec: { min: 400, max: 2600 }
}
})
});
assert.equal(unauthorizedResponse.status, 401);
const validResponse = await fetch(`${baseUrl}/api/config/alarms`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({
thresholds: {
temperature: { min: 19, max: 29 },
ph: { min: 6.5, max: 7.8 },
do: { min: 3, max: 13 },
ec: { min: 400, max: 2600 }
}
})
});
const saved = await validResponse.json();
assert.equal(validResponse.status, 200);
assert.equal(saved.thresholds.temperature.min, 19);
const readResponse = await fetch(`${baseUrl}/api/config/alarms`);
const read = await readResponse.json();
assert.equal(read.thresholds.ec.max, 2600);
});
test('rejects invalid alarm thresholds', async () => {
const response = await fetch(`${baseUrl}/api/config/alarms`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({
thresholds: {
temperature: { min: 30, max: 20 },
ph: { min: 6.5, max: 7.8 },
do: { min: 3, max: 13 },
ec: { min: 400, max: 2600 }
}
})
});
assert.equal(response.status, 400);
});
test('demo acquisition writes live JSON and CSV files', async () => {
await fetch(`${baseUrl}/api/config/runtime`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({
rate: 1,
enabledSensors: ['temperature', 'ph', 'do', 'ec']
@ -212,7 +366,10 @@ test('demo acquisition writes live JSON and CSV files', async () => {
test('disabled sensors are published as disabled without CSV rows', async () => {
await fetch(`${baseUrl}/api/config/runtime`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({
rate: 1,
enabledSensors: ['temperature']
@ -228,6 +385,71 @@ test('disabled sensors are published as disabled without CSV rows', async () =>
assert.equal(fs.existsSync(path.join(temporaryLogs, 'ph.csv')), false);
});
test('acquisition persists alarm events and exposes them through the API', async () => {
fs.writeFileSync(temporaryAlarms, JSON.stringify({
temperature: { min: 20, max: 24 },
ph: { min: 6.8, max: 7.5 },
do: { min: 4, max: 12 },
ec: { min: 500, max: 2500 }
}));
fs.rmSync(path.join(temporaryLogs, 'alarms.csv'), { force: true });
await fetch(`${baseUrl}/api/config/runtime`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({
rate: 1,
enabledSensors: ['temperature']
})
});
const result = await collectReadings();
const alarmsCsv = fs.readFileSync(path.join(temporaryLogs, 'alarms.csv'), 'utf8');
const response = await fetch(`${baseUrl}/api/alarms`);
const alarmEvents = await response.json();
assert.equal(result.alarmEvents.length, 1);
assert.match(alarmsCsv, /^timestamp,sensor,state,value,message/m);
assert.match(alarmsCsv, /"temperature","CRITICAL"/);
assert.equal(response.status, 200);
assert.equal(alarmEvents[0].sensor, 'temperature');
assert.equal(alarmEvents[0].state, 'CRITICAL');
fs.writeFileSync(temporaryAlarms, JSON.stringify({
temperature: { min: 20, max: 30 },
ph: { min: 6.8, max: 7.5 },
do: { min: 4, max: 12 },
ec: { min: 500, max: 2500 }
}));
});
test('prunes historical CSV rows outside retention window', async () => {
const csvPath = path.join(temporaryLogs, 'retention-test.csv');
fs.writeFileSync(
csvPath,
[
'timestamp,value',
'2026-01-01T00:00:00.000Z,10',
'2026-06-20T00:00:00.000Z,20'
].join('\n') + '\n'
);
const pruned = await pruneCsvByRetention(
csvPath,
'timestamp,value\n',
30,
new Date('2026-06-25T00:00:00.000Z')
);
const content = fs.readFileSync(csvPath, 'utf8');
assert.equal(pruned, true);
assert.doesNotMatch(content, /2026-01-01/);
assert.match(content, /2026-06-20/);
});
test('hardware null readings are marked offline and not appended as zero', async () => {
const timestamp = new Date().toISOString();
const csvPath = path.join(temporaryLogs, 'ph.csv');
@ -248,7 +470,10 @@ test('clears historical CSV files and preserves headers', async () => {
}
const response = await fetch(`${baseUrl}/api/history/clear`, {
method: 'POST'
method: 'POST',
headers: {
'X-API-Token': 'test-token'
}
});
assert.equal(response.status, 200);

@ -15,9 +15,18 @@ test('dashboard starts live polling and exposes required controls', () => {
assert.match(html, /id="history-interval"/);
assert.match(html, /name="enabled-sensor"/);
assert.match(html, /id="api-token-input"/);
assert.match(html, /id="history-retention-days"/);
assert.match(html, /id="alarm-history-list"/);
assert.match(html, /id="alarm-threshold-controls"/);
assert.match(dashboard, /updateDashboard\(\);/);
assert.match(dashboard, /updateEnabledSensors/);
assert.match(dashboard, /\/api\/config\/runtime/);
assert.match(dashboard, /\/api\/alarms/);
assert.match(dashboard, /saveAlarmThresholds/);
assert.match(dashboard, /\/api\/config\/alarms/);
assert.match(dashboard, /updateHistoryRetention/);
assert.match(dashboard, /getApiHeaders/);
assert.match(
dashboard,
/setInterval\(updateDashboard,\s*POLLING_INTERVAL_MS\)/
@ -40,6 +49,7 @@ test('terminal output does not append untrusted HTML', () => {
assert.doesNotMatch(service, /terminalOutput\.innerHTML/);
assert.match(service, /line\.textContent = message/);
assert.match(service, /window\.getApiHeaders/);
});
test('sensor Makefiles reference their real implementation objects', () => {

Loading…
Cancel
Save