148 files

This commit is contained in:
Home Assistant Version Control
2026-09-22 20:21:39 +00:00
parent 43021c1d3a
commit 94e9f32a43
148 changed files with 15528 additions and 2013 deletions
@@ -48,6 +48,11 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
@state() private _error = "";
@state() private _sensors: ProblemSensor[] = [];
@state() private _selected: Set<string> = 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<string, string> = {};
@state() private _objects: Array<{ entry_id: string; name: string }> = [];
@state() private _users: HAUser[] = [];
@state() private _responsible = "";
// #136: minutes the problem must persist before the created task triggers
@@ -74,18 +79,28 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
this._error = "";
this._sensors = [];
this._selected = new Set();
this._objectNames = {};
this._responsible = "";
this._forMinutes = "0";
try {
if (!this._userService) this._userService = new UserService(this.hass);
else this._userService.updateHass(this.hass);
const [resp, users] = await Promise.all([
const [resp, users, objs] = await Promise.all([
this.hass.connection.sendMessagePromise<DiscoverResponse>({
type: "maintenance_supporter/problem_sensors/discover",
}),
// 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 } }> }>({
type: "maintenance_supporter/objects",
})
.catch(() => ({ objects: [] })),
]);
this._objects = (objs.objects || [])
.filter((o) => !o.object.archived_at)
.map((o) => ({ entry_id: o.entry_id, name: o.object.name }));
this._sensors = resp.sensors || [];
this._selected = new Set(this._sensors.map((s) => s.entity_id));
this._users = users;
@@ -96,6 +111,21 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
}
}
/** The object name a row will be adopted into (typed, else the suggestion). */
private _effectiveName(s: ProblemSensor): string {
const typed = (this._objectNames[s.entity_id] ?? "").trim();
return typed || s.suggested_object_name;
}
/** An existing object the row lands in: the suggestion'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);
if (name === s.suggested_object_name && s.suggested_entry_id) return s.suggested_entry_id;
const hit = this._objects.find((o) => o.name.trim().toLowerCase() === name.toLowerCase());
return hit ? hit.entry_id : null;
}
private _close(): void {
this._open = false;
}
@@ -125,8 +155,8 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
.map((s) => ({
entity_id: s.entity_id,
name: s.name,
entry_id: s.suggested_entry_id ?? undefined,
object_name: s.suggested_object_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,
@@ -177,6 +207,10 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
/>
<span>${t("selected", L)}: ${this._selected.size} / ${this._sensors.length}</span>
</label>
<div class="row-sub adopt-object-hint">${t("adopt_object_hint", L)}</div>
<datalist id="adopt-object-names">
${this._objects.map((o) => html`<option value=${o.name}></option>`)}
</datalist>
<div class="list">
${this._sensors.map((s) => {
const checked = this._selected.has(s.entity_id);
@@ -201,8 +235,22 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
</span>
</div>
${sub ? html`<div class="row-sub">${sub}</div>` : nothing}
${checked
? html`<div class="row-object" @click=${(e: Event) => e.stopPropagation()}>
<input
class="adopt-object"
list="adopt-object-names"
aria-label=${t("object", L)}
placeholder=${s.suggested_object_name}
.value=${this._objectNames[s.entity_id] ?? s.suggested_object_name}
@input=${(e: Event) => {
this._objectNames = { ...this._objectNames, [s.entity_id]: (e.target as HTMLInputElement).value };
}}
/>
</div>`
: nothing}
<div class="row-target">
→ ${s.suggested_object_name}${s.suggested_entry_id
→ ${this._effectiveName(s)}${this._existingEntryFor(s)
? nothing
: html` <span class="new-tag">${t("adopt_problem_new_object", L)}</span>`}
</div>
@@ -365,6 +413,13 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
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;
@@ -18,12 +18,16 @@ import { runWs } from "../helpers/ws-run";
import type { TriggerType } from "../types";
import { REQUIRED_COMPLETION_KEYS, REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
import "./ms-textfield";
import { defaultNotifyIcon } from "../helpers/notify-icons";
import "./ms-date-field";
const MAINTENANCE_TYPE_KEYS = ["cleaning", "inspection", "replacement", "calibration", "service", "reading", "custom"];
const PRIORITY_KEYS = ["low", "normal", "high"];
const SCHEDULE_TYPE_KEYS = ["time_based", "weekdays", "nth_weekday", "day_of_month", "sensor_based", "one_time", "manual"];
const CALENDAR_KINDS = ["weekdays", "nth_weekday", "day_of_month"];
const SCHEDULE_TYPE_KEYS = ["time_based", "weekdays", "nth_weekday", "day_of_month", "calendar", "sensor_based", "one_time", "manual"];
// The nested calendar kinds — the three date patterns plus, since #187, a HA
// calendar entity (one occurrence per event). Mirrors _CALENDAR_KINDS in
// helpers/schedule.py (tripwire: tests/test_cross_layer_vocab.py).
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];
@@ -194,6 +198,8 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _domLastDay = false;
@state() private _domBusiness = false;
@state() private _calOffset = "0";
// #187: the HA calendar entity whose events are the occurrences ("calendar" kind).
@state() private _calendarEntity = "";
// Recurrence extras (apply to interval + calendar kinds): a seasonal active
// window and a finite-series end condition.
@state() private _seasonMonths: number[] = [];
@@ -209,6 +215,8 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _notes = "";
@state() private _documentationUrl = "";
@state() private _customIcon = "";
/** #185: mdi icon for this task's push notifications ("" = type default). */
@state() private _notifyIcon = "";
@state() private _priority = "normal";
@state() private _labels = "";
/** D#183: external todo.* lists the task is mirrored into while due. */
@@ -402,6 +410,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._domDay = sched?.kind === "day_of_month" && (sched.day ?? 1) >= 1 ? String(sched.day ?? 1) : "1";
this._domLastDay = sched?.kind === "day_of_month" && sched.day === -1;
this._domBusiness = sched?.kind === "day_of_month" && sched.business === true;
this._calendarEntity = sched?.kind === "calendar" ? (sched.entity_id || "") : "";
this._calOffset = sched?.offset ? String(sched.offset) : "0";
// Recurrence extras — read from the nested schedule wherever it lives.
this._seasonMonths = Array.isArray(sched?.season_months) ? [...sched!.season_months] : [];
@@ -420,6 +429,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._notes = task.notes || "";
this._documentationUrl = task.documentation_url || "";
this._customIcon = task.custom_icon || "";
this._notifyIcon = task.notify_icon || "";
this._priority = task.priority || "normal";
this._labels = (task.labels || []).join(", ");
this._mirrorTodoEntities = [...(task.mirror_todo_entities || [])];
@@ -577,6 +587,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._notes = "";
this._documentationUrl = "";
this._customIcon = "";
this._notifyIcon = "";
this._priority = "normal";
this._labels = "";
this._mirrorTodoEntities = [];
@@ -1266,6 +1277,11 @@ export class MaintenanceTaskDialog extends LitElement {
this._error = t("trigger_hint_overlap", this._lang);
return;
}
if (this._scheduleType === "calendar" && !this._calendarEntity.trim().startsWith("calendar.")) {
// #187: the backend would store entity_id null (a task without dates).
this._error = t("calendar_entity_required", this._lang);
return;
}
this._loading = true;
this._error = "";
try {
@@ -1326,6 +1342,8 @@ export class MaintenanceTaskDialog extends LitElement {
data.notes = this._notes || null;
data.documentation_url = this._documentationUrl || null;
data.custom_icon = this._customIcon || null;
// #185: null clears the override (the update path pops the key).
data.notify_icon = this._notifyIcon.trim() || null;
data.priority = this._priority;
data.labels = this._labels
.split(",")
@@ -2071,6 +2089,7 @@ export class MaintenanceTaskDialog extends LitElement {
if (this._scheduleType === "one_time") {
return this._dueDate ? { kind: "one_time", due_date: this._dueDate } : null;
}
if (this._scheduleType === "calendar" && !this._calendarEntity.trim()) return null;
if (CALENDAR_KINDS.includes(this._scheduleType)) {
return { ...this._buildSchedule(), ...this._recurrenceExtras() };
}
@@ -2088,7 +2107,7 @@ export class MaintenanceTaskDialog extends LitElement {
private static readonly _PREVIEW_RELEVANT = new Set([
"_open", "_scheduleType", "_intervalDays", "_intervalUnit", "_intervalAnchor",
"_dueDate", "_weekdays", "_nth", "_nthWeekday", "_domDay", "_domLastDay",
"_domBusiness", "_calOffset", "_seasonMonths", "_endsMode", "_endsCount",
"_domBusiness", "_calOffset", "_calendarEntity", "_seasonMonths", "_endsMode", "_endsCount",
"_endsUntil", "_lastPerformed",
]);
@@ -2231,6 +2250,10 @@ export class MaintenanceTaskDialog extends LitElement {
weekday: parseInt(this._nthWeekday, 10),
});
}
if (this._scheduleType === "calendar") {
// #187: once per event of the picked HA calendar entity.
return withOffset({ kind: "calendar", entity_id: this._calendarEntity.trim() });
}
// (#83) "last day" wins over the day number; business rolls weekends back.
const schedule: Record<string, unknown> = {
kind: "day_of_month",
@@ -2345,6 +2368,33 @@ export class MaintenanceTaskDialog extends LitElement {
</div>
${this._renderCalOffsetField()}`;
}
if (this._scheduleType === "calendar") {
// #187: pick the HA calendar. Same <ha-form> entity-selector idiom as the
// trigger pickers (never <ha-entity-picker> directly — see the
// dialog-no-lazy-load-elements tripwire), with the text fallback the
// picker probe flips to in a broken context.
return html`
${this._entityPickerFallback ? html`
<ms-textfield
label="${t("calendar_entity_label", L)}"
helper="${t("calendar_entity_hint", L)}"
.value=${this._calendarEntity}
@input=${(e: Event) => (this._calendarEntity = (e.target as HTMLInputElement).value.trim())}
></ms-textfield>
` : html`
<ha-form
class="entity-picker-form calendar-entity-form"
.hass=${this.hass}
.schema=${[{ name: "calendar_entity", selector: { entity: { domain: "calendar" } } }]}
.data=${{ calendar_entity: this._calendarEntity }}
.computeLabel=${() => t("calendar_entity_label", L)}
.computeHelper=${() => t("calendar_entity_hint", L)}
@value-changed=${(e: CustomEvent) => {
this._calendarEntity = ((e.detail.value as { calendar_entity?: string }).calendar_entity || "").trim();
}}
></ha-form>`}
${this._renderCalOffsetField()}`;
}
if (this._scheduleType === "day_of_month") {
return html`
${this._domLastDay ? nothing : html`
@@ -2962,6 +3012,18 @@ export class MaintenanceTaskDialog extends LitElement {
@value-changed=${(e: CustomEvent) =>
(this._customIcon = (e.detail.value as string) || "")}
></ha-icon-picker>
<ha-icon-picker
class="notify-icon-picker"
.hass=${this.hass}
label="${t("notify_icon", L)}"
.value=${this._notifyIcon}
@value-changed=${(e: CustomEvent) =>
(this._notifyIcon = (e.detail.value as string) || "")}
></ha-icon-picker>
<div class="field-help notify-icon-help">
<ha-icon icon=${this._notifyIcon.trim() || defaultNotifyIcon(this._type)}></ha-icon>
${t("notify_icon_hint", L).replace("{default}", defaultNotifyIcon(this._type))}
</div>
${this._availableTags.length > 0
? html`
<div class="select-row">