257 files

This commit is contained in:
Home Assistant Version Control
2026-10-02 16:06:24 +00:00
parent 7c55603942
commit c29f5cc088
258 changed files with 24621 additions and 3136 deletions
@@ -18,6 +18,7 @@ import {
EWA_ALPHA_RANGE,
PART_QTY_RANGE,
SCHEDULE_OFFSET_MAX_DAYS,
TRIGGER_DAYS_BEFORE_RANGE,
TRIGGER_FOR_MINUTES_RANGE,
TRIGGER_TARGET_CHANGES_RANGE,
WARNING_DAYS_RANGE,
@@ -39,7 +40,10 @@ const CALENDAR_KINDS = ["weekdays", "nth_weekday", "day_of_month", "calendar"];
// #168: kinds that produce a due DATE and therefore take a time of day
// (mirrors SCHEDULE_TIME_KINDS in config_flow_helpers.py).
const SCHEDULE_TIME_KINDS = ["time_based", "one_time", ...CALENDAR_KINDS];
const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime"];
const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime", "due_date"];
// "due_date" alone is the one-time task's date field — the trigger type
// carries its own label.
const triggerTypeLabel = (key: string, L: string): string => t(key === "due_date" ? "trigger_type_due_date" : key, L);
// The type selector additionally offers "compound" (a group of conditions
// joined by AND/OR); its per-condition sub-type is limited to the flat kinds.
const TRIGGER_TYPE_KEYS_WITH_COMPOUND = [...TRIGGER_TYPE_KEYS, "compound"];
@@ -68,6 +72,7 @@ interface CompoundConditionDraft {
targetChanges: string;
runtimeHours: string;
onStates: string;
daysBefore: string;
/** Original keys this editor has no fields for (attribute, baseline, ...).
* Spread back on save so a compound roundtrip never drops them (#103 class). */
carry: Partial<TriggerConfig>;
@@ -78,7 +83,7 @@ function emptyCondition(): CompoundConditionDraft {
entityIds: "", type: "threshold", attribute: "", above: "", below: "",
equals: "", notEquals: "", forMinutes: "0",
targetValue: "", deltaMode: false, fromState: "", toState: "",
targetChanges: "", runtimeHours: "", onStates: "", carry: {},
targetChanges: "", runtimeHours: "", onStates: "", daysBefore: "0", carry: {},
};
}
@@ -90,6 +95,7 @@ const MANAGED_CONDITION_KEYS = new Set([
"trigger_target_value", "trigger_delta_mode",
"trigger_from_state", "trigger_to_state", "trigger_target_changes",
"trigger_runtime_hours", "trigger_on_states",
"trigger_days_before",
]);
/** The per-type trigger fields BOTH editors own — the flat trigger form and
@@ -117,6 +123,7 @@ export function typeFieldsFromConfig(c: TriggerConfig): TriggerTypeFields {
targetChanges: c.trigger_target_changes?.toString() ?? "",
runtimeHours: c.trigger_runtime_hours?.toString() ?? "",
onStates: (c.trigger_on_states || []).join(", "),
daysBefore: c.trigger_days_before?.toString() ?? "0",
};
}
@@ -144,6 +151,9 @@ export function applyTypeFields(c: TriggerConfig, d: TriggerTypeFields): void {
const h = parseFloat(d.runtimeHours); if (!isNaN(h)) c.trigger_runtime_hours = h;
const on = (d.onStates || "").split(",").map((s) => s.trim()).filter(Boolean);
if (on.length > 0) c.trigger_on_states = on;
} else if (d.type === "due_date") {
const days = parseInt(d.daysBefore, 10);
c.trigger_days_before = isNaN(days) ? 0 : days;
}
}
@@ -184,6 +194,13 @@ export class MaintenanceTaskDialog extends LitElement {
@property({ type: Boolean, attribute: "checklists-enabled" }) public checklistsEnabled = false;
@property({ type: Boolean, attribute: "schedule-time-enabled" }) public scheduleTimeEnabled = false;
@property({ type: Boolean, attribute: "completion-actions-enabled" }) public completionActionsEnabled = false;
/** Advanced features (Settings → Advanced Features): the dialog offers what
* the panel then shows — with Adaptive off it let you switch adaptive
* scheduling on while the feedback question and every result stayed
* hidden (audit 2026-09-28). What a task already has stays visible. */
@property({ type: Boolean, attribute: "adaptive-feature" }) public adaptiveFeature = false;
@property({ type: Boolean, attribute: "seasonal-feature" }) public seasonalFeature = false;
@property({ type: Boolean, attribute: "environmental-feature" }) public environmentalFeature = false;
@property({ type: Number, attribute: "default-warning-days" }) public defaultWarningDays = 7;
/** The object's spare parts — offered as "consumes parts" checkboxes. */
@state() private parts: Array<{ id: string; name: string; unit?: string }> = [];
@@ -285,6 +302,7 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _triggerToState = "";
@state() private _triggerTargetChanges = "";
@state() private _triggerRuntimeHours = "";
@state() private _triggerDaysBefore = "0";
@state() private _triggerRuntimeMaxSession = "";
// Comma-separated "running" states for the runtime trigger (#103) —
// empty = the backend default ["on"]. Must roundtrip on edit: adopted
@@ -366,6 +384,13 @@ export class MaintenanceTaskDialog extends LitElement {
// so ha-form can drive the data fields when the service schema is known.
@state() private _actionService = "";
@state() private _actionTargetEntity = "";
/** The stored target as it came, and the entity the field started with.
* The field edits one entity, but the API and imports accept several
* entities, devices, areas, labels and floors — rebuilding the target
* from the field dropped them on every save, a rename included (audit
* 2026-09-28). An untouched field sends the stored target back verbatim. */
private _actionTargetStored: Record<string, unknown> | null = null;
private _actionTargetInitial = "";
/** 2.95: not when the task completes itself (a reset wired by discovery). */
@state() private _actionSkipAuto = false;
/** 2.95: the task already has an action (suggested setups wire counter
@@ -382,7 +407,7 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _qcNotes = "";
@state() private _qcCost = "";
@state() private _qcDuration = "";
@state() private _qcFeedback: "" | "needed" | "not_needed" = "";
@state() private _qcFeedback: "" | "needed" | "not_needed" | "not_sure" = "";
// Environmental entity (adaptive_config)
@state() private _environmentalEntity = "";
@@ -398,6 +423,9 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _adaptiveSeasonal = true;
@state() private _adaptivePrediction = true;
private _adaptiveInitial = "";
/** The task had adaptive scheduling on when the dialog opened — its
* section stays reachable even with the Adaptive feature off. */
private _adaptiveWasEnabled = false;
private _adaptiveSnapshot(): string {
return JSON.stringify([
@@ -544,6 +572,10 @@ export class MaintenanceTaskDialog extends LitElement {
this._actionService = oca.service;
const tgt = oca.target?.entity_id;
this._actionTargetEntity = Array.isArray(tgt) ? (tgt[0] || "") : (tgt || "");
this._actionTargetStored = oca.target && typeof oca.target === "object"
? JSON.parse(JSON.stringify(oca.target)) as Record<string, unknown>
: null;
this._actionTargetInitial = this._actionTargetEntity;
this._actionData = (oca.data && typeof oca.data === "object") ? { ...oca.data } : {};
this._actionDataJsonFallback = "";
this._actionSkipAuto = oca.skip_auto === true;
@@ -551,6 +583,8 @@ export class MaintenanceTaskDialog extends LitElement {
} else {
this._actionService = "";
this._actionTargetEntity = "";
this._actionTargetStored = null;
this._actionTargetInitial = "";
this._actionData = {};
this._actionDataJsonFallback = "";
this._actionSkipAuto = false;
@@ -560,7 +594,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._qcNotes = qcd?.notes || "";
this._qcCost = qcd?.cost != null ? String(qcd.cost) : "";
this._qcDuration = qcd?.duration != null ? String(qcd.duration) : "";
this._qcFeedback = (qcd?.feedback as "needed" | "not_needed" | undefined) || "";
this._qcFeedback = (qcd?.feedback as "needed" | "not_needed" | "not_sure" | undefined) || "";
const ac: Partial<AdaptiveConfig> = task.adaptive_config || {};
this._environmentalEntity = ac.environmental_entity || "";
@@ -574,6 +608,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._adaptiveSeasonal = ac.seasonal_enabled !== false;
this._adaptivePrediction = ac.sensor_prediction_enabled !== false;
this._adaptiveInitial = this._adaptiveSnapshot();
this._adaptiveWasEnabled = this._adaptiveEnabled;
if (task.trigger_config) {
const tc = task.trigger_config;
@@ -668,9 +703,12 @@ export class MaintenanceTaskDialog extends LitElement {
this._adaptiveSeasonal = true;
this._adaptivePrediction = true;
this._adaptiveInitial = this._adaptiveSnapshot();
this._adaptiveWasEnabled = false;
// v1.3.0
this._actionService = "";
this._actionTargetEntity = "";
this._actionTargetStored = null;
this._actionTargetInitial = "";
this._actionData = {};
this._actionDataJsonFallback = "";
this._actionSkipAuto = false;
@@ -703,6 +741,7 @@ export class MaintenanceTaskDialog extends LitElement {
targetChanges: this._triggerTargetChanges,
runtimeHours: this._triggerRuntimeHours,
onStates: this._triggerOnStates,
daysBefore: this._triggerDaysBefore,
};
}
@@ -721,6 +760,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._triggerTargetChanges = f.targetChanges;
this._triggerRuntimeHours = f.runtimeHours;
this._triggerOnStates = f.onStates;
this._triggerDaysBefore = f.daysBefore;
}
private _resetTriggerFields(): void {
@@ -786,7 +826,7 @@ export class MaintenanceTaskDialog extends LitElement {
const svc = this._actionService.trim();
if (!svc || !/^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(svc)) {
this._actionTestResult = "error";
this._actionTestError = "Invalid service format (expected 'domain.service')";
this._actionTestError = t("action_test_bad_format", this._lang);
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 5000);
return;
}
@@ -795,7 +835,7 @@ export class MaintenanceTaskDialog extends LitElement {
// 2. Service exists?
if (!this.hass?.services?.[domain]?.[name]) {
this._actionTestResult = "error";
this._actionTestError = `Service "${svc}" is not registered in Home Assistant. Check spelling and that the integration providing it is loaded.`;
this._actionTestError = t("action_test_unknown_service", this._lang).replace("{service}", svc);
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 8000);
return;
}
@@ -813,14 +853,19 @@ export class MaintenanceTaskDialog extends LitElement {
]);
if (entityDomain !== domain && !crossDomainServices.has(domain)) {
this._actionTestResult = "error";
this._actionTestError = `Service "${svc}" only works on ${domain}.* entities; entity "${tgt}" is in ${entityDomain}.* — pick a service that matches the entity domain (e.g. ${entityDomain}.${name})`;
this._actionTestError = t("action_test_domain_mismatch", this._lang)
.replace("{service}", svc)
.replace("{domain}", domain)
.replace("{entity}", tgt)
.replace("{entity_domain}", entityDomain)
.replace("{suggestion}", `${entityDomain}.${name}`);
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 8000);
return;
}
// 5. Entity exists?
if (!this.hass.states?.[tgt]) {
this._actionTestResult = "error";
this._actionTestError = `Target entity "${tgt}" not found in Home Assistant — the entity may have been renamed or its integration removed.`;
this._actionTestError = t("action_test_entity_missing", this._lang).replace("{entity}", tgt);
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 8000);
return;
}
@@ -868,6 +913,34 @@ export class MaintenanceTaskDialog extends LitElement {
}));
}
/** The stored target reaches beyond the one entity the field shows. */
private _actionTargetExtended(): boolean {
const st = this._actionTargetStored;
if (!st) return false;
return Object.entries(st).some(([key, v]) =>
key === "entity_id" ? Array.isArray(v) && v.length > 1 : Array.isArray(v) ? v.length > 0 : !!v);
}
/** Names of everything the stored target points at, for the hint. */
private _actionTargetSummary(): string {
const st = this._actionTargetStored || {};
const ids = (v: unknown): string[] =>
(Array.isArray(v) ? v : [v]).filter((s): s is string => typeof s === "string" && !!s);
const h = this.hass as unknown as {
states?: Record<string, { attributes?: { friendly_name?: string } }>;
devices?: Record<string, { name_by_user?: string | null; name?: string | null }>;
areas?: Record<string, { name?: string }>;
floors?: Record<string, { name?: string }>;
};
return [
...ids(st.entity_id).map((id) => h.states?.[id]?.attributes?.friendly_name || id),
...ids(st.device_id).map((id) => h.devices?.[id]?.name_by_user || h.devices?.[id]?.name || id),
...ids(st.area_id).map((id) => h.areas?.[id]?.name || id),
...ids(st.floor_id).map((id) => h.floors?.[id]?.name || id),
...ids(st.label_id),
].join(", ");
}
private _renderCompletionActionsSection(L: string) {
if (!this.completionActionsEnabled && !this._actionPresent) return nothing;
const schema = this._serviceSchema();
@@ -903,6 +976,9 @@ export class MaintenanceTaskDialog extends LitElement {
this._actionTargetEntity = v.target_entity || "";
}}
></ha-form>
${this._actionTargetExtended() && this._actionTargetEntity.trim() === this._actionTargetInitial
? html`<p class="field-help ca-target-more">${t("on_complete_action_target_more", L).replace("{targets}", this._actionTargetSummary())}</p>`
: nothing}
<p class="field-help ca-domain-hint">
${t("on_complete_action_target_hint", L)}
</p>
@@ -972,10 +1048,11 @@ export class MaintenanceTaskDialog extends LitElement {
></ms-textfield>
<select class="qc-feedback"
.value=${this._qcFeedback}
@change=${(e: Event) => { this._qcFeedback = (e.target as HTMLSelectElement).value as "" | "needed" | "not_needed"; }}>
@change=${(e: Event) => { this._qcFeedback = (e.target as HTMLSelectElement).value as "" | "needed" | "not_needed" | "not_sure"; }}>
<option value="">${t("quick_complete_defaults_feedback_none", L)}</option>
<option value="needed">${t("quick_complete_defaults_feedback_needed", L)}</option>
<option value="not_needed">${t("quick_complete_defaults_feedback_not_needed", L)}</option>
<option value="not_sure">${t("feedback_not_sure", L)}</option>
</select>
</details>` : nothing}
`;
@@ -1233,9 +1310,9 @@ export class MaintenanceTaskDialog extends LitElement {
.value=${s.unit || ""}
@input=${(e: Event) => this._patchReading(s.id, { unit: (e.target as HTMLInputElement).value })}
></ms-textfield>
<mwc-icon-button class="phase-remove reading-remove" @click=${() => (this._readings = this._readings.filter((x) => x.id !== s.id))}>
<ha-icon-button class="phase-remove reading-remove" @click=${() => (this._readings = this._readings.filter((x) => x.id !== s.id))}>
<ha-icon icon="mdi:delete-outline"></ha-icon>
</mwc-icon-button>
</ha-icon-button>
</div>
`)}
${this._readings.length < MAX_READING_SLOTS ? html`
@@ -1307,9 +1384,9 @@ export class MaintenanceTaskDialog extends LitElement {
@input=${(e: Event) => this._patchPhaseDef(d.id, { partQty: (e.target as HTMLInputElement).value })} />
` : nothing}
` : nothing}
<mwc-icon-button class="phase-remove" @click=${() => this._removePhaseDef(d.id)}>
<ha-icon-button class="phase-remove" @click=${() => this._removePhaseDef(d.id)}>
<ha-icon icon="mdi:delete-outline"></ha-icon>
</mwc-icon-button>
</ha-icon-button>
</div>
${this.checklistsEnabled ? html`
<textarea
@@ -1692,7 +1769,11 @@ export class MaintenanceTaskDialog extends LitElement {
if (svc && /^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(svc)) {
const action: Record<string, unknown> = { service: svc };
const tgt = this._actionTargetEntity.trim();
if (tgt) action.target = { entity_id: tgt };
if (this._actionTargetStored && tgt === this._actionTargetInitial) {
action.target = JSON.parse(JSON.stringify(this._actionTargetStored));
} else if (tgt) {
action.target = { entity_id: tgt };
}
const dataDict = this._buildActionData();
if (Object.keys(dataDict).length > 0) {
action.data = dataDict;
@@ -1741,7 +1822,7 @@ export class MaintenanceTaskDialog extends LitElement {
@change=${(e: Event) => (this._triggerType = (e.target as HTMLSelectElement).value as TriggerType)}
>
${TRIGGER_TYPE_KEYS_WITH_COMPOUND.map(
(key) => html`<option value=${key} ?selected=${key === this._triggerType}>${t(key, L)}</option>`
(key) => html`<option value=${key} ?selected=${key === this._triggerType}>${triggerTypeLabel(key, L)}</option>`
)}
</select>
</div>
@@ -1917,7 +1998,7 @@ export class MaintenanceTaskDialog extends LitElement {
@change=${(e: Event) => this._patchCondition(i, { type: (e.target as HTMLSelectElement).value as TriggerType })}
>
${TRIGGER_TYPE_KEYS.map(
(key) => html`<option value=${key} ?selected=${key === c.type}>${t(key, L)}</option>`
(key) => html`<option value=${key} ?selected=${key === c.type}>${triggerTypeLabel(key, L)}</option>`
)}
</select>
</div>
@@ -1990,6 +2071,7 @@ export class MaintenanceTaskDialog extends LitElement {
* adapt. Collapsed unless adaptive is already enabled. */
private _renderAdaptiveSection(L: string) {
if (this._scheduleType === "one_time" || this._scheduleType === "manual") return nothing;
if (!this.adaptiveFeature && !this._adaptiveWasEnabled) return nothing;
return html`
<details class="adaptive-section" ?open=${this._adaptiveEnabled}>
<summary>${t("adaptive_section_title", L)}</summary>
@@ -2025,14 +2107,14 @@ export class MaintenanceTaskDialog extends LitElement {
.value=${this._adaptiveAlpha}
@input=${(e: Event) => (this._adaptiveAlpha = (e.target as HTMLInputElement).value)}
></ms-textfield>
<label>
${this.seasonalFeature ? html`<label>
<input
type="checkbox"
.checked=${this._adaptiveSeasonal}
@change=${(e: Event) => (this._adaptiveSeasonal = (e.target as HTMLInputElement).checked)}
/>
${t("adaptive_seasonal_enabled", L)}
</label>
</label>` : nothing}
<label>
<input
type="checkbox"
@@ -2177,6 +2259,13 @@ export class MaintenanceTaskDialog extends LitElement {
})}
`;
}
if (c.type === "due_date") {
return html`
<ms-textfield label="${t("days_before", L)}" type="number" step="1"
min=${TRIGGER_DAYS_BEFORE_RANGE[0]} max=${TRIGGER_DAYS_BEFORE_RANGE[1]} .value=${c.daysBefore}
@input=${(e: Event) => this._patchCondition(i, { daysBefore: (e.target as HTMLInputElement).value })}></ms-textfield>
`;
}
return nothing;
}
@@ -2641,6 +2730,17 @@ export class MaintenanceTaskDialog extends LitElement {
).replace("{count}", String(n)),
);
parts.push(t("trigger_hint_state_now", L).replace("{value}", String(st.state)));
} else if (this._triggerType === "due_date") {
const days = parseInt(this._triggerDaysBefore, 10) || 0;
parts.push(
days === 0 ? t("trigger_hint_due_date_same_day", L)
: days === 1 ? t("trigger_hint_due_date_one", L)
: t("trigger_hint_due_date", L).replace("{days}", String(days)),
);
const reported = raw == null ? "" : String(raw);
if (reported && !isNaN(Date.parse(reported))) {
parts.push(t("trigger_hint_due_date_now", L).replace("{date}", formatDate(reported, L)));
}
}
if (!parts.length) return overlap;
return html`<div class="trigger-live-hint">${parts.join(" ")}</div>${overlap}`;
@@ -2788,6 +2888,20 @@ export class MaintenanceTaskDialog extends LitElement {
<div class="field-help">${t("runtime_on_states_help", L)}</div>
`;
}
if (this._triggerType === "due_date") {
return html`
<ms-textfield
label="${t("days_before", L)}"
type="number"
step="1"
min=${TRIGGER_DAYS_BEFORE_RANGE[0]}
max=${TRIGGER_DAYS_BEFORE_RANGE[1]}
.value=${this._triggerDaysBefore}
@input=${(e: Event) => (this._triggerDaysBefore = (e.target as HTMLInputElement).value)}
></ms-textfield>
<div class="field-help">${t("days_before_help", L)}</div>
`;
}
return nothing;
}
@@ -3086,7 +3200,7 @@ export class MaintenanceTaskDialog extends LitElement {
</div>` : nothing}
` : nothing}
${this._renderTriggerFields()}
${this._scheduleType === "sensor_based" ? html`
${this._scheduleType === "sensor_based" && (this.environmentalFeature || this._environmentalInitial) ? html`
${this._entityPickerFallback ? html`
<ms-textfield
label="${t("environmental_entity_optional", L)}"
@@ -3389,6 +3503,8 @@ export class MaintenanceTaskDialog extends LitElement {
font-size: 13px;
}
.phase-remove {
display: inline-flex;
--ha-icon-button-size: 36px;
--mdc-icon-button-size: 36px;
color: var(--secondary-text-color);
}