198 files

This commit is contained in:
Home Assistant Version Control
2026-10-11 23:51:59 +00:00
parent 4a64770282
commit 669d229519
198 changed files with 4891 additions and 2815 deletions
@@ -75,6 +75,8 @@ interface Overview {
available: boolean;
configured: boolean;
task_ok?: boolean;
/** The fleet object's entry (its parts are the battery types). */
entry_id?: string | null;
total: number;
low: BatteryRow[];
soon: BatteryRow[];
@@ -84,6 +86,9 @@ interface Overview {
needs_now: Record<string, number>;
needs_soon: Record<string, number>;
types: string[];
// #208: the counted rechargeable spares — charged (stock) and waiting to
// be charged (restoring). An older backend omits it.
charging?: { part_id: string; name: string; reuse: string; stock: number; restoring: number }[];
excluded?: { entity_id: string; device_name: string }[];
// #135 follow-up: fleet-wide "track self-charging devices" opt-in.
track_self_charging?: boolean;
@@ -550,6 +555,34 @@ export class MaintenanceBatteryFleetSection extends LitElement {
: nothing}
`}
${ov.charging?.length
? html`
<div class="bf-charging">
<span class="bf-label">${t("battery_fleet_charging", L)}</span>
${ov.charging.map(
(c) => html`<span class="bf-charge-item">
<span class="bf-charge-name">${c.name}</span>
<span>${t("battery_fleet_ready", L).replace("{count}", String(c.stock))}</span>
${c.restoring > 0
? html`<span class="bf-charge-waiting">${t(`part_waiting_${c.reuse}`, L).replace("{count}", String(c.restoring))}</span>
${this._canWrite
? html`<button
class="bf-charged"
title=${t("part_ready_again_hint", L)}
.disabled=${this._marking}
@click=${() => this._act({
type: "maintenance_supporter/part/ready_again",
entry_id: ov.entry_id,
part_id: c.part_id,
})}
><ha-icon icon="mdi:battery-charging-100"></ha-icon>${t(`part_reuse_${c.reuse}`, L)}</button>`
: nothing}`
: nothing}
</span>`,
)}
</div>
`
: nothing}
${ov.soon.length
? html`
<div class="bf-soon">
@@ -730,6 +763,46 @@ export class MaintenanceBatteryFleetSection extends LitElement {
align-items: baseline;
gap: 8px;
}
.bf-charging {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 8px 0;
}
.bf-charge-item {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 12px;
background: var(--secondary-background-color, #eee);
font-size: 13px;
}
.bf-charge-name {
font-weight: 500;
}
.bf-charge-waiting {
color: var(--warning-color, #f57c00);
}
.bf-charged {
display: inline-flex;
align-items: center;
gap: 4px;
font: inherit;
font-size: 12px;
min-height: 28px;
padding: 2px 10px;
border-radius: 14px;
border: 1px solid var(--primary-color);
background: transparent;
color: var(--primary-color);
cursor: pointer;
}
.bf-charged ha-icon {
--mdc-icon-size: 16px;
}
.bf-label {
font-size: 12px;
text-transform: uppercase;
@@ -8,7 +8,7 @@
*/
import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { isSafeLink } from "../helpers/url";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf } from "../styles";
import { runWs } from "../helpers/ws-run";
@@ -230,17 +230,18 @@ export class MaintenanceDocumentsSection extends LitElement {
this._hint = "";
let deduped = 0;
let dupInObject = 0;
const refused: string[] = [];
try {
// Bulk upload: one refused file (too large, rejected) is noted and
// the rest still go up — unlike the photo dialogs, which stop.
// Bulk upload: one refused file (too large, rejected, the connection
// dropped) is noted by name and the rest still go up — unlike the
// photo dialogs, which stop.
for (const file of files) {
let doc;
try {
doc = await uploadDocument(this.hass, entryId, file, [cat]);
} catch (e) {
const key = e instanceof Error ? e.message : "";
if (key !== "doc_too_large" && key !== "doc_upload_failed") throw e;
this._error = t(key, this._lang);
const key = e instanceof Error && e.message === "doc_too_large" ? "doc_too_large" : "doc_upload_failed";
refused.push(`${file.name}: ${t(key, this._lang)}`);
continue;
}
if (doc.duplicate_in_object) dupInObject++;
@@ -250,9 +251,10 @@ export class MaintenanceDocumentsSection extends LitElement {
if (dupInObject) this._hint = t("doc_dup_in_object", this._lang);
else if (deduped) this._hint = t("doc_deduped", this._lang);
await this._load();
} catch {
this._error = t("doc_upload_failed", this._lang);
} finally {
// Said AFTER the reload: _load() clears the error line, so a refusal
// set before it flashed for a moment and was gone (#209).
if (refused.length && entryId === this.entryId) this._error = refused.join(" · ");
this._busy = false;
}
}
@@ -277,7 +279,7 @@ export class MaintenanceDocumentsSection extends LitElement {
if (doc.kind === "file") void this._preview(doc);
// Only open http(s) links — never a javascript:/data: URL (the same scheme
// guard the rest of the panel applies before opening user-supplied URLs).
else if (isSafeHttpUrl(doc.url)) window.open(doc.url, "_blank", "noopener");
else if (isSafeLink(doc.url)) window.open(doc.url, "_blank", "noopener");
}
private _startEdit(doc: MaintenanceDocument): void {
@@ -340,7 +342,7 @@ export class MaintenanceDocumentsSection extends LitElement {
this._linkDescription = "";
this._addingLink = false;
},
"doc_link_invalid",
"doc_link_not_allowed",
);
}
@@ -521,7 +523,7 @@ export class MaintenanceDocumentsSection extends LitElement {
</button>`
: html`<a
class="icon-btn"
href=${isSafeHttpUrl(doc.url) ? doc.url : "#"}
href=${isSafeLink(doc.url) ? doc.url : "#"}
target="_blank"
rel="noopener noreferrer"
title=${t("doc_open", L)}
@@ -9,7 +9,7 @@
*/
import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { isSafeHttpUrl, isSafeLink } from "../helpers/url";
import { renderNotesMarkdown } from "../helpers/notes-markdown";
import { property, state } from "lit/decorators.js";
import { t, langOf } from "../styles";
@@ -231,26 +231,28 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
private _renderMetaRow(obj: MaintenanceObject) {
const L = this._lang;
const items: Array<[string, string]> = [];
const items: Array<[string, string, boolean?]> = [];
if (obj.area_id) items.push([t("area", L), obj.area_id]);
if (obj.manufacturer) items.push([t("manufacturer", L), obj.manufacturer]);
if (obj.model) items.push([t("model", L), obj.model]);
if (obj.serial_number) items.push([t("serial_number_label", L), obj.serial_number]);
if (obj.installation_date) items.push([t("installed", L), obj.installation_date]);
if (obj.warranty_expiry) items.push([t("warranty", L), obj.warranty_expiry]);
if (obj.documentation_url) items.push([t("documentation_url_label", L), obj.documentation_url]);
// The documentation link may also open an app (#207).
if (obj.documentation_url) items.push([t("documentation_url_label", L), obj.documentation_url, isSafeLink(obj.documentation_url)]);
if (items.length === 0) return nothing;
return html`
<div class="meta">
${items.map(
([label, value]) => html`
([label, value, docLink]) => html`
<div class="meta-item">
<span class="meta-label">${label}</span>
<span class="meta-value">${
// Only render http(s) values as links (never javascript:/data:);
// value-based so it works in every UI language, not just English.
isSafeHttpUrl(value)
// The documentation link was checked by the app-link rule above.
docLink || isSafeHttpUrl(value)
? html`<a href="${value}" target="_blank" rel="noopener noreferrer">${value}</a>`
: value
}</span>
@@ -22,6 +22,9 @@ import { restockUnits, unitPrice } from "../helpers/parts-cost";
// Per-part document links (v2.26) — the task-documents component in part mode.
import "./task-documents";
/** #208: what readies a used part again (helpers/parts.py REUSE_KINDS). */
export const REUSE_KINDS = ["charge", "sharpen", "clean", "dry", "refill", "restore"] as const;
interface PartForm {
id?: string;
name: string;
@@ -33,6 +36,9 @@ interface PartForm {
unit: string;
cost: string;
stock: string;
/** #208: "" = used up, else what readies a used one again. */
reuse: string;
restoring: string;
reorder_threshold: string;
restock_quantity: string;
package_size: string;
@@ -50,6 +56,8 @@ const EMPTY_FORM: PartForm = {
unit: "",
cost: "",
stock: "",
reuse: "",
restoring: "",
reorder_threshold: "",
restock_quantity: "",
package_size: "",
@@ -112,6 +120,8 @@ export class MaintenancePartsSection extends LitElement {
unit: part.unit || "",
cost: part.cost != null ? String(part.cost) : "",
stock: part.stock != null ? String(part.stock) : "",
reuse: part.reuse || "",
restoring: part.restoring ? String(part.restoring) : "",
reorder_threshold: part.reorder_threshold != null ? String(part.reorder_threshold) : "",
restock_quantity: part.restock_quantity != null ? String(part.restock_quantity) : "",
package_size: part.package_size != null ? String(part.package_size) : "",
@@ -133,6 +143,8 @@ export class MaintenancePartsSection extends LitElement {
unit: f.unit.trim() || null,
cost: num(f.cost),
stock: num(f.stock),
reuse: f.reuse || null,
...(f.reuse ? { restoring: num(f.restoring) ?? 0 } : {}),
reorder_threshold: num(f.reorder_threshold),
restock_quantity: num(f.restock_quantity),
package_size: num(f.package_size),
@@ -193,6 +205,23 @@ export class MaintenancePartsSection extends LitElement {
}
}
/** #208: the used ones of a reusable part are ready again (charged,
* sharpened, ...) — back from the pile to the stock. */
private async _readyAgain(part: MaintenancePart): Promise<void> {
if (this._busy) return;
const result = await this._send<{ stock: number; restoring: number }>({
type: "maintenance_supporter/part/ready_again",
entry_id: this.entryId,
part_id: part.id,
});
if (result) {
part.stock = result.stock;
part.restoring = result.restoring;
this.requestUpdate();
this._notifyChanged();
}
}
private _identLine(part: MaintenancePart): string {
return [part.vendor, part.mpn ? `MPN: ${part.mpn}` : "", part.gtin ? `GTIN: ${part.gtin}` : ""]
.filter(Boolean)
@@ -220,6 +249,17 @@ export class MaintenancePartsSection extends LitElement {
>`
: nothing}
</div>
${tracked && part.reuse && (part.restoring ?? 0) > 0
? html`<div class="part-waiting">
<span class="waiting-badge">${t(`part_waiting_${part.reuse}`, L).replace("{count}", formatNumber(part.restoring!, L))}</span>
${this.canWrite
? html`<button class="ready-again" title=${t("part_ready_again_hint", L)} .disabled=${this._busy}
@click=${() => this._readyAgain(part)}>
<ha-icon icon="mdi:check"></ha-icon>${t(`part_reuse_${part.reuse}`, L)}
</button>`
: nothing}
</div>`
: nothing}
<div class="part-meta">
${ident ? html`<span>${ident}</span>` : nothing}
${part.storage_location
@@ -322,6 +362,23 @@ export class MaintenancePartsSection extends LitElement {
${this._field(t("part_unit", L), "unit")}
${this._field(t("part_cost", L), "cost", { type: "number" })}
${this._field(t("part_stock", L), "stock", { type: "number" })}
<label class="form-field">
<span>${t("part_reuse", L)}</span>
<select
class="reuse-select"
.value=${f.reuse}
@change=${(e: Event) => {
this._editing = { ...this._editing!, reuse: (e.target as HTMLSelectElement).value };
}}
>
<option value="" ?selected=${!f.reuse}>${t("part_reuse_none", L)}</option>
${REUSE_KINDS.map(
(kind) => html`<option value=${kind} ?selected=${f.reuse === kind}>${t(`part_reuse_${kind}`, L)}</option>`,
)}
</select>
</label>
${f.reuse ? this._field(t("part_restoring", L), "restoring", { type: "number" }) : nothing}
${f.reuse ? html`<div class="form-hint">${t("part_reuse_hint", L)}</div>` : nothing}
${this._field(t("part_reorder_threshold", L), "reorder_threshold", { type: "number" })}
${this._field(t("part_package_size", L), "package_size", { type: "number" })}
${this._field(
@@ -477,6 +534,45 @@ export class MaintenancePartsSection extends LitElement {
.part-meta .loc ha-icon {
--mdc-icon-size: 13px;
}
.part-waiting {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.waiting-badge {
font-size: 12px;
padding: 1px 8px;
border-radius: 10px;
background: var(--secondary-background-color, #eee);
color: var(--primary-text-color);
}
.ready-again {
display: inline-flex;
align-items: center;
gap: 4px;
font: inherit;
font-size: 12px;
padding: 2px 10px;
min-height: 28px;
border-radius: 14px;
border: 1px solid var(--primary-color);
background: transparent;
color: var(--primary-color);
cursor: pointer;
}
.ready-again ha-icon {
--mdc-icon-size: 16px;
}
.reuse-select {
font: inherit;
padding: 6px;
border-radius: 4px;
border: 1px solid var(--divider-color, #ccc);
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
}
.restock-input {
width: 64px;
padding: 4px;
@@ -12,7 +12,7 @@ import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf } from "../styles";
import { runWs } from "../helpers/ws-run";
import { openSignedDocument } from "../helpers/document-url";
import { isSafeHttpUrl } from "../helpers/url";
import { isSafeLink } from "../helpers/url";
import { formatBytes } from "../helpers/format-bytes";
import type { HomeAssistant } from "../types";
@@ -145,7 +145,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
// 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");
if (isSafeLink(doc.url)) window.open(doc.url, "_blank", "noopener");
return;
}
await runWs(this, () => openSignedDocument(this.hass, doc.id), { onError: (m) => { this._error = m; } });
@@ -15,7 +15,7 @@ import { t, ensureLocale, langOf } from "../styles";
import { runWs } from "../helpers/ws-run";
import { downloadUrl } from "../helpers/download";
import { downloadSignedDocument, openSignedDocument } from "../helpers/document-url";
import { isSafeHttpUrl } from "../helpers/url";
import { isSafeLink } from "../helpers/url";
import { formatBytes } from "../helpers/format-bytes";
import { docCategory, docDisplayName, CATEGORY_ICONS } from "../helpers/document-categories";
import { DOC_FILTER_MIN, filterDocuments } from "../helpers/document-filter";
@@ -152,7 +152,7 @@ export class MaintenanceTaskDocuments extends LitElement {
// 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");
if (isSafeLink(doc.url)) window.open(doc.url, "_blank", "noopener");
return;
}
// A per-task page hint jumps straight to the relevant page via the PDF