197 files
This commit is contained in:
+35
@@ -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;
|
||||
|
||||
+145
-8
@@ -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>
|
||||
|
||||
+3
-3
@@ -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
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user