393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -5,17 +5,20 @@ import { property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { live } from "lit/directives/live.js";
import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types";
import { t, langOf, personStyles, syncCurrencyDecimals} from "../styles";
import { t, langOf, personStyles, syncCurrencyDecimals, formatNumber } from "../styles";
import { signApiPath } from "../helpers/document-url";
import { downloadUrl } from "../helpers/download";
import { UserService } from "../user-service";
import { AVATAR_PALETTE, personOf, renderPersonAvatar } from "../helpers/person";
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
import { downloadTextFile } from "../helpers/download";
import { invalidateSettingsCache } from "../helpers/settings-cache";
import { SETTING_INT_RANGES, settingIntRange } from "../helpers/setting-ranges";
import { invalidateSettingsCache, ROW_ACTION_STYLES } from "../helpers/settings-cache";
import { SETTING_INT_RANGES, VACATION_BUFFER_DAYS_RANGE, settingIntRange } from "../helpers/setting-ranges";
import { ToastTimer } from "../helpers/toast";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { runWs } from "../helpers/ws-run";
import { DOCS_ARCHIVE_MAX_BYTES, postMultipart } from "../helpers/photo-upload";
import { countryName, detectionReasons, dwellingLabel, type HomeProfile } from "../helpers/home-profile";
import "./ms-date-field";
/** One household member and the notify services they actually resolve to.
@@ -37,6 +40,9 @@ interface SettingsResponse {
objects_table_columns?: string[];
/** v2.21: template-gallery curation — hidden template ids. */
disabled_template_ids?: string[];
/** v2.93: home profile dwelling type — auto | house | apartment. */
home_type?: string;
home_region?: string;
general: {
default_warning_days: number;
notifications_enabled: boolean;
@@ -501,9 +507,18 @@ export class MaintenanceSettingsView extends LitElement {
}
};
private readonly _toastTimer = new ToastTimer();
private _showToast(msg: string): void {
this._toast = msg;
setTimeout(() => { this._toast = ""; }, 3000);
this._toastTimer.schedule(() => { this._toast = ""; });
}
disconnectedCallback(): void {
super.disconnectedCallback();
// A pending hide must not fire into a detached view (DRY audit 2026-09-26).
this._toastTimer.clear();
this._toast = "";
}
/** One WS round-trip; a rejection becomes a toast carrying the server's
@@ -595,6 +610,7 @@ export class MaintenanceSettingsView extends LitElement {
${this._renderVacation(L)}
${this._renderPrintQr(L)}
${this._renderImportExport(L)}
${this._renderHomeProfile(L)}
${this._renderTemplateToggles(L)}
${this._toast ? html`<div class="settings-toast">${this._toast}</div>` : nothing}
`;
@@ -705,6 +721,8 @@ export class MaintenanceSettingsView extends LitElement {
@state() private _allTemplates: Array<{ id: string; name: string; category: string; disabled?: boolean }> = [];
@state() private _templateCategories: Record<string, Record<string, string>> = {};
/** v2.93: served with the templates read (see helpers/home-profile.ts). */
@state() private _homeProfile: HomeProfile | null = null;
@state() private _tplOpenGroups: Set<string> = new Set();
// One-shot request guard: keyed on a plain flag, NOT on the result being
@@ -719,15 +737,102 @@ export class MaintenanceSettingsView extends LitElement {
const res = await this.hass.connection.sendMessagePromise<{
templates: Array<{ id: string; name: string; category: string; disabled?: boolean }>;
categories: Record<string, Record<string, string>>;
profile?: HomeProfile;
}>({
type: "maintenance_supporter/templates",
language: this._lang,
});
this._allTemplates = res.templates || [];
this._templateCategories = res.categories || {};
this._homeProfile = res.profile ?? null;
} catch { /* section renders empty; retried on next open */ }
}
// --- Section: Home profile (v2.93) ---
private _renderHomeProfile(L: string) {
this._loadTemplates();
const p = this._homeProfile;
const setting = this._settings!.home_type || "auto";
const c = p?.climate;
const climate = c && c.koppen
? t("home_climate_value", L)
.replace("{koppen}", c.koppen)
.replace("{cold}", String(c.coldest_c ?? "–"))
.replace("{warm}", String(c.warmest_c ?? "–"))
: t("home_climate_unknown", L);
const country = countryName(p?.country, L);
return html`
<div class="settings-section" data-section="home_profile">
<h3>${t("home_profile_title", L)}</h3>
<p class="section-desc">${t("home_profile_hint", L)}</p>
<label class="setting-row">
<span class="setting-label">${t("home_type_label", L)}</span>
<select class="home-type" .value=${live(setting)}
@change=${(e: Event) => void this._setHomeType((e.target as HTMLSelectElement).value)}>
<option value="auto" ?selected=${setting === "auto"}>
${t("home_type_auto", L).replace("{detected}", dwellingLabel(p?.dwelling_detected ?? "unknown", L))}
</option>
<option value="house" ?selected=${setting === "house"}>${t("home_dwelling_house", L)}</option>
<option value="apartment" ?selected=${setting === "apartment"}>${t("home_dwelling_apartment", L)}</option>
</select>
</label>
${p && p.dwelling_reasons.length
? html`<div class="setting-hint home-detected">
${t("home_detected_from", L).replace("{reasons}", detectionReasons(p.dwelling_reasons, L))}
</div>`
: nothing}
<div class="setting-row home-climate">
<span class="setting-label">${t("home_climate_label", L)}</span>
<span class="home-climate-value">${country ? `${country} · ` : ""}${climate}</span>
</div>
${p?.regions?.length ? this._renderHomeRegion(p, L) : nothing}
${p?.hemisphere === "south" ? html`<div class="setting-hint">${t("home_hemisphere_south", L)}</div>` : nothing}
${p && p.traits.length
? html`<div class="home-traits">
${p.traits.map((tr) => html`<span class="home-trait">${t(`home_trait_${tr}`, L)}</span>`)}
</div>`
: nothing}
</div>
`;
}
/** 2.94: the state / province whose rules the templates follow — shown
* only where the country has such rules (data/regions). */
private _renderHomeRegion(p: HomeProfile, L: string) {
const regions = p.regions || [];
const setting = this._settings!.home_region || "auto";
const value = regions.some((r) => r.code === setting) ? setting : "auto";
const detected = p.region_detected_name || t("home_region_none", L);
return html`
<label class="setting-row">
<span class="setting-label">${t("home_region_label", L)}</span>
<select class="home-region" .value=${live(value)}
@change=${(e: Event) => void this._setHomeRegion((e.target as HTMLSelectElement).value)}>
<option value="auto" ?selected=${value === "auto"}>${t("home_type_auto", L).replace("{detected}", detected)}</option>
${regions.map((r) => html`<option value=${r.code} ?selected=${value === r.code}>${r.name}</option>`)}
</select>
</label>
<div class="setting-hint">${t("home_region_hint", L)}</div>
`;
}
private async _setHomeRegion(value: string): Promise<void> {
if (await this._updateSetting("home_region", value)) {
// The region changes intervals and which checks exist — refetch.
this._templatesRequested = false;
await this._loadTemplates();
}
}
private async _setHomeType(value: string): Promise<void> {
if (await this._updateSetting("home_type", value)) {
// The dwelling drives the recommendations — refetch the profile.
this._templatesRequested = false;
await this._loadTemplates();
}
}
private _renderTemplateToggles(L: string) {
this._loadTemplates();
const hidden = new Set(this._settings!.disabled_template_ids || []);
@@ -916,7 +1021,7 @@ export class MaintenanceSettingsView extends LitElement {
<span class="setting-label">${t("settings_row_actions", L)}</span>
<select .value=${live(g.row_action_style || "buttons_compact")}
@change=${(e: Event) => this._updateSetting("row_action_style", (e.target as HTMLSelectElement).value)}>
${(["buttons_compact", "buttons", "icons"] as const).map((v) => html`
${ROW_ACTION_STYLES.map((v) => html`
<option value=${v} ?selected=${(g.row_action_style || "buttons_compact") === v}>${t(`row_actions_${v}`, L)}</option>`)}
</select>
</label>
@@ -1336,8 +1441,8 @@ export class MaintenanceSettingsView extends LitElement {
</div>
<label class="vac-field">
<span class="filter-label">${t("vacation_buffer", L)}</span>
<input type="number" min="0" max="14" .value=${String(this._vacBuffer)}
@change=${(e: Event) => this._setVacationBuffer(parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
<input type="number" min=${VACATION_BUFFER_DAYS_RANGE[0]} max=${VACATION_BUFFER_DAYS_RANGE[1]} .value=${String(this._vacBuffer)}
@change=${(e: Event) => this._setVacationBuffer(e)} />
</label>
</div>
@@ -1499,9 +1604,21 @@ export class MaintenanceSettingsView extends LitElement {
this._saveVacation(patch);
}
private _setVacationBuffer(value: number): void {
if (value < 0 || value > 14) return;
this._saveVacation({ buffer_days: value });
/** Out of range used to be dropped SILENTLY here while the Lovelace
* vacation card sent it and showed the server's error — now both reject
* it up front with the same message (DRY audit 2026-09-26). */
private _setVacationBuffer(e: Event): void {
const input = e.target as HTMLInputElement;
const [min, max] = VACATION_BUFFER_DAYS_RANGE;
const v = Number(input.value);
if (input.value.trim() !== "" && Number.isInteger(v) && v >= min && v <= max) {
this._saveVacation({ buffer_days: v });
return;
}
this._showToast(
t("settings_value_out_of_range", this._lang).replace("{min}", String(min)).replace("{max}", String(max)),
);
input.value = String(this._vacBuffer);
}
private _toggleVacationExempt(taskId: string, on: boolean): void {
@@ -2001,24 +2118,23 @@ export class MaintenanceSettingsView extends LitElement {
try {
const form = new FormData();
form.append("file", file, file.name);
const resp = await fetch("/api/maintenance_supporter/documents/archive", {
method: "POST",
headers: { Authorization: `Bearer ${this.hass.auth?.data?.access_token ?? ""}` },
body: form,
});
if (!resp.ok) {
this._showToast(t("action_error", this._lang));
} else {
const result = (await resp.json()) as { blobs_written: number; documents_created: number };
this._showToast(
t("settings_docs_import_success", this._lang)
.replace("{blobs}", String(result.blobs_written ?? 0))
.replace("{docs}", String(result.documents_created ?? 0))
);
this.dispatchEvent(new CustomEvent("settings-changed"));
}
} catch {
this._showToast(t("action_error", this._lang));
const result = await postMultipart<{ blobs_written: number; documents_created: number }>(
this.hass, "/api/maintenance_supporter/documents/archive", form, "docs_archive_too_large",
);
this._showToast(
t("settings_docs_import_success", this._lang)
.replace("{blobs}", String(result.blobs_written ?? 0))
.replace("{docs}", String(result.documents_created ?? 0))
);
this.dispatchEvent(new CustomEvent("settings-changed"));
} catch (e) {
// A 413 names the limit; anything else stays the generic message.
this._showToast(
e instanceof Error && e.message === "docs_archive_too_large"
? t("docs_archive_too_large", this._lang)
.replace("{max}", `${formatNumber(DOCS_ARCHIVE_MAX_BYTES / (1024 * 1024), this._lang)} MB`)
: t("action_error", this._lang),
);
}
input.value = "";
this._docArchiveLoading = false;
@@ -2061,6 +2177,12 @@ export class MaintenanceSettingsView extends LitElement {
margin: 0 0 4px 0;
font-size: 16px;
}
.home-climate-value { font-size: 14px; color: var(--secondary-text-color); text-align: right; }
.home-traits { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.home-trait {
font-size: 12px; padding: 3px 8px; border-radius: 12px;
background: var(--secondary-background-color); color: var(--primary-text-color);
}
.section-desc {
font-size: 13px;
color: var(--secondary-text-color);
@@ -2173,7 +2295,12 @@ export class MaintenanceSettingsView extends LitElement {
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 14px;
flex-shrink: 0;
/* May shrink: a select is as wide as its longest option, and German
"Buttons (auf dem Handy nur Symbole)" pushed the row 75 px past a
360 px screen (overflow sweep 2026-09-27). */
flex: 0 1 auto;
min-width: 0;
max-width: 60%;
}
.settings-actions {