147 files
This commit is contained in:
@@ -8,11 +8,27 @@ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const DETAIL_DAYS = 30;
|
||||
const MINI_DAYS = 14;
|
||||
|
||||
/** history/history_during_period rows — compressed keys over WS ({s, lu});
|
||||
* the uncompressed REST shape is tolerated defensively. */
|
||||
interface HAHistoryRow {
|
||||
s?: string;
|
||||
lu?: number;
|
||||
state?: string;
|
||||
last_updated?: string | number;
|
||||
last_changed?: string | number;
|
||||
}
|
||||
|
||||
export class StatisticsService {
|
||||
private _hass: HomeAssistant;
|
||||
private _cache = new Map<string, EntityStatisticsCache>();
|
||||
private _pending = new Map<string, Promise<StatisticsPoint[]>>();
|
||||
|
||||
/** #141: entities whose DETAIL series came from recorder state history
|
||||
* instead of long-term statistics (binary sensors and other entities
|
||||
* without a state_class never have statistics) — the chart footnote
|
||||
* tells the two apart. */
|
||||
public readonly historyFallbackIds = new Set<string>();
|
||||
|
||||
constructor(hass: HomeAssistant) {
|
||||
this._hass = hass;
|
||||
}
|
||||
@@ -141,7 +157,23 @@ export class StatisticsService {
|
||||
})) as Record<string, HAStatisticsRow[]>;
|
||||
|
||||
const rows = result[entityId] || [];
|
||||
const points = this._normalizeRows(rows, isCounter);
|
||||
let points = this._normalizeRows(rows, isCounter);
|
||||
|
||||
// #141: entities without long-term statistics (binary sensors, sensors
|
||||
// without a state_class) always come back empty here — fall back to
|
||||
// recorder STATE HISTORY (typically ~10 days) so a problem sensor's
|
||||
// on/off timeline draws as a 0/1 step line instead of a flat value.
|
||||
if (points.length < 2) {
|
||||
const historyPoints = await this._fetchHistoryFallback(entityId, startTime);
|
||||
if (historyPoints.length >= 2) {
|
||||
points = historyPoints;
|
||||
this.historyFallbackIds.add(entityId);
|
||||
} else {
|
||||
this.historyFallbackIds.delete(entityId);
|
||||
}
|
||||
} else {
|
||||
this.historyFallbackIds.delete(entityId);
|
||||
}
|
||||
|
||||
this._cache.set(cacheKey, {
|
||||
entityId,
|
||||
@@ -157,6 +189,60 @@ export class StatisticsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Recorder state history as chart points. Binary states map to 0/1, and
|
||||
* every value CHANGE gets a doubled point ((t, old), (t, new)) so the
|
||||
* line renderer draws a step instead of a slope between samples. */
|
||||
private async _fetchHistoryFallback(entityId: string, startTime: string): Promise<StatisticsPoint[]> {
|
||||
try {
|
||||
const result = (await this._hass.connection.sendMessagePromise({
|
||||
type: "history/history_during_period",
|
||||
start_time: startTime,
|
||||
end_time: new Date().toISOString(),
|
||||
entity_ids: [entityId],
|
||||
minimal_response: true,
|
||||
no_attributes: true,
|
||||
})) as Record<string, HAHistoryRow[]>;
|
||||
|
||||
let rows = result?.[entityId] || [];
|
||||
// A chatty numeric entity can return thousands of rows — stride-sample
|
||||
// BEFORE the step expansion (the expansion preserves the edges that
|
||||
// survive sampling).
|
||||
if (rows.length > 1000) {
|
||||
const stride = Math.ceil(rows.length / 500);
|
||||
rows = rows.filter((_, i) => i % stride === 0 || i === rows.length - 1);
|
||||
}
|
||||
|
||||
const points: StatisticsPoint[] = [];
|
||||
let prev: number | null = null;
|
||||
for (const row of rows) {
|
||||
const state = row.s ?? row.state;
|
||||
if (state == null || state === "unknown" || state === "unavailable") continue;
|
||||
let val: number;
|
||||
if (state === "on" || state === "open" || state === "true") val = 1;
|
||||
else if (state === "off" || state === "closed" || state === "false") val = 0;
|
||||
else {
|
||||
val = parseFloat(state);
|
||||
if (!Number.isFinite(val)) continue;
|
||||
}
|
||||
const raw = row.lu ?? row.last_updated ?? row.last_changed;
|
||||
const ts = typeof raw === "number" ? raw * 1000 : raw != null ? Date.parse(raw) : NaN;
|
||||
if (!Number.isFinite(ts)) continue;
|
||||
if (prev != null && prev !== val) points.push({ ts, val: prev });
|
||||
points.push({ ts, val });
|
||||
prev = val;
|
||||
}
|
||||
points.sort((a, b) => a.ts - b.ts);
|
||||
// Extend the last known state to "now" so the line reaches the right
|
||||
// edge (the caller must NOT append trigger_current_value here — for a
|
||||
// state_change trigger that is the change COUNTER, not the state).
|
||||
if (points.length && prev != null) points.push({ ts: Date.now(), val: prev });
|
||||
return points;
|
||||
} catch (err) {
|
||||
console.warn(`[maintenance-supporter] History fallback failed for ${entityId}:`, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchBatch(
|
||||
entityIds: string[],
|
||||
period: "hour" | "day",
|
||||
|
||||
Reference in New Issue
Block a user