429 files

This commit is contained in:
Home Assistant Version Control committed 2026-08-30 02:43:31 +00:00
1 parent 472c19bceb
commit 963cce81a7
566 files changed
+50311 -47616

No files matched your search

@@ -44,6 +44,7 @@ export function renderPredictionSection(task: MaintenanceTask, lang: string, fea
<span class="prediction-value prediction-days${task.days_until_threshold === 0 ? " exceeded" : task.sensor_prediction_urgency ? " urgent" : ""}">${task.days_until_threshold === 0 ? t("threshold_exceeded", lang) : "~" + Math.round(task.days_until_threshold!) + " " + t("days", lang)}</span>
${task.threshold_prediction_date ? html`<span class="prediction-date">${formatDate(task.threshold_prediction_date, lang)}</span>` : nothing}
${task.threshold_prediction_confidence ? html`<span class="confidence-dot ${task.threshold_prediction_confidence}"></span>` : nothing}
${(task.prediction_cycles ?? 0) > 0 ? html`<span class="prediction-cycles">${t("prediction_cycles", lang)}: ${task.prediction_cycles}</span>` : nothing}
</div>
` : nothing}
${hasEnv && features.environmental ? html`
@@ -39,6 +39,100 @@ export interface SparklineContext {
* [Q1 − 1.5·IQR, Q3 + 1.5·IQR]. Robust to a few wild glitch readings (a
* pressure sensor spiking to 100 while it normally sits at 1.5–3). No-ops on
* short series (< 4 points) where quartiles aren't meaningful. */
/** A binary HA state as the 0/1 level the history fallback plots, or null
* for states the fallback cannot express (then no trigger view is built). */
export function binaryLevel(state: string | null | undefined): 0 | 1 | null {
const s = (state ?? "").trim().toLowerCase();
if (s === "on" || s === "open" || s === "true") return 1;
if (s === "off" || s === "closed" || s === "false") return 0;
return null;
}
export interface StateChangeView {
points: ChartPoint[];
/** "alarm": single-shot latch (target 1 + a to-state) — 1 while the alert
* state is held, 0 otherwise. "count": change counter since the last
* service, stepped, honouring the hold filter. */
mode: "alarm" | "count";
}
/** #141 (round 2): the recorder fallback plots the RAW entity state, but a
* state_change trigger is about transitions. A water-valve switch that is
* "on" all week with a few second-long "off" blips drew a flat 1 with the
* "Target changes: 1" line on the same axis — while the header said 0 / 1,
* because those blips never held for the #136 hold time. Re-express the
* step series in the trigger's own terms:
*
* - latch (target_changes == 1 with a to-state): 1 = alert state HELD for
* the hold time, 0 = fine. Reads as "is the trigger active" — for a
* problem sensor (to-state on) this equals the raw line, for a switch
* watched for "off" it is the inverse the user expects.
* - counter (everything else): the cumulative number of matching, held
* transitions since the last service, anchored so the line ends at the
* trigger's live count (transitions before the recorder window are
* unknown but already in that count).
*
* Returns null when the from/to states cannot be expressed as 0/1. */
export function stateChangeView(
points: ChartPoint[],
tc: { trigger_from_state?: string | null; trigger_to_state?: string | null; trigger_target_changes?: number; trigger_for_minutes?: number },
opts: { since: number | null; current: number | null; now?: number },
): StateChangeView | null {
if (points.length < 2) return null;
const now = opts.now ?? Date.now();
const holdMs = Math.max(0, tc.trigger_for_minutes ?? 0) * 60000;
const fromLevel = tc.trigger_from_state ? binaryLevel(tc.trigger_from_state) : null;
const toLevel = tc.trigger_to_state ? binaryLevel(tc.trigger_to_state) : null;
if ((tc.trigger_from_state && fromLevel === null) || (tc.trigger_to_state && toLevel === null)) return null;
// Collapse the doubled step points into held segments (start, level).
const sorted = [...points].sort((a, b) => a.ts - b.ts);
const segments: { start: number; level: number }[] = [];
for (const p of sorted) {
const last = segments[segments.length - 1];
if (!last || last.level !== p.val) segments.push({ start: p.ts, level: p.val });
}
const held = (i: number) => {
const end = i + 1 < segments.length ? segments[i + 1].start : now;
return end - segments[i].start >= holdMs;
};
const step = (out: ChartPoint[], ts: number, val: number) => {
const last = out[out.length - 1];
if (last && last.val !== val) out.push({ ts, val: last.val });
out.push({ ts, val });
};
const latch = (tc.trigger_target_changes ?? 1) === 1 && toLevel !== null;
if (latch) {
const out: ChartPoint[] = [];
segments.forEach((seg, i) => step(out, seg.start, seg.level === toLevel && held(i) ? 1 : 0));
const lastVal = out[out.length - 1]?.val ?? 0;
out.push({ ts: now, val: lastVal });
return { points: out, mode: "alarm" };
}
const since = opts.since ?? segments[0].start;
let counted = 0;
const stepsAt: number[] = [];
for (let i = 1; i < segments.length; i++) {
const prev = segments[i - 1], seg = segments[i];
if (fromLevel !== null && prev.level !== fromLevel) continue;
if (toLevel !== null && seg.level !== toLevel) continue;
if (!held(i) || seg.start < since) continue;
counted += 1;
stepsAt.push(seg.start);
}
const base = Math.max(0, (opts.current ?? counted) - counted);
const out: ChartPoint[] = [{ ts: Math.max(since, segments[0].start), val: base }];
let val = base;
for (const ts of stepsAt) {
val += 1;
step(out, ts, val);
}
out.push({ ts: now, val });
return { points: out, mode: "count" };
}
export function filterOutliers(points: ChartPoint[]): ChartPoint[] {
if (points.length < 4) return points;
const vals = points.map((p) => p.val).sort((a, b) => a - b);
@@ -276,6 +370,13 @@ function renderChart(task: MaintenanceTask, unit: string, ctx: SparklineContext)
const entityId = tc.entity_id || "";
let points = rawStatsPoints(task, ctx);
// #141 round 2: a state_change trigger on recorder history draws the
// trigger's view (alert held / change count), not the raw entity state.
let stateView: StateChangeView | null = null;
if (triggerType === "state_change" && entityId && ctx.historyFallbackIds?.has(entityId)) {
stateView = stateChangeView(points, tc, { since: lastServiceTs(task), current: task.trigger_current_value ?? null });
if (stateView) points = stateView.points;
}
// Runtime accumulates hours with no stored intermediate snapshots: synthesize
// the current cycle (0 at the last service → live value now) so the chart
// resets each completion and always has a drawable 2-point line.
@@ -326,8 +427,9 @@ function renderChart(task: MaintenanceTask, unit: string, ctx: SparklineContext)
}
targetValue = tc.trigger_target_value;
forceZero = true;
} else if (triggerType === "state_change" && tc.trigger_target_changes) {
// Change counts / runtime hours already accumulate from zero.
} else if (triggerType === "state_change" && tc.trigger_target_changes && stateView?.mode !== "alarm") {
// Change counts / runtime hours already accumulate from zero. A latch
// ("alarm" view) has no meaningful target line: its axis is alert/ok.
targetValue = tc.trigger_target_changes;
forceZero = true;
} else if (triggerType === "runtime" && tc.trigger_runtime_hours) {
@@ -341,15 +443,25 @@ function renderChart(task: MaintenanceTask, unit: string, ctx: SparklineContext)
// exists — a slow 0.25 %/day decline classifies stable yet still crosses
// the threshold in a foreseeable number of days.
let projection: ChartPoint[] | null = null;
// A projection pointing AWAY from the configured threshold is noise (the
// ET-4800 finding: a refill-skewed rising rate on a below-threshold ink
// sensor projected the ink level going UP) — draw it only when it heads
// toward the bound, or when no bound is configured at all.
const rate = task.degradation_rate;
const pointsAway =
rate != null &&
((tc.trigger_below != null && tc.trigger_above == null && rate > 0) ||
(tc.trigger_above != null && tc.trigger_below == null && rate < 0));
if (
targetValue == null &&
task.degradation_rate != null &&
rate != null &&
!pointsAway &&
(task.degradation_trend !== "stable" || task.days_until_threshold != null) &&
task.degradation_trend !== "insufficient_data" &&
points.length >= 2
) {
const lp = points[points.length - 1];
projection = [lp, { ts: lp.ts + 30 * 86400000, val: lp.val + task.degradation_rate * 30 }];
projection = [lp, { ts: lp.ts + 30 * 86400000, val: lp.val + rate * 30 }];
}
const events: ChartEvent[] = task.history
@@ -380,7 +492,7 @@ function renderChart(task: MaintenanceTask, unit: string, ctx: SparklineContext)
if (entityId && ctx.historyFallbackIds?.has(entityId) && !statsFetchedEmpty) {
return html`<div class="chart-note">
<ha-icon icon="mdi:information-outline"></ha-icon>
${t("chart_history_fallback", ctx.lang)}
${t(stateView?.mode === "alarm" ? "chart_history_alarm" : stateView?.mode === "count" ? "chart_history_count" : "chart_history_fallback", ctx.lang)}
</div>`;
}
if (statsFetchedEmpty) {
@@ -11,6 +11,7 @@
import { html, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { renderNotesMarkdown } from "../helpers/notes-markdown";
import { t, formatDate, formatDateTime, formatRecurrence } from "../styles";
import type { AdvancedFeatures, HomeAssistant, MaintenanceTask, ManualDocRef } from "../types";
import { renderTriggerSection, type SparklineContext } from "./sparkline";
@@ -222,8 +223,8 @@ function renderPhasesCard(task: MaintenanceTask, ctx: TaskDetailContext) {
const last = lastByPhase.get(id);
return html`
<div class="phase-step ${i === cursor ? "current" : ""}"
title=${i === cursor ? t("phase_current", L) : (ctx.isOperator ? t("phase_set", L) : "")}
@click=${() => { if (ctx.isOperator && i !== cursor) ctx.setPhaseCursor(i); }}>
title=${i === cursor ? t("phase_current", L) : t("phase_set", L)}
@click=${() => { if (i !== cursor) ctx.setPhaseCursor(i); }}>
<span class="phase-step-name">${i + 1}. ${name}</span>
${last ? html`<span class="phase-step-last">${formatDate(last, L)}</span>` : nothing}
</div>
@@ -284,7 +285,7 @@ function renderTaskMeta(task: MaintenanceTask, ctx: TaskDetailContext) {
${task.notes ? html`
<div class="task-meta-row">
<ha-icon icon="mdi:note-text-outline"></ha-icon>
<span class="task-meta-notes">${task.notes}</span>
<span class="task-meta-notes">${renderNotesMarkdown(task.notes)}</span>
</div>
` : nothing}
${safeTaskUrl ? html`
@@ -494,6 +495,12 @@ export function renderOverviewTab(task: MaintenanceTask, ctx: TaskDetailContext)
function renderHistoryTab(task: MaintenanceTask, ctx: TaskDetailContext) {
return html`
<div class="tab-content history-tab">
<div class="history-add-past">
<ha-button appearance="plain" class="history-add-past-btn" @click=${() => ctx.openComplete(task)}>
<ha-icon icon="mdi:calendar-plus"></ha-icon>
${t("history_add_past", ctx.lang)}
</ha-button>
</div>
${renderHistoryFilters(task, ctx.history)}
${renderHistoryList(task, ctx.history)}
</div>