Skip to content

Commit c301dd2

Browse files
committed
feat(alerts): send daily summary at sunset instead of fixed hour
Schedule the daily production summary at sunset (sun.sun next_setting + 10 min offset to let the last production interval settle) instead of a fixed 21:00. Falls back to 21:00 fixed when solar data is unavailable (HAOS down) or today's sunset already passed, so no day is ever skipped or double-sent. Stop promising a fixed hour in the user-facing label. Closes #6
1 parent 0787164 commit c301dd2

7 files changed

Lines changed: 134 additions & 18 deletions

File tree

‎ARCHITECTURE.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ GET /api/solar/stream SSE: push live throttled 1s + heartbeat 25s (mensaje
6666
6767
- Motor `server/src/alerts.js` (tick 60 s, flancos + anti-rebote): inversor_offline
6868
(solo de día + scraper fresco), fox_offline (pinza unavailable solo de día),
69-
corte_red (heurística isla), bateria_baja (SOC ≤ reserva), resumen_diario 21:00.
69+
corte_red (heurística isla), bateria_baja (SOC ≤ reserva), resumen_diario al
70+
anochecer (sun.sun next_setting + offset, fallback 21:00 sin dato solar).
7071
- Web Push VAPID (`push.js`, `routes-push.js`): prefs por tipo, quiet hours con
7172
cola consolidada, i18n server-side es/en/zh. Sin VAPID arranca con push off.
7273
- Toda alerta disparada se audita (`audit_log`, action `alert:<tipo>`) para

‎app/src/i18n/locales/en/translation.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@
448448
"corte_red": "Grid outage",
449449
"corte_red_ok": "Grid restored",
450450
"bateria_baja": "Low battery",
451-
"resumen_diario": "Daily summary (9:00 PM)"
451+
"resumen_diario": "Daily summary at sunset"
452452
}
453453
}
454454
},

‎app/src/i18n/locales/es/translation.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@
448448
"corte_red": "Corte de red",
449449
"corte_red_ok": "Red recuperada",
450450
"bateria_baja": "Batería baja",
451-
"resumen_diario": "Resumen diario (21:00)"
451+
"resumen_diario": "Resumen diario al anochecer"
452452
}
453453
}
454454
},

‎app/src/i18n/locales/zh-CN/translation.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@
446446
"corte_red": "电网停电",
447447
"corte_red_ok": "电网已恢复",
448448
"bateria_baja": "电池电量低",
449-
"resumen_diario": "每日总结(21:00)"
449+
"resumen_diario": "每日总结(日落时)"
450450
}
451451
}
452452
},

‎server/src/alerts.js‎

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@
2525
// critical: la casa sigue funcionando, solo falta producción.
2626
// - bateria_baja: SOC ≤ reserva (install_config.batteryReservePct, defecto 20).
2727
// Se rearma cuando SOC > reserva + 5.
28-
// - resumen_diario: scheduler propio a las 21:00 local con los KPI del día.
28+
// - resumen_diario: scheduler propio que se envía al ANOCHECER (next_setting de
29+
// sun.sun + un offset para asentar las últimas lecturas) con los KPI del día.
30+
// Fallback a hora fija si falta el dato solar. Ver proximoEnvioResumen.
2931
//
3032
// notifyFn inyectable para tests (defecto: notifyAll de push.js).
3133
// Cada disparo queda en audit_log (action 'alert:<tipo>') para poder ajustar
3234
// umbrales con historial real (2-Ago-2026: corte_red no tenía historial y no
3335
// se podía evaluar su tasa de falsos positivos).
34-
import { kvGet, audit } from './db.js'
36+
import { kvGet, kvSet, audit } from './db.js'
3537
import { ENTITIES } from './config.js'
3638
import { notifyAll } from './push.js'
3739

@@ -40,7 +42,14 @@ const TICKS_FOX_OFFLINE = 3
4042
const TICKS_CORTE_RED = 3
4143
const TICKS_RED_RECUPERADA = 2
4244
const HISTERESIS_SOC = 5
43-
export const HORA_RESUMEN_DIARIO = 21 // hora local
45+
export const HORA_RESUMEN_DIARIO = 21 // hora local (fallback si no hay dato solar)
46+
// Minutos tras el anochecer antes de enviar el resumen: dejan que el último
47+
// intervalo de producción del día quede registrado antes de leer los KPI.
48+
export const RESUMEN_OFFSET_MIN = 10
49+
// Reintento breve mientras esperamos a que sun.sun llegue como estado al
50+
// arrancar/reconectar HAOS (el acuse de subscribe_entities precede a los
51+
// estados). Si el fallback aún queda lejos, reprogramamos al anochecer real.
52+
const RECHECK_BOOT_MS = 30_000
4453

