Skip to content

Commit f1bea7f

Browse files
committed
fix(push): inversor_offline solo de dia y con scraper fresco
El inversor se apaga cada noche (normal): evaluar inverterOnline 24/7 generaba falso positivo nocturno. Ahora solo se evalua con sun.sun above_horizon (fail-open si falta) y lastUpdate <15 min — alertar con datos stale es avisar sin informacion nueva. La recuperacion nocturna se notifica al amanecer. Tests 30/30. v0.3.1
1 parent 2fefa14 commit f1bea7f

4 files changed

Lines changed: 75 additions & 15 deletions

File tree

‎app/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "helios",
33
"private": true,
4-
"version": "0.3.0",
4+
"version": "0.3.1",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

‎server/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "helios-server",
3-
"version": "0.3.0",
3+
"version": "0.3.1",
44
"private": true,
55
"type": "module",
66
"engines": {

‎server/src/alerts.js‎

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
// Triggers (decisión del usuario 2-Ago-2026):
99
// - inversor_offline / inversor_ok: scraper.attributes.inverterOnline === 0,
1010
// sostenido 2 ticks (2 min) para no avisar por un hueco puntual del scraper.
11+
// SOLO se evalúa con scraper fresco (<15 min) y de DÍA (sun.sun
12+
// above_horizon): el inversor se apaga cada noche (decisión del usuario:
13+
// "está offline todas las noches") y alertar con datos viejos es avisar sin
14+
// información nueva (2-Ago: datos stale desde las 23:33 toda la noche).
15+
// Si sun.sun no está disponible se evalúa igualmente (fail-open: es una
16+
// alerta crítica y sun.sun es de las entidades más fiables de HAOS).
1117
// - corte_red / corte_red_ok: HEURÍSTICA — con red presente la pinza de red
1218
// oscila siempre (import/export); en isla (backup Solis) grid ≈ 0 sostenido
1319
// mientras la casa sigue consumiendo desde batería/PV. Firma: scraper fresco
@@ -52,19 +58,23 @@ export function createAlertsEngine({ db, ha, solar, notifyFn = notifyAll }) {
5258
const lastUpd = attrs.lastUpdate ? new Date(attrs.lastUpdate).getTime() : null
5359
const fresco = lastUpd !== null && Date.now() - lastUpd < 15 * 60000
5460

55-
// ── Inversor offline ────────────────────────────────────────────────
56-
if (attrs.inverterOnline === 0) {
57-
estado.inversor.mal++
58-
if (!estado.inversor.alertado && estado.inversor.mal >= TICKS_INVERSOR_OFFLINE) {
59-
estado.inversor.alertado = true
60-
notifyFn(db, 'inversor_offline', {}, { severity: 'critical' })
61-
}
62-
} else {
63-
if (estado.inversor.alertado) {
64-
estado.inversor.alertado = false
65-
notifyFn(db, 'inversor_ok', {}, { severity: 'normal' })
61+
// ── Inversor offline: solo de día y con scraper fresco ──────────────
62+
const sun = ha.getState(ENTITIES.sun)
63+
const deDia = !sun || sun.state === 'above_horizon' // fail-open si falta sun.sun
64+
if (fresco && deDia) {
65+
if (attrs.inverterOnline === 0) {
66+
estado.inversor.mal++
67+
if (!estado.inversor.alertado && estado.inversor.mal >= TICKS_INVERSOR_OFFLINE) {
68+
estado.inversor.alertado = true
69+
notifyFn(db, 'inversor_offline', {}, { severity: 'critical' })
70+
}
71+
} else {
72+
if (estado.inversor.alertado) {
73+
estado.inversor.alertado = false
74+
notifyFn(db, 'inversor_ok', {}, { severity: 'normal' })
75+
}
76+
estado.inversor.mal = 0
6677
}
67-
estado.inversor.mal = 0
6878
}
6979

7080
// ── Corte de red (heurística, ver cabecera) ─────────────────────────

‎server/test/push.test.js‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,14 @@ describe('motor notifyUsers', () => {
146146

147147
// --- Motor de alertas (flancos y anti-rebote) --------------------------------
148148

149-
function mockHa({ inverterOnline = 1, gridPower = 0.5, lastUpdate = new Date().toISOString() } = {}) {
149+
function mockHa({ inverterOnline = 1, gridPower = 0.5, lastUpdate = new Date().toISOString(), sunState = 'above_horizon' } = {}) {
150150
return {
151151
connected: true,
152152
getState: (id) => {
153153
if (id.endsWith('scraper')) {
154154
return { state: 'ok', attributes: { inverterOnline, currentGridPower: gridPower, lastUpdate } }
155155
}
156+
if (id === 'sun.sun') return { state: sunState, attributes: {} }
156157
return null
157158
},
158159
}
@@ -201,6 +202,55 @@ describe('motor de alertas', () => {
201202
expect(llamadas).toHaveLength(2)
202203
})
203204

205+
it('inversor: NO dispara de noche (sun below_horizon) aunque esté offline', () => {
206+
const { llamadas, notifyFn } = capturaNotifs()
207+
const engine = createAlertsEngine({
208+
db,
209+
ha: mockHa({ inverterOnline: 0, sunState: 'below_horizon' }),
210+
solar: mockSolar(),
211+
notifyFn,
212+
})
213+
engine.tick()
214+
engine.tick()
215+
engine.tick()
216+
engine.tick()
217+
expect(llamadas).toHaveLength(0)
218+
})
219+
220+
it('inversor: NO dispara con scraper antiguo (>15 min) ni reevalúa hasta que refresca', () => {
221+
const { llamadas, notifyFn } = capturaNotifs()
222+
const viejo = new Date(Date.now() - 30 * 60000).toISOString()
223+
const ha = mockHa({ inverterOnline: 0, lastUpdate: viejo })
224+
const engine = createAlertsEngine({ db, ha, solar: mockSolar(), notifyFn })
225+
engine.tick()
226+
engine.tick()
227+
engine.tick()
228+
expect(llamadas).toHaveLength(0)
229+
// El scraper refresca y sigue offline de día: ahora sí, a los 2 ticks
230+
ha.getState = mockHa({ inverterOnline: 0 }).getState
231+
engine.tick()
232+
engine.tick()
233+
expect(llamadas.map((l) => l.tipo)).toEqual(['inversor_offline'])
234+
})
235+
236+
it('inversor: la recuperación nocturna se notifica al reanudarse la evaluación (amanecer)', () => {
237+
const { llamadas, notifyFn } = capturaNotifs()
238+
const ha = mockHa({ inverterOnline: 0 })
239+
const engine = createAlertsEngine({ db, ha, solar: mockSolar(), notifyFn })
240+
engine.tick()
241+
engine.tick()
242+
expect(llamadas.map((l) => l.tipo)).toEqual(['inversor_offline'])
243+
// Anochece y el inversor vuelve: de noche no se evalúa
244+
ha.getState = mockHa({ inverterOnline: 1, sunState: 'below_horizon' }).getState
245+
engine.tick()
246+
engine.tick()
247+
expect(llamadas).toHaveLength(1)
248+
// Amanece: la evaluación se reanuda y se notifica la recuperación
249+
ha.getState = mockHa({ inverterOnline: 1 }).getState
250+
engine.tick()
251+
expect(llamadas.map((l) => l.tipo)).toEqual(['inversor_offline', 'inversor_ok'])
252+
})
253+
204254
it('corte de red: firma sostenida 3 ticks dispara crítica y recupera a los 2', () => {
205255
const { llamadas, notifyFn } = capturaNotifs()
206256
// Noche: grid 0, consumo 0,5 kW, batería descargando

0 commit comments

Comments
 (0)