158 files

This commit is contained in:
Home Assistant Version Control
2026-08-11 11:40:58 +00:00
parent c6db6ca558
commit 5388267a3e
158 changed files with 11840 additions and 698 deletions
@@ -6,6 +6,11 @@ import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, Trig
import { formatDate, t, weekdayName } from "../styles";
import { UserService } from "../user-service";
import { partLinkKey } from "../helpers/shared-parts";
import {
ENVIRONMENTAL_PICKER_DEVICE_CLASSES,
ENVIRONMENTAL_PICKER_DOMAINS,
TRIGGER_PICKER_DOMAINS,
} from "../helpers/trigger-domains";
import { describeWsError } from "../ws-errors";
import { REQUIRED_COMPLETION_KEYS, REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
@@ -25,6 +30,7 @@ const TRIGGER_TYPE_KEYS_WITH_COMPOUND = [...TRIGGER_TYPE_KEYS, "compound"];
interface CompoundConditionDraft {
entityIds: string; // comma-separated raw input
type: string; // threshold | counter | state_change | runtime
attribute: string; // "" = use the entity state
above: string;
below: string;
forMinutes: string;
@@ -42,7 +48,7 @@ interface CompoundConditionDraft {
function emptyCondition(): CompoundConditionDraft {
return {
entityIds: "", type: "threshold", above: "", below: "", forMinutes: "0",
entityIds: "", type: "threshold", attribute: "", above: "", below: "", forMinutes: "0",
targetValue: "", deltaMode: false, fromState: "", toState: "",
targetChanges: "", runtimeHours: "", onStates: "", carry: {},
};
@@ -51,7 +57,7 @@ function emptyCondition(): CompoundConditionDraft {
/** Keys the compound editor owns via its own form fields — everything else
* travels through `carry` untouched. */
const MANAGED_CONDITION_KEYS = new Set([
"entity_id", "entity_ids", "type",
"entity_id", "entity_ids", "type", "attribute",
"trigger_above", "trigger_below", "trigger_for_minutes",
"trigger_target_value", "trigger_delta_mode",
"trigger_from_state", "trigger_to_state", "trigger_target_changes",
@@ -64,6 +70,7 @@ function conditionToDraft(c: TriggerConfig): CompoundConditionDraft {
return {
entityIds: ids.join(", "),
type: c.type || "threshold",
attribute: c.attribute || "",
above: c.trigger_above?.toString() ?? "",
below: c.trigger_below?.toString() ?? "",
forMinutes: c.trigger_for_minutes?.toString() ?? "0",
@@ -86,6 +93,7 @@ function draftToCondition(d: CompoundConditionDraft): TriggerConfig | null {
const ids = d.entityIds.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length === 0) return null;
const c: TriggerConfig = { ...(d.carry || {}), entity_id: ids[0], entity_ids: ids, type: d.type };
if (d.attribute) c.attribute = d.attribute;
if (d.type === "threshold") {
const a = parseFloat(d.above); if (!isNaN(a)) c.trigger_above = a;
const b = parseFloat(d.below); if (!isNaN(b)) c.trigger_below = b;
@@ -133,6 +141,11 @@ export class MaintenanceTaskDialog extends LitElement {
parts: Array<{ id: string; name: string; unit?: string }>;
}> = [];
@state() private _open = false;
// #129: flips the trigger entity pickers back to comma text fields when the
// HA picker fails to lay out in this mount context (see _probeEntityPickers).
@state() private _entityPickerFallback = false;
private _pickerProbeTimer: ReturnType<typeof setTimeout> | undefined;
private _pickerProbeStrikes = 0;
@state() private _loading = false;
@state() private _error = "";
@state() private _entryId = "";
@@ -263,6 +276,22 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _environmentalAttribute = "";
private _environmentalInitial = ""; // for change detection on save
private _environmentalAttributeInitial = "";
// Adaptive tuning (parity with the options flow's adaptive step) — Store-
// managed like the environmental binding, saved through task/set_adaptive.
@state() private _adaptiveEnabled = false;
@state() private _adaptiveAlpha = "0.3";
@state() private _adaptiveMin = "7";
@state() private _adaptiveMax = "365";
@state() private _adaptiveSeasonal = true;
@state() private _adaptivePrediction = true;
private _adaptiveInitial = "";
private _adaptiveSnapshot(): string {
return JSON.stringify([
this._adaptiveEnabled, this._adaptiveAlpha, this._adaptiveMin,
this._adaptiveMax, this._adaptiveSeasonal, this._adaptivePrediction,
]);
}
private _userService: UserService | null = null;
private get _lang(): string {
@@ -374,6 +403,13 @@ export class MaintenanceTaskDialog extends LitElement {
this._environmentalAttribute = ac.environmental_attribute || "";
this._environmentalInitial = this._environmentalEntity;
this._environmentalAttributeInitial = this._environmentalAttribute;
this._adaptiveEnabled = !!ac.enabled;
this._adaptiveAlpha = (ac.ewa_alpha ?? 0.3).toString();
this._adaptiveMin = (ac.min_interval_days ?? 7).toString();
this._adaptiveMax = (ac.max_interval_days ?? 365).toString();
this._adaptiveSeasonal = ac.seasonal_enabled !== false;
this._adaptivePrediction = ac.sensor_prediction_enabled !== false;
this._adaptiveInitial = this._adaptiveSnapshot();
if (task.trigger_config) {
const tc = task.trigger_config;
@@ -460,6 +496,13 @@ export class MaintenanceTaskDialog extends LitElement {
this._environmentalAttribute = "";
this._environmentalInitial = "";
this._environmentalAttributeInitial = "";
this._adaptiveEnabled = false;
this._adaptiveAlpha = "0.3";
this._adaptiveMin = "7";
this._adaptiveMax = "365";
this._adaptiveSeasonal = true;
this._adaptivePrediction = true;
this._adaptiveInitial = this._adaptiveSnapshot();
// v1.3.0
this._actionService = "";
this._actionTargetEntity = "";
@@ -802,6 +845,46 @@ export class MaintenanceTaskDialog extends LitElement {
}
}
/** Per-entity attribute options — generic entity_id-keyed cache, fetched
* lazily; serves the compound condition rows AND the environmental
* attribute dropdown. (Parity round: the flow's compound path always had
* an attribute step; the dialog only carried it without an editor.) */
@state() private _conditionAttrOptions: Record<
string,
{ suggested: string[]; available: Array<{ name: string; numeric: boolean }> }
> = {};
private _conditionAttrPending = new Set<string>();
private _fetchConditionAttributes(entityId: string): void {
if (!entityId || !this.hass) return;
if (this._conditionAttrOptions[entityId] || this._conditionAttrPending.has(entityId)) return;
this._conditionAttrPending.add(entityId);
void this.hass.connection
.sendMessagePromise({
type: "maintenance_supporter/entity/attributes",
entity_id: entityId,
})
.then((result) => {
const r = result as {
suggested_attributes: string[];
available_attributes: Array<{ name: string; numeric: boolean }>;
};
this._conditionAttrOptions = {
...this._conditionAttrOptions,
[entityId]: {
suggested: r.suggested_attributes || [],
available: r.available_attributes || [],
},
};
})
.catch(() => {
this._conditionAttrOptions = {
...this._conditionAttrOptions,
[entityId]: { suggested: [], available: [] },
};
});
}
private async _fetchEntityAttributes(entityId: string): Promise<void> {
if (!entityId || !this.hass) {
this._suggestedAttributes = [];
@@ -894,6 +977,14 @@ export class MaintenanceTaskDialog extends LitElement {
private async _save(): Promise<void> {
if (this._loading) return; // synchronous re-entry guard (double-click)
if (!this._name.trim()) return;
if (this._adaptiveSnapshot() !== this._adaptiveInitial) {
const minIv = parseInt(this._adaptiveMin, 10);
const maxIv = parseInt(this._adaptiveMax, 10);
if (!isNaN(minIv) && !isNaN(maxIv) && minIv > maxIv) {
this._error = `${t("adaptive_min_interval", this._lang)} > ${t("adaptive_max_interval", this._lang)}`;
return;
}
}
this._loading = true;
this._error = "";
try {
@@ -1116,6 +1207,30 @@ export class MaintenanceTaskDialog extends LitElement {
}
}
// Adaptive tuning is Store-managed like the environmental binding —
// dedicated endpoint, only called when something actually changed.
if (savedTaskId && this._adaptiveSnapshot() !== this._adaptiveInitial) {
const alpha = parseFloat(this._adaptiveAlpha);
const minIv = parseInt(this._adaptiveMin, 10);
const maxIv = parseInt(this._adaptiveMax, 10);
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/set_adaptive",
entry_id: this._entryId,
task_id: savedTaskId,
enabled: this._adaptiveEnabled,
...(alpha >= 0.1 && alpha <= 0.9 ? { ewa_alpha: alpha } : {}),
...(!isNaN(minIv) && minIv >= 1 ? { min_interval_days: minIv } : {}),
...(!isNaN(maxIv) && maxIv >= 1 ? { max_interval_days: maxIv } : {}),
seasonal_enabled: this._adaptiveSeasonal,
sensor_prediction_enabled: this._adaptivePrediction,
});
this._adaptiveInitial = this._adaptiveSnapshot();
} catch {
/* non-fatal — task itself saved */
}
}
this._open = false;
this.dispatchEvent(new CustomEvent("task-saved"));
} catch (e) {
@@ -1127,6 +1242,11 @@ export class MaintenanceTaskDialog extends LitElement {
private _close(): void {
this._open = false;
if (this._pickerProbeTimer !== undefined) {
clearTimeout(this._pickerProbeTimer);
this._pickerProbeTimer = undefined;
}
this._pickerProbeStrikes = 0;
}
private _renderTriggerFields() {
@@ -1148,17 +1268,40 @@ export class MaintenanceTaskDialog extends LitElement {
</select>
</div>
${isCompound ? this._renderCompoundEditor() : html`
<ms-textfield
label="${t("entity_id", L)} (${t("comma_separated", L)})"
.value=${this._triggerEntityIds.length > 0 ? this._triggerEntityIds.join(", ") : this._triggerEntityId}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
const ids = raw.split(",").map((s: string) => s.trim()).filter(Boolean);
${this._entityPickerFallback ? html`
<ms-textfield
label="${t("entity_id", L)} (${t("comma_separated", L)})"
.value=${this._triggerEntityIds.length > 0 ? this._triggerEntityIds.join(", ") : this._triggerEntityId}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
const ids = raw.split(",").map((s: string) => s.trim()).filter(Boolean);
this._triggerEntityId = ids[0] || "";
this._triggerEntityIds = ids;
if (ids[0]) this._fetchEntityAttributes(ids[0]);
}}
></ms-textfield>
` : html`
<ha-form
class="entity-picker-form"
.hass=${this.hass}
.schema=${[{
name: "trigger_entities",
selector: { entity: { multiple: true, domain: TRIGGER_PICKER_DOMAINS } },
}]}
.data=${{
trigger_entities: this._triggerEntityIds.length > 0
? this._triggerEntityIds
: this._triggerEntityId ? [this._triggerEntityId] : [],
}}
.computeLabel=${() => t("entity_id", L)}
@value-changed=${(e: CustomEvent) => {
const ids = ((e.detail.value as { trigger_entities?: string[] }).trigger_entities || []).filter(Boolean);
this._triggerEntityId = ids[0] || "";
this._triggerEntityIds = ids;
if (ids[0]) this._fetchEntityAttributes(ids[0]);
else this._fetchEntityAttributes("");
}}
></ms-textfield>
></ha-form>`}
${this._triggerEntityIds.length > 1 ? html`
<div class="select-row">
<label>${t("entity_logic", L)}</label>
@@ -1275,11 +1418,28 @@ export class MaintenanceTaskDialog extends LitElement {
@click=${() => this._removeCondition(i)}
>✕</button>
</div>
<ms-textfield
label="${t("entity_id", L)} (${t("comma_separated", L)})"
.value=${c.entityIds}
@input=${(e: Event) => this._patchCondition(i, { entityIds: (e.target as HTMLInputElement).value })}
></ms-textfield>
${this._entityPickerFallback ? html`
<ms-textfield
label="${t("entity_id", L)} (${t("comma_separated", L)})"
.value=${c.entityIds}
@input=${(e: Event) => this._patchCondition(i, { entityIds: (e.target as HTMLInputElement).value })}
></ms-textfield>
` : html`
<ha-form
class="entity-picker-form"
.hass=${this.hass}
.schema=${[{
name: "condition_entities",
selector: { entity: { multiple: true, domain: TRIGGER_PICKER_DOMAINS } },
}]}
.data=${{ condition_entities: c.entityIds.split(",").map((s) => s.trim()).filter(Boolean) }}
.computeLabel=${() => t("entity_id", L)}
@value-changed=${(e: CustomEvent) => {
const ids = ((e.detail.value as { condition_entities?: string[] }).condition_entities || []).filter(Boolean);
this._patchCondition(i, { entityIds: ids.join(", ") });
}}
></ha-form>`}
${this._renderConditionAttribute(c, i)}
<div class="select-row">
<label>${t("trigger_type", L)}</label>
<select
@@ -1296,6 +1456,198 @@ export class MaintenanceTaskDialog extends LitElement {
`;
}
/** State field bound to an entity (#129 follow-up): HA's state selector
* suggests the entity's known states instead of free text. Falls back to
* the plain textfield without an entity or when the pickers are broken in
* this context (same _entityPickerFallback flag). */
private _renderStateField(args: {
label: string;
value: string;
entityId: string;
onInput: (v: string) => void;
}) {
if (this._entityPickerFallback || !args.entityId) {
return html`
<ms-textfield
label=${args.label}
.value=${args.value}
@input=${(e: Event) => args.onInput((e.target as HTMLInputElement).value)}
></ms-textfield>
`;
}
return html`
<ha-form
class="state-picker-form"
.hass=${this.hass}
.schema=${[{ name: "s", selector: { state: { entity_id: args.entityId } } }]}
.data=${{ s: args.value }}
.computeLabel=${() => args.label}
@value-changed=${(e: CustomEvent) =>
args.onInput(((e.detail.value as { s?: string }).s || "").trim())}
></ha-form>
`;
}
/** Multi-state variant for runtime ON-states — keeps the internal
* comma-string representation so the save path stays unchanged. */
private _renderOnStatesField(args: { value: string; entityId: string; onInput: (v: string) => void }) {
const L = this._lang;
if (this._entityPickerFallback || !args.entityId) {
return html`
<ms-textfield
label="${t("runtime_on_states", L)}"
placeholder="on"
.value=${args.value}
@input=${(e: Event) => args.onInput((e.target as HTMLInputElement).value)}
></ms-textfield>
`;
}
return html`
<ha-form
class="state-picker-form"
.hass=${this.hass}
.schema=${[{ name: "s", selector: { state: { entity_id: args.entityId, multiple: true } } }]}
.data=${{ s: (args.value || "").split(",").map((x) => x.trim()).filter(Boolean) }}
.computeLabel=${() => t("runtime_on_states", L)}
@value-changed=${(e: CustomEvent) =>
args.onInput((((e.detail.value as { s?: string[] }).s) || []).join(", "))}
></ha-form>
`;
}
/** Adaptive-scheduling tuning (parity with the options flow's adaptive
* step). Hidden for one-time/manual tasks — there is no recurrence to
* adapt. Collapsed unless adaptive is already enabled. */
private _renderAdaptiveSection(L: string) {
if (this._scheduleType === "one_time" || this._scheduleType === "manual") return nothing;
return html`
<details class="adaptive-section" ?open=${this._adaptiveEnabled}>
<summary>${t("adaptive_section_title", L)}</summary>
<label>
<input
type="checkbox"
.checked=${this._adaptiveEnabled}
@change=${(e: Event) => (this._adaptiveEnabled = (e.target as HTMLInputElement).checked)}
/>
${t("adaptive_enabled", L)}
</label>
${this._adaptiveEnabled ? html`
<ms-textfield
label="${t("adaptive_min_interval", L)}"
type="number"
min="1"
.value=${this._adaptiveMin}
@input=${(e: Event) => (this._adaptiveMin = (e.target as HTMLInputElement).value)}
></ms-textfield>
<ms-textfield
label="${t("adaptive_max_interval", L)}"
type="number"
min="1"
.value=${this._adaptiveMax}
@input=${(e: Event) => (this._adaptiveMax = (e.target as HTMLInputElement).value)}
></ms-textfield>
<ms-textfield
label="${t("adaptive_ewa_alpha", L)}"
type="number"
min="0.1"
max="0.9"
step="0.1"
.value=${this._adaptiveAlpha}
@input=${(e: Event) => (this._adaptiveAlpha = (e.target as HTMLInputElement).value)}
></ms-textfield>
<label>
<input
type="checkbox"
.checked=${this._adaptiveSeasonal}
@change=${(e: Event) => (this._adaptiveSeasonal = (e.target as HTMLInputElement).checked)}
/>
${t("adaptive_seasonal_enabled", L)}
</label>
<label>
<input
type="checkbox"
.checked=${this._adaptivePrediction}
@change=${(e: Event) => (this._adaptivePrediction = (e.target as HTMLInputElement).checked)}
/>
${t("adaptive_prediction_enabled", L)}
</label>
` : nothing}
</details>
`;
}
/** Environmental attribute — the same live-fetched dropdown the flat and
* compound attribute fields use, keyed by the environmental entity. */
private _renderEnvironmentalAttribute(L: string) {
this._fetchConditionAttributes(this._environmentalEntity);
const opts = this._conditionAttrOptions[this._environmentalEntity];
if (opts && opts.available.length > 0) {
return html`
<div class="select-row">
<label>${t("environmental_attribute_optional", L)}</label>
<select
.value=${this._environmentalAttribute}
@change=${(e: Event) => (this._environmentalAttribute = (e.target as HTMLSelectElement).value)}
>
<option value="" ?selected=${!this._environmentalAttribute}>${t("use_entity_state", L)}</option>
${opts.suggested.map(
(attr) => html`<option value=${attr} ?selected=${attr === this._environmentalAttribute}>${attr} ★</option>`
)}
${opts.available
.filter((a) => !opts.suggested.includes(a.name))
.map(
(a) => html`<option value=${a.name} ?selected=${a.name === this._environmentalAttribute}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
)}
</select>
</div>
`;
}
return html`
<ms-textfield
label="${t("environmental_attribute_optional", L)}"
.value=${this._environmentalAttribute}
@input=${(e: Event) => (this._environmentalAttribute = (e.target as HTMLInputElement).value.trim())}
></ms-textfield>
`;
}
/** Attribute selector for one compound condition — the same live-fetched
* dropdown the flat editor has, keyed by the condition's first entity. */
private _renderConditionAttribute(c: CompoundConditionDraft, i: number) {
const L = this._lang;
const firstId = c.entityIds.split(",")[0]?.trim() || "";
if (firstId) this._fetchConditionAttributes(firstId);
const opts = firstId ? this._conditionAttrOptions[firstId] : undefined;
if (opts && opts.available.length > 0) {
return html`
<div class="select-row">
<label>${t("attribute_optional", L)}</label>
<select
.value=${c.attribute}
@change=${(e: Event) => this._patchCondition(i, { attribute: (e.target as HTMLSelectElement).value })}
>
<option value="" ?selected=${!c.attribute}>${t("use_entity_state", L)}</option>
${opts.suggested.map(
(attr) => html`<option value=${attr} ?selected=${attr === c.attribute}>${attr} ★</option>`
)}
${opts.available
.filter((a) => !opts.suggested.includes(a.name))
.map(
(a) => html`<option value=${a.name} ?selected=${a.name === c.attribute}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
)}
</select>
</div>
`;
}
return html`
<ms-textfield
label="${t("attribute_optional", L)}"
.value=${c.attribute}
@input=${(e: Event) => this._patchCondition(i, { attribute: (e.target as HTMLInputElement).value.trim() })}
></ms-textfield>
`;
}
/** Type-specific inputs for a single compound condition (mirrors the flat
* per-type fields, bound to the condition draft). */
private _renderConditionTypeFields(c: CompoundConditionDraft, i: number) {
@@ -1322,21 +1674,34 @@ export class MaintenanceTaskDialog extends LitElement {
`;
}
if (c.type === "state_change") {
const condEntity = c.entityIds.split(",")[0]?.trim() || "";
return html`
<ms-textfield label="${t("from_state_optional", L)}" .value=${c.fromState}
@input=${(e: Event) => this._patchCondition(i, { fromState: (e.target as HTMLInputElement).value })}></ms-textfield>
<ms-textfield label="${t("to_state_optional", L)}" .value=${c.toState}
@input=${(e: Event) => this._patchCondition(i, { toState: (e.target as HTMLInputElement).value })}></ms-textfield>
${this._renderStateField({
label: t("from_state_optional", L),
value: c.fromState,
entityId: condEntity,
onInput: (v) => this._patchCondition(i, { fromState: v }),
})}
${this._renderStateField({
label: t("to_state_optional", L),
value: c.toState,
entityId: condEntity,
onInput: (v) => this._patchCondition(i, { toState: v }),
})}
<ms-textfield label="${t("target_changes", L)}" type="number" .value=${c.targetChanges}
@input=${(e: Event) => this._patchCondition(i, { targetChanges: (e.target as HTMLInputElement).value })}></ms-textfield>
`;
}
if (c.type === "runtime") {
const condEntity = c.entityIds.split(",")[0]?.trim() || "";
return html`
<ms-textfield label="${t("runtime_hours", L)}" type="number" .value=${c.runtimeHours}
@input=${(e: Event) => this._patchCondition(i, { runtimeHours: (e.target as HTMLInputElement).value })}></ms-textfield>
<ms-textfield label="${t("runtime_on_states", L)}" placeholder="on" .value=${c.onStates}
@input=${(e: Event) => this._patchCondition(i, { onStates: (e.target as HTMLInputElement).value })}></ms-textfield>
${this._renderOnStatesField({
value: c.onStates,
entityId: condEntity,
onInput: (v) => this._patchCondition(i, { onStates: v }),
})}
`;
}
return nothing;
@@ -1396,6 +1761,7 @@ export class MaintenanceTaskDialog extends LitElement {
protected updated(changed: Map<PropertyKey, unknown>): void {
super.updated?.(changed);
this._scheduleEntityPickerProbe();
for (const key of changed.keys()) {
if (MaintenanceTaskDialog._PREVIEW_RELEVANT.has(String(key))) {
this._schedulePreviewRefresh();
@@ -1404,6 +1770,62 @@ export class MaintenanceTaskDialog extends LitElement {
}
}
/** #129 SAFETY NET (not the primary fix): HA's modern pickers resolve data
* via Lit context — events that must bubble up to providers on the
* <home-assistant> element. A dialog mounted outside that tree gets
* pickers that upgrade to an EMPTY shadow root. The root cause is solved
* by mounting dialogs inside <home-assistant>'s shadow root
* (dialog-mount.ts); this probe remains as defense in depth for unknown
* contexts: two consecutive zero-height measurements of the leaf pickers
* inside a visible dialog flip the trigger fields back to the
* comma-separated text inputs. */
private _scheduleEntityPickerProbe(): void {
if (
this._entityPickerFallback
|| this._pickerProbeTimer !== undefined
|| !this._open
|| this._scheduleType !== "sensor_based"
) return;
this._pickerProbeTimer = setTimeout(() => this._probeEntityPickers(), 1500);
}
private _probeEntityPickers(): void {
this._pickerProbeTimer = undefined;
if (this._entityPickerFallback || !this._open) return;
const form = this.shadowRoot?.querySelector<HTMLElement>("ha-form.entity-picker-form");
const dialogVisible = (this.shadowRoot?.querySelector<HTMLElement>(".content")?.offsetHeight ?? 0) > 0;
if (!form || !dialogVisible) {
this._pickerProbeStrikes = 0;
return;
}
// The broken-context signature is subtle: the form (and even a
// ha-entities-picker wrapper) may keep its label height while the
// ha-entity-picker LEAVES upgrade to empty shadow roots — so collect the
// leaf pickers across ALL picker forms and require every one to lay out.
const collectLeaves = (el: Element | null, out: HTMLElement[], depth = 0): void => {
if (!el || depth > 10) return;
if ((el.tagName?.toLowerCase() ?? "") === "ha-entity-picker") out.push(el as HTMLElement);
for (const root of [el.shadowRoot, el]) {
if (!root) continue;
for (const child of Array.from(root.children ?? [])) collectLeaves(child, out, depth + 1);
}
};
const forms = [...(this.shadowRoot?.querySelectorAll<HTMLElement>("ha-form.entity-picker-form") ?? [])];
const leaves: HTMLElement[] = [];
for (const f of forms) collectLeaves(f, leaves);
const broken = leaves.length === 0 || leaves.some((leaf) => leaf.offsetHeight === 0);
if (form.offsetHeight === 0 || broken) {
this._pickerProbeStrikes += 1;
if (this._pickerProbeStrikes >= 2) {
this._entityPickerFallback = true;
return;
}
this._pickerProbeTimer = setTimeout(() => this._probeEntityPickers(), 700);
} else {
this._pickerProbeStrikes = 0;
}
}
private _schedulePreviewRefresh(): void {
if (this._previewTimer) clearTimeout(this._previewTimer);
this._previewTimer = setTimeout(() => void this._fetchSchedulePreview(), 300);
@@ -1773,17 +2195,19 @@ export class MaintenanceTaskDialog extends LitElement {
}
if (this._triggerType === "state_change") {
return html`
<ms-textfield
label="${t("from_state_optional", L)}"
.value=${this._triggerFromState}
@input=${(e: Event) => (this._triggerFromState = (e.target as HTMLInputElement).value)}
></ms-textfield>
${this._renderStateField({
label: t("from_state_optional", L),
value: this._triggerFromState,
entityId: this._triggerEntityId,
onInput: (v) => (this._triggerFromState = v),
})}
<div class="field-help">${t("state_value_help", L)}</div>
<ms-textfield
label="${t("to_state_optional", L)}"
.value=${this._triggerToState}
@input=${(e: Event) => (this._triggerToState = (e.target as HTMLInputElement).value)}
></ms-textfield>
${this._renderStateField({
label: t("to_state_optional", L),
value: this._triggerToState,
entityId: this._triggerEntityId,
onInput: (v) => (this._triggerToState = v),
})}
<ms-textfield
label="${t("target_changes", L)}"
type="number"
@@ -1803,12 +2227,11 @@ export class MaintenanceTaskDialog extends LitElement {
.value=${this._triggerRuntimeHours}
@input=${(e: Event) => (this._triggerRuntimeHours = (e.target as HTMLInputElement).value)}
></ms-textfield>
<ms-textfield
label="${t("runtime_on_states", L)}"
placeholder="on"
.value=${this._triggerOnStates}
@input=${(e: Event) => (this._triggerOnStates = (e.target as HTMLInputElement).value)}
></ms-textfield>
${this._renderOnStatesField({
value: this._triggerOnStates,
entityId: this._triggerEntityId,
onInput: (v) => (this._triggerOnStates = v),
})}
<div class="field-help">${t("runtime_on_states_help", L)}</div>
`;
}
@@ -2064,20 +2487,34 @@ export class MaintenanceTaskDialog extends LitElement {
` : nothing}
${this._renderTriggerFields()}
${this._scheduleType === "sensor_based" ? html`
<ms-textfield
label="${t("environmental_entity_optional", L)}"
helper="${t("environmental_entity_helper", L)}"
.value=${this._environmentalEntity}
@input=${(e: Event) => (this._environmentalEntity = (e.target as HTMLInputElement).value.trim())}
></ms-textfield>
${this._environmentalEntity ? html`
${this._entityPickerFallback ? html`
<ms-textfield
label="${t("environmental_attribute_optional", L)}"
.value=${this._environmentalAttribute}
@input=${(e: Event) => (this._environmentalAttribute = (e.target as HTMLInputElement).value.trim())}
label="${t("environmental_entity_optional", L)}"
helper="${t("environmental_entity_helper", L)}"
.value=${this._environmentalEntity}
@input=${(e: Event) => (this._environmentalEntity = (e.target as HTMLInputElement).value.trim())}
></ms-textfield>
` : nothing}
` : html`
<ha-form
class="entity-picker-form"
.hass=${this.hass}
.schema=${[{
name: "environmental_entity",
selector: { entity: {
domain: ENVIRONMENTAL_PICKER_DOMAINS,
device_class: ENVIRONMENTAL_PICKER_DEVICE_CLASSES,
} },
}]}
.data=${{ environmental_entity: this._environmentalEntity }}
.computeLabel=${() => t("environmental_entity_optional", L)}
.computeHelper=${() => t("environmental_entity_helper", L)}
@value-changed=${(e: CustomEvent) => {
this._environmentalEntity = ((e.detail.value as { environmental_entity?: string }).environmental_entity || "").trim();
}}
></ha-form>`}
${this._environmentalEntity ? this._renderEnvironmentalAttribute(L) : nothing}
` : nothing}
${this._renderAdaptiveSection(L)}
<ms-textfield
label="${t("notes_optional", L)}"
.value=${this._notes}
@@ -2157,17 +2594,35 @@ export class MaintenanceTaskDialog extends LitElement {
font-weight: 500;
padding-bottom: 12px;
}
/* v1.3.0: completion-action sections */
.ca-section {
/* #129: entity/state pickers in the trigger form (ha-form + selector) */
.entity-picker-form,
.state-picker-form {
display: block;
margin: 8px 0;
}
/* v1.3.0: completion-action sections (.adaptive-section shares the shell
but keeps its own class — tests count .ca-section elements) */
.ca-section,
.adaptive-section {
border: 1px solid var(--divider-color);
border-radius: 6px;
padding: 8px 12px;
margin-top: 8px;
}
.ca-section > summary {
.ca-section > summary,
.adaptive-section > summary {
cursor: pointer;
font-weight: 500;
}
.adaptive-section ms-textfield {
width: 100%;
margin-top: 8px;
display: block;
}
.adaptive-section label {
display: block;
margin-top: 8px;
}
.ca-section ms-textfield,
.ca-section ha-entity-picker,
.ca-section ha-service-picker,