197 files

This commit is contained in:
Home Assistant Version Control
2026-08-21 18:53:37 +00:00
parent 89d9f5e49b
commit 383e9ed04e
197 changed files with 4386 additions and 1837 deletions
@@ -50,6 +50,9 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
@state() private _selected: Set<string> = new Set();
@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;
@@ -72,6 +75,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
this._sensors = [];
this._selected = new Set();
this._responsible = "";
this._forMinutes = "0";
try {
if (!this._userService) this._userService = new UserService(this.hass);
else this._userService.updateHass(this.hass);
@@ -126,6 +130,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
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 this.hass.connection.sendMessagePromise<AdoptResponse>({
type: "maintenance_supporter/problem_sensors/adopt",
@@ -214,6 +219,23 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
</div>
`}
${!this._loading && this._sensors.length > 0
? html`
<label class="responsible">
<span>${t("for_at_least_minutes", L)}</span>
<input
class="for-input"
type="number"
min="0"
max="1440"
.value=${this._forMinutes}
@input=${(e: Event) => (this._forMinutes = (e.target as HTMLInputElement).value)}
/>
</label>
<div class="for-hint">${t("adopt_for_minutes_hint", L)}</div>
`
: nothing}
${!this._loading && this._sensors.length > 0 && this._users.length > 0
? html`
<label class="responsible">
@@ -382,6 +404,19 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
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;
@@ -66,11 +66,17 @@ interface Overview {
needs_soon: Record<string, number>;
types: string[];
excluded?: { entity_id: string; device_name: string }[];
// #135 follow-up: fleet-wide "track self-charging devices" opt-in.
track_self_charging?: boolean;
}
export class MaintenanceBatteryFleetSection extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
/** Flat mode drops the section's own card chrome (border/background) —
* set by the Lovelace card wrapper, whose ha-card provides the chrome. */
@property({ type: Boolean }) public flat = false;
@state() private _ov: Overview | null = null;
@state() private _loading = false;
@state() private _marking = false;
@@ -198,6 +204,26 @@ export class MaintenanceBatteryFleetSection extends LitElement {
}
}
// #135 follow-up: fleet-wide opt-in that keeps self-charging devices
// (phones, vacuums, smart rings) in the roster as rechargeables.
private async _setTrackSelf(e: Event): Promise<void> {
const enabled = (e.target as HTMLInputElement).checked;
if (this._marking) return;
this._marking = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/battery_fleet/set_track_self_charging",
enabled,
});
await this._load();
} catch (e2) {
this._error = describeWsError(e2, this._lang);
} finally {
this._marking = false;
}
}
/** Lazy: the recorder-backed history is fetched once, when the roster is
* first expanded — most panel visits never open it. */
private _loadHistory = async (e: Event): Promise<void> => {
@@ -517,6 +543,16 @@ export class MaintenanceBatteryFleetSection extends LitElement {
></ha-selector>
<div class="bf-roster-hint">${t("battery_fleet_add_hint", L)}</div>
</div>
<label class="bf-track-self">
<input
type="checkbox"
.checked=${!!ov.track_self_charging}
.disabled=${this._marking}
@change=${this._setTrackSelf}
/>
${t("battery_fleet_track_self", L)}
</label>
<div class="bf-roster-hint">${t("battery_fleet_track_self_hint", L)}</div>
</details>
`
: nothing}
@@ -558,6 +594,13 @@ export class MaintenanceBatteryFleetSection extends LitElement {
flex-direction: column;
gap: 10px;
}
:host([flat]) .bf-card {
background: transparent;
border: none;
border-radius: 0;
margin: 0;
padding: 0;
}
.bf-head {
display: flex;
align-items: center;
@@ -614,22 +657,69 @@ export class MaintenanceBatteryFleetSection extends LitElement {
.bf-list {
font-weight: 500;
}
/* Cross-row column alignment (same subgrid pattern as the task table,
* issue 66): the LIST owns one column template, every row spans it via
* subgrid, and each element is PINNED to its column below - so an
* optional element (sparkline, percentage, forecast date) leaves its
* column empty instead of letting the rest of the row drift. max-content
* columns collapse to 0 when a whole list never fills them (the low list
* has no status chip, no sparkline, no date). */
.bf-rows {
display: flex;
flex-direction: column;
gap: 4px;
display: grid;
grid-template-columns: minmax(0, 1fr) repeat(9, max-content);
column-gap: 8px;
}
.bf-row {
display: flex;
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
align-items: center;
gap: 10px;
padding: 6px 0;
border-bottom: 1px solid var(--divider-color);
}
/* Column pinning: 1 name, 2 status/offline chip, 3 type, 4 charging
* icon, 5 sparkline, 6 level bar, 7 percentage, 8 row action
* (mark-one / record-swap), 9 forecast date, 10 exclude eye. */
.bf-dev {
flex: 1;
grid-column: 1;
min-width: 0;
}
.bf-offline,
.bf-status {
grid-column: 2;
justify-self: end;
}
.bf-type {
grid-column: 3;
}
.bf-recharge {
grid-column: 4;
}
.bf-spark {
grid-column: 5;
}
.bf-bar {
grid-column: 6;
}
.bf-level {
grid-column: 7;
justify-self: end;
}
.bf-row .bf-mark {
grid-column: 8;
}
.bf-predicted {
grid-column: 9;
justify-self: end;
}
.bf-row .bf-mark.bf-exclude {
grid-column: 10;
}
.bf-offline {
color: var(--secondary-text-color);
font-size: 12px;
font-style: italic;
}
.bf-type {
color: var(--secondary-text-color);
font-size: 13px;
@@ -648,13 +738,48 @@ export class MaintenanceBatteryFleetSection extends LitElement {
flex: 0 0 auto;
cursor: help;
}
/* On phones the row cannot fit name + chips + curve + bar + date: the
* decorations yield (the percentage still carries the number). */
/* On phones the row cannot fit name + chips + curve + bar + date in ONE
* line: the decorations yield (the percentage still carries the number)
* and the row wraps to two lines - the name spans the full width, the
* status chip moves under it (left, into the name column) and the rest
* keeps its pinned subgrid column, so type / percentage / date / eye
* stay aligned across rows. Without this the fixed max-content columns
* overflowed 400 px and the chips overlapped the wrapped names. */
@media (max-width: 640px) {
.bf-spark,
.bf-bar {
display: none;
}
.bf-row {
row-gap: 2px;
}
.bf-dev {
grid-column: 1 / 9;
grid-row: 1;
}
.bf-offline,
.bf-status {
/* Line 1, RIGHT - over the date+eye columns, which are wide enough
* for any chip. Pinning the chip into the 1fr rest column instead
* overlapped the type text (the rest column shrinks below chip
* width, and a span onto an fr track never grows fixed tracks). */
grid-column: 9 / 11;
grid-row: 1;
justify-self: end;
}
.bf-type {
grid-row: 2;
max-width: 44vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bf-recharge,
.bf-level,
.bf-predicted,
.bf-row .bf-mark {
grid-row: 2;
}
}
.bf-spark-line {
fill: none;
@@ -834,6 +959,18 @@ export class MaintenanceBatteryFleetSection extends LitElement {
border-radius: 10px;
padding: 1px 4px 1px 10px;
}
.bf-track-self {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
font-size: 13px;
cursor: pointer;
}
.bf-track-self input {
accent-color: var(--primary-color);
margin: 0;
}
`;
}
@@ -3,7 +3,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant } from "../types";
import { t, nativeFieldStyles } from "../styles";
import { langOf, t, nativeFieldStyles } from "../styles";
export interface ConfirmOptions {
title: string;
@@ -90,7 +90,7 @@ export class MaintenanceConfirmDialog extends LitElement {
render() {
if (!this._open) return nothing;
const lang = this.hass?.language || "en";
const lang = langOf(this.hass);
return html`
<ha-dialog open @closed=${this._cancel}>
<div class="dialog-title">${this._title}</div>
@@ -15,7 +15,7 @@ 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 { docDisplayName, CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
import type { HomeAssistant } from "../types";
interface MaintenanceDocument {
@@ -263,7 +263,7 @@ export class MaintenanceDocumentsSection extends LitElement {
}
private async _delete(doc: MaintenanceDocument): Promise<void> {
const name = doc.title || doc.filename || doc.url || "";
const name = docDisplayName(doc);
if (!window.confirm(t("doc_delete_confirm", this._lang).replace("{name}", name))) return;
this._busy = true;
this._error = "";
@@ -447,7 +447,7 @@ export class MaintenanceDocumentsSection extends LitElement {
}
}}
>
<div class="doc-title">${doc.title || doc.filename || doc.url}</div>
<div class="doc-title">${docDisplayName(doc)}</div>
<div class="doc-meta">${meta}</div>
</div>
<div class="doc-row-actions">
@@ -8,6 +8,7 @@ import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant } from "../types";
import { t } from "../styles";
import { downloadTextFile } from "../helpers/download";
interface QrResult {
svg_data_uri: string;
@@ -194,16 +195,13 @@ ${safeSub ? `<div class="sub">${safeSub}</div>` : ""}
const svgContent = decodeURIComponent(
result.svg_data_uri.replace("data:image/svg+xml,", ""),
);
const blob = new Blob([svgContent], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const name = this._taskName
? `${this._objectName}-${this._taskName}`
: this._objectName;
a.download = `qr-${sanitizeFilename(name)}-${suffix}.svg`;
a.click();
URL.revokeObjectURL(url);
// helpers/download.ts, not a hand-rolled anchor: the detached-anchor +
// immediate-revoke pattern this used silently fails in the Companion
// WebView (drift audit 2026-08 caught the bypass).
downloadTextFile(svgContent, `qr-${sanitizeFilename(name)}-${suffix}.svg`, "image/svg+xml");
}
private _close(): void {
@@ -7,10 +7,12 @@
*/
import { LitElement, html, css, nothing } from "lit";
import { docDisplayName } from "../helpers/document-categories";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { openSignedDocument } from "../helpers/document-url";
import { isSafeHttpUrl } from "../helpers/url";
import { formatBytes } from "../helpers/format-bytes";
import type { HomeAssistant } from "../types";
@@ -135,7 +137,10 @@ export class MaintenanceStorageSectionCard extends LitElement {
private async _openResult(doc: SearchResult): Promise<void> {
if (doc.kind === "weblink") {
window.open(doc.url, "_blank", "noopener");
// Defense-in-depth (mirrors task-documents/documents-section): never
// hand a non-http(s) weblink to window.open, even though the WS add
// path already rejects such schemes.
if (isSafeHttpUrl(doc.url)) window.open(doc.url, "_blank", "noopener");
return;
}
try {
@@ -150,7 +155,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
<div class="obj-row result-row" title=${t("doc_open", L)} @click=${() => this._openResult(doc)}>
<ha-icon icon=${doc.kind === "weblink" ? "mdi:link-variant" : "mdi:file-document-outline"}></ha-icon>
<div class="result-info">
<div class="result-title">${doc.title || doc.filename || doc.url}</div>
<div class="result-title">${docDisplayName(doc)}</div>
<div class="result-obj">${doc.object_name}</div>
</div>
<ha-icon class="result-open" icon=${doc.kind === "weblink" ? "mdi:open-in-new" : "mdi:eye-outline"}></ha-icon>
@@ -1149,6 +1149,8 @@ export class MaintenanceTaskDialog extends LitElement {
if (this._triggerFromState) triggerConfig.trigger_from_state = this._triggerFromState;
if (this._triggerToState) triggerConfig.trigger_to_state = this._triggerToState;
if (this._triggerTargetChanges) { const v = parseInt(this._triggerTargetChanges, 10); if (!isNaN(v)) triggerConfig.trigger_target_changes = v; }
// #136: the new state must hold this long before a change counts.
if (this._triggerForMinutes) { const v = parseInt(this._triggerForMinutes, 10); if (!isNaN(v)) triggerConfig.trigger_for_minutes = v; }
} else if (this._triggerType === "runtime") {
if (this._triggerRuntimeHours) { const v = parseFloat(this._triggerRuntimeHours); if (!isNaN(v)) triggerConfig.trigger_runtime_hours = v; }
const onStates = this._triggerOnStates.split(",").map((s) => s.trim()).filter(Boolean);
@@ -2246,6 +2248,14 @@ export class MaintenanceTaskDialog extends LitElement {
@input=${(e: Event) => (this._triggerTargetChanges = (e.target as HTMLInputElement).value)}
></ms-textfield>
<div class="field-help">${t("target_changes_help", L)}</div>
<ms-textfield
label="${t("for_at_least_minutes", L)}"
type="number"
min="0"
.value=${this._triggerForMinutes}
@input=${(e: Event) => (this._triggerForMinutes = (e.target as HTMLInputElement).value)}
></ms-textfield>
<div class="field-help">${t("for_minutes_state_help", L)}</div>
`;
}
if (this._triggerType === "runtime") {
@@ -15,8 +15,9 @@ import { t, ensureLocale, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { downloadUrl } from "../helpers/download";
import { downloadSignedDocument, openSignedDocument } from "../helpers/document-url";
import { isSafeHttpUrl } from "../helpers/url";
import { formatBytes } from "../helpers/format-bytes";
import { CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
import { docDisplayName, CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
import type { HomeAssistant } from "../types";
interface Doc {
@@ -144,7 +145,11 @@ export class MaintenanceTaskDocuments extends LitElement {
private async _open(doc: Doc): Promise<void> {
if (doc.kind === "weblink") {
window.open(doc.url, "_blank", "noopener");
// Defense-in-depth: the add-link WS path already refuses non-http(s)
// schemes, but a weblink stored before that check (or via a future
// path) must never reach window.open as javascript:/data: — guard the
// sink too, like documents-section does.
if (isSafeHttpUrl(doc.url)) window.open(doc.url, "_blank", "noopener");
return;
}
// A per-task page hint jumps straight to the relevant page via the PDF
@@ -206,7 +211,7 @@ export class MaintenanceTaskDocuments extends LitElement {
>
<option value="" ?selected=${!this._attachId}>${t("doc_link_existing", L)}</option>
${available.map(
(d) => html`<option value=${d.id} ?selected=${d.id === this._attachId}>${d.title || d.filename || d.url}</option>`,
(d) => html`<option value=${d.id} ?selected=${d.id === this._attachId}>${docDisplayName(d)}</option>`,
)}
</select>
<button class="tdoc-btn" ?disabled=${this._busy || !this._attachId} @click=${this._link}>
@@ -240,7 +245,7 @@ export class MaintenanceTaskDocuments extends LitElement {
}
}}
>
<div class="tdoc-title">${doc.title || doc.filename || doc.url}</div>
<div class="tdoc-title">${docDisplayName(doc)}</div>
<div class="tdoc-meta">
${meta}${page ? html` · <span class="tdoc-pagetag">${t("doc_page", L)} ${page}</span>` : nothing}
</div>