4554
export function createAlertsEngine({ db, ha, solar, notifyFn = notifyAll }) {
4655
const estado = {
@@ -169,20 +178,72 @@ export async function enviarResumenDiario(db, ha, solar, notifyFn = notifyAll) {
169178
}
170179
}
171180

172-
// Scheduler del resumen: mismo patrón que scheduleNightly de index.js pero a
173-
// las 21:00 local (la consolidación nocturna corre a las 00:10).
181+
// Calcula el próximo momento de envío del resumen a partir del anochecer.
182+
// sun.sun.attributes.next_setting es SIEMPRE el próximo anochecer (instante
183+
// futuro, hoy si no ha anochecido o mañana si ya): disparamos sunset +
184+
// RESUMEN_OFFSET_MIN. Si no hay dato solar (HAOS caído), fallback a hora fija
185+
// HORA_RESUMEN_DIARIO (hoy si no ha pasado, mañana en caso contrario). Así nunca
186+
// se salta un día. La guarda anti-doble-envío del mismo día la lleva
187+
// ejecutarResumenSiToca (persistida en kv). `ahora` se inyecta para tests.
188+
export function proximoEnvioResumen(ha, ahora = new Date()) {
189+
const sunsetIso = ha.getState(ENTITIES.sun)?.attributes?.next_setting
190+
const sunset = sunsetIso ? new Date(sunsetIso) : null
191+
if (sunset && Number.isFinite(sunset.getTime())) {
192+
return new Date(sunset.getTime() + RESUMEN_OFFSET_MIN * 60000)
193+
}
194+
const fb = new Date(ahora)
195+
fb.setHours(HORA_RESUMEN_DIARIO, 0, 0, 0)
196+
if (fb.getTime() <= ahora.getTime()) fb.setDate(fb.getDate() + 1)
197+
return fb
198+
}
199+
200+
// Clave de día local (YYYY-MM-DD) — la hora del anochecer es local y la guarda
201+
// debe contar por día calendario del servidor.
202+
function fechaLocalKey(d) {
203+
const y = d.getFullYear()
204+
const m = String(d.getMonth() + 1).padStart(2, '0')
205+
const dia = String(d.getDate()).padStart(2, '0')
206+
return `${y}-${m}-${dia}`
207+
}
208+
209+
const KV_RESUMEN_FECHA = 'resumen_ultimo_envio'
210+
211+
// Envía el resumen SOLO si no se ha enviado ya hoy (guarda persistida en kv,
212+
// sobrevive a reinicios). Devuelve true si envió. Evita el doble-envío cuando
213+
// el path fallback (21:00) y el path sunset (+offset) caen el mismo día.
214+
export async function ejecutarResumenSiToca(db, ha, solar, notifyFn = notifyAll) {
215+
const ahora = new Date()
216+
if (kvGet(db, KV_RESUMEN_FECHA) === fechaLocalKey(ahora)) return false
217+
await enviarResumenDiario(db, ha, solar, notifyFn)
218+
kvSet(db, KV_RESUMEN_FECHA, fechaLocalKey(ahora))
219+
return true
220+
}
221+
222+
// Scheduler del resumen: se envía al anochecer (ver proximoEnvioResumen); la
223+
// consolidación nocturna corre a las 00:10. Delay con suelo de 1 min por
224+
// seguridad anti-bucle cerrado.
174225
export function scheduleResumenDiario(db, ha, solar, notifyFn) {
175-
const now = new Date()
176-
const next = new Date(now)
177-
next.setHours(HORA_RESUMEN_DIARIO, 0, 0, 0)
178-
if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1)
226+
const ahora = new Date()
227+
const next = proximoEnvioResumen(ha, ahora)
228+
const sunsetDisponible = !!ha.getState(ENTITIES.sun)?.attributes?.next_setting
229+
const delay = next.getTime() - ahora.getTime()
230+
// Carrera de arranque: al iniciar (o justo tras reconectar HAOS) sun.sun
231+
// puede no haber llegado todavía como estado. Si el dato no está y el
232+
// fallback aún queda lejos, reintentamos en 30 s para programar al anochecer
233+
// real; si sigue sin haber sunset al acercarnos al fallback, usamos la hora
234+
// fija (HAOS caído todo el día).
235+
if (!sunsetDisponible && delay > RECHECK_BOOT_MS) {
236+
setTimeout(() => scheduleResumenDiario(db, ha, solar, notifyFn), RECHECK_BOOT_MS).unref()
237+
return
238+
}
239+
console.log(`[helios] resumen diario programado para ${next.toISOString()}`)
179240
setTimeout(async () => {
180241
try {
181-
await enviarResumenDiario(db, ha, solar, notifyFn)
182-
console.log('[helios] resumen diario enviado')
242+
const envio = await ejecutarResumenSiToca(db, ha, solar, notifyFn)
243+
console.log(envio ? '[helios] resumen diario enviado' : '[helios] resumen diario ya enviado hoy, omitido')
183244
} catch (err) {
184245
console.error('[helios] resumen diario error:', err.message)
185246
}
186247
scheduleResumenDiario(db, ha, solar, notifyFn)
187-
}, next.getTime() - now.getTime()).unref()
248+
}, Math.max(delay, 60_000)).unref()
188249
}

