/** * Statistics service for fetching and caching HA recorder statistics. * Used by sparkline charts to display dense time-series data. */ import type { HomeAssistant, StatisticsPoint, EntityStatisticsCache, HAStatisticsRow } from "./types"; const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const DETAIL_DAYS = 30; const MINI_DAYS = 14; export class StatisticsService { private _hass: HomeAssistant; private _cache = new Map(); private _pending = new Map>(); constructor(hass: HomeAssistant) { this._hass = hass; } updateHass(hass: HomeAssistant): void { this._hass = hass; } /** * Statistics for the detail chart. Hourly resolution for short ranges, * daily beyond ~5 weeks (keeps a 1-year window at ~365 points). */ async getDetailStats( entityId: string, isCounter: boolean, days: number = DETAIL_DAYS, ): Promise { return this._getStats(entityId, days <= 35 ? "hour" : "day", days, isCounter); } /** * Get 14 days of daily statistics for the mini-sparkline (60x20 chart). */ async getMiniStats(entityId: string, isCounter: boolean): Promise { return this._getStats(entityId, "day", MINI_DAYS, isCounter); } /** * Batch-fetch mini stats for multiple entities in at most 2 WS calls * (one for counter-type, one for non-counter-type entities). */ async getBatchMiniStats( entities: Array<{ entityId: string; isCounter: boolean }>, ): Promise> { const result = new Map(); const toFetch: Array<{ entityId: string; isCounter: boolean }> = []; // Check cache first, collect cache misses (same key shape as _getStats // so the single-entity mini path and the batch path share the cache). for (const e of entities) { const cacheKey = `${e.entityId}:day:${MINI_DAYS}`; const cached = this._cache.get(cacheKey); if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { result.set(e.entityId, cached.points); } else { toFetch.push(e); } } if (toFetch.length === 0) return result; // Group by type (counter vs non-counter need different stat types) const counterIds = toFetch.filter((e) => e.isCounter).map((e) => e.entityId); const nonCounterIds = toFetch.filter((e) => !e.isCounter).map((e) => e.entityId); const startTime = new Date(Date.now() - MINI_DAYS * 24 * 60 * 60 * 1000).toISOString(); const promises: Promise[] = []; if (counterIds.length > 0) { promises.push( this._fetchBatch(counterIds, "day", startTime, ["state", "sum", "change"], true, result), ); } if (nonCounterIds.length > 0) { promises.push( this._fetchBatch(nonCounterIds, "day", startTime, ["mean", "min", "max"], false, result), ); } await Promise.all(promises); return result; } clearCache(): void { this._cache.clear(); this._pending.clear(); } private async _getStats( entityId: string, period: "hour" | "day", days: number, isCounter: boolean, ): Promise { // days must be part of the key: the same entity/period at a different // range (7d vs 30d) is a different dataset, not a cache hit. const cacheKey = `${entityId}:${period}:${days}`; const cached = this._cache.get(cacheKey); if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { return cached.points; } // Deduplicate in-flight requests if (this._pending.has(cacheKey)) { return this._pending.get(cacheKey)!; } const promise = this._fetchAndNormalize(entityId, period, days, isCounter, cacheKey); this._pending.set(cacheKey, promise); try { return await promise; } finally { this._pending.delete(cacheKey); } } private async _fetchAndNormalize( entityId: string, period: "hour" | "day", days: number, isCounter: boolean, cacheKey: string, ): Promise { const startTime = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); const types = isCounter ? ["state", "sum", "change"] : ["mean", "min", "max"]; try { const result = (await this._hass.connection.sendMessagePromise({ type: "recorder/statistics_during_period", start_time: startTime, statistic_ids: [entityId], period, types, })) as Record; const rows = result[entityId] || []; const points = this._normalizeRows(rows, isCounter); this._cache.set(cacheKey, { entityId, fetchedAt: Date.now(), period, points, }); return points; } catch (err) { console.warn(`[maintenance-supporter] Failed to fetch statistics for ${entityId}:`, err); return []; } } private async _fetchBatch( entityIds: string[], period: "hour" | "day", startTime: string, types: string[], isCounter: boolean, out: Map, ): Promise { try { const response = (await this._hass.connection.sendMessagePromise({ type: "recorder/statistics_during_period", start_time: startTime, statistic_ids: entityIds, period, types, })) as Record; for (const entityId of entityIds) { const rows = response[entityId] || []; const points = this._normalizeRows(rows, isCounter); out.set(entityId, points); this._cache.set(`${entityId}:${period}:${MINI_DAYS}`, { entityId, fetchedAt: Date.now(), period, points, }); } } catch (err) { console.warn("[maintenance-supporter] Batch statistics fetch failed:", err); } } private _normalizeRows(rows: HAStatisticsRow[], isCounter: boolean): StatisticsPoint[] { const points: StatisticsPoint[] = []; for (const row of rows) { let val: number | null = null; if (isCounter) { val = row.state ?? null; } else { val = row.mean ?? null; } if (val === null) continue; const point: StatisticsPoint = { ts: row.start, // HA returns epoch milliseconds val, }; if (!isCounter) { if (row.min != null) point.min = row.min; if (row.max != null) point.max = row.max; } points.push(point); } points.sort((a, b) => a.ts - b.ts); return points; } }