Compare commits

...

6 Commits

@ -35,6 +35,54 @@ ventana de procesamiento en vez de bloquear un segundo por sensor.
- Chart.js 4.5.1 y SheetJS 0.20.3 instalados localmente.
- Servicios systemd, configuración Nginx e instalador para Raspberry Pi.
## Configuracion parcial de sensores
- `config/runtime.json` guarda `enabledSensors`.
- 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.
- La API compara tokens en tiempo constante y emite headers defensivos basicos.
- Los POST criticos usan rate limit configurable en memoria.
- 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.
- Las graficas filtran visualmente el dia actual; los CSV mantienen todos los
registros disponibles dentro de la retencion configurada.
## Notificaciones
- `config/notifications.json` permite activar webhook y Telegram.
- `GET /api/config/notifications` devuelve configuracion redactada.
- `POST /api/config/notifications` valida y persiste canales con token.
- El recolector despacha notificaciones desde eventos de `logs/alarms.csv`.
- `docs/TELEGRAM_BOT_SETUP.md` documenta creacion del bot, obtencion de
`chatId`, configuracion, pruebas y diagnostico.
## Modos
- `EZO_MODE=demo`: adquisición y comandos simulados.

@ -77,15 +77,67 @@ La guía de preparación, detección I2C, calibración y prueba integral está e
## Sensores conectados parcialmente
Para pruebas con solo EZO-RTD conectado, limite la adquisicion al sensor de
temperatura:
temperatura desde el panel de configuracion o en `config/runtime.json`:
```json
{
"loggingRateSeconds": 1,
"enabledSensors": ["temperature"]
}
```
Tambien puede forzarlo temporalmente por entorno:
```bash
EZO_MODE=hardware EZO_ENABLED_SENSORS=temperature npm run acquire
```
En produccion puede dejar `EZO_ENABLED_SENSORS=temperature` en
`/etc/default/photobioreactor`. Cuando esten conectados los cuatro circuitos,
deje la variable vacia para consultar RTD, pH, DO y EC.
`/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`.
La API aplica comparacion de token en tiempo constante y headers defensivos
basicos (`nosniff`, `DENY`, `same-origin`, `no-store`).
Los POST criticos aplican rate limit en memoria mediante
`API_RATE_LIMIT_WINDOW_MS` y `API_RATE_LIMIT_MAX`.
## 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.
Las graficas del dashboard muestran solo los datos del dia actual para evitar
saturacion visual. Los CSV conservan el periodo completo configurado por
retencion y siguen disponibles para exportacion.
## 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.
## Notificaciones
`config/notifications.json` define canales de webhook y Telegram. El recolector
envia notificaciones cuando una alarma persistente alcanza `minSeverity`. La API
expone configuracion redactada en `GET /api/config/notifications`; guardar
requiere token y nunca devuelve secretos sin redaccion.
La configuracion completa del bot, `chatId`, pruebas y diagnostico esta en
[`docs/TELEGRAM_BOT_SETUP.md`](docs/TELEGRAM_BOT_SETUP.md).
## Archivos de datos
@ -107,6 +159,7 @@ timestamp,value
- Estado actual: [`PROJECT_STATUS.md`](PROJECT_STATUS.md)
- Comandos y calibración: [`docs/EZO_COMMANDS.md`](docs/EZO_COMMANDS.md)
- Despliegue: [`docs/RASPBERRY_PI_DEPLOYMENT.md`](docs/RASPBERRY_PI_DEPLOYMENT.md)
- Telegram: [`docs/TELEGRAM_BOT_SETUP.md`](docs/TELEGRAM_BOT_SETUP.md)
- Diseño extendido: [`ARCHITECTURE.md`](ARCHITECTURE.md)
No se utiliza un driver personalizado del kernel. Linux ya proporciona la capa