‎server/src/index.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,7 @@ serve({ fetch: app.fetch, port: config.port, hostname: config.host }, (info) =>
576576

577577
setInterval(() => cleanSessions(db), 3600 * 1000).unref()
578578
// Motor de alertas push (inversor/corte red/batería) cada minuto + resumen
579-
// diario a las 21:00. Flush de la cola de quiet hours con el tick horario.
579+
// diario al anochecer. Flush de la cola de quiet hours con el tick horario.
580580
const alertsEngine = alerts.createAlertsEngine({ db, ha, solar })
581581
setInterval(() => {
582582
try {

‎server/test/push.test.js‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import Database from 'better-sqlite3'
55
import webpush from 'web-push'
66
import { initSchema } from '../src/db.js'
77
import { configurePush, notifyUsers, flushNotificationQueue, _setSendFn, _resetForTests } from '../src/push.js'
8-
import { createAlertsEngine, enviarResumenDiario } from '../src/alerts.js'
8+
import { createAlertsEngine, enviarResumenDiario, proximoEnvioResumen, ejecutarResumenSiToca, RESUMEN_OFFSET_MIN, HORA_RESUMEN_DIARIO } from '../src/alerts.js'
99

1010
// Par VAPID real (setVapidDetails valida formato; claves fake no pasan).
1111
const KEYS = webpush.generateVAPIDKeys()
@@ -370,3 +370,57 @@ describe('motor de alertas', () => {
370370
expect(llamadas[0].opciones.severity).toBe('normal')
371371
})
372372
})
373+
374+
// --- Scheduler del resumen: anochecer vs fallback -----------------------------
375+
376+
function haConSun(nextSetting) {
377+
return {
378+
connected: true,
379+
getState: (id) => (id === 'sun.sun' ? { state: 'above_horizon', attributes: { next_setting: nextSetting } } : null),
380+
}
381+
}
382+
const haSinSun = { connected: true, getState: () => null }
383+
384+
describe('proximoEnvioResumen (anochecer con fallback)', () => {
385+
it('con sunset HOY → sunset + offset', () => {
386+
const mediodia = new Date('2026-08-08T12:00:00+02:00')
387+
const next = proximoEnvioResumen(haConSun('2026-08-08T21:30:00+02:00'), mediodia)
388+
const esperado = new Date(`2026-08-08T21:30:00+02:00`).getTime() + RESUMEN_OFFSET_MIN * 60000
389+
expect(next.getTime()).toBe(esperado)
390+
})
391+
392+
it('con sunset MAÑANA (ya anocheció hoy) → sunset de mañana + offset (sigue al próximo anochecer)', () => {
393+
const noche = new Date('2026-08-08T22:00:00+02:00')
394+
const next = proximoEnvioResumen(haConSun('2026-08-09T21:14:00+02:00'), noche)
395+
expect(next.getTime()).toBe(new Date('2026-08-09T21:14:00+02:00').getTime() + RESUMEN_OFFSET_MIN * 60000)
396+
})
397+
398+
it('sin dato solar (HAOS caído) de día → fallback 21:00 hoy', () => {
399+
const mediodia = new Date('2026-08-08T12:00:00+02:00')
400+
const next = proximoEnvioResumen(haSinSun, mediodia)
401+
expect(next.getTime()).toBe(new Date('2026-08-08T21:00:00+02:00').getTime())
402+
})
403+
404+
it('sin dato solar pasado 21:00 → fallback 21:00 de mañana', () => {
405+
const noche = new Date('2026-08-08T22:00:00+02:00')
406+
const next = proximoEnvioResumen(haSinSun, noche)
407+
expect(next.getTime()).toBe(new Date(`2026-08-09T21:00:00+02:00`).getTime())
408+
})
409+
410+
it('el offset de asentamiento respeta RESUMEN_OFFSET_MIN y HORA_RESUMEN_DIARIO exportados', () => {
411+
expect(RESUMEN_OFFSET_MIN).toBeGreaterThan(0)
412+
expect(HORA_RESUMEN_DIARIO).toBe(21)
413+
})
414+
})
415+
416+
describe('ejecutarResumenSiToca (guarda anti-doble-envío)', () => {
417+
it('envía una vez y la segunda llamada el mismo día se omite', async () => {
418+
const llamadas = []
419+
const notifyFn = async (db, tipo, datos, opciones) => llamadas.push({ tipo, datos, opciones })
420+
const r1 = await ejecutarResumenSiToca(db, mockHa(), mockSolar(), notifyFn)
421+
const r2 = await ejecutarResumenSiToca(db, mockHa(), mockSolar(), notifyFn)
422+
expect(r1).toBe(true)
423+
expect(r2).toBe(false)
424+
expect(llamadas).toHaveLength(1)
425+
})
426+
})

0 commit comments

Comments
 (0)