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'
3537import { ENTITIES } from './config.js'
3638import { notifyAll } from './push.js'
3739
@@ -40,7 +42,14 @@ const TICKS_FOX_OFFLINE = 3
4042const TICKS_CORTE_RED = 3
4143const TICKS_RED_RECUPERADA = 2
4244const 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
4554export 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.
174225export 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}
0 commit comments