diff --git a/CHANGELOG.md b/CHANGELOG.md index ae74acb..60456ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ y este proyecto se adhiere a [Versionado Semántico](https://semver.org/lang/es/ ### Changed +- **`corte_red` desactivado por defecto (issue #19)**: la alerta de corte de red daba falsos críticos porque la firma diferencial no es fiable con los sensores actuales (la pinza del circuito no respaldado está casi siempre a ~0 W por consumo bajo, y el scraper no expone tensión de red ni flag de EPS). Se mantiene `inversor_offline` (apagón total) y `fox_offline`. Para rehabilitarla en el futuro: `install_config.corteRedEnabled = true`; al disparar, el `audit_log` guarda ahora los datos del disparo (gridMag, respaldoKw, noRespaldadaKw, batteryPower, fresco). - **Migración del toolchain de build**: Vite 7 → 8 (Rolldown), @vitejs/plugin-react 5 → 6, Tailwind CSS 3 → 4 (config en CSS vía `@theme` + `tw-animate-css`, plugin Vite `@tailwindcss/vite`). - **React Router 7 → 8.3.0**: imports migrados de `react-router-dom` a `react-router`. - **Backend**: @hono/node-server 1.x → 2.1.0. diff --git a/server/src/alerts.js b/server/src/alerts.js index 6badf84..7e2a7e2 100644 --- a/server/src/alerts.js +++ b/server/src/alerts.js @@ -86,6 +86,23 @@ export function createAlertsEngine({ db, ha, solar, notifyFn = notifyAll }) { } } + // corte_red NO es detectable de forma fiable con los sensores actuales: la + // pinza del circuito NO respaldado está casi siempre a ~0 W por consumo bajo + // (no por corte), y el scraper no expone tensión de red ni flag de EPS/isla. + // Firma observada en producción (9-Ago-2026): disparos que siguen la ventana + // de descarga de la batería en autoconsumo nublado normal, sin evento de red. + // → Desactivado por defecto (9-Ago-2026, issue #19). `inversor_offline` + // cubre un apagón total y `fox_offline` la pérdida de la pinza del Fox. + // Para rehabilitarlo: `install_config.corteRedEnabled = true` (kv). + function corteRedEnabled() { + try { + const cfg = JSON.parse(kvGet(db, 'install_config') || '{}') + return cfg.corteRedEnabled === true + } catch { + return false + } + } + function tick() { if (!ha.connected) return // sin HAOS no hay datos fiables; la alerta HAOS ya se ve por SSE const live = solar.computeLive(ha) @@ -132,6 +149,9 @@ export function createAlertsEngine({ db, ha, solar, notifyFn = notifyAll }) { } // ── Corte de red (firma diferencial de pinzas, ver cabecera) ─────── + // Desactivado por defecto: ver corteRedEnabled(). El bloque solo se evalúa + // si el flag `install_config.corteRedEnabled` está en true. + if (corteRedEnabled()) { const gridMag = Math.abs(Number(attrs.currentGridPower)) || 0 const respaldoKw = live.respaldoKw ?? 0 const noRespaldadaKw = live.noRespaldadaKw ?? 0 @@ -151,7 +171,7 @@ export function createAlertsEngine({ db, ha, solar, notifyFn = notifyAll }) { estado.red.ok = 0 if (!estado.red.alertado && estado.red.mal >= TICKS_CORTE_RED) { estado.red.alertado = true - disparar('corte_red', {}, { severity: 'critical' }) + disparar('corte_red', { gridMag, respaldoKw, noRespaldadaKw, batteryPower: live.batteryPower, fresco }, { severity: 'critical' }) } } else { estado.red.mal = 0 @@ -160,10 +180,11 @@ export function createAlertsEngine({ db, ha, solar, notifyFn = notifyAll }) { if (estado.red.ok >= TICKS_RED_RECUPERADA) { estado.red.alertado = false estado.red.ok = 0 - disparar('corte_red_ok', {}, { severity: 'normal' }) + disparar('corte_red_ok', { gridMag, respaldoKw, noRespaldadaKw, batteryPower: live.batteryPower, fresco }, { severity: 'normal' }) } } } + } // ── Batería baja (SOC ≤ reserva) ──────────────────────────────────── const reserva = reservaPct() diff --git a/server/test/push.test.js b/server/test/push.test.js index 0543c64..378bf69 100644 --- a/server/test/push.test.js +++ b/server/test/push.test.js @@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import Database from 'better-sqlite3' import webpush from 'web-push' import { initSchema } from '../src/db.js' +import { kvGet, kvSet } from '../src/db.js' import { configurePush, notifyUsers, flushNotificationQueue, _setSendFn, _resetForTests } from '../src/push.js' import { createAlertsEngine, enviarResumenDiario, proximoEnvioResumen, ejecutarResumenSiToca, RESUMEN_OFFSET_MIN, HORA_RESUMEN_DIARIO } from '../src/alerts.js' @@ -173,6 +174,19 @@ function capturaNotifs() { return { llamadas, notifyFn: (db, tipo, datos, opciones) => llamadas.push({ tipo, datos, opciones }) } } +// corte_red está desactivado por defecto (issue #19): los tests de la heurística +// lo habilitan vía install_config.corteRedEnabled. +function habilitaCorteRed() { + const prev = (() => { + try { + return JSON.parse(kvGet(db, 'install_config') || '{}') + } catch { + return {} + } + })() + kvSet(db, 'install_config', JSON.stringify({ ...prev, corteRedEnabled: true })) +} + describe('motor de alertas', () => { it('inversor offline: dispara al 2º tick, no repite, y avisa al recuperar', () => { const { llamadas, notifyFn } = capturaNotifs() @@ -285,6 +299,7 @@ describe('motor de alertas', () => { }) it('corte de red: firma sostenida 3 ticks dispara crítica y recupera a los 2', () => { + habilitaCorteRed() const { llamadas, notifyFn } = capturaNotifs() // Corte real: el circuito NO respaldado cae (~0), el respaldado sigue por EPS+batería const ha = mockHa({ gridPower: 0 }) @@ -305,6 +320,7 @@ describe('motor de alertas', () => { }) it('corte de red: NO dispara con scraper antiguo (>15 min)', () => { + habilitaCorteRed() const { llamadas, notifyFn } = capturaNotifs() const viejo = new Date(Date.now() - 30 * 60000).toISOString() const ha = mockHa({ gridPower: 0, lastUpdate: viejo }) @@ -323,6 +339,7 @@ describe('motor de alertas', () => { it('corte de red: NO dispara en autoconsumo (grid=0 pero ambas pinzas >0)', () => { // El falso positivo canónico: casa autoabasteciéndose con red presente + habilitaCorteRed() const { llamadas, notifyFn } = capturaNotifs() const ha = mockHa({ gridPower: 0 }) const engine = createAlertsEngine({ @@ -338,6 +355,7 @@ describe('motor de alertas', () => { it('corte de red: NO dispara en peak-shaving (batería descargando, grid=0, red presente)', () => { // El inversor descarga batería para no comprar en franja cara: grid≈0, // batería descargando — antes disparaba, ahora no + habilitaCorteRed() const { llamadas, notifyFn } = capturaNotifs() const ha = mockHa({ gridPower: 0 }) const engine = createAlertsEngine({ @@ -353,6 +371,7 @@ describe('motor de alertas', () => { it('corte de red: NO dispara cuando el Fox pierde Modbus (incidente tipo 12-Jul-2025)', () => { // El Fox reporta r_volt≈0 durante horas, pero la vivienda sigue consumiendo: // caída de comms del Fox, no corte eléctrico. La firma diferencial lo descarta. + habilitaCorteRed() const { llamadas, notifyFn } = capturaNotifs() const ha = mockHa({ gridPower: 0 }) const engine = createAlertsEngine({ @@ -367,6 +386,7 @@ describe('motor de alertas', () => { it('corte de red: NO dispara en apagón total sin EPS (ambas pinzas a 0)', () => { // Sin EPS, todo se apaga; esta alerta no es la responsable (la cubre inversor_offline) + habilitaCorteRed() const { llamadas, notifyFn } = capturaNotifs() const ha = mockHa({ gridPower: 0 }) const engine = createAlertsEngine({ @@ -384,6 +404,7 @@ describe('motor de alertas', () => { // reportando 0 W mientras la red está perfecta (Fox on-grid, calentador a // 232 V) y la batería carga desde FV. La firma diferencial por sí sola // dispararía un crítico falso; la cláusula batteryPower < -0.05 lo filtra. + habilitaCorteRed() const { llamadas, notifyFn } = capturaNotifs() const ha = mockHa({ gridPower: 0 }) const engine = createAlertsEngine({ @@ -396,6 +417,30 @@ describe('motor de alertas', () => { expect(llamadas).toHaveLength(0) }) + it('corte de red: DESACTIVADO por defecto (issue #19), no dispara aunque la firma se cumpla', () => { + // Sin install_config.corteRedEnabled → la heurística no se evalúa: ni con la + // firma de "corte real" ni con la del falso positivo. Sin flags → 0 alarmas. + const { llamadas, notifyFn } = capturaNotifs() + const ha = mockHa({ gridPower: 0 }) + const solar = mockSolar({ respaldoKw: 0.4, noRespaldadaKw: 0.02, consumption: 0.42, batteryPower: -0.3 }) + const engine = createAlertsEngine({ db, ha, solar, notifyFn }) + for (let i = 0; i < 8; i++) engine.tick() + expect(llamadas).toHaveLength(0) + }) + + it('corte de red: habilitado, dispara con los datos del disparo en el audit', () => { + habilitaCorteRed() + const { llamadas, notifyFn } = capturaNotifs() + const ha = mockHa({ gridPower: 0 }) + const solar = mockSolar({ respaldoKw: 0.4, noRespaldadaKw: 0.02, consumption: 0.42, batteryPower: -0.3 }) + const engine = createAlertsEngine({ db, ha, solar, notifyFn }) + engine.tick() + engine.tick() + engine.tick() + expect(llamadas.map((l) => l.tipo)).toEqual(['corte_red']) + expect(llamadas[0].datos).toMatchObject({ gridMag: 0, respaldoKw: 0.4, noRespaldadaKw: 0.02, batteryPower: -0.3, fresco: true }) + }) + it('batería baja: dispara al cruzar la reserva y se rearma con histéresis', () => { const { llamadas, notifyFn } = capturaNotifs() const solar = mockSolar({ soc: 19 })