158 files
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user