182 files

This commit is contained in:
Home Assistant Version Control
2026-08-17 12:11:35 +00:00
parent 7dddf6bb13
commit ece15a1c1b
183 changed files with 11457 additions and 3092 deletions
@@ -9,7 +9,7 @@
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { UserService } from "../user-service";
import type { HAUser, HomeAssistant } from "../types";
@@ -55,7 +55,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
private _userService: UserService | null = null;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
updated(changed: Map<string, unknown>): void {
@@ -6,7 +6,8 @@
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant } from "../types";
@@ -84,7 +85,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
private _localeReady = false;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
connectedCallback(): void {
@@ -232,24 +233,14 @@ export class MaintenanceBatteryFleetSection extends LitElement {
</svg>`;
}
private static readonly _SORT_KEY = "ms_bf_roster_sort";
private static _storedSort(): "name" | "urgency" {
try {
const v = localStorage.getItem(MaintenanceBatteryFleetSection._SORT_KEY);
return v === "name" ? "name" : "urgency";
} catch {
return "urgency";
}
return lsGet(LS_KEYS.batteryRosterSort) === "name" ? "name" : "urgency";
}
private _setSort(mode: "name" | "urgency"): void {
this._rosterSort = mode;
try {
localStorage.setItem(MaintenanceBatteryFleetSection._SORT_KEY, mode);
} catch {
// storage unavailable — the toggle still works for this visit
}
// Storage may be unavailable — the toggle still works for this visit.
lsSet(LS_KEYS.batteryRosterSort, mode);
}
/** Urgency (the default, issue #123): low rows first — emptiest first —
@@ -8,7 +8,8 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL } from "../styles";
import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL, langOf } from "../styles";
import { registerCustomCard } from "../helpers/register-card";
import { describeWsError } from "../ws-errors";
import { sectionCardSharedStyles } from "./section-card-shared-styles";
import type { BudgetStatus, HomeAssistant } from "../types";
@@ -40,7 +41,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
}
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
private get _isAdmin(): boolean {
@@ -277,9 +278,7 @@ if (!customElements.get("maintenance-budget-section-card")) {
);
}
(window as { customCards?: unknown[] }).customCards =
(window as { customCards?: unknown[] }).customCards || [];
((window as { customCards?: unknown[] }).customCards!).push({
registerCustomCard({
type: "maintenance-budget-section-card",
name: "Maintenance Supporter — Budget",
description: "Inline monthly + yearly budget editor",
@@ -10,10 +10,12 @@
import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { downloadUrl } from "../helpers/download";
import { downloadSignedDocument, openSignedDocument, signDocumentPath } from "../helpers/document-url";
import { formatBytes } from "../helpers/format-bytes";
import { CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
import type { HomeAssistant } from "../types";
interface MaintenanceDocument {
@@ -28,16 +30,6 @@ interface MaintenanceDocument {
added_at?: string;
}
const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const;
const CATEGORY_ICONS: Record<string, string> = {
manual: "mdi:book-open-variant",
warranty: "mdi:shield-check",
invoice: "mdi:receipt-text-outline",
spare_parts: "mdi:cog-outline",
photo: "mdi:image-outline",
other: "mdi:file-document-outline",
};
export class MaintenanceDocumentsSection extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entryId!: string;
@@ -67,16 +59,11 @@ export class MaintenanceDocumentsSection extends LitElement {
}
private async _sign(doc: MaintenanceDocument): Promise<string> {
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
});
return signed.path;
return signDocumentPath(this.hass, doc.id);
}
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
updated(changed: Map<string, unknown>): void {
@@ -210,12 +197,7 @@ export class MaintenanceDocumentsSection extends LitElement {
private async _download(doc: MaintenanceDocument): Promise<void> {
try {
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 30,
});
downloadUrl(signed.path, doc.filename || doc.title || "document");
await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
} catch (e) {
this._error = describeWsError(e, this._lang);
}
@@ -228,15 +210,9 @@ export class MaintenanceDocumentsSection extends LitElement {
this._lightboxUrl = this._thumbs[doc.id] || (await this._sign(doc));
return;
}
// Open the tab synchronously (in the click gesture) so it isn't popup-blocked,
// then point it at the freshly signed URL once it resolves.
const win = window.open("about:blank", "_blank");
try {
const url = await this._sign(doc);
// Absolute URL so it always resolves against the blank popup (about:blank).
if (win) win.location.href = new URL(url, window.location.origin).href;
await openSignedDocument(this.hass, doc.id);
} catch (e) {
if (win) win.close();
this._error = describeWsError(e, this._lang);
}
}
@@ -3,7 +3,7 @@
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../styles";
import { t, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import "./ms-textfield";
import type {
@@ -26,7 +26,7 @@ export class MaintenanceGroupDialog extends LitElement {
@state() private _selected: Set<string> = new Set(); // "entry_id:task_id"
private get _lang(): string {
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
return langOf(this.hass);
}
public openCreate(): void {
@@ -8,7 +8,8 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { registerCustomCard } from "../helpers/register-card";
import { describeWsError } from "../ws-errors";
import { sectionCardSharedStyles } from "./section-card-shared-styles";
import type { HomeAssistant } from "../types";
@@ -47,7 +48,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
}
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
private get _isAdmin(): boolean {
@@ -330,9 +331,7 @@ if (!customElements.get("maintenance-groups-section-card")) {
);
}
(window as { customCards?: unknown[] }).customCards =
(window as { customCards?: unknown[] }).customCards || [];
((window as { customCards?: unknown[] }).customCards!).push({
registerCustomCard({
type: "maintenance-groups-section-card",
name: "Maintenance Supporter — Groups",
description: "Inline group CRUD",
@@ -9,7 +9,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../styles";
import { t, langOf } from "../styles";
import type { HomeAssistant } from "../types";
import { describeWsError } from "../ws-errors";
@@ -50,7 +50,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
private _originalSnapshot: HistoryEntryDraft | null = null;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
// #130: selectable parts + the edited selection (part key -> quantity;
@@ -10,6 +10,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant } from "../types";
import { signDocumentPath } from "../helpers/document-url";
export class MaintenanceHistoryPhoto extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -29,12 +30,7 @@ export class MaintenanceHistoryPhoto extends LitElement {
private async _sign(): Promise<void> {
try {
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${this.docId}`,
expires: 300,
});
this._url = signed.path;
this._url = await signDocumentPath(this.hass, this.docId);
} catch {
this._failed = true;
}
@@ -3,7 +3,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant, MaintenanceObject, MaintenanceObjectResponse } from "../types";
import { t } from "../styles";
import { t, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import "./ms-textfield";
@@ -33,7 +33,7 @@ export class MaintenanceObjectDialog extends LitElement {
@state() private _entryId: string | null = null; // null = create, string = update
private get _lang(): string {
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
return langOf(this.hass);
}
public openCreate(): void {
@@ -11,7 +11,7 @@
import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { property, state } from "lit/decorators.js";
import { t, STATUS_COLORS } from "../styles";
import { t, STATUS_COLORS, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant, MaintenanceObject, MaintenanceTask } from "../types";
@@ -31,7 +31,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
@state() private _error = "";
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
public async openFor(entryId: string): Promise<void> {
@@ -15,7 +15,7 @@
import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant, MaintenancePart } from "../types";
// Per-part document links (v2.26) — the task-documents component in part mode.
@@ -70,7 +70,7 @@ export class MaintenancePartsSection extends LitElement {
@state() private _docsFor: string | null = null;
private get _lang(): string {
return this.hass?.locale?.language || this.hass?.language || "en";
return langOf(this.hass);
}
public connectedCallback(): void {
@@ -12,7 +12,7 @@
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant, SavedView, SavedViewFilters } from "../types";
@@ -33,7 +33,7 @@ export class MaintenanceSavedViewsDialog extends LitElement {
private _localeReady = false;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
updated(changed: Map<string, unknown>): void {
@@ -171,7 +171,7 @@ export class MaintenanceSavedViewsDialog extends LitElement {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 340px;
min-width: min(360px, calc(100vw - 24px));
max-width: 480px;
width: 90vw;
max-height: 80vh;
@@ -3,7 +3,7 @@
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../styles";
import { t, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant } from "../types";
@@ -23,7 +23,7 @@ export class SeasonalOverridesDialog extends LitElement {
@state() private _values: string[] = new Array(12).fill("");
private get _lang(): string {
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
return langOf(this.hass);
}
public open(entryId: string, taskId: string, currentOverrides: Record<number, number> | null | undefined): void {
@@ -4,7 +4,9 @@ import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types";
import { t } from "../styles";
import { t, langOf } from "../styles";
import { signApiPath } from "../helpers/document-url";
import { downloadUrl } from "../helpers/download";
import { UserService } from "../user-service";
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
import { downloadTextFile } from "../helpers/download";
@@ -160,7 +162,7 @@ export class MaintenanceSettingsView extends LitElement {
private _userService: UserService | null = null;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
updated(changedProps: Map<string, unknown>): void {
@@ -1430,6 +1432,7 @@ export class MaintenanceSettingsView extends LitElement {
<button @click=${this._exportJson}>${t("settings_export_json", L)}</button>
<button @click=${this._exportYaml}>${t("settings_export_yaml", L)}</button>
<button @click=${this._exportCsv}>${t("settings_export_csv", L)}</button>
<button @click=${this._exportSettings}>${t("settings_export_settings", L)}</button>
</div>
<div class="settings-actions docs-archive-block">
<h4>${t("settings_docs_archive", L)}</h4>
@@ -1510,6 +1513,22 @@ export class MaintenanceSettingsView extends LitElement {
}
}
/** The SECOND export: the global entry's settings (groups, saved views,
* vacation, notification/budget settings, feature toggles) — the objects
* export deliberately excludes them. Re-import via the regular import. */
private async _exportSettings(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/settings/export",
}) as { data: string };
const ts = new Date().toISOString().slice(0, 10);
this._downloadFile(result.data, `maintenance_settings_${ts}.json`, "application/json");
this._showToast(t("settings_export_success", this._lang));
} catch {
this._showToast(t("action_error", this._lang));
}
}
private async _exportYaml(): Promise<void> {
try {
const ids = this._selectedEntryIds;
@@ -1573,17 +1592,8 @@ export class MaintenanceSettingsView extends LitElement {
try {
const raw = this._selectedEntryIds;
const q = raw ? `?entry_ids=${encodeURIComponent(raw.join(","))}` : "";
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/documents/archive${q}`,
expires: 300,
});
const a = document.createElement("a");
a.href = signed.path;
a.download = "maintenance-documents.zip";
document.body.appendChild(a);
a.click();
a.remove();
const signed = await signApiPath(this.hass, `/api/maintenance_supporter/documents/archive${q}`);
downloadUrl(signed, "maintenance-documents.zip");
} catch {
this._showToast(t("action_error", this._lang));
}
@@ -8,8 +8,9 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { openSignedDocument } from "../helpers/document-url";
import { formatBytes } from "../helpers/format-bytes";
import type { HomeAssistant } from "../types";
@@ -55,7 +56,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
private _searchTimer = 0;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
updated(changed: Map<string, unknown>): void {
@@ -137,17 +138,9 @@ export class MaintenanceStorageSectionCard extends LitElement {
window.open(doc.url, "_blank", "noopener");
return;
}
const win = window.open("about:blank", "_blank");
try {
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
});
// Absolute URL so it navigates the blank popup reliably (about:blank base).
if (win) win.location.href = new URL(s.path, window.location.origin).href;
await openSignedDocument(this.hass, doc.id);
} catch (e) {
if (win) win.close();
this._error = describeWsError(e, this._lang);
}
}
@@ -11,7 +11,7 @@
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant } from "../types";
@@ -61,7 +61,7 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
private _localeReady = false;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
updated(changed: Map<string, unknown>): void {
@@ -3,7 +3,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types";
import { formatDate, t, weekdayName } from "../styles";
import { formatDate, t, weekdayName, langOf } from "../styles";
import { UserService } from "../user-service";
import { partLinkKey } from "../helpers/shared-parts";
import {
@@ -24,6 +24,8 @@ const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime"];
// The type selector additionally offers "compound" (a group of conditions
// joined by AND/OR); its per-condition sub-type is limited to the flat kinds.
const TRIGGER_TYPE_KEYS_WITH_COMPOUND = [...TRIGGER_TYPE_KEYS, "compound"];
// Backend defaults for the adaptive tuning fields (ewa_alpha / min / max days).
const ADAPTIVE_DEFAULTS = { alpha: "0.3", min: "7", max: "365" } as const;
/** One condition of a compound trigger — a flat trigger the user edits inline.
* String-typed like the top-level fields (form inputs); coerced on save. */
@@ -279,9 +281,9 @@ export class MaintenanceTaskDialog extends LitElement {
// 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 _adaptiveAlpha: string = ADAPTIVE_DEFAULTS.alpha;
@state() private _adaptiveMin: string = ADAPTIVE_DEFAULTS.min;
@state() private _adaptiveMax: string = ADAPTIVE_DEFAULTS.max;
@state() private _adaptiveSeasonal = true;
@state() private _adaptivePrediction = true;
private _adaptiveInitial = "";
@@ -295,7 +297,7 @@ export class MaintenanceTaskDialog extends LitElement {
private _userService: UserService | null = null;
private get _lang(): string {
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
return langOf(this.hass);
}
public async openCreate(entryId: string, objects?: Array<{ entry_id: string; object: { name: string } }>): Promise<void> {
@@ -404,9 +406,9 @@ export class MaintenanceTaskDialog extends LitElement {
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._adaptiveAlpha = ac.ewa_alpha?.toString() ?? ADAPTIVE_DEFAULTS.alpha;
this._adaptiveMin = ac.min_interval_days?.toString() ?? ADAPTIVE_DEFAULTS.min;
this._adaptiveMax = ac.max_interval_days?.toString() ?? ADAPTIVE_DEFAULTS.max;
this._adaptiveSeasonal = ac.seasonal_enabled !== false;
this._adaptivePrediction = ac.sensor_prediction_enabled !== false;
this._adaptiveInitial = this._adaptiveSnapshot();
@@ -497,9 +499,9 @@ export class MaintenanceTaskDialog extends LitElement {
this._environmentalInitial = "";
this._environmentalAttributeInitial = "";
this._adaptiveEnabled = false;
this._adaptiveAlpha = "0.3";
this._adaptiveMin = "7";
this._adaptiveMax = "365";
this._adaptiveAlpha = ADAPTIVE_DEFAULTS.alpha;
this._adaptiveMin = ADAPTIVE_DEFAULTS.min;
this._adaptiveMax = ADAPTIVE_DEFAULTS.max;
this._adaptiveSeasonal = true;
this._adaptivePrediction = true;
this._adaptiveInitial = this._adaptiveSnapshot();
@@ -1314,34 +1316,13 @@ export class MaintenanceTaskDialog extends LitElement {
</select>
</div>
` : nothing}
${this._availableAttributes.length > 0
? html`
<div class="select-row">
<label>${t("attribute_optional", L)}</label>
<select
.value=${this._triggerAttribute}
@change=${(e: Event) => (this._triggerAttribute = (e.target as HTMLSelectElement).value)}
>
<option value="" ?selected=${!this._triggerAttribute}>${t("use_entity_state", L)}</option>
${this._suggestedAttributes.map(
(attr) => html`<option value=${attr} ?selected=${attr === this._triggerAttribute}>${attr} ★</option>`
)}
${this._availableAttributes
.filter((a) => !this._suggestedAttributes.includes(a.name))
.map(
(a) => html`<option value=${a.name} ?selected=${a.name === this._triggerAttribute}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
)}
</select>
</div>
`
: html`
<ms-textfield
label="${t("attribute_optional", L)}"
.value=${this._triggerAttribute}
@input=${(e: Event) => (this._triggerAttribute = (e.target as HTMLInputElement).value)}
></ms-textfield>
`
}
${this._renderAttributeSelect({
label: t("attribute_optional", L),
value: this._triggerAttribute,
suggested: this._suggestedAttributes,
available: this._availableAttributes,
onSelect: (v) => (this._triggerAttribute = v),
})}
${this._renderTriggerTypeFields()}
${this._renderTriggerLiveHint()}
`}
@@ -1576,76 +1557,73 @@ export class MaintenanceTaskDialog extends LitElement {
`;
}
/** Shared attribute selector: "use entity state" + suggested★ + remaining
* attributes (non-numeric flagged), textfield fallback when none known. */
private _renderAttributeSelect(cfg: {
label: string;
value: string;
suggested: string[];
available: { name: string; numeric: boolean }[];
onSelect: (value: string) => void;
}) {
const L = this._lang;
if (cfg.available.length > 0) {
return html`
<div class="select-row">
<label>${cfg.label}</label>
<select
.value=${cfg.value}
@change=${(e: Event) => cfg.onSelect((e.target as HTMLSelectElement).value)}
>
<option value="" ?selected=${!cfg.value}>${t("use_entity_state", L)}</option>
${cfg.suggested.map(
(attr) => html`<option value=${attr} ?selected=${attr === cfg.value}>${attr} ★</option>`
)}
${cfg.available
.filter((a) => !cfg.suggested.includes(a.name))
.map(
(a) => html`<option value=${a.name} ?selected=${a.name === cfg.value}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
)}
</select>
</div>
`;
}
return html`
<ms-textfield
label="${cfg.label}"
.value=${cfg.value}
@input=${(e: Event) => cfg.onSelect((e.target as HTMLInputElement).value.trim())}
></ms-textfield>
`;
}
/** 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>
`;
return this._renderAttributeSelect({
label: t("environmental_attribute_optional", L),
value: this._environmentalAttribute,
suggested: opts?.suggested ?? [],
available: opts?.available ?? [],
onSelect: (v) => (this._environmentalAttribute = v),
});
}
/** 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>
`;
return this._renderAttributeSelect({
label: t("attribute_optional", this._lang),
value: c.attribute,
suggested: opts?.suggested ?? [],
available: opts?.available ?? [],
onSelect: (v) => this._patchCondition(i, { attribute: v }),
});
}
/** Type-specific inputs for a single compound condition (mirrors the flat
@@ -11,10 +11,12 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { downloadUrl } from "../helpers/download";
import { downloadSignedDocument, openSignedDocument } from "../helpers/document-url";
import { formatBytes } from "../helpers/format-bytes";
import { CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
import type { HomeAssistant } from "../types";
interface Doc {
@@ -31,16 +33,6 @@ interface Doc {
part_ids?: string[];
}
const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const;
const CATEGORY_ICONS: Record<string, string> = {
manual: "mdi:book-open-variant",
warranty: "mdi:shield-check",
invoice: "mdi:receipt-text-outline",
spare_parts: "mdi:cog-outline",
photo: "mdi:image-outline",
other: "mdi:file-document-outline",
};
export class MaintenanceTaskDocuments extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entryId!: string;
@@ -59,7 +51,7 @@ export class MaintenanceTaskDocuments extends LitElement {
private _localeReady = false;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
/** The id linked docs reference — a task id or (part mode) a part id. */
@@ -158,19 +150,9 @@ export class MaintenanceTaskDocuments extends LitElement {
// A per-task page hint jumps straight to the relevant page via the PDF
// viewer's #page=N fragment (client-side, so it never breaks the signature).
const page = this._pageFor(doc);
const frag = page ? `#page=${page}` : "";
const win = window.open("about:blank", "_blank");
try {
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
});
// Absolute URL: a fragment on a *root-relative* path won't resolve against
// the blank popup's about:blank base, so it would silently stay blank.
if (win) win.location.href = new URL(s.path + frag, window.location.origin).href;
await openSignedDocument(this.hass, doc.id, page ? `#page=${page}` : "");
} catch (e) {
if (win) win.close();
this._error = describeWsError(e, this._lang);
}
}
@@ -196,12 +178,7 @@ export class MaintenanceTaskDocuments extends LitElement {
private async _download(doc: Doc): Promise<void> {
try {
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 30,
});
downloadUrl(s.path, doc.filename || doc.title || "document");
await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
} catch (e) {
this._error = describeWsError(e, this._lang);
}
@@ -14,7 +14,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence } from "../styles";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { renderWeibullSection } from "../renderers/weibull";
import { renderPredictionSection } from "../renderers/prediction";
@@ -62,7 +62,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
private _featuresLoaded = false;
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
/** Open the dialog. Loads fresh data from /object via WS so dialog stays in
@@ -13,7 +13,8 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { t, ensureLocale, langOf } from "../styles";
import { registerCustomCard } from "../helpers/register-card";
import { describeWsError } from "../ws-errors";
import { sectionCardSharedStyles } from "./section-card-shared-styles";
import type { HomeAssistant } from "../types";
@@ -55,7 +56,7 @@ export class MaintenanceVacationSectionCard extends LitElement {
}
private get _lang(): string {
return this.hass?.language || "en";
return langOf(this.hass);
}
private get _isAdmin(): boolean {
@@ -317,9 +318,7 @@ if (!customElements.get("maintenance-vacation-section-card")) {
);
}
(window as { customCards?: unknown[] }).customCards =
(window as { customCards?: unknown[] }).customCards || [];
((window as { customCards?: unknown[] }).customCards!).push({
registerCustomCard({
type: "maintenance-vacation-section-card",
name: "Maintenance Supporter — Vacation",
description: "Inline vacation mode toggle + dates",