Agregar configuracion de sensores habilitados

main
EMOTIONS-HUNTER 1 month ago
parent 0771ed48c7
commit f94e8864b5

@ -35,6 +35,12 @@ ventana de procesamiento en vez de bloquear un segundo por sensor.
- Chart.js 4.5.1 y SheetJS 0.20.3 instalados localmente. - Chart.js 4.5.1 y SheetJS 0.20.3 instalados localmente.
- Servicios systemd, configuración Nginx e instalador para Raspberry Pi. - 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.
## Modos ## Modos
- `EZO_MODE=demo`: adquisición y comandos simulados. - `EZO_MODE=demo`: adquisición y comandos simulados.

@ -77,15 +77,24 @@ La guía de preparación, detección I2C, calibración y prueba integral está e
## Sensores conectados parcialmente ## Sensores conectados parcialmente
Para pruebas con solo EZO-RTD conectado, limite la adquisicion al sensor de 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 ```bash
EZO_MODE=hardware EZO_ENABLED_SENSORS=temperature npm run acquire EZO_MODE=hardware EZO_ENABLED_SENSORS=temperature npm run acquire
``` ```
En produccion puede dejar `EZO_ENABLED_SENSORS=temperature` en En produccion puede dejar `EZO_ENABLED_SENSORS=temperature` en
`/etc/default/photobioreactor`. Cuando esten conectados los cuatro circuitos, `/etc/default/photobioreactor` como override. Cuando esten conectados los cuatro
deje la variable vacia para consultar RTD, pH, DO y EC. circuitos, deje la variable vacia y habilite RTD, pH, DO y EC desde el panel.
## Archivos de datos ## Archivos de datos

@ -39,6 +39,22 @@ const SENSOR_ALIASES = {
do: 'do', do: 'do',
ec: 'ec' ec: 'ec'
}; };
const DEFAULT_ENABLED_SENSORS = Object.keys(SENSOR_FILES);
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) { async function atomicWriteFile(filePath, content) {
await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
@ -52,26 +68,47 @@ async function readRuntimeConfig() {
const rawConfig = await fsPromises.readFile(RUNTIME_CONFIG_FILE, 'utf8'); const rawConfig = await fsPromises.readFile(RUNTIME_CONFIG_FILE, 'utf8');
const config = JSON.parse(rawConfig); const config = JSON.parse(rawConfig);
const rate = Number(config.loggingRateSeconds); const rate = Number(config.loggingRateSeconds);
const environmentSensors = String(process.env.EZO_ENABLED_SENSORS || '').trim();
return { return {
loggingRateSeconds: ALLOWED_LOGGING_RATES.has(rate) ? rate : 1 loggingRateSeconds: ALLOWED_LOGGING_RATES.has(rate) ? rate : 1,
enabledSensors: environmentSensors
? normalizeEnabledSensors(environmentSensors)
: normalizeEnabledSensors(config.enabledSensors)
}; };
} catch { } catch {
return { loggingRateSeconds: 1 }; return {
loggingRateSeconds: 1,
enabledSensors: normalizeEnabledSensors(process.env.EZO_ENABLED_SENSORS)
};
} }
} }
async function writeRuntimeConfig(rate) { async function writeRuntimeConfig(updates) {
const numericRate = Number(rate); const currentConfig = await readRuntimeConfig();
const numericRate = updates.rate === undefined
? currentConfig.loggingRateSeconds
: Number(updates.rate);
if (!ALLOWED_LOGGING_RATES.has(numericRate)) { 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;
}
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; error.status = 400;
throw error; throw error;
} }
const config = { const config = {
loggingRateSeconds: numericRate, loggingRateSeconds: numericRate,
enabledSensors,
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };
await atomicWriteFile( await atomicWriteFile(
@ -90,15 +127,15 @@ function detectAcquisitionMode() {
if (requestedMode === 'hardware' && !hardwareReady) { if (requestedMode === 'hardware' && !hardwareReady) {
return { return {
mode: 'unavailable', 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)) { 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() { function buildDemoReadings() {
@ -110,12 +147,8 @@ function buildDemoReadings() {
}; };
} }
async function readHardwareSensors() { async function readHardwareSensors(enabledSensors) {
const enabledSensors = String(process.env.EZO_ENABLED_SENSORS || '') const helperArguments = ['/dev/i2c-1', ...enabledSensors];
.split(',')
.map((sensor) => SENSOR_ALIASES[sensor.trim().toLowerCase()])
.filter(Boolean);
const helperArguments = ['/dev/i2c-1', ...new Set(enabledSensors)];
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync(
ACQUISITION_HELPER, ACQUISITION_HELPER,
helperArguments, helperArguments,
@ -124,7 +157,7 @@ async function readHardwareSensors() {
const readings = JSON.parse(stdout.trim()); const readings = JSON.parse(stdout.trim());
if (!readings || typeof readings !== 'object') { 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; return readings;
@ -179,30 +212,59 @@ async function publishReading(sensorId, rawValue, timestamp, mode) {
return true; 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() { async function collectReadings() {
const modeInfo = detectAcquisitionMode(); const modeInfo = detectAcquisitionMode();
const runtimeConfig = await readRuntimeConfig();
const enabledSensorSet = new Set(runtimeConfig.enabledSensors);
if (modeInfo.mode === 'unavailable') { if (modeInfo.mode === 'unavailable') {
throw new Error(modeInfo.message); throw new Error(modeInfo.message);
} }
const readings = modeInfo.mode === 'hardware' const readings = modeInfo.mode === 'hardware'
? await readHardwareSensors() ? await readHardwareSensors(runtimeConfig.enabledSensors)
: buildDemoReadings(); : buildDemoReadings();
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const results = await Promise.all( const results = await Promise.all(
Object.keys(SENSOR_FILES).map(async (sensorId) => ({ Object.keys(SENSOR_FILES).map(async (sensorId) => {
if (!enabledSensorSet.has(sensorId)) {
await publishDisabled(sensorId, timestamp);
return { sensorId, online: false, disabled: true };
}
return {
sensorId, sensorId,
online: await publishReading( online: await publishReading(
sensorId, sensorId,
readings[sensorId], readings[sensorId],
timestamp, timestamp,
modeInfo.mode modeInfo.mode
) ),
})) disabled: false
};
})
); );
return { mode: modeInfo.mode, timestamp, results }; return {
mode: modeInfo.mode,
timestamp,
results,
enabledSensors: runtimeConfig.enabledSensors
};
} }
async function publishAllOffline(error) { async function publishAllOffline(error) {
@ -226,11 +288,13 @@ async function publishAllOffline(error) {
module.exports = { module.exports = {
ACQUISITION_HELPER, ACQUISITION_HELPER,
ALLOWED_LOGGING_RATES, ALLOWED_LOGGING_RATES,
DEFAULT_ENABLED_SENSORS,
RUNTIME_CONFIG_FILE, RUNTIME_CONFIG_FILE,
SENSOR_FILES, SENSOR_FILES,
atomicWriteFile, atomicWriteFile,
collectReadings, collectReadings,
detectAcquisitionMode, detectAcquisitionMode,
normalizeEnabledSensors,
publishAllOffline, publishAllOffline,
publishReading, publishReading,
readRuntimeConfig, readRuntimeConfig,

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

@ -30,6 +30,12 @@ const HISTORY_SENSOR_MAP = {
do: { file: 'do.csv', name: 'DO' }, do: { file: 'do.csv', name: 'DO' },
ec: { file: 'ec.csv', name: 'EC' } ec: { file: 'ec.csv', name: 'EC' }
}; };
const HISTORY_SENSOR_IDS = {
temperature: 'rtd',
ph: 'ph',
do: 'do',
ec: 'ec'
};
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
@ -120,8 +126,12 @@ app.get('/api/sensors/:type', async (req, res) => {
try { try {
if (sensorType === 'all') { if (sensorType === 'all') {
const runtimeConfig = await readRuntimeConfig();
const enabledHistorySensors = runtimeConfig.enabledSensors
.map((sensorId) => HISTORY_SENSOR_IDS[sensorId])
.filter(Boolean);
const histories = await Promise.all( const histories = await Promise.all(
Object.keys(HISTORY_SENSOR_MAP).map(readSensorHistory) enabledHistorySensors.map(readSensorHistory)
); );
return res.json( return res.json(
histories.flat().sort((left, right) => histories.flat().sort((left, right) =>
@ -158,12 +168,16 @@ app.post('/api/sensors/:type/command', async (req, res) => {
app.get('/api/config/logging', async (req, res) => { app.get('/api/config/logging', async (req, res) => {
const config = await readRuntimeConfig(); const config = await readRuntimeConfig();
res.json({ success: true, rate: config.loggingRateSeconds }); res.json({
success: true,
rate: config.loggingRateSeconds,
enabledSensors: config.enabledSensors
});
}); });
app.post('/api/config/logging', async (req, res) => { app.post('/api/config/logging', async (req, res) => {
try { try {
const config = await writeRuntimeConfig(req.body.rate); const config = await writeRuntimeConfig({ rate: req.body.rate });
console.log( console.log(
`[CONFIG] Frecuencia de adquisición: ${config.loggingRateSeconds}s` `[CONFIG] Frecuencia de adquisición: ${config.loggingRateSeconds}s`
); );
@ -180,6 +194,39 @@ app.post('/api/config/logging', async (req, res) => {
} }
}); });
app.get('/api/config/runtime', async (req, res) => {
const config = await readRuntimeConfig();
res.json({
success: true,
rate: config.loggingRateSeconds,
enabledSensors: config.enabledSensors,
availableSensors: Object.keys(HISTORY_FILES)
});
});
app.post('/api/config/runtime', async (req, res) => {
try {
const config = await writeRuntimeConfig({
rate: req.body.rate,
enabledSensors: req.body.enabledSensors
});
console.log(
`[CONFIG] Sensores activos: ${config.enabledSensors.join(', ')}`
);
res.json({
success: true,
rate: config.loggingRateSeconds,
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', async (req, res) => { app.post('/api/history/clear', async (req, res) => {
try { try {
await fs.mkdir(LOGS_DIRECTORY, { recursive: true }); await fs.mkdir(LOGS_DIRECTORY, { recursive: true });

@ -1,3 +1,6 @@
{ {
"loggingRateSeconds": 1 "loggingRateSeconds": 1,
"enabledSensors": [
"temperature"
]
} }

@ -129,6 +129,11 @@ h1 {
border-color: rgba(125, 135, 144, 0.45); border-color: rgba(125, 135, 144, 0.45);
} }
.metric-card.disabled {
border-color: rgba(125, 135, 144, 0.28);
background: #f8faf9;
}
.metric-card.normal { .metric-card.normal {
border-color: rgba(22, 138, 74, 0.45); border-color: rgba(22, 138, 74, 0.45);
} }
@ -185,6 +190,10 @@ h1 {
color: var(--offline-neutral); color: var(--offline-neutral);
} }
.metric-state.disabled {
color: var(--offline-neutral);
}
.metric-reading { .metric-reading {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@ -307,6 +316,10 @@ h1 {
color: var(--offline-neutral); color: var(--offline-neutral);
} }
.sensor-health .disabled {
color: var(--offline-neutral);
}
.sensor-health .warning { .sensor-health .warning {
color: var(--warning); color: var(--warning);
} }
@ -544,6 +557,29 @@ h1 {
color: var(--muted) !important; 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;
}
.terminal-card, .terminal-card,
.calibration-card { .calibration-card {
overflow: hidden; overflow: hidden;

@ -13,13 +13,17 @@ let dashboardUpdateInProgress = false;
const stateTranslations = { const stateTranslations = {
"NORMAL": "NORMAL", "NORMAL": "NORMAL",
"OFFLINE": "DESCONECTADO", "OFFLINE": "DESCONECTADO",
"DISABLED": "DESHABILITADO",
"WARNING": "ADVERTENCIA", "WARNING": "ADVERTENCIA",
"CRITICAL": "CRÍTICO" "CRITICAL": "CRÍTICO"
}; };
let currentEnabledSensorIds = new Set(["temperature", "ph", "do", "ec"]);
const sensors = [ const sensors = [
{ {
id: "temperature", id: "temperature",
sensorId: "temperature",
name: "Temperatura", name: "Temperatura",
file: "../data/EZORTD.json", file: "../data/EZORTD.json",
key: "temperature", key: "temperature",
@ -73,6 +77,7 @@ const historicalCharts = [
}, },
{ {
id: "ph-history", id: "ph-history",
sensorId: "ph",
title: "pH vs Tiempo", title: "pH vs Tiempo",
file: "../logs/ph.csv", file: "../logs/ph.csv",
valueKey: "ph", valueKey: "ph",
@ -83,6 +88,7 @@ const historicalCharts = [
}, },
{ {
id: "do-history", id: "do-history",
sensorId: "do",
title: "Oxígeno Disuelto vs Tiempo", title: "Oxígeno Disuelto vs Tiempo",
file: "../logs/do.csv", file: "../logs/do.csv",
valueKey: "do", valueKey: "do",
@ -93,6 +99,7 @@ const historicalCharts = [
}, },
{ {
id: "ec-history", id: "ec-history",
sensorId: "ec",
title: "Conductividad vs Tiempo", title: "Conductividad vs Tiempo",
file: "../logs/ec.csv", file: "../logs/ec.csv",
valueKey: "ec", valueKey: "ec",
@ -106,6 +113,16 @@ const historicalCharts = [
const chartInstances = new Map(); const chartInstances = new Map();
async function readSensor(sensor) { async function readSensor(sensor) {
if (!currentEnabledSensorIds.has(sensor.id)) {
return {
...sensor,
disabled: true,
online: false,
numericValue: null,
value: "--"
};
}
try { try {
const response = await fetch(`${sensor.file}?t=${Date.now()}`, { const response = await fetch(`${sensor.file}?t=${Date.now()}`, {
cache: "no-store" cache: "no-store"
@ -138,6 +155,7 @@ async function readSensor(sensor) {
return { return {
...sensor, ...sensor,
disabled: false,
online: true, online: true,
numericValue: rawValue, numericValue: rawValue,
timestamp: data.timestamp || null, timestamp: data.timestamp || null,
@ -148,6 +166,7 @@ async function readSensor(sensor) {
return { return {
...sensor, ...sensor,
disabled: false,
online: false, online: false,
numericValue: null, numericValue: null,
value: "DESCONECTADO" value: "DESCONECTADO"
@ -166,25 +185,27 @@ function setSensorState(result) {
// Traducir el estado para mostrar en pantalla, manteniendo la clase en inglés // Traducir el estado para mostrar en pantalla, manteniendo la clase en inglés
stateElement.textContent = stateTranslations[state] || state; 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); stateElement.classList.toggle(className, className === stateClass);
cardElement.classList.toggle(className, className === stateClass); cardElement.classList.toggle(className, className === stateClass);
}); });
} }
function renderSystemStatus(results) { function renderSystemStatus(results) {
const activeSensors = results.filter((result) => result.online).length; const enabledResults = results.filter((result) => !result.disabled);
const offlineSensors = results.length - activeSensors; const activeSensors = enabledResults.filter((result) => result.online).length;
const offlineSensors = enabledResults.length - activeSensors;
const criticalSensors = results.filter((result) => result.alarmState === "CRITICAL").length; const criticalSensors = results.filter((result) => result.alarmState === "CRITICAL").length;
const warningSensors = results.filter((result) => result.alarmState === "WARNING").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 anyOnline = activeSensors > 0;
const overallDot = document.getElementById("overall-dot"); const overallDot = document.getElementById("overall-dot");
const overallStatus = document.getElementById("overall-status"); const overallStatus = document.getElementById("overall-status");
const healthList = document.getElementById("sensor-health"); const healthList = document.getElementById("sensor-health");
document.getElementById("active-count").textContent = document.getElementById("active-count").textContent =
`${activeSensors} / ${results.length}`; `${activeSensors} / ${enabledResults.length}`;
document.getElementById("offline-count").textContent = String(offlineSensors); document.getElementById("offline-count").textContent = String(offlineSensors);
document.getElementById("last-update").textContent = document.getElementById("last-update").textContent =
new Date().toLocaleString(); new Date().toLocaleString();
@ -230,10 +251,12 @@ async function updateDashboard() {
dashboardUpdateInProgress = true; dashboardUpdateInProgress = true;
try { try {
const [results, alarmConfig] = await Promise.all([ const [runtimeConfig, alarmConfig] = await Promise.all([
Promise.all(sensors.map(readSensor)), readRuntimeConfig(),
readAlarmConfig() readAlarmConfig()
]); ]);
currentEnabledSensorIds = new Set(runtimeConfig.enabledSensors);
const results = await Promise.all(sensors.map(readSensor));
const evaluatedResults = results.map((result) => const evaluatedResults = results.map((result) =>
evaluateSensorAlarm(result, alarmConfig.thresholds) evaluateSensorAlarm(result, alarmConfig.thresholds)
); );
@ -246,6 +269,25 @@ async function updateDashboard() {
} }
} }
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 readAlarmConfig() { async function readAlarmConfig() {
try { try {
const response = await fetch(`${ALARM_CONFIG_FILE}?t=${Date.now()}`, { const response = await fetch(`${ALARM_CONFIG_FILE}?t=${Date.now()}`, {
@ -273,6 +315,14 @@ async function readAlarmConfig() {
} }
function evaluateSensorAlarm(sensorResult, thresholds) { function evaluateSensorAlarm(sensorResult, thresholds) {
if (sensorResult.disabled) {
return {
...sensorResult,
alarmState: "DISABLED",
alarmMessage: "Sensor no habilitado en la configuracion actual"
};
}
if (!sensorResult.online) { if (!sensorResult.online) {
return { return {
...sensorResult, ...sensorResult,
@ -342,7 +392,7 @@ function buildAlarmMessage(sensorResult, limits, alarmType) {
function getAlarmEvents(results) { function getAlarmEvents(results) {
return results return results
.filter((result) => result.alarmState !== "NORMAL") .filter((result) => !["NORMAL", "DISABLED"].includes(result.alarmState))
.map((result) => ({ .map((result) => ({
sensorId: result.id, sensorId: result.id,
sensorName: result.name, sensorName: result.name,
@ -509,13 +559,28 @@ function formatTimestamp(rawTimestamp) {
return rawTimestamp; return rawTimestamp;
} }
function setChartAvailability(chartConfig, hasData) { function setChartAvailability(chartConfig, hasData, message = "No hay datos históricos disponibles") {
const emptyElement = document.getElementById(chartConfig.emptyElement); const emptyElement = document.getElementById(chartConfig.emptyElement);
const frameElement = emptyElement.closest(".chart-frame"); const frameElement = emptyElement.closest(".chart-frame");
emptyElement.textContent = message;
frameElement.classList.toggle("empty", !hasData); 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) { function buildChartDataset(chartConfig, points) {
return { return {
labels: points.map((point) => point.label), labels: points.map((point) => point.label),
@ -605,14 +670,23 @@ function renderHistoricalChart(chartConfig, points) {
} }
async function updateHistoricalTrends() { async function updateHistoricalTrends() {
const runtimeConfig = await readRuntimeConfig();
currentEnabledSensorIds = new Set(runtimeConfig.enabledSensors);
const chartData = await Promise.all( const chartData = await Promise.all(
historicalCharts.map(async (chartConfig) => ({ historicalCharts.map(async (chartConfig) => ({
chartConfig, chartConfig,
points: await readHistoricalData(chartConfig) points: currentEnabledSensorIds.has(chartConfig.sensorId)
? await readHistoricalData(chartConfig)
: []
})) }))
); );
chartData.forEach(({ chartConfig, points }) => { chartData.forEach(({ chartConfig, points }) => {
if (!currentEnabledSensorIds.has(chartConfig.sensorId)) {
clearHistoricalChart(chartConfig, "Sensor deshabilitado");
return;
}
renderHistoricalChart(chartConfig, points); renderHistoricalChart(chartConfig, points);
}); });
} }
@ -669,11 +743,15 @@ window.updateLoggingRate = async function () {
async function loadLoggingRate() { async function loadLoggingRate() {
try { 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(); const result = await response.json();
if (!response.ok) return; if (!response.ok) return;
currentLoggingRateSeconds = result.rate; currentLoggingRateSeconds = result.rate;
if (Array.isArray(result.enabledSensors)) {
currentEnabledSensorIds = new Set(result.enabledSensors);
setEnabledSensorControls(result.enabledSensors);
}
document.getElementById("data-logging-rate").value = String(result.rate); document.getElementById("data-logging-rate").value = String(result.rate);
document.getElementById("sampling-interval").textContent = document.getElementById("sampling-interval").textContent =
`${result.rate} ${result.rate === 1 ? "segundo" : "segundos"}`; `${result.rate} ${result.rate === 1 ? "segundo" : "segundos"}`;
@ -682,6 +760,51 @@ async function loadLoggingRate() {
} }
} }
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: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rate: currentLoggingRateSeconds,
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(); loadLoggingRate();
window.clearHistoricalData = async function () { window.clearHistoricalData = async function () {

@ -2,6 +2,7 @@
async function handleExportExcel() { async function handleExportExcel() {
try { try {
const enabledSensors = await getEnabledSensorsForExport();
console.log("[MOCK] Iniciando generación de reporte Excel..."); console.log("[MOCK] Iniciando generación de reporte Excel...");
// 1. Crear un nuevo libro de trabajo (Workbook) en blanco // 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 // Definimos los sensores y el nombre que tendrá cada pestaña en el Excel
const sensors = [ const sensors = [
{ id: 'rtd', sheetName: 'Temperatura_RTD' }, { id: 'rtd', sensorId: 'temperature', sheetName: 'Temperatura_RTD' },
{ id: 'ph', sheetName: 'Sensor_pH' }, { id: 'ph', sensorId: 'ph', sheetName: 'Sensor_pH' },
{ id: 'do', sheetName: 'Oxigeno_DO' }, { id: 'do', sensorId: 'do', sheetName: 'Oxigeno_DO' },
{ id: 'ec', sheetName: 'Conductividad_EC' } { 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 // 2. Iterar sobre cada sensor, obtener sus datos y crear su hoja
for (const sensor of sensors) { for (const sensor of sensors) {

@ -26,11 +26,39 @@ function escapeCsvValue(value) {
return `"${text.replace(/"/g, '""')}"`; 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 * Manejador principal del evento click de los botones
*/ */
async function handleExport(sensorType) { async function handleExport(sensorType) {
try { try {
await assertSensorExportEnabled(sensorType);
// 1. Obtener datos (Mock actual, fetch real en el futuro) // 1. Obtener datos (Mock actual, fetch real en el futuro)
const data = await fetchHistoricalData(sensorType); const data = await fetchHistoricalData(sensorType);

@ -162,6 +162,17 @@
</select> </select>
</div> </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 style="margin-top: auto;"> <div style="margin-top: auto;">
<button class="btn-command" onclick="clearHistoricalData()" <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;"> style="padding: 8px 15px; background-color: #ff4444; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">

@ -59,6 +59,35 @@ test('returns real CSV history for all sensors', async () => {
assert.equal(typeof data[0].Valor, 'number'); 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' },
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 () => { test('serves the dashboard in local development mode', async () => {
const response = await fetch(`${baseUrl}/`); const response = await fetch(`${baseUrl}/`);
const html = await response.text(); const html = await response.text();
@ -123,7 +152,47 @@ test('persists logging rates used by acquisition', async () => {
assert.equal(JSON.parse(fs.readFileSync(temporaryRuntime)).loggingRateSeconds, 5); 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' },
body: JSON.stringify({
rate: 1,
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']);
});
test('rejects empty enabled sensor configuration', async () => {
const response = await fetch(`${baseUrl}/api/config/runtime`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rate: 1,
enabledSensors: []
})
});
assert.equal(response.status, 400);
});
test('demo acquisition writes live JSON and CSV files', async () => { test('demo acquisition writes live JSON and CSV files', async () => {
await fetch(`${baseUrl}/api/config/runtime`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rate: 1,
enabledSensors: ['temperature', 'ph', 'do', 'ec']
})
});
const result = await collectReadings(); const result = await collectReadings();
assert.equal(result.mode, 'demo'); assert.equal(result.mode, 'demo');
@ -140,6 +209,25 @@ 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' },
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('hardware null readings are marked offline and not appended as zero', async () => { test('hardware null readings are marked offline and not appended as zero', async () => {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const csvPath = path.join(temporaryLogs, 'ph.csv'); const csvPath = path.join(temporaryLogs, 'ph.csv');

@ -14,7 +14,10 @@ test('dashboard starts live polling and exposes required controls', () => {
const dashboard = read('frontend/dashboard.js'); const dashboard = read('frontend/dashboard.js');
assert.match(html, /id="history-interval"/); assert.match(html, /id="history-interval"/);
assert.match(html, /name="enabled-sensor"/);
assert.match(dashboard, /updateDashboard\(\);/); assert.match(dashboard, /updateDashboard\(\);/);
assert.match(dashboard, /updateEnabledSensors/);
assert.match(dashboard, /\/api\/config\/runtime/);
assert.match( assert.match(
dashboard, dashboard,
/setInterval\(updateDashboard,\s*POLLING_INTERVAL_MS\)/ /setInterval\(updateDashboard,\s*POLLING_INTERVAL_MS\)/

Loading…
Cancel
Save