Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 23 additions & 2 deletions server/src/alerts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand Down
45 changes: 45 additions & 0 deletions server/test/push.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 })
Expand All @@ -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 })
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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 })
Expand Down
Loading