@ -3,6 +3,7 @@ const fsPromises = require('node:fs/promises');
const path = require('node:path');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const { dispatchAlarmNotifications } = require('./notification-service');
const execFileAsync = promisify(execFile);
@ -16,6 +17,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 +36,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',
@ -39,6 +47,25 @@ const SENSOR_ALIASES = {
do: 'do',
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)
? value
: String(value || '').split(',');
const sensors = [
...new Set(
source
.map((sensor) => SENSOR_ALIASES[String(sensor).trim().toLowerCase()])
.filter(Boolean)
)
];
return sensors.length > 0 ? sensors : [...fallback];
}
async function atomicWriteFile(filePath, content) {
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
@ -52,26 +79,62 @@ 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
loggingRateSeconds: ALLOWED_LOGGING_RATES.has(rate) ? rate : 1,
historyRetentionDays: ALLOWED_RETENTION_DAYS.has(retentionDays)
? retentionDays
: 30,
enabledSensors: environmentSensors
? normalizeEnabledSensors(environmentSensors)
: normalizeEnabledSensors(config.enabledSensors)
};
} catch {
return { loggingRateSeconds: 1 };
return {
loggingRateSeconds: 1,
historyRetentionDays: 30,
enabledSensors: normalizeEnabledSensors(process.env.EZO_ENABLED_SENSORS)
};
}
}
async function writeRuntimeConfig(rate) {
const numericRate = Number(rate);
async function writeRuntimeConfig(updates) {
const currentConfig = await readRuntimeConfig();
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 válida.');
const error = new Error('Frecuencia no valida.');
error.status = 400;
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, []);
if (enabledSensors.length === 0) {
const error = new Error('Debe habilitar al menos un sensor.');
error.status = 400;
throw error;
}
const config = {
loggingRateSeconds: numericRate,
historyRetentionDays: retentionDays,
enabledSensors,
updatedAt: new Date().toISOString()
};
await atomicWriteFile(
@ -90,15 +153,15 @@ function detectAcquisitionMode() {
if (requestedMode === 'hardware' && !hardwareReady) {
return {
mode: 'unavailable',
message: 'Se solicitó hardware, pero /dev/i2c-1 o EZO_ACQUIRE no está disponible.'
message: 'Se solicito hardware, pero /dev/i2c-1 o EZO_ACQUIRE no esta disponible.'
};
}
if (requestedMode === 'hardware' || (requestedMode === 'auto' && hardwareReady)) {
return { mode: 'hardware', message: 'Adquisición I2C real activa.' };
return { mode: 'hardware', message: 'Adquisicion I2C real activa.' };
}
return { mode: 'demo', message: 'Adquisición de demostración activa.' };
return { mode: 'demo', message: 'Adquisicion de demostracion activa.' };
}
function buildDemoReadings() {
@ -110,12 +173,8 @@ function buildDemoReadings() {
};
}
async function readHardwareSensors() {
const enabledSensors = String(process.env.EZO_ENABLED_SENSORS || '')
.split(',')
.map((sensor) => SENSOR_ALIASES[sensor.trim().toLowerCase()])
.filter(Boolean);
const helperArguments = ['/dev/i2c-1', ...new Set(enabledSensors)];
async function readHardwareSensors(enabledSensors) {
const helperArguments = ['/dev/i2c-1', ...enabledSensors];
const { stdout } = await execFileAsync(
ACQUISITION_HELPER,
helperArguments,
@ -124,7 +183,7 @@ async function readHardwareSensors() {
const readings = JSON.parse(stdout.trim());
if (!readings || typeof readings !== 'object') {
throw new Error('EZO_ACQUIRE devolvió una respuesta inválida.');
throw new Error('EZO_ACQUIRE devolvio una respuesta invalida.');
}
return readings;
@ -150,6 +209,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);
@ -179,30 +442,75 @@ async function publishReading(sensorId, rawValue, timestamp, mode) {
return true;
}
async function publishDisabled(sensorId, timestamp) {
const sensor = SENSOR_FILES[sensorId];
await atomicWriteFile(
path.join(DATA_DIRECTORY, sensor.json),
`${JSON.stringify({
online: false,
disabled: true,
timestamp,
error: 'Sensor deshabilitado por configuracion'
})}\n`
);
}
async function collectReadings() {
const modeInfo = detectAcquisitionMode();
const runtimeConfig = await readRuntimeConfig();
const enabledSensorSet = new Set(runtimeConfig.enabledSensors);
if (modeInfo.mode === 'unavailable') {
throw new Error(modeInfo.message);
}
const readings = modeInfo.mode === 'hardware'
? await readHardwareSensors()
? await readHardwareSensors(runtimeConfig.enabledSensors)
: buildDemoReadings();
const timestamp = new Date().toISOString();
const results = await Promise.all(
Object.keys(SENSOR_FILES).map(async (sensorId) => ({
sensorId,
online: await publishReading(
Object.keys(SENSOR_FILES).map(async (sensorId) => {
if (!enabledSensorSet.has(sensorId)) {
await publishDisabled(sensorId, timestamp);
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,
readings[sensorId],
rawValue,
timestamp,
modeInfo.mode
)
}))
);
return { mode: modeInfo.mode, timestamp, results };
return {
sensorId,
online,
value: Number.isFinite(value) ? value : null,
disabled: false
};
})
);
const alarmEvents = await persistAlarmEvents(results, timestamp);
const notificationResults = await dispatchAlarmNotifications(alarmEvents);
const prunedFiles = await enforceHistoryRetention(
runtimeConfig.historyRetentionDays,
new Date(timestamp)
);
return {
mode: modeInfo.mode,
timestamp,
results,
alarmEvents,
notificationResults,
prunedFiles,
enabledSensors: runtimeConfig.enabledSensors
};
}
async function publishAllOffline(error) {
@ -226,13 +534,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
};

@ -19,8 +19,10 @@ async function run() {
try {
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}/4 sensores.`
`[ACQUISITION] ${result.timestamp} ${result.mode}: ${onlineCount}/${expectedCount} sensores habilitados, ${alarmCount} eventos de alarma.`
);
} catch (error) {
console.error(`[ACQUISITION] ${error.message}`);

@ -0,0 +1,269 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const ROOT_DIRECTORY = path.join(__dirname, '..');
const NOTIFICATION_CONFIG_FILE = process.env.NOTIFICATION_CONFIG_FILE
? path.resolve(process.env.NOTIFICATION_CONFIG_FILE)
: path.join(ROOT_DIRECTORY, 'config', 'notifications.json');
const SEVERITY_RANK = {
RECOVERY: 0,
NORMAL: 0,
WARNING: 1,
OFFLINE: 2,
CRITICAL: 3
};
const DEFAULT_NOTIFICATION_CONFIG = {
enabled: false,
minSeverity: 'CRITICAL',
channels: {
webhook: {
enabled: false,
url: '',
headers: {}
},
telegram: {
enabled: false,
botToken: '',
chatId: ''
}
}
};
function mergeNotificationConfig(config = {}) {
return {
enabled: config.enabled === true,
minSeverity: SEVERITY_RANK[config.minSeverity] === undefined
? DEFAULT_NOTIFICATION_CONFIG.minSeverity
: config.minSeverity,
channels: {
webhook: {
...DEFAULT_NOTIFICATION_CONFIG.channels.webhook,
...(config.channels && config.channels.webhook)
},
telegram: {
...DEFAULT_NOTIFICATION_CONFIG.channels.telegram,
...(config.channels && config.channels.telegram)
}
}
};
}
async function readNotificationConfig() {
try {
const rawConfig = await fs.readFile(NOTIFICATION_CONFIG_FILE, 'utf8');
return mergeNotificationConfig(JSON.parse(rawConfig));
} catch {
return mergeNotificationConfig();
}
}
function validateNotificationConfig(rawConfig) {
const config = mergeNotificationConfig(rawConfig);
if (SEVERITY_RANK[config.minSeverity] === undefined) {
throw Object.assign(new Error('Severidad minima no valida.'), { status: 400 });
}
if (config.channels.webhook.enabled && !isValidHttpUrl(config.channels.webhook.url)) {
throw Object.assign(new Error('URL de webhook no valida.'), { status: 400 });
}
if (config.channels.telegram.enabled) {
if (!config.channels.telegram.botToken || !config.channels.telegram.chatId) {
throw Object.assign(
new Error('Telegram requiere botToken y chatId.'),
{ status: 400 }
);
}
}
if (
config.channels.webhook.headers &&
(typeof config.channels.webhook.headers !== 'object' ||
Array.isArray(config.channels.webhook.headers))
) {
throw Object.assign(new Error('Headers de webhook invalidos.'), { status: 400 });
}
return config;
}
async function writeNotificationConfig(rawConfig) {
const currentConfig = await readNotificationConfig();
const config = validateNotificationConfig(
resolvePreservedSecrets(rawConfig, currentConfig)
);
await fs.mkdir(path.dirname(NOTIFICATION_CONFIG_FILE), { recursive: true });
await fs.writeFile(
NOTIFICATION_CONFIG_FILE,
`${JSON.stringify(config, null, 4)}\n`,
'utf8'
);
return config;
}
function resolvePreservedSecrets(rawConfig, currentConfig) {
const nextConfig = mergeNotificationConfig(rawConfig);
if (
nextConfig.channels.telegram.botToken === '__KEEP__' &&
currentConfig.channels.telegram.botToken
) {
nextConfig.channels.telegram.botToken = currentConfig.channels.telegram.botToken;
}
if (
nextConfig.channels.webhook.headers === '__KEEP__' &&
currentConfig.channels.webhook.headers
) {
nextConfig.channels.webhook.headers = currentConfig.channels.webhook.headers;
}
return nextConfig;
}
function redactNotificationConfig(config) {
return {
...config,
channels: {
webhook: {
...config.channels.webhook,
headers: Object.keys(config.channels.webhook.headers || {}).length > 0
? '[configured]'
: {}
},
telegram: {
...config.channels.telegram,
botToken: config.channels.telegram.botToken ? '[configured]' : ''
}
}
};
}
function isValidHttpUrl(value) {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol);
} catch {
return false;
}
}
function shouldNotify(event, minSeverity) {
const eventRank = SEVERITY_RANK[event.state] ?? 0;
const minimumRank = SEVERITY_RANK[minSeverity] ?? SEVERITY_RANK.CRITICAL;
return eventRank >= minimumRank;
}
function buildNotificationMessage(event) {
const value = event.value === '' || event.value === null || event.value === undefined
? 'N/A'
: event.value;
return [
`Photobioreactor alarm: ${event.state}`,
`Sensor: ${event.sensorId || event.sensor}`,
`Value: ${value}`,
`Message: ${event.message}`,
`Time: ${event.timestamp}`
].join('\n');
}
async function dispatchAlarmNotifications(events) {
const config = await readNotificationConfig();
if (!config.enabled || !Array.isArray(events) || events.length === 0) {
return [];
}
const eligibleEvents = events.filter((event) =>
shouldNotify(event, config.minSeverity)
);
const results = [];
for (const event of eligibleEvents) {
if (config.channels.webhook.enabled) {
results.push(await sendSafely('webhook', event, () =>
sendWebhookNotification(event, config.channels.webhook)
));
}
if (config.channels.telegram.enabled) {
results.push(await sendSafely('telegram', event, () =>
sendTelegramNotification(event, config.channels.telegram)
));
}
}
return results;
}
async function sendSafely(channel, event, send) {
try {
await send();
return {
channel,
sensorId: event.sensorId || event.sensor,
state: event.state,
success: true
};
} catch (error) {
return {
channel,
sensorId: event.sensorId || event.sensor,
state: event.state,
success: false,
error: error.message
};
}
}
async function sendWebhookNotification(event, channelConfig) {
await postJson(channelConfig.url, {
event,
text: buildNotificationMessage(event)
}, channelConfig.headers || {});
}
async function sendTelegramNotification(event, channelConfig) {
const url = `https://api.telegram.org/bot${channelConfig.botToken}/sendMessage`;
await postJson(url, {
chat_id: channelConfig.chatId,
text: buildNotificationMessage(event)
});
}
async function postJson(url, payload, headers = {}) {
if (typeof fetch !== 'function') {
throw new Error('fetch no esta disponible en este runtime.');
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
}
module.exports = {
DEFAULT_NOTIFICATION_CONFIG,
NOTIFICATION_CONFIG_FILE,
dispatchAlarmNotifications,
readNotificationConfig,
redactNotificationConfig,
resolvePreservedSecrets,
shouldNotify,
validateNotificationConfig,
writeNotificationConfig
};

@ -1,5 +1,6 @@
const express = require('express');
const cors = require('cors');
const crypto = require('node:crypto');
const fs = require('node:fs/promises');
const path = require('node:path');
const {
@ -11,18 +12,30 @@ const {
readRuntimeConfig,
writeRuntimeConfig
} = require('./acquisition-service');
const {
readNotificationConfig,
redactNotificationConfig,
writeNotificationConfig
} = require('./notification-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 API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000);
const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 60);
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' },
@ -30,9 +43,117 @@ const HISTORY_SENSOR_MAP = {
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'];
const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'same-origin',
'Cache-Control': 'no-store'
};
const rateLimitBuckets = new Map();
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((req, res, next) => {
Object.entries(SECURITY_HEADERS).forEach(([name, value]) => {
res.setHeader(name, value);
});
next();
});
app.use('/frontend', express.static(path.join(__dirname, '..', 'frontend')));
app.use('/data', express.static(path.join(__dirname, '..', 'data'), {
etag: false,
@ -71,6 +192,68 @@ 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 (tokensMatch(providedToken, API_AUTH_TOKEN)) {
return next();
}
return res.status(401).json({
success: false,
error: 'Token de API requerido.'
});
}
function tokensMatch(providedToken, expectedToken) {
const provided = Buffer.from(String(providedToken));
const expected = Buffer.from(String(expectedToken));
if (provided.length !== expected.length) {
return false;
}
return crypto.timingSafeEqual(provided, expected);
}
function getRateLimitKey(req) {
const token = req.get('X-API-Token') || '';
const identity = token || req.ip || req.socket.remoteAddress || 'unknown';
return `${identity}:${req.method}:${req.path}`;
}
function rateLimitCriticalApi(req, res, next) {
if (API_RATE_LIMIT_MAX <= 0 || API_RATE_LIMIT_WINDOW_MS <= 0) {
return next();
}
const now = Date.now();
const key = getRateLimitKey(req);
const bucket = rateLimitBuckets.get(key) || [];
const recentRequests = bucket.filter((timestamp) =>
now - timestamp < API_RATE_LIMIT_WINDOW_MS
);
if (recentRequests.length >= API_RATE_LIMIT_MAX) {
const retryAfterMs = API_RATE_LIMIT_WINDOW_MS - (now - recentRequests[0]);
res.setHeader('Retry-After', String(Math.max(1, Math.ceil(retryAfterMs / 1000))));
rateLimitBuckets.set(key, recentRequests);
return res.status(429).json({
success: false,
error: 'Demasiadas solicitudes. Intente nuevamente en unos segundos.'
});
}
recentRequests.push(now);
rateLimitBuckets.set(key, recentRequests);
return next();
}
app.get('/api/system/ezo', (req, res) => {
const commandMode = detectMode();
const acquisitionMode = detectAcquisitionMode();
@ -120,8 +303,12 @@ app.get('/api/sensors/:type', async (req, res) => {
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(
Object.keys(HISTORY_SENSOR_MAP).map(readSensorHistory)
enabledHistorySensors.map(readSensorHistory)
);
return res.json(
histories.flat().sort((left, right) =>
@ -140,7 +327,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, rateLimitCriticalApi, async (req, res) => {
try {
const result = await executeEzoCommand(
req.params.type.toLowerCase(),
@ -158,12 +361,74 @@ app.post('/api/sensors/:type/command', async (req, res) => {
app.get('/api/config/logging', async (req, res) => {
const config = await readRuntimeConfig();
res.json({ success: true, rate: config.loggingRateSeconds });
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 {
const config = await writeRuntimeConfig(req.body.rate);
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, rateLimitCriticalApi, 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.get('/api/config/notifications', async (req, res) => {
const config = await readNotificationConfig();
res.json({
success: true,
config: redactNotificationConfig(config)
});
});
app.post('/api/config/notifications', requireApiToken, rateLimitCriticalApi, async (req, res) => {
try {
const config = await writeNotificationConfig(req.body.config || req.body);
console.log('[CONFIG] Notificaciones actualizadas.');
res.json({
success: true,
config: redactNotificationConfig(config),
message: 'Configuracion de notificaciones actualizada.'
});
} catch (error) {
res.status(error.status || 500).json({
success: false,
error: error.message
});
}
});
app.post('/api/config/logging', requireApiToken, rateLimitCriticalApi, async (req, res) => {
try {
const config = await writeRuntimeConfig({ rate: req.body.rate });
console.log(
`[CONFIG] Frecuencia de adquisición: ${config.loggingRateSeconds}s`
);
@ -180,7 +445,43 @@ app.post('/api/config/logging', async (req, res) => {
}
});
app.post('/api/history/clear', async (req, res) => {
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, rateLimitCriticalApi, 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, rateLimitCriticalApi, async (req, res) => {
try {
await fs.mkdir(LOGS_DIRECTORY, { recursive: true });
await Promise.all(
@ -216,5 +517,6 @@ if (require.main === module) {
module.exports = {
app,
HISTORY_FILES,
parseHistoryCsv
parseHistoryCsv,
resetRateLimits: () => rateLimitBuckets.clear()
};

@ -0,0 +1,16 @@
{
"enabled": false,
"minSeverity": "CRITICAL",
"channels": {
"webhook": {
"enabled": false,
"url": "",
"headers": {}
},
"telegram": {
"enabled": false,
"botToken": "",
"chatId": ""
}
}
}

@ -1,3 +1,7 @@
{
"loggingRateSeconds": 1
"loggingRateSeconds": 1,
"historyRetentionDays": 30,
"enabledSensors": [
"temperature"
]
}

@ -4,3 +4,10 @@ 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=
# Limite de acciones criticas por ventana; use 0 para desactivar.
API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX=60
# Archivo de canales webhook/Telegram; no exponga secretos en frontend.
NOTIFICATION_CONFIG_FILE=/etc/photobioreactor/notifications.json

@ -0,0 +1,295 @@
# Guia de configuracion de Telegram
Esta guia explica como activar notificaciones de alarmas del Photobioreactor
Dashboard hacia Telegram. El envio ocurre desde el backend de la Raspberry Pi,
por lo que las alertas pueden salir aunque el navegador del dashboard este
cerrado.
## Requisitos
- Raspberry Pi con acceso a Internet.
- API y recolector funcionando mediante `systemd`.
- Un token de API configurado en `API_AUTH_TOKEN` si se desea proteger cambios
desde el dashboard.
- Un bot de Telegram creado con BotFather.
- Un `chatId` de usuario, grupo o canal donde el bot pueda escribir.
## Flujo general
```text
Sensor EZO
|
Recolector de adquisicion
|
Evaluacion de umbrales
|
Evento de alarma o recuperacion
|
logs/alarms.csv
|
api/notification-service.js
|
Telegram Bot API
|
Chat configurado
```
El sistema no envia mensajes en cada lectura. Solo envia notificaciones cuando
se genera un evento de alarma elegible, por ejemplo una transicion a `WARNING`,
`CRITICAL`, `OFFLINE` o una recuperacion si la severidad minima lo permite.
## Crear el bot
1. Abra Telegram y busque `@BotFather`.
2. Envie:
```text
/newbot
```
3. Asigne un nombre visible al bot.
4. Asigne un usuario terminado en `bot`, por ejemplo:
```text
photobioreactor_alerts_bot
```
5. BotFather entregara un token con formato parecido a:
```text
1234567890:AAExampleTokenDoNotShare
```
Guarde este token como secreto. No lo publique en Git, capturas o mensajes.
## Obtener el chatId
### Chat directo con el bot
1. Abra el bot recien creado.
2. Presione `Start` o envie cualquier mensaje, por ejemplo:
```text
hola
```
3. En una terminal, consulte las actualizaciones:
```bash
curl "https://api.telegram.org/bot<TOKEN_DEL_BOT>/getUpdates"
```
4. Busque el campo `chat.id`. El valor puede verse como:
```json
"chat":{"id":123456789,"first_name":"Cristian","type":"private"}
```
En este ejemplo, el `chatId` es:
```text
123456789
```
### Grupo de Telegram
1. Agregue el bot al grupo.
2. Envie un mensaje dentro del grupo mencionando o usando el bot.
3. Ejecute:
```bash
curl "https://api.telegram.org/bot<TOKEN_DEL_BOT>/getUpdates"
```
4. Busque el `chat.id` del grupo. Normalmente es negativo, por ejemplo:
```text
-1001234567890
```
Si no aparece ningun resultado, envie otro mensaje en el grupo y repita la
consulta. En algunos grupos puede ser necesario permitir que el bot lea mensajes
o usar comandos dirigidos al bot.
## Configuracion desde el dashboard
1. Abra el dashboard en la red local de la Raspberry Pi.
2. Si `API_AUTH_TOKEN` esta configurado, escriba ese valor en el campo `Token
API`.
3. Vaya al panel de notificaciones.
4. Active `Activas`.
5. Seleccione la severidad minima:
| Severidad minima | Resultado |
|---|---|
| `WARNING` | Envia warnings, offline y criticos |
| `OFFLINE` | Envia offline y criticos |
| `CRITICAL` | Solo envia alarmas criticas |
6. Active `Telegram`.
7. Escriba el `Chat ID`.
8. Escriba el `Bot token`.
9. Guarde la configuracion.
Despues de guardar, el backend nunca devuelve el token completo al navegador.
El dashboard lo mostrara como configurado y en guardados posteriores puede dejar
el campo de token vacio para conservar el valor existente.
## Configuracion directa en Raspberry Pi
En produccion, el instalador usa:
```text
/etc/photobioreactor/notifications.json
```
La ruta se define en:
```text
NOTIFICATION_CONFIG_FILE=/etc/photobioreactor/notifications.json
```
Ejemplo de configuracion:
```json
{
"enabled": true,
"minSeverity": "CRITICAL",
"channels": {
"webhook": {
"enabled": false,
"url": "",
"headers": {}
},
"telegram": {
"enabled": true,
"botToken": "1234567890:AAExampleTokenDoNotShare",
"chatId": "123456789"
}
}
}
```
Proteja el archivo porque contiene secretos:
```bash
sudo chown photobioreactor:photobioreactor /etc/photobioreactor/notifications.json
sudo chmod 600 /etc/photobioreactor/notifications.json
sudo systemctl restart photobioreactor-acquisition
```
## Prueba manual de Telegram
Antes de probar el dashboard, confirme que Telegram acepta el token y el chat:
```bash
curl -X POST "https://api.telegram.org/bot<TOKEN_DEL_BOT>/sendMessage" \
-H "Content-Type: application/json" \
-d '{"chat_id":"<CHAT_ID>","text":"Prueba de alarmas del fotobiorreactor"}'
```
Si Telegram responde con `"ok":true`, el bot y el chat estan bien configurados.
## Prueba desde el sistema
1. Confirme que el recolector esta activo:
```bash
systemctl status photobioreactor-acquisition
```
2. Revise eventos recientes:
```bash
tail -n 20 /opt/photobioreactor/logs/alarms.csv
```
3. Genere una condicion de alarma de prueba ajustando temporalmente un umbral
desde el dashboard. Por ejemplo, establezca un maximo de temperatura por
debajo de la lectura actual para forzar `CRITICAL`.
4. Espere el siguiente ciclo de adquisicion.
5. Confirme que se registro el evento y llego el mensaje.
6. Restaure el umbral correcto.
Evite hacer esta prueba durante una calibracion real o una corrida experimental
critica.
## Mensaje enviado
El mensaje tiene este formato:
```text
Photobioreactor alarm: CRITICAL
Sensor: temperature
Value: 31.2
Message: Temperature is above configured range.
Time: 2026-06-27T12:00:00.000Z
```
El texto se construye en `api/notification-service.js`. Si mas adelante se
necesita un formato distinto, ese es el punto central para modificarlo.
## Seguridad recomendada
- Configure `API_AUTH_TOKEN` en produccion.
- No suba tokens de Telegram al repositorio.
- Use `/etc/photobioreactor/notifications.json` para secretos en Raspberry Pi.
- Mantenga permisos `600` en el archivo de notificaciones.
- Si el token se expone, regenere el token desde BotFather.
- No publique el dashboard directamente a Internet.
## Diagnostico
### No llega ningun mensaje
- Verifique Internet en la Raspberry:
```bash
curl https://api.telegram.org
```
- Verifique token y chat con la prueba manual de `sendMessage`.
- Confirme que `enabled` y `channels.telegram.enabled` estan en `true`.
- Confirme que `minSeverity` no esta filtrando el evento.
- Confirme que realmente hubo una transicion de alarma en `logs/alarms.csv`.
### El dashboard no permite guardar
- Si `API_AUTH_TOKEN` esta configurado, debe escribirlo en el campo `Token API`.
- Revise rate limit si se hicieron muchos cambios seguidos.
- Consulte logs de la API:
```bash
journalctl -u photobioreactor-api -f
```
### El bot responde en chat directo pero no en grupo
- Confirme que el bot esta agregado al grupo.
- Use el `chatId` del grupo, no el chat privado.
- En grupos, el `chatId` normalmente es negativo.
- Envie un mensaje nuevo al grupo y repita `getUpdates`.
### El token aparece como `[configured]`
Ese comportamiento es correcto. El backend redacta el secreto cuando el
dashboard consulta la configuracion. Para cambiar el token, escriba uno nuevo y
guarde. Para conservarlo, deje el campo vacio.
## Estado actual de implementacion
Implementado:
- Configuracion persistente en `config/notifications.json` o
`NOTIFICATION_CONFIG_FILE`.
- Canal Telegram mediante Bot API `sendMessage`.
- Redaccion de token hacia el frontend.
- Conservacion de token existente desde el dashboard.
- Filtro por severidad minima.
- Integracion con eventos generados por el recolector.
Pendiente de validacion en Raspberry:
- Envio real desde la red donde trabajara el equipo.
- Comportamiento durante desconexiones fisicas de sensores.
- Registro operativo de errores de envio en corridas largas.

@ -129,6 +129,11 @@ h1 {
border-color: rgba(125, 135, 144, 0.45);
}
.metric-card.disabled {
border-color: rgba(125, 135, 144, 0.28);
background: #f8faf9;
}
.metric-card.normal {
border-color: rgba(22, 138, 74, 0.45);
}
@ -185,6 +190,10 @@ h1 {
color: var(--offline-neutral);
}
.metric-state.disabled {
color: var(--offline-neutral);
}
.metric-reading {
display: flex;
align-items: baseline;
@ -307,6 +316,10 @@ h1 {
color: var(--offline-neutral);
}
.sensor-health .disabled {
color: var(--offline-neutral);
}
.sensor-health .warning {
color: var(--warning);
}
@ -348,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;
@ -441,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));
}
}
@ -466,10 +541,15 @@ h1 {
.metrics-grid,
.status-grid,
.sensor-health,
.threshold-grid,
.charts-grid {
grid-template-columns: 1fr;
}
.notification-config-group {
grid-template-columns: 1fr;
}
.metric-card {
min-height: 178px;
}
@ -544,6 +624,61 @@ h1 {
color: var(--muted) !important;
}
.sensor-config-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin: 0;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
}
.sensor-config-group legend {
color: var(--muted);
font-size: 0.9rem;
font-weight: 700;
}
.sensor-config-group label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.notification-config-group {
display: grid;
grid-template-columns: repeat(4, minmax(0, auto));
align-items: end;
gap: 10px;
margin: 0;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
}
.notification-config-group legend {
color: var(--muted);
font-size: 0.9rem;
font-weight: 700;
}
.notification-config-group label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted) !important;
}
.notification-config-group input,
.notification-config-group select {
min-height: 34px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
}
.terminal-card,
.calibration-card {
overflow: hidden;

@ -1,25 +1,61 @@
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;
const DAY_IN_MS = 24 * 60 * 60 * 1000;
// 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;
let notificationConfigLoaded = false;
let telegramTokenConfigured = false;
// Diccionario para traducir estados en la UI sin romper las clases CSS
const stateTranslations = {
"NORMAL": "NORMAL",
"OFFLINE": "DESCONECTADO",
"DISABLED": "DESHABILITADO",
"WARNING": "ADVERTENCIA",
"CRITICAL": "CRÍTICO"
};
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",
sensorId: "temperature",
name: "Temperatura",
file: "../data/EZORTD.json",
key: "temperature",
@ -63,6 +99,7 @@ const sensors = [
const historicalCharts = [
{
id: "temperature",
sensorId: "temperature",
title: "Temperatura vs Tiempo",
file: "../logs/temperature.csv",
valueKey: "temperature",
@ -73,6 +110,7 @@ const historicalCharts = [
},
{
id: "ph-history",
sensorId: "ph",
title: "pH vs Tiempo",
file: "../logs/ph.csv",
valueKey: "ph",
@ -83,6 +121,7 @@ const historicalCharts = [
},
{
id: "do-history",
sensorId: "do",
title: "Oxígeno Disuelto vs Tiempo",
file: "../logs/do.csv",
valueKey: "do",
@ -93,6 +132,7 @@ const historicalCharts = [
},
{
id: "ec-history",
sensorId: "ec",
title: "Conductividad vs Tiempo",
file: "../logs/ec.csv",
valueKey: "ec",
@ -106,6 +146,16 @@ const historicalCharts = [
const chartInstances = new Map();
async function readSensor(sensor) {
if (!currentEnabledSensorIds.has(sensor.id)) {
return {
...sensor,
disabled: true,
online: false,
numericValue: null,
value: "--"
};
}
try {
const response = await fetch(`${sensor.file}?t=${Date.now()}`, {
cache: "no-store"
@ -138,6 +188,7 @@ async function readSensor(sensor) {
return {
...sensor,
disabled: false,
online: true,
numericValue: rawValue,
timestamp: data.timestamp || null,
@ -148,6 +199,7 @@ async function readSensor(sensor) {
return {
...sensor,
disabled: false,
online: false,
numericValue: null,
value: "DESCONECTADO"
@ -166,25 +218,27 @@ function setSensorState(result) {
// Traducir el estado para mostrar en pantalla, manteniendo la clase en inglés
stateElement.textContent = stateTranslations[state] || state;
["online", "normal", "warning", "critical", "offline"].forEach((className) => {
["online", "normal", "warning", "critical", "offline", "disabled"].forEach((className) => {
stateElement.classList.toggle(className, className === stateClass);
cardElement.classList.toggle(className, className === stateClass);
});
}
function renderSystemStatus(results) {
const activeSensors = results.filter((result) => result.online).length;
const offlineSensors = results.length - activeSensors;
const enabledResults = results.filter((result) => !result.disabled);
const activeSensors = enabledResults.filter((result) => result.online).length;
const offlineSensors = enabledResults.length - activeSensors;
const criticalSensors = results.filter((result) => result.alarmState === "CRITICAL").length;
const warningSensors = results.filter((result) => result.alarmState === "WARNING").length;
const allNormal = results.every((result) => result.alarmState === "NORMAL");
const allNormal = enabledResults.length > 0 &&
enabledResults.every((result) => result.alarmState === "NORMAL");
const anyOnline = activeSensors > 0;
const overallDot = document.getElementById("overall-dot");
const overallStatus = document.getElementById("overall-status");
const healthList = document.getElementById("sensor-health");
document.getElementById("active-count").textContent =
`${activeSensors} / ${results.length}`;
`${activeSensors} / ${enabledResults.length}`;
document.getElementById("offline-count").textContent = String(offlineSensors);
document.getElementById("last-update").textContent =
new Date().toLocaleString();
@ -230,10 +284,12 @@ async function updateDashboard() {
dashboardUpdateInProgress = true;
try {
const [results, alarmConfig] = await Promise.all([
Promise.all(sensors.map(readSensor)),
const [runtimeConfig, alarmConfig] = await Promise.all([
readRuntimeConfig(),
readAlarmConfig()
]);
currentEnabledSensorIds = new Set(runtimeConfig.enabledSensors);
const results = await Promise.all(sensors.map(readSensor));
const evaluatedResults = results.map((result) =>
evaluateSensorAlarm(result, alarmConfig.thresholds)
);
@ -241,14 +297,100 @@ async function updateDashboard() {
evaluatedResults.forEach(setSensorState);
renderSystemStatus(evaluatedResults);
renderAlarmSummary(evaluatedResults, alarmConfig);
renderAlarmHistory();
} finally {
dashboardUpdateInProgress = false;
}
}
async function readRuntimeConfig() {
try {
const response = await fetch('/api/config/runtime', { cache: 'no-store' });
const result = await response.json();
if (!response.ok || !Array.isArray(result.enabledSensors)) {
throw new Error("Runtime configuration unavailable");
}
return result;
} catch (error) {
console.warn("No se pudo cargar la configuracion de sensores:", error);
return {
rate: currentLoggingRateSeconds,
enabledSensors: [...currentEnabledSensorIds]
};
}
}
async function loadNotificationConfig() {
try {
const response = await fetch('/api/config/notifications', { cache: 'no-store' });
const result = await response.json();
if (!response.ok || !result.config) return;
const config = result.config;
telegramTokenConfigured = config.channels.telegram.botToken === "[configured]";
document.getElementById("notifications-enabled").checked = config.enabled === true;
document.getElementById("notification-min-severity").value = config.minSeverity || "CRITICAL";
document.getElementById("webhook-enabled").checked = config.channels.webhook.enabled === true;
document.getElementById("webhook-url").value = config.channels.webhook.url || "";
document.getElementById("telegram-enabled").checked = config.channels.telegram.enabled === true;
document.getElementById("telegram-chat-id").value = config.channels.telegram.chatId || "";
document.getElementById("telegram-bot-token").placeholder = telegramTokenConfigured
? "Token configurado"
: "Bot token";
notificationConfigLoaded = true;
} catch (error) {
console.warn("No se pudo cargar la configuracion de notificaciones:", error);
}
}
window.saveNotificationConfig = async function () {
const botTokenInput = document.getElementById("telegram-bot-token");
const botToken = botTokenInput.value.trim();
const config = {
enabled: document.getElementById("notifications-enabled").checked,
minSeverity: document.getElementById("notification-min-severity").value,
channels: {
webhook: {
enabled: document.getElementById("webhook-enabled").checked,
url: document.getElementById("webhook-url").value.trim(),
headers: {}
},
telegram: {
enabled: document.getElementById("telegram-enabled").checked,
botToken: botToken || (telegramTokenConfigured ? "__KEEP__" : ""),
chatId: document.getElementById("telegram-chat-id").value.trim()
}
}
};
try {
const response = await fetch('/api/config/notifications', {
method: 'POST',
headers: getApiHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ config })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || "No se pudo guardar notificaciones.");
}
botTokenInput.value = "";
notificationConfigLoaded = false;
await loadNotificationConfig();
alert("Configuracion de notificaciones actualizada.");
} catch (error) {
console.error("Error guardando notificaciones:", error);
alert(error.message);
}
};
async function readAlarmConfig() {
try {
const response = await fetch(`${ALARM_CONFIG_FILE}?t=${Date.now()}`, {
const response = await fetch('/api/config/alarms', {
cache: "no-store"
});
@ -256,7 +398,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,
@ -272,7 +417,86 @@ 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 {
...sensorResult,
alarmState: "DISABLED",
alarmMessage: "Sensor no habilitado en la configuracion actual"
};
}
if (!sensorResult.online) {
return {
...sensorResult,
@ -342,7 +566,7 @@ function buildAlarmMessage(sensorResult, limits, alarmType) {
function getAlarmEvents(results) {
return results
.filter((result) => result.alarmState !== "NORMAL")
.filter((result) => !["NORMAL", "DISABLED"].includes(result.alarmState))
.map((result) => ({
sensorId: result.id,
sensorName: result.name,
@ -407,6 +631,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()}`, {
@ -451,6 +719,7 @@ function parseHistoricalCsv(csvText, valueKey) {
return rows
.map((line) => splitCsvLine(line))
.map((columns) => {
const rawTimestamp = columns[timestampIndex];
const rawValue = Number(columns[valueIndex]);
if (!Number.isFinite(rawValue)) {
@ -458,13 +727,49 @@ function parseHistoricalCsv(csvText, valueKey) {
}
return {
label: formatTimestamp(columns[timestampIndex]),
timestampMs: parseTimestampMs(rawTimestamp),
label: formatTimestamp(rawTimestamp),
value: rawValue
};
})
.filter(Boolean);
}
function parseTimestampMs(rawTimestamp) {
const numericTimestamp = Number(rawTimestamp);
if (Number.isFinite(numericTimestamp)) {
if (numericTimestamp > 1000000000000) {
return numericTimestamp;
}
if (numericTimestamp > 1000000000) {
return numericTimestamp * 1000;
}
}
const parsedDate = new Date(rawTimestamp);
if (!Number.isNaN(parsedDate.getTime())) {
return parsedDate.getTime();
}
return Number.NaN;
}
function filterPointsForCurrentDay(points) {
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const startMs = startOfToday.getTime();
const endMs = startMs + DAY_IN_MS;
return points.filter((point) =>
Number.isFinite(point.timestampMs) &&
point.timestampMs >= startMs &&
point.timestampMs < endMs
);
}
function splitCsvLine(line) {
return line
.split(",")
@ -488,34 +793,37 @@ function formatTimestamp(rawTimestamp) {
return "";
}
const numericTimestamp = Number(rawTimestamp);
const parsedMs = parseTimestampMs(rawTimestamp);
if (Number.isFinite(numericTimestamp)) {
if (numericTimestamp > 1000000000000) {
return new Date(numericTimestamp).toLocaleTimeString();
}
if (numericTimestamp > 1000000000) {
return new Date(numericTimestamp * 1000).toLocaleTimeString();
}
}
const parsedDate = new Date(rawTimestamp);
if (!Number.isNaN(parsedDate.getTime())) {
return parsedDate.toLocaleTimeString();
if (Number.isFinite(parsedMs)) {
return new Date(parsedMs).toLocaleTimeString();
}
return rawTimestamp;
}
function setChartAvailability(chartConfig, hasData) {
function setChartAvailability(chartConfig, hasData, message = "No hay datos históricos disponibles") {
const emptyElement = document.getElementById(chartConfig.emptyElement);
const frameElement = emptyElement.closest(".chart-frame");
emptyElement.textContent = message;
frameElement.classList.toggle("empty", !hasData);
}
function clearHistoricalChart(chartConfig, message) {
const existingChart = chartInstances.get(chartConfig.id);
if (existingChart) {
existingChart.data.labels = [];
existingChart.data.datasets.forEach((dataset) => {
dataset.data = [];
});
existingChart.update("none");
}
setChartAvailability(chartConfig, false, message);
}
function buildChartDataset(chartConfig, points) {
return {
labels: points.map((point) => point.label),
@ -605,14 +913,23 @@ function renderHistoricalChart(chartConfig, points) {
}
async function updateHistoricalTrends() {
const runtimeConfig = await readRuntimeConfig();
currentEnabledSensorIds = new Set(runtimeConfig.enabledSensors);
const chartData = await Promise.all(
historicalCharts.map(async (chartConfig) => ({
chartConfig,
points: await readHistoricalData(chartConfig)
points: currentEnabledSensorIds.has(chartConfig.sensorId)
? filterPointsForCurrentDay(await readHistoricalData(chartConfig))
: []
}))
);
chartData.forEach(({ chartConfig, points }) => {
if (!currentEnabledSensorIds.has(chartConfig.sensorId)) {
clearHistoricalChart(chartConfig, "Sensor deshabilitado");
return;
}
renderHistoricalChart(chartConfig, points);
});
}
@ -631,6 +948,7 @@ updateDashboard();
setInterval(updateDashboard, POLLING_INTERVAL_MS);
updateHistoricalTrends();
startHistoryPolling();
loadNotificationConfig();
// --- FUNCIONES DE CONTROL DE ALMACENAMIENTO ---
@ -651,7 +969,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 })
});
@ -669,11 +987,18 @@ window.updateLoggingRate = async function () {
async function loadLoggingRate() {
try {
const response = await fetch('/api/config/logging', { cache: 'no-store' });
const response = await fetch('/api/config/runtime', { cache: 'no-store' });
const result = await response.json();
if (!response.ok) return;
currentLoggingRateSeconds = result.rate;
if (Array.isArray(result.enabledSensors)) {
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"}`;
@ -682,6 +1007,84 @@ 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);
}
function setEnabledSensorControls(enabledSensors) {
const enabled = new Set(enabledSensors);
document.querySelectorAll('input[name="enabled-sensor"]').forEach((input) => {
input.checked = enabled.has(input.value);
});
}
window.updateEnabledSensors = async function () {
const enabledSensors = getSelectedEnabledSensors();
if (enabledSensors.length === 0) {
alert("Debe quedar al menos un sensor habilitado.");
setEnabledSensorControls([...currentEnabledSensorIds]);
return;
}
try {
const response = await fetch('/api/config/runtime', {
method: 'POST',
headers: getApiHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
rate: currentLoggingRateSeconds,
historyRetentionDays: currentHistoryRetentionDays,
enabledSensors
})
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "No se pudo actualizar la configuracion");
currentEnabledSensorIds = new Set(result.enabledSensors);
setEnabledSensorControls(result.enabledSensors);
await updateDashboard();
alert(`Sensores habilitados: ${result.enabledSensors.join(", ")}`);
} catch (error) {
console.error("Error configurando sensores habilitados:", error);
alert("No se pudo actualizar la configuracion de sensores.");
}
};
loadLoggingRate();
window.clearHistoricalData = async function () {
@ -690,7 +1093,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.

@ -2,6 +2,7 @@
async function handleExportExcel() {
try {
const enabledSensors = await getEnabledSensorsForExport();
console.log("[MOCK] Iniciando generación de reporte Excel...");
// 1. Crear un nuevo libro de trabajo (Workbook) en blanco
@ -9,11 +10,15 @@ async function handleExportExcel() {
// Definimos los sensores y el nombre que tendrá cada pestaña en el Excel
const sensors = [
{ id: 'rtd', sheetName: 'Temperatura_RTD' },
{ id: 'ph', sheetName: 'Sensor_pH' },
{ id: 'do', sheetName: 'Oxigeno_DO' },
{ id: 'ec', sheetName: 'Conductividad_EC' }
];
{ id: 'rtd', sensorId: 'temperature', sheetName: 'Temperatura_RTD' },
{ id: 'ph', sensorId: 'ph', sheetName: 'Sensor_pH' },
{ id: 'do', sensorId: 'do', sheetName: 'Oxigeno_DO' },
{ id: 'ec', sensorId: 'ec', sheetName: 'Conductividad_EC' }
].filter((sensor) => enabledSensors.has(sensor.sensorId));
if (sensors.length === 0) {
throw new Error('No hay sensores habilitados para exportar.');
}
// 2. Iterar sobre cada sensor, obtener sus datos y crear su hoja
for (const sensor of sensors) {

@ -26,11 +26,39 @@ function escapeCsvValue(value) {
return `"${text.replace(/"/g, '""')}"`;
}
async function getEnabledSensorsForExport() {
const response = await fetch('/api/config/runtime', { cache: 'no-store' });
const config = await response.json();
if (!response.ok || !Array.isArray(config.enabledSensors)) {
throw new Error('No se pudo leer la configuracion de sensores.');
}
return new Set(config.enabledSensors);
}
function normalizeExportSensor(sensorType) {
if (sensorType === 'rtd') return 'temperature';
return sensorType;
}
async function assertSensorExportEnabled(sensorType) {
if (sensorType === 'all') return;
const enabledSensors = await getEnabledSensorsForExport();
const sensorId = normalizeExportSensor(sensorType);
if (!enabledSensors.has(sensorId)) {
throw new Error(`El sensor ${sensorType.toUpperCase()} esta deshabilitado.`);
}
}
/**
* Manejador principal del evento click de los botones
*/
async function handleExport(sensorType) {
try {
await assertSensorExportEnabled(sensorType);
// 1. Obtener datos (Mock actual, fetch real en el futuro)
const data = await fetchHistoricalData(sensorType);

@ -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,12 +127,25 @@
<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">
<div class="panel-heading">
<h2>Tendencias Históricas</h2>
<p>Actualización: <span id="history-interval">10 segundos</span></p>
<p>Periodo visible: hoy · Actualización: <span id="history-interval">10 segundos</span></p>
</div>
<div class="card config-card" style="margin-bottom: 20px; padding: 20px; border-left: 4px solid #9c27b0;">
<h3>Configuración de Almacenamiento</h3>
@ -162,6 +175,58 @@
</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>
<label><input type="checkbox" name="enabled-sensor" value="ph"> pH</label>
<label><input type="checkbox" name="enabled-sensor" value="do"> DO</label>
<label><input type="checkbox" name="enabled-sensor" value="ec"> EC</label>
<button class="btn-command" type="button" onclick="updateEnabledSensors()">
Aplicar sensores
</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>
<fieldset class="notification-config-group">
<legend>Notificaciones</legend>
<label><input id="notifications-enabled" type="checkbox"> Activas</label>
<label>
Severidad
<select id="notification-min-severity">
<option value="WARNING">WARNING</option>
<option value="OFFLINE">OFFLINE</option>
<option value="CRITICAL">CRITICAL</option>
</select>
</label>
<label><input id="webhook-enabled" type="checkbox"> Webhook</label>
<input id="webhook-url" type="url" placeholder="https://example.com/webhook">
<label><input id="telegram-enabled" type="checkbox"> Telegram</label>
<input id="telegram-chat-id" type="text" placeholder="Chat ID">
<input id="telegram-bot-token" type="password" autocomplete="off" placeholder="Bot token">
<button class="btn-command" type="button" onclick="saveNotificationConfig()">
Guardar notificaciones
</button>
</fieldset>
<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,6 +8,7 @@ fi
PROJECT_SOURCE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROJECT_TARGET="/opt/photobioreactor"
CONFIG_TARGET="/etc/photobioreactor"
SERVICE_USER="photobioreactor"
apt-get update
@ -18,6 +19,7 @@ if ! id "${SERVICE_USER}" >/dev/null 2>&1; then
fi
install -d -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${PROJECT_TARGET}"
install -d -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${CONFIG_TARGET}"
cp -a "${PROJECT_SOURCE}/." "${PROJECT_TARGET}/"
rm -rf "${PROJECT_TARGET}/node_modules"
@ -27,6 +29,8 @@ make -C sensors/EZOCommand
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${PROJECT_TARGET}"
install -m 0644 deployment/photobioreactor.env /etc/default/photobioreactor
install -o "${SERVICE_USER}" -g "${SERVICE_USER}" -m 0600 \
config/notifications.json "${CONFIG_TARGET}/notifications.json"
install -m 0644 deployment/photobioreactor-api.service /etc/systemd/system/
install -m 0644 deployment/photobioreactor-acquisition.service /etc/systemd/system/
install -m 0644 deployment/nginx-photobioreactor.conf \

@ -8,15 +8,45 @@ 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');
const temporaryNotifications = path.join(temporaryRoot, 'notifications.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 }
}));
fs.writeFileSync(temporaryNotifications, JSON.stringify({
enabled: false,
minSeverity: 'CRITICAL',
channels: {
webhook: { enabled: false, url: '', headers: {} },
telegram: { enabled: false, botToken: '', chatId: '' }
}
}));
process.env.LOGS_DIRECTORY = temporaryLogs;
process.env.DATA_DIRECTORY = temporaryData;
process.env.RUNTIME_CONFIG_FILE = temporaryRuntime;
process.env.ALARM_CONFIG_FILE = temporaryAlarms;
process.env.NOTIFICATION_CONFIG_FILE = temporaryNotifications;
process.env.EZO_MODE = 'demo';
process.env.API_AUTH_TOKEN = 'test-token';
process.env.API_RATE_LIMIT_WINDOW_MS = '60000';
process.env.API_RATE_LIMIT_MAX = '1000';
const { app, HISTORY_FILES } = require('../api/server');
const { collectReadings, publishReading } = require('../api/acquisition-service');
const { app, HISTORY_FILES, resetRateLimits } = require('../api/server');
const {
collectReadings,
pruneCsvByRetention,
publishReading
} = require('../api/acquisition-service');
const {
dispatchAlarmNotifications,
readNotificationConfig,
writeNotificationConfig
} = require('../api/notification-service');
let server;
let baseUrl;
@ -59,25 +89,94 @@ test('returns real CSV history for all sensors', async () => {
assert.equal(typeof data[0].Valor, 'number');
});
test('all history export respects enabled sensors', async () => {
fs.writeFileSync(
path.join(temporaryLogs, 'temperature.csv'),
'timestamp,value\n2026-06-22T12:00:00.000Z,25.1\n'
);
fs.writeFileSync(
path.join(temporaryLogs, 'ph.csv'),
'timestamp,value\n2026-06-22T12:00:00.000Z,7.2\n'
);
fs.writeFileSync(path.join(temporaryLogs, 'do.csv'), 'timestamp,value\n');
fs.writeFileSync(path.join(temporaryLogs, 'ec.csv'), 'timestamp,value\n');
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 response = await fetch(`${baseUrl}/api/sensors/all`);
const data = await response.json();
assert.equal(response.status, 200);
assert.equal(data.length, 1);
assert.equal(data[0].Sensor, 'RTD');
});
test('serves the dashboard in local development mode', async () => {
const response = await fetch(`${baseUrl}/`);
const html = await response.text();
assert.equal(response.status, 200);
assert.equal(response.headers.get('x-content-type-options'), 'nosniff');
assert.equal(response.headers.get('x-frame-options'), 'DENY');
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);
const wrongTokenResponse = await fetch(`${baseUrl}/api/config/runtime`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'wrong-token'
},
body: JSON.stringify({
rate: 1,
enabledSensors: ['temperature']
})
});
assert.equal(wrongTokenResponse.status, 401);
});
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();
@ -90,7 +189,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();
@ -102,14 +204,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();
@ -123,7 +231,276 @@ test('persists logging rates used by acquisition', async () => {
assert.equal(JSON.parse(fs.readFileSync(temporaryRuntime)).loggingRateSeconds, 5);
});
test('persists enabled sensors used by acquisition', 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: 30,
enabledSensors: ['temperature']
})
});
const data = await response.json();
assert.equal(response.status, 200);
assert.deepEqual(data.enabledSensors, ['temperature']);
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',
'X-API-Token': 'test-token'
},
body: JSON.stringify({
rate: 1,
enabledSensors: []
})
});
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('persists notification configuration with redacted secrets', async () => {
const response = await fetch(`${baseUrl}/api/config/notifications`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({
config: {
enabled: true,
minSeverity: 'WARNING',
channels: {
webhook: {
enabled: true,
url: 'https://example.test/alarm',
headers: { Authorization: 'Bearer secret' }
},
telegram: {
enabled: true,
botToken: '123456:secret',
chatId: '42'
}
}
}
})
});
const data = await response.json();
assert.equal(response.status, 200);
assert.equal(data.config.enabled, true);
assert.equal(data.config.channels.webhook.headers, '[configured]');
assert.equal(data.config.channels.telegram.botToken, '[configured]');
const readResponse = await fetch(`${baseUrl}/api/config/notifications`);
const readData = await readResponse.json();
assert.equal(readData.config.channels.telegram.botToken, '[configured]');
});
test('dispatches webhook notifications for eligible alarm events', async () => {
await writeNotificationConfig({
enabled: true,
minSeverity: 'WARNING',
channels: {
webhook: {
enabled: true,
url: 'https://example.test/alarm',
headers: { Authorization: 'Bearer secret' }
},
telegram: {
enabled: false,
botToken: '',
chatId: ''
}
}
});
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options) => {
calls.push({ url, options });
return { ok: true, status: 200 };
};
try {
const result = await dispatchAlarmNotifications([{
timestamp: '2026-06-27T12:00:00.000Z',
sensorId: 'temperature',
state: 'CRITICAL',
value: 31.5,
message: 'Valor fuera de rango'
}]);
assert.equal(result.length, 1);
assert.equal(result[0].success, true);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, 'https://example.test/alarm');
assert.match(calls[0].options.body, /temperature/);
} finally {
global.fetch = originalFetch;
await writeNotificationConfig({
enabled: false,
minSeverity: 'CRITICAL',
channels: {
webhook: { enabled: false, url: '', headers: {} },
telegram: { enabled: false, botToken: '', chatId: '' }
}
});
}
});
test('preserves existing Telegram token when requested by notification UI', async () => {
await writeNotificationConfig({
enabled: true,
minSeverity: 'CRITICAL',
channels: {
webhook: { enabled: false, url: '', headers: {} },
telegram: {
enabled: true,
botToken: '123456:secret',
chatId: '42'
}
}
});
await writeNotificationConfig({
enabled: true,
minSeverity: 'WARNING',
channels: {
webhook: { enabled: false, url: '', headers: {} },
telegram: {
enabled: true,
botToken: '__KEEP__',
chatId: '43'
}
}
});
const config = await readNotificationConfig();
assert.equal(config.channels.telegram.botToken, '123456:secret');
assert.equal(config.channels.telegram.chatId, '43');
});
test('demo acquisition writes live JSON and CSV files', async () => {
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', 'ph', 'do', 'ec']
})
});
const result = await collectReadings();
assert.equal(result.mode, 'demo');
@ -140,6 +517,93 @@ 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',
'X-API-Token': 'test-token'
},
body: JSON.stringify({
rate: 1,
enabledSensors: ['temperature']
})
});
fs.rmSync(path.join(temporaryLogs, 'ph.csv'), { force: true });
const result = await collectReadings();
const phData = JSON.parse(fs.readFileSync(path.join(temporaryData, 'EZOPH.json')));
assert.equal(result.results.find((item) => item.sensorId === 'ph').disabled, true);
assert.equal(phData.disabled, true);
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');
@ -160,7 +624,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);

@ -14,13 +14,46 @@ test('dashboard starts live polling and exposes required controls', () => {
const dashboard = read('frontend/dashboard.js');
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="notifications-enabled"/);
assert.match(html, /id="webhook-url"/);
assert.match(html, /id="telegram-bot-token"/);
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, /saveNotificationConfig/);
assert.match(dashboard, /\/api\/config\/notifications/);
assert.match(dashboard, /getApiHeaders/);
assert.match(dashboard, /filterPointsForCurrentDay/);
assert.match(
dashboard,
/setInterval\(updateDashboard,\s*POLLING_INTERVAL_MS\)/
);
});
test('api protects critical endpoints with token and rate limiting hooks', () => {
const server = read('api/server.js');
const notifications = read('api/notification-service.js');
assert.match(server, /API_RATE_LIMIT_WINDOW_MS/);
assert.match(server, /API_RATE_LIMIT_MAX/);
assert.match(server, /rateLimitCriticalApi/);
assert.match(server, /Retry-After/);
assert.match(server, /timingSafeEqual/);
assert.match(server, /\/api\/config\/notifications/);
assert.match(notifications, /redactNotificationConfig/);
assert.match(notifications, /sendTelegramNotification/);
assert.match(notifications, /sendWebhookNotification/);
});
test('dashboard HTML has balanced structural containers', () => {
const html = read('frontend/index.html');
const openDivs = (html.match(/<div\b/g) || []).length;
@ -37,6 +70,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