/** Dialog to discover HA "problem" sensors and adopt them as maintenance tasks. * * Lists binary/problem sensors that aren't already tracked, preselects them all, * and turns each into a maintenance task that triggers while the problem is * active and clears when it resolves. A selection either attaches to a suggested * existing object or spins up a fresh object bound to the sensor's device. * * 2.94 (duplicates from adopting in a real home): a sensor whose device no * object is linked to goes by default to the existing object that device most * likely is (and links it); a sensor that object probably already watches * under another name starts unticked with that task's name; a summary says * per object how many tasks it has before and after. Typing another object * asks the server again (problem_sensors/preview). */ import { css, html, LitElement, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { t, ensureLocale, langOf } from "../styles"; import { runWs } from "../helpers/ws-run"; import { TRIGGER_FOR_MINUTES_RANGE } from "../helpers/setting-ranges"; import { UserService } from "../user-service"; import type { HAUser, HomeAssistant } from "../types"; interface ProblemSensor { entity_id: string; name: string; state: string; device_id: string | null; device_name: string | null; area_name: string | null; suggested_entry_id: string | null; suggested_object_name: string; suggested_part_id: string | null; suggested_part_name: string | null; candidate?: { entry_id: string; name: string; reasons: string[] } | null; target_entry_id?: string | null; covered_by?: Covered; } type Covered = { task_id: string; name: string; reason: string } | null; interface DiscoverResponse { sensors: ProblemSensor[]; } interface AdoptResponse { tasks_created: number; objects_created: number; created: Array<{ entry_id: string; task_id: string; name: string }>; total: number; errors?: string[]; } export class MaintenanceAdoptProblemSensorsDialog extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @state() private _open = false; @state() private _loading = false; @state() private _adopting = false; @state() private _error = ""; @state() private _sensors: ProblemSensor[] = []; @state() private _selected: Set = new Set(); /** #188: the object each selected sensor goes to — typed per row; the same * name on several rows makes them ONE object, an existing object's name * adds them to it. */ @state() private _objectNames: Record = {}; @state() private _objects: Array<{ entry_id: string; name: string; tasks: number }> = []; /** 2.94: the task each sensor's target probably already has for it. */ @state() private _covered: Record = {}; private _previewTimer: ReturnType | undefined; @state() private _users: HAUser[] = []; @state() private _responsible = ""; // #136: minutes the problem must persist before the created task triggers // (one field applied to every selection). "0" = react to the first flicker. @state() private _forMinutes = "0"; private _localeReady = false; private _userService: UserService | null = null; private get _lang(): string { return langOf(this.hass); } updated(changed: Map): void { if (changed.has("hass") && this.hass && !this._localeReady) { this._localeReady = true; ensureLocale(this._lang).then(() => this.requestUpdate()); } } public async open(): Promise { this._open = true; this._loading = true; this._error = ""; this._sensors = []; this._selected = new Set(); this._objectNames = {}; this._responsible = ""; this._forMinutes = "0"; if (!this._userService) this._userService = new UserService(this.hass); else this._userService.updateHass(this.hass); const [resp, users, objs] = await Promise.all([ runWs(this, { type: "maintenance_supporter/problem_sensors/discover" }, { onError: (m) => { this._error = m; }, }), // Best-effort: adoption works fine without the user list. this._userService.getUsers().catch(() => [] as HAUser[]), // Best-effort too: the existing objects feed the name suggestions. this.hass.connection .sendMessagePromise<{ objects: Array<{ entry_id: string; object: { name: string; archived_at?: string | null }; tasks?: unknown[] }>; }>({ type: "maintenance_supporter/objects", }) .catch(() => ({ objects: [] })), ]); this._loading = false; if (resp === undefined) return; this._objects = (objs?.objects || []) .filter((o) => !o.object.archived_at) .map((o) => ({ entry_id: o.entry_id, name: o.object.name, tasks: (o.tasks || []).length })); this._sensors = resp?.sensors || []; this._covered = Object.fromEntries(this._sensors.map((s) => [s.entity_id, s.covered_by ?? null])); // What the target probably already watches starts unticked. this._selected = new Set(this._sensors.filter((s) => !s.covered_by).map((s) => s.entity_id)); this._users = users; } /** The default object: the one linked to the device, else the one the * device most likely is (2.94), else a new one named after the device. */ private _defaultName(s: ProblemSensor): string { return s.suggested_entry_id ? s.suggested_object_name : (s.candidate?.name ?? s.suggested_object_name); } /** The object name a row will be adopted into (typed, else the default). */ private _effectiveName(s: ProblemSensor): string { const typed = (this._objectNames[s.entity_id] ?? "").trim(); return typed || this._defaultName(s); } /** An existing object the row lands in: the default's entry when the name * is untouched, else an existing object with exactly that name. */ private _existingEntryFor(s: ProblemSensor): string | null { const name = this._effectiveName(s); const defaultEntry = s.suggested_entry_id || s.target_entry_id || null; if (name === this._defaultName(s) && defaultEntry) return defaultEntry; const hit = this._objects.find((o) => o.name.trim().toLowerCase() === name.toLowerCase()); return hit ? hit.entry_id : null; } /** Another object typed: the server judges the hints again (debounced). */ private _schedulePreview(): void { clearTimeout(this._previewTimer); this._previewTimer = setTimeout(() => void this._preview(), 350); } private async _preview(): Promise { const selections = this._sensors.map((s) => ({ entity_id: s.entity_id, name: s.name, entry_id: this._existingEntryFor(s), })); const resp = await runWs<{ covered: Record }>( this, { type: "maintenance_supporter/problem_sensors/preview", selections }, { onError: () => {} }, ); if (resp?.covered) this._covered = { ...this._covered, ...resp.covered }; } /** Per target object: tasks before → after (only ticked rows count). */ private _summary(L: string) { const groups = new Map(); for (const s of this._sensors) { if (!this._selected.has(s.entity_id)) continue; const entry = this._existingEntryFor(s); const name = this._effectiveName(s); const key = entry ?? `new:${name.toLowerCase()}`; const g = groups.get(key) ?? { name, entry, adding: 0 }; g.adding += 1; groups.set(key, g); } if (groups.size === 0) return nothing; return html`
${t("adopt_summary_title", L)}
${[...groups.values()].map((g) => { if (!g.entry) { return html`
${t("setups_new_object_count", L).replace("{name}", g.name).replace("{count}", String(g.adding))}
`; } const before = this._objects.find((o) => o.entry_id === g.entry)?.tasks ?? 0; return html`
${t("adopt_summary_existing", L) .replace("{name}", g.name) .replace("{before}", String(before)) .replace("{after}", String(before + g.adding))}
`; })}
`; } private _close(): void { clearTimeout(this._previewTimer); this._open = false; } private _toggle = (entityId: string): void => { const next = new Set(this._selected); if (next.has(entityId)) next.delete(entityId); else next.add(entityId); this._selected = next; }; private _toggleAll = (): void => { if (this._selected.size === this._sensors.length) { this._selected = new Set(); } else { this._selected = new Set(this._sensors.map((s) => s.entity_id)); } }; private _adopt = async (): Promise => { if (this._selected.size === 0 || this._adopting) return; this._error = ""; const selections = this._sensors .filter((s) => this._selected.has(s.entity_id)) .map((s) => ({ entity_id: s.entity_id, name: s.name, entry_id: this._existingEntryFor(s) ?? undefined, object_name: this._effectiveName(s), device_id: s.device_id ?? undefined, part_id: s.suggested_part_id ?? undefined, responsible_user_id: this._responsible || undefined, for_minutes: parseInt(this._forMinutes, 10) > 0 ? parseInt(this._forMinutes, 10) : undefined, })); const result = await runWs( this, { type: "maintenance_supporter/problem_sensors/adopt", selections }, { busy: (b) => { this._adopting = b; }, onError: (m) => { this._error = m; } }, ); if (result === undefined) return; this.dispatchEvent( new CustomEvent("problem-sensors-adopted", { bubbles: true, composed: true, detail: result, }), ); this._open = false; }; /** The target line: the found object with why it was chosen, else the name. */ private _targetLabel(s: ProblemSensor, L: string): string { if (s.candidate && this._existingEntryFor(s) === s.candidate.entry_id) { const reasons = s.candidate.reasons.map((r) => t(`setups_reason_${r}`, L)).join(", "); return t("setups_target_match", L).replace("{name}", s.candidate.name).replace("{reasons}", reasons); } return this._effectiveName(s); } render() { if (!this._open) return html``; const L = this._lang; const allSelected = this._sensors.length > 0 && this._selected.size === this._sensors.length; return html`
e.stopPropagation()}>
${t("adopt_problem_title", L)}
${t("adopt_problem_hint", L)}
${this._error ? html`
${this._error}
` : nothing} ${this._loading ? html`
…
` : this._sensors.length === 0 ? html`
${t("adopt_problem_none", L)}
` : html`
${t("adopt_object_hint", L)}
${this._objects.map((o) => html``)}
${this._sensors.map((s) => { const checked = this._selected.has(s.entity_id); const active = s.state === "on"; const sub = [s.device_name, s.area_name] .filter(Boolean) .join(" · "); return html` `; })}
`} ${!this._loading && this._sensors.length > 0 ? this._summary(L) : nothing} ${!this._loading && this._sensors.length > 0 ? html`
${t("adopt_for_minutes_hint", L)}
` : nothing} ${!this._loading && this._sensors.length > 0 && this._users.length > 0 ? html` ` : nothing}
${t("cancel", L)} ${t("adopt_problem_adopt", L)}
`; } static styles = css` .overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; } .card { background: var(--card-background-color, #fff); color: var(--primary-text-color); border-radius: 12px; padding: 20px; display: flex; flex-direction: column; gap: 12px; min-width: min(360px, calc(100vw - 24px)); max-width: 560px; width: 90vw; max-height: 80vh; overflow: hidden; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); } .title { font-size: 18px; font-weight: 500; } .hint { color: var(--secondary-text-color); font-size: 13px; } .error { color: var(--error-color, #f44336); font-size: 13px; } .loading, .empty { color: var(--secondary-text-color); font-size: 14px; padding: 12px 0; } .select-all { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--secondary-text-color); cursor: pointer; } .select-all input { cursor: pointer; } .list { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; max-height: 50vh; } .row { display: flex; align-items: flex-start; gap: 10px; padding: 8px; border: 1px solid var(--divider-color); border-radius: 6px; cursor: pointer; } .row input { margin-top: 2px; cursor: pointer; } .row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; } .row-top { display: flex; align-items: center; gap: 8px; } .row-name { font-weight: 500; font-size: 13px; } .row-sub { color: var(--secondary-text-color); font-size: 12px; } .row-object { margin-top: 6px; } .row-object input { width: 100%; box-sizing: border-box; padding: 6px 8px; font: inherit; border: 1px solid var(--divider-color); border-radius: 6px; background: var(--card-background-color); color: var(--primary-text-color); } .adopt-object-hint { margin: 0 0 8px; } .row-target { color: var(--secondary-text-color); font-size: 12px; } .row-part { color: var(--secondary-text-color); font-size: 12px; display: flex; align-items: center; gap: 4px; } .row-part ha-icon { --mdc-icon-size: 14px; } .new-tag { font-style: italic; } .maybe { font-size: 11px; color: var(--warning-color, #ff9800); } .summary { font-size: 12px; border-top: 1px dashed var(--divider-color); padding-top: 6px; } .summary-title { font-weight: 500; margin-bottom: 2px; } .summary-line { color: var(--primary-color); } .chip { font-size: 11px; padding: 1px 8px; border-radius: 10px; white-space: nowrap; } .chip-active { background: var(--error-color, #f44336); color: #fff; } .chip-ok { background: var(--divider-color); color: var(--secondary-text-color); } .responsible { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--secondary-text-color); flex-wrap: wrap; } .for-input { width: 70px; background: var(--card-background-color); color: var(--primary-text-color); border: 1px solid var(--divider-color); border-radius: 4px; padding: 4px 6px; } .for-hint { font-size: 11px; color: var(--secondary-text-color); margin: -4px 0 2px; } .responsible select { flex: 1; min-width: 140px; padding: 4px 6px; border-radius: 4px; border: 1px solid var(--divider-color); background: var(--card-background-color, #fff); color: var(--primary-text-color); font-size: 13px; } .actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 8px; } `; } if (!customElements.get("maintenance-adopt-problem-sensors-dialog")) { customElements.define( "maintenance-adopt-problem-sensors-dialog", MaintenanceAdoptProblemSensorsDialog, ); }