Patrón completo "app web local sobre Home Assistant": backend Node + Hono + SQLite, frontend React con provider de datos en vivo. Pensado para ser reproducido tal cual en otras apps del entorno.
server/
package.json # type: module, node >= 20, 4 deps: hono, @hono/node-server, ws, better-sqlite3
.env # PORT, HAOS_URL, HAOS_TOKEN, AUTH_USER, AUTH_PASS, STATIC_DIR, DATA_DIR, precios
src/
index.js # entry: carga .env manual, arranca HA + Hono + jobs
config.js # config por env + ENTITIES + LIVE_ENTITIES + ENERGY_ENTITIES
ha.js # cliente WebSocket HAOS
solar.js # lógica de negocio (live, series, KPIs, backfill)
auth.js # login/sesiones/rate-limit/middleware
db.js # better-sqlite3: schema + helpers
data/helios.db # SQLite (WAL)
public/ # dist/ del frontend (SPA)
Sin ORM, sin framework de más, sin TypeScript en backend (JS plano ESM).
Despliegue: systemd con usuario dedicado + AmbientCapabilities=CAP_NET_BIND_SERVICE
para escuchar en :80 sin root. NPM (Nginx Proxy Manager) delante con SSL.
WebSocket persistente a ws://<haos>/api/websocket, protocolo nativo HA:
- Conectar → server envía
auth_required→ enviamos{type:"auth", access_token}. auth_ok→ suscribir entidades:{id, type:"subscribe_entities", entity_ids:[...]}.- Primer evento
event.a= snapshot completo ({s: state, a: attributes, lu}). Siguientes:event.ccon diffs ({"+": {...}}/"-"). - Llamadas RPC:
call({type:"recorder/statistics_during_period", ...})conidincremental; respuestastype:"result"resueltas por id (Map de pending + timeout 60s). - Reconexión: backoff exponencial 1s→30s; al reconectar re-suscribe.
auth_invalid→ fatal, no reintentar.
El cliente mantiene Map<entity_id, {state, attributes, lastUpdated}> y emite
entity por cada cambio. Todo el resto de la app lee de ahí (cero polling REST).
Series históricas: recorder/statistics_during_period:
period:"5minute", types:["mean"]→ curvas de potencia del día (retención ~10 días).period:"day", types:["state","sum"]→ histórico diario.- Contadores lifetime (pinzas, medidores): diario = diff de
state. - Utility meters / templates diarios: diario = diff de
sum(elstatede la fila diaria es el valor a las 00:00 = 0 — trampa importante).
- Contadores lifetime (pinzas, medidores): diario = diff de
POST /api/auth/login user+pass timingSafeEqual → cookie helios_session=id.hmac
POST /api/auth/logout
GET /api/auth/me
GET /api/solar/live computeLive() + alertas
GET /api/solar/day?date 288 puntos 5-min + flag estimated
GET /api/solar/kpis?date hoy (live) o pasado (SQLite daily)
GET /api/solar/history días desde SQLite (from/to)
POST /api/solar/history/refresh re-backfill manual
GET /api/solar/stream SSE: push live throttled 1s + heartbeat 25s (mensaje
data {"type":"ping"}; el comentario `: ping` lo ignora EventSource)
## Alertas push (v0.3)
- Motor `server/src/alerts.js` (tick 60 s, flancos + anti-rebote): inversor_offline
(solo de día + scraper fresco), fox_offline (pinza unavailable solo de día),
corte_red (heurística isla), bateria_baja (SOC ≤ reserva), resumen_diario al
anochecer (sun.sun next_setting + offset, fallback 21:00 sin dato solar).
- Web Push VAPID (`push.js`, `routes-push.js`): prefs por tipo, quiet hours con
cola consolidada, i18n server-side es/en/zh. Sin VAPID arranca con push off.
- Toda alerta disparada se audita (`audit_log`, action `alert:<tipo>`) para
ajustar umbrales con historial real.
- Migraciones de esquema: `user_version` en db.js (v1 daily solis/fox, v2 sessions
viejo). Los CREATE IF NOT EXISTS son idempotentes; las transformaciones van en
migraciones numeradas, nunca editadas a posteriori.
GET/PUT /api/config ajustes instalación (kv)
- Auth: middleware en
/api/*(menos login). Cookieid.hmachttpOnly, SameSite=Lax, 30 días,Securesolo six-forwarded-proto=https(compatible LAN http + NPM https). Secret HMAC: env o autogenerado persistido enkv(sobrevive reinicios). Rate-limit login: 5 fallos → 5 min por IP (en memoria). - SSE:
streamSSEde Hono; cabecerasX-Accel-Buffering: noyCache-Control: no-cache(imprescindible detrás de nginx/NPM para no bufferar). - Static:
serveStatic(root)+ fallback GET*→index.htmlexcepto/api/*y/assets/*. - Jobs: consolidación nocturna 00:10 (backfill diario + baseline consumo), limpieza de sesiones cada hora.
daily(date PK, production_kwh, consumption_kwh, grid_import_kwh, grid_export_kwh,
battery_charged_kwh, battery_discharged_kwh, solis_kwh, fox_kwh)
sessions(id PK, created_at, expires_at, ua)
kv(key PK, value) -- session_secret, cons_baseline, install_configjournal_mode = WAL. Migraciones tolerantes:PRAGMA table_info+ALTER TABLE ADD COLUMNsi falta.- El histórico vive AQUÍ (no depende de la retención de HA): backfill inicial desde statistics de HA + consolidación nocturna del día anterior.
- Reglas de sanidad al importar: caps físicos por campo y offsets de glitches conocidos
de contadores (ver
FOX_GLITCH_OFFSETS).
Contrato síncrono (los componentes no conocen HTTP):
interface EnergyDataApi {
connectionStatus: 'connected' | 'reconnecting' | 'demo';
today: Date; now: Date; nowMin: number; liveTick: number;
sunriseMin: number; sunsetMin: number;
refresh(): void; // limpia cachés y refetchea
getLivePower(atMin?, tick?): LivePower; // live (SSE) o punto del replay
getDaySeries(date?): PowerPoint[]; // caché + fetch perezoso
isDayEstimated(date?): boolean;
getKpis(date?, untilMin?): DayKpis; // caché; untilMin → integra cliente
getHistory(range?): HistoryDay[];
}Mecánica:
- Live:
EventSource('/api/solar/stream')→liveRef+bump()(stateversion). - Cachés en refs:
dayCache,dayEstimated,kpisCache,historyRef; setpendinganti-duplicados. Los getters disparan fetch si falta y devuelven lo último conocido (o vacío); al completar,bump()re-renderiza. - value =
useMemo([version, now, connectionStatus, locationLat, locationLon])con closures nuevas cada vez → losuseMemode los consumidores recomputan al cambiar datos. - Clock 1s (
now), refresco periódico 5 min (day+kpis+history de hoy). - 401 en cualquier fetch → evento
helios-unauthorized→AuthGatemuestraLogin. - Sol:
suncalccon lat/lon de Ajustes si hay; si no,sun.sunde HA.
- Medidores físicos siempre mejor que templates derivados (el template de consumo tenía bug de signo y da 0/inflado).
currentGridPower(Solis) es CON SIGNO de fábrica →Math.abs+ campo dirección.- Para totales diarios de UMs: diff de
sum, nuncastate. - Sensores sin
state_classno tienen statistics: cualquier potencia nueva que se quiera graficar necesitadevice_class+state_class: measurementen HA. - Consumo real de la casa =
max(medidores, balance)conbalance = producción + import − export − carga + descarga(los circuitos sin medidor quedan cubiertos por la derivada). - Detrás de NPM/nginx: SSE necesita
X-Accel-Buffering: no. - Verificar el frontend contra la API real (Playwright headless), no solo curl.