20240627.0 (#21192)

This commit is contained in:
Bram Kragten 2024-06-27 20:02:18 +02:00 committed by GitHub
commit d72e8c35d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 280 additions and 107 deletions

View File

@ -168,7 +168,7 @@
"@rollup/plugin-node-resolve": "15.2.3", "@rollup/plugin-node-resolve": "15.2.3",
"@rollup/plugin-replace": "5.0.7", "@rollup/plugin-replace": "5.0.7",
"@types/babel__plugin-transform-runtime": "7.9.5", "@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.15", "@types/chromecast-caf-receiver": "6.0.16",
"@types/chromecast-caf-sender": "1.0.10", "@types/chromecast-caf-sender": "1.0.10",
"@types/color-name": "1.1.4", "@types/color-name": "1.1.4",
"@types/glob": "8.1.0", "@types/glob": "8.1.0",

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "home-assistant-frontend" name = "home-assistant-frontend"
version = "20240626.2" version = "20240627.0"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "The Home Assistant frontend" description = "The Home Assistant frontend"
readme = "README.md" readme = "README.md"

View File

@ -1,72 +1,92 @@
import "@material/mwc-button/mwc-button"; import "@material/mwc-button/mwc-button";
import "@material/mwc-list/mwc-list-item";
import { mdiCamera } from "@mdi/js"; import { mdiCamera } from "@mdi/js";
import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import type QrScanner from "qr-scanner"; import type QrScanner from "qr-scanner";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation"; import { stopPropagation } from "../common/dom/stop_propagation";
import { LocalizeFunc } from "../common/translations/localize"; import { LocalizeFunc } from "../common/translations/localize";
import { addExternalBarCodeListener } from "../external_app/external_app_entrypoint";
import { HomeAssistant } from "../types";
import "./ha-alert"; import "./ha-alert";
import "./ha-button-menu"; import "./ha-button-menu";
import "./ha-list-item";
import "./ha-textfield"; import "./ha-textfield";
import type { HaTextField } from "./ha-textfield"; import type { HaTextField } from "./ha-textfield";
@customElement("ha-qr-scanner") @customElement("ha-qr-scanner")
class HaQrScanner extends LitElement { class HaQrScanner extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public localize!: LocalizeFunc; @property({ attribute: false }) public localize!: LocalizeFunc;
@property() public description?: string;
@property({ attribute: "alternative_option_label" })
public alternativeOptionLabel?: string;
@property() public error?: string;
@state() private _cameras?: QrScanner.Camera[]; @state() private _cameras?: QrScanner.Camera[];
@state() private _error?: string; @state() private _manual = false;
private _qrScanner?: QrScanner; private _qrScanner?: QrScanner;
private _qrNotFoundCount = 0; private _qrNotFoundCount = 0;
@query("video", true) private _video!: HTMLVideoElement; private _removeListener?: UnsubscribeFunc;
@query("#canvas-container", true) private _canvasContainer!: HTMLDivElement; @query("video", true) private _video?: HTMLVideoElement;
@query("#canvas-container", true) private _canvasContainer?: HTMLDivElement;
@query("ha-textfield") private _manualInput?: HaTextField; @query("ha-textfield") private _manualInput?: HaTextField;
public disconnectedCallback(): void { public disconnectedCallback(): void {
super.disconnectedCallback(); super.disconnectedCallback();
this._qrNotFoundCount = 0; this._qrNotFoundCount = 0;
if (this._nativeBarcodeScanner) {
this._closeExternalScanner();
}
if (this._qrScanner) { if (this._qrScanner) {
this._qrScanner.stop(); this._qrScanner.stop();
this._qrScanner.destroy(); this._qrScanner.destroy();
this._qrScanner = undefined; this._qrScanner = undefined;
} }
while (this._canvasContainer.lastChild) { while (this._canvasContainer?.lastChild) {
this._canvasContainer.removeChild(this._canvasContainer.lastChild); this._canvasContainer.removeChild(this._canvasContainer.lastChild);
} }
} }
public connectedCallback(): void { public connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
if (this.hasUpdated && navigator.mediaDevices) { if (this.hasUpdated) {
this._loadQrScanner(); this._loadQrScanner();
} }
} }
protected firstUpdated() { protected firstUpdated() {
if (navigator.mediaDevices) { this._loadQrScanner();
this._loadQrScanner();
}
} }
protected updated(changedProps: PropertyValues) { protected updated(changedProps: PropertyValues) {
if (changedProps.has("_error") && this._error) { if (changedProps.has("error") && this.error) {
fireEvent(this, "qr-code-error", { message: this._error }); alert(`error: ${this.error}`);
this._notifyExternalScanner(this.error);
} }
} }
protected render(): TemplateResult { protected render() {
return html`${this._error if (this._nativeBarcodeScanner && !this._manual) {
? html`<ha-alert alert-type="error">${this._error}</ha-alert>` return nothing;
}
return html`${this.error
? html`<ha-alert alert-type="error">${this.error}</ha-alert>`
: ""} : ""}
${navigator.mediaDevices ${navigator.mediaDevices && !this._manual
? html`<video></video> ? html`<video></video>
<div id="canvas-container"> <div id="canvas-container">
${this._cameras && this._cameras.length > 1 ${this._cameras && this._cameras.length > 1
@ -80,21 +100,26 @@ class HaQrScanner extends LitElement {
></ha-icon-button> ></ha-icon-button>
${this._cameras!.map( ${this._cameras!.map(
(camera) => html` (camera) => html`
<mwc-list-item <ha-list-item
.value=${camera.id} .value=${camera.id}
@click=${this._cameraChanged} @click=${this._cameraChanged}
>${camera.label}</mwc-list-item
> >
${camera.label}
</ha-list-item>
` `
)} )}
</ha-button-menu>` </ha-button-menu>`
: ""} : nothing}
</div>` </div>`
: html`<ha-alert alert-type="warning"> : html`${this._manual
${!window.isSecureContext ? nothing
? this.localize("ui.components.qr-scanner.only_https_supported") : html`<ha-alert alert-type="warning">
: this.localize("ui.components.qr-scanner.not_supported")} ${!window.isSecureContext
</ha-alert> ? this.localize(
"ui.components.qr-scanner.only_https_supported"
)
: this.localize("ui.components.qr-scanner.not_supported")}
</ha-alert>`}
<p>${this.localize("ui.components.qr-scanner.manual_input")}</p> <p>${this.localize("ui.components.qr-scanner.manual_input")}</p>
<div class="row"> <div class="row">
<ha-textfield <ha-textfield
@ -102,33 +127,44 @@ class HaQrScanner extends LitElement {
@keyup=${this._manualKeyup} @keyup=${this._manualKeyup}
@paste=${this._manualPaste} @paste=${this._manualPaste}
></ha-textfield> ></ha-textfield>
<mwc-button @click=${this._manualSubmit} <mwc-button @click=${this._manualSubmit}>
>${this.localize("ui.common.submit")}</mwc-button ${this.localize("ui.common.submit")}
> </mwc-button>
</div>`}`; </div>`}`;
} }
private get _nativeBarcodeScanner(): boolean {
return Boolean(this.hass.auth.external?.config.hasBarCodeScanner);
}
private async _loadQrScanner() { private async _loadQrScanner() {
if (this._nativeBarcodeScanner) {
this._openExternalScanner();
return;
}
if (!navigator.mediaDevices) {
return;
}
const QrScanner = (await import("qr-scanner")).default; const QrScanner = (await import("qr-scanner")).default;
if (!(await QrScanner.hasCamera())) { if (!(await QrScanner.hasCamera())) {
this._error = "No camera found"; this._reportError("No camera found");
return; return;
} }
QrScanner.WORKER_PATH = "/static/js/qr-scanner-worker.min.js"; QrScanner.WORKER_PATH = "/static/js/qr-scanner-worker.min.js";
this._listCameras(QrScanner); this._listCameras(QrScanner);
this._qrScanner = new QrScanner( this._qrScanner = new QrScanner(
this._video, this._video!,
this._qrCodeScanned, this._qrCodeScanned,
this._qrCodeError this._qrCodeError
); );
// @ts-ignore // @ts-ignore
const canvas = this._qrScanner.$canvas; const canvas = this._qrScanner.$canvas;
this._canvasContainer.appendChild(canvas); this._canvasContainer!.appendChild(canvas);
canvas.style.display = "block"; canvas.style.display = "block";
try { try {
await this._qrScanner.start(); await this._qrScanner.start();
} catch (err: any) { } catch (err: any) {
this._error = err; this._reportError(err);
} }
} }
@ -140,16 +176,16 @@ class HaQrScanner extends LitElement {
if (err === "No QR code found") { if (err === "No QR code found") {
this._qrNotFoundCount++; this._qrNotFoundCount++;
if (this._qrNotFoundCount === 250) { if (this._qrNotFoundCount === 250) {
this._error = err; this._reportError(err);
} }
return; return;
} }
this._error = err.message || err; this._reportError(err.message || err);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(err); console.log(err);
}; };
private _qrCodeScanned = async (qrCodeString: string): Promise<void> => { private _qrCodeScanned = (qrCodeString: string): void => {
this._qrNotFoundCount = 0; this._qrNotFoundCount = 0;
fireEvent(this, "qr-code-scanned", { value: qrCodeString }); fireEvent(this, "qr-code-scanned", { value: qrCodeString });
}; };
@ -175,6 +211,62 @@ class HaQrScanner extends LitElement {
this._qrScanner?.setCamera((ev.target as any).value); this._qrScanner?.setCamera((ev.target as any).value);
} }
private _openExternalScanner() {
this._removeListener = addExternalBarCodeListener((msg) => {
if (msg.command === "bar_code/scan_result") {
if (msg.payload.format !== "qr_code") {
this._notifyExternalScanner(
`Wrong barcode scanned! ${msg.payload.format}: ${msg.payload.rawValue}, we need a QR code.`
);
} else {
this._qrCodeScanned(msg.payload.rawValue);
}
} else if (msg.command === "bar_code/aborted") {
this._closeExternalScanner();
if (msg.payload.reason === "canceled") {
fireEvent(this, "qr-code-closed");
} else {
this._manual = true;
}
}
return true;
});
this.hass.auth.external!.fireMessage({
type: "bar_code/scan",
payload: {
title: this.title || "Scan QR code",
description: this.description || "Scan a barcode.",
alternative_option_label:
this.alternativeOptionLabel || "Click to manually enter the barcode",
},
});
}
private _closeExternalScanner() {
this._removeListener?.();
this._removeListener = undefined;
this.hass.auth.external!.fireMessage({
type: "bar_code/close",
});
}
private _notifyExternalScanner(message: string) {
if (!this.hass.auth.external) {
return;
}
this.hass.auth.external.fireMessage({
type: "bar_code/notify",
payload: {
message,
},
});
this.error = undefined;
}
private _reportError(message: string) {
fireEvent(this, "qr-code-error", { message });
}
static styles = css` static styles = css`
canvas { canvas {
width: 100%; width: 100%;
@ -210,6 +302,7 @@ declare global {
interface HASSDomEvents { interface HASSDomEvents {
"qr-code-scanned": { value: string }; "qr-code-scanned": { value: string };
"qr-code-error": { message: string }; "qr-code-error": { message: string };
"qr-code-closed": undefined;
} }
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {

View File

@ -11,6 +11,7 @@ import {
fetchEntitySourcesWithCache, fetchEntitySourcesWithCache,
} from "../../data/entity_sources"; } from "../../data/entity_sources";
import type { AreaSelector } from "../../data/selector"; import type { AreaSelector } from "../../data/selector";
import { ConfigEntry, getConfigEntries } from "../../data/config_entries";
import { import {
filterSelectorDevices, filterSelectorDevices,
filterSelectorEntities, filterSelectorEntities,
@ -37,6 +38,8 @@ export class HaAreaSelector extends LitElement {
@state() private _entitySources?: EntitySources; @state() private _entitySources?: EntitySources;
@state() private _configEntries?: ConfigEntry[];
private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup); private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup);
private _hasIntegration(selector: AreaSelector) { private _hasIntegration(selector: AreaSelector) {
@ -72,6 +75,12 @@ export class HaAreaSelector extends LitElement {
this._entitySources = sources; this._entitySources = sources;
}); });
} }
if (!this._configEntries && this._hasIntegration(this.selector)) {
this._configEntries = [];
getConfigEntries(this.hass).then((entries) => {
this._configEntries = entries;
});
}
} }
protected render() { protected render() {
@ -136,7 +145,9 @@ export class HaAreaSelector extends LitElement {
const deviceIntegrations = this._entitySources const deviceIntegrations = this._entitySources
? this._deviceIntegrationLookup( ? this._deviceIntegrationLookup(
this._entitySources, this._entitySources,
Object.values(this.hass.entities) Object.values(this.hass.entities),
Object.values(this.hass.devices),
this._configEntries
) )
: undefined; : undefined;

View File

@ -11,6 +11,7 @@ import {
fetchEntitySourcesWithCache, fetchEntitySourcesWithCache,
} from "../../data/entity_sources"; } from "../../data/entity_sources";
import type { DeviceSelector } from "../../data/selector"; import type { DeviceSelector } from "../../data/selector";
import { ConfigEntry, getConfigEntries } from "../../data/config_entries";
import { import {
filterSelectorDevices, filterSelectorDevices,
filterSelectorEntities, filterSelectorEntities,
@ -27,6 +28,8 @@ export class HaDeviceSelector extends LitElement {
@state() private _entitySources?: EntitySources; @state() private _entitySources?: EntitySources;
@state() private _configEntries?: ConfigEntry[];
@property() public value?: any; @property() public value?: any;
@property() public label?: string; @property() public label?: string;
@ -75,6 +78,12 @@ export class HaDeviceSelector extends LitElement {
this._entitySources = sources; this._entitySources = sources;
}); });
} }
if (!this._configEntries && this._hasIntegration(this.selector)) {
this._configEntries = [];
getConfigEntries(this.hass).then((entries) => {
this._configEntries = entries;
});
}
} }
protected render() { protected render() {
@ -123,7 +132,9 @@ export class HaDeviceSelector extends LitElement {
const deviceIntegrations = this._entitySources const deviceIntegrations = this._entitySources
? this._deviceIntegrationLookup( ? this._deviceIntegrationLookup(
this._entitySources, this._entitySources,
Object.values(this.hass.entities) Object.values(this.hass.entities),
Object.values(this.hass.devices),
this._configEntries
) )
: undefined; : undefined;

View File

@ -11,6 +11,7 @@ import {
fetchEntitySourcesWithCache, fetchEntitySourcesWithCache,
} from "../../data/entity_sources"; } from "../../data/entity_sources";
import type { FloorSelector } from "../../data/selector"; import type { FloorSelector } from "../../data/selector";
import { ConfigEntry, getConfigEntries } from "../../data/config_entries";
import { import {
filterSelectorDevices, filterSelectorDevices,
filterSelectorEntities, filterSelectorEntities,
@ -37,6 +38,8 @@ export class HaFloorSelector extends LitElement {
@state() private _entitySources?: EntitySources; @state() private _entitySources?: EntitySources;
@state() private _configEntries?: ConfigEntry[];
private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup); private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup);
private _hasIntegration(selector: FloorSelector) { private _hasIntegration(selector: FloorSelector) {
@ -72,6 +75,12 @@ export class HaFloorSelector extends LitElement {
this._entitySources = sources; this._entitySources = sources;
}); });
} }
if (!this._configEntries && this._hasIntegration(this.selector)) {
this._configEntries = [];
getConfigEntries(this.hass).then((entries) => {
this._configEntries = entries;
});
}
} }
protected render() { protected render() {
@ -136,7 +145,9 @@ export class HaFloorSelector extends LitElement {
const deviceIntegrations = this._entitySources const deviceIntegrations = this._entitySources
? this._deviceIntegrationLookup( ? this._deviceIntegrationLookup(
this._entitySources, this._entitySources,
Object.values(this.hass.entities) Object.values(this.hass.entities),
Object.values(this.hass.devices),
this._configEntries
) )
: undefined; : undefined;

View File

@ -5,6 +5,7 @@ import type {
EntityRegistryDisplayEntry, EntityRegistryDisplayEntry,
EntityRegistryEntry, EntityRegistryEntry,
} from "./entity_registry"; } from "./entity_registry";
import { ConfigEntry } from "./config_entries";
import type { EntitySources } from "./entity_sources"; import type { EntitySources } from "./entity_sources";
export { export {
@ -142,9 +143,11 @@ export const getDeviceEntityDisplayLookup = (
export const getDeviceIntegrationLookup = ( export const getDeviceIntegrationLookup = (
entitySources: EntitySources, entitySources: EntitySources,
entities: EntityRegistryDisplayEntry[] | EntityRegistryEntry[] entities: EntityRegistryDisplayEntry[] | EntityRegistryEntry[],
): Record<string, string[]> => { devices?: DeviceRegistryEntry[],
const deviceIntegrations: Record<string, string[]> = {}; configEntries?: ConfigEntry[]
): Record<string, Set<string>> => {
const deviceIntegrations: Record<string, Set<string>> = {};
for (const entity of entities) { for (const entity of entities) {
const source = entitySources[entity.entity_id]; const source = entitySources[entity.entity_id];
@ -152,10 +155,22 @@ export const getDeviceIntegrationLookup = (
continue; continue;
} }
if (!deviceIntegrations[entity.device_id!]) { deviceIntegrations[entity.device_id!] =
deviceIntegrations[entity.device_id!] = []; deviceIntegrations[entity.device_id!] || new Set<string>();
deviceIntegrations[entity.device_id!].add(source.domain);
}
// Lookup devices that have no entities
if (devices && configEntries) {
for (const device of devices) {
for (const config_entry_id of device.config_entries) {
const entry = configEntries.find((e) => e.entry_id === config_entry_id);
if (entry?.domain) {
deviceIntegrations[device.id] =
deviceIntegrations[device.id] || new Set<string>();
deviceIntegrations[device.id].add(entry.domain);
}
}
} }
deviceIntegrations[entity.device_id!].push(source.domain);
} }
return deviceIntegrations; return deviceIntegrations;
}; };

View File

@ -696,7 +696,7 @@ export const entityMeetsTargetSelector = (
export const filterSelectorDevices = ( export const filterSelectorDevices = (
filterDevice: DeviceSelectorFilter, filterDevice: DeviceSelectorFilter,
device: DeviceRegistryEntry, device: DeviceRegistryEntry,
deviceIntegrationLookup?: Record<string, string[]> | undefined deviceIntegrationLookup?: Record<string, Set<string>> | undefined
): boolean => { ): boolean => {
const { const {
manufacturer: filterManufacturer, manufacturer: filterManufacturer,
@ -713,7 +713,7 @@ export const filterSelectorDevices = (
} }
if (filterIntegration && deviceIntegrationLookup) { if (filterIntegration && deviceIntegrationLookup) {
if (!deviceIntegrationLookup?.[device.id]?.includes(filterIntegration)) { if (!deviceIntegrationLookup?.[device.id]?.has(filterIntegration)) {
return false; return false;
} }
} }

View File

@ -205,14 +205,13 @@ class DialogZWaveJSAddNode extends LitElement {
Search device Search device
</mwc-button>` </mwc-button>`
: this._status === "qr_scan" : this._status === "qr_scan"
? html`${this._error ? html` <ha-qr-scanner
? html`<ha-alert alert-type="error" .hass=${this.hass}
>${this._error}</ha-alert
>`
: ""}
<ha-qr-scanner
.localize=${this.hass.localize} .localize=${this.hass.localize}
.error=${this._error}
@qr-code-scanned=${this._qrCodeScanned} @qr-code-scanned=${this._qrCodeScanned}
@qr-code-error=${this._qrCodeError}
@qr-code-closed=${this._startOver}
></ha-qr-scanner> ></ha-qr-scanner>
<mwc-button <mwc-button
slot="secondaryAction" slot="secondaryAction"
@ -361,7 +360,7 @@ class DialogZWaveJSAddNode extends LitElement {
</p> </p>
</div> </div>
${this._supportsSmartStart ${this._supportsSmartStart
? html` <div class="outline"> ? html`<div class="outline">
<h2> <h2>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.zwave_js.add_node.qr_code" "ui.panel.config.zwave_js.add_node.qr_code"
@ -498,9 +497,7 @@ class DialogZWaveJSAddNode extends LitElement {
</ha-alert>` </ha-alert>`
: ""} : ""}
<a <a
href=${`/config/devices/device/${ href=${`/config/devices/device/${this._device?.id}`}
this._device?.id
}`}
> >
<mwc-button> <mwc-button>
${this.hass.localize( ${this.hass.localize(
@ -599,6 +596,10 @@ class DialogZWaveJSAddNode extends LitElement {
this._handleQrCodeScanned(ev.detail.value); this._handleQrCodeScanned(ev.detail.value);
} }
private _qrCodeError(ev: CustomEvent): void {
this._error = ev.detail.message;
}
private async _handleQrCodeScanned(qrCodeString: string): Promise<void> { private async _handleQrCodeScanned(qrCodeString: string): Promise<void> {
this._error = undefined; this._error = undefined;
if (this._status !== "qr_scan" || this._qrProcessing) { if (this._status !== "qr_scan" || this._qrProcessing) {

View File

@ -1,4 +1,4 @@
import { PropertyValueMap, PropertyValues, ReactiveElement } from "lit"; import { PropertyValues, ReactiveElement } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { MediaQueriesListener } from "../../../common/dom/media_query"; import { MediaQueriesListener } from "../../../common/dom/media_query";
@ -23,35 +23,29 @@ declare global {
@customElement("hui-card") @customElement("hui-card")
export class HuiCard extends ReactiveElement { export class HuiCard extends ReactiveElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public preview = false; @property({ type: Boolean }) public preview = false;
@property({ type: Boolean }) public isPanel = false; @property({ type: Boolean }) public isPanel = false;
set config(config: LovelaceCardConfig | undefined) { @property({ attribute: false }) public config?: LovelaceCardConfig;
if (!config) return;
if (config.type !== this._config?.type) { @property({ attribute: false }) public hass?: HomeAssistant;
this._buildElement(config);
} else if (config !== this.config) { public load() {
this._element?.setConfig(config); if (!this.config) {
fireEvent(this, "card-updated"); throw new Error("Cannot build card without config");
} }
this._config = config; this._loadElement(this.config);
} }
@property({ attribute: false })
public get config() {
return this._config;
}
private _config?: LovelaceCardConfig;
private _element?: LovelaceCard; private _element?: LovelaceCard;
private _listeners: MediaQueriesListener[] = []; private _listeners: MediaQueriesListener[] = [];
protected createRenderRoot() { protected createRenderRoot() {
const style = document.createElement("style");
style.textContent = `hui-card { display: contents }`;
this.append(style);
return this; return this;
} }
@ -90,57 +84,76 @@ export class HuiCard extends ReactiveElement {
return this._element?.getLayoutOptions?.() ?? {}; return this._element?.getLayoutOptions?.() ?? {};
} }
private _createElement(config: LovelaceCardConfig) { private _loadElement(config: LovelaceCardConfig) {
const element = createCardElement(config); this._element = createCardElement(config);
element.hass = this.hass; if (this.hass) {
element.preview = this.preview; this._element.hass = this.hass;
}
this._element.preview = this.preview;
// For backwards compatibility // For backwards compatibility
(element as any).editMode = this.preview; (this._element as any).editMode = this.preview;
// Update element when the visibility of the card changes (e.g. conditional card or filter card) // Update element when the visibility of the card changes (e.g. conditional card or filter card)
element.addEventListener("card-visibility-changed", (ev: Event) => { this._element.addEventListener("card-visibility-changed", (ev: Event) => {
ev.stopPropagation(); ev.stopPropagation();
this._updateVisibility(); this._updateVisibility();
}); });
element.addEventListener( this._element.addEventListener(
"ll-upgrade", "ll-upgrade",
(ev: Event) => { (ev: Event) => {
ev.stopPropagation(); ev.stopPropagation();
element.hass = this.hass; if (this.hass) {
element.preview = this.preview; this._element!.hass = this.hass;
}
fireEvent(this, "card-updated"); fireEvent(this, "card-updated");
}, },
{ once: true } { once: true }
); );
element.addEventListener( this._element.addEventListener(
"ll-rebuild", "ll-rebuild",
(ev: Event) => { (ev: Event) => {
ev.stopPropagation(); ev.stopPropagation();
this._buildElement(config); this._loadElement(config);
fireEvent(this, "card-updated"); fireEvent(this, "card-updated");
}, },
{ once: true } { once: true }
); );
return element;
}
private _buildElement(config: LovelaceCardConfig) {
this._element = this._createElement(config);
while (this.lastChild) { while (this.lastChild) {
this.removeChild(this.lastChild); this.removeChild(this.lastChild);
} }
this._updateVisibility(); this._updateVisibility();
} }
protected willUpdate(changedProps: PropertyValues<typeof this>): void {
super.willUpdate(changedProps);
if (!this._element) {
this.load();
}
}
protected update(changedProps: PropertyValues<typeof this>) { protected update(changedProps: PropertyValues<typeof this>) {
super.update(changedProps); super.update(changedProps);
if (this._element) { if (this._element) {
if (changedProps.has("config") && this.hasUpdated) {
const oldConfig = changedProps.get("config");
if (this.config !== oldConfig && this.config) {
const typeChanged = this.config?.type !== oldConfig?.type;
if (typeChanged) {
this._loadElement(this.config);
} else {
this._element?.setConfig(this.config);
fireEvent(this, "card-updated");
}
}
}
if (changedProps.has("hass")) { if (changedProps.has("hass")) {
try { try {
this._element.hass = this.hass; if (this.hass) {
this._element.hass = this.hass;
}
} catch (e: any) { } catch (e: any) {
this._buildElement(createErrorCardConfig(e.message, null)); this._loadElement(createErrorCardConfig(e.message, null));
} }
} }
if (changedProps.has("preview")) { if (changedProps.has("preview")) {
@ -149,18 +162,14 @@ export class HuiCard extends ReactiveElement {
// For backwards compatibility // For backwards compatibility
(this._element as any).editMode = this.preview; (this._element as any).editMode = this.preview;
} catch (e: any) { } catch (e: any) {
this._buildElement(createErrorCardConfig(e.message, null)); this._loadElement(createErrorCardConfig(e.message, null));
} }
} }
if (changedProps.has("isPanel")) { if (changedProps.has("isPanel")) {
this._element.isPanel = this.isPanel; this._element.isPanel = this.isPanel;
} }
} }
}
protected willUpdate(
changedProps: PropertyValueMap<any> | Map<PropertyKey, unknown>
): void {
if (changedProps.has("hass") || changedProps.has("preview")) { if (changedProps.has("hass") || changedProps.has("preview")) {
this._updateVisibility(); this._updateVisibility();
} }

View File

@ -41,6 +41,7 @@ class HuiConditionalCard extends HuiConditionalBase implements LovelaceCard {
element.hass = this.hass; element.hass = this.hass;
element.preview = this.preview; element.preview = this.preview;
element.config = cardConfig; element.config = cardConfig;
element.load();
return element; return element;
} }

View File

@ -249,6 +249,7 @@ export class HuiEntityFilterCard
element.hass = this.hass; element.hass = this.hass;
element.preview = this.preview; element.preview = this.preview;
element.config = cardConfig; element.config = cardConfig;
element.load();
return element; return element;
} }
} }

View File

@ -92,6 +92,7 @@ class HuiGridCard extends HuiStackCard<GridCardConfig> {
} }
:host([square]) #root > *:not([hidden]) { :host([square]) #root > *:not([hidden]) {
display: block;
grid-row: 1 / 1; grid-row: 1 / 1;
grid-column: 1 / 1; grid-column: 1 / 1;
} }

View File

@ -30,7 +30,7 @@ export class HuiHorizontalStackCard extends HuiStackCard {
height: 100%; height: 100%;
gap: var(--horizontal-stack-card-gap, var(--stack-card-gap, 8px)); gap: var(--horizontal-stack-card-gap, var(--stack-card-gap, 8px));
} }
#root > * { #root > hui-card > * {
flex: 1 1 0; flex: 1 1 0;
min-width: 0; min-width: 0;
} }

View File

@ -56,7 +56,7 @@ export abstract class HuiStackCard<T extends StackCardConfig = StackCardConfig>
card.hass = this.hass; card.hass = this.hass;
}); });
} }
if (changedProperties.has("editMode")) { if (changedProperties.has("preview")) {
this._cards.forEach((card) => { this._cards.forEach((card) => {
card.preview = this.preview; card.preview = this.preview;
}); });
@ -69,6 +69,7 @@ export abstract class HuiStackCard<T extends StackCardConfig = StackCardConfig>
element.hass = this.hass; element.hass = this.hass;
element.preview = this.preview; element.preview = this.preview;
element.config = cardConfig; element.config = cardConfig;
element.load();
return element; return element;
} }

View File

@ -238,6 +238,14 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
></ha-relative-time> ></ha-relative-time>
`; `;
} }
if (content === "last-updated") {
return html`
<ha-relative-time
.hass=${this.hass}
.datetime=${stateObj.last_updated}
></ha-relative-time>
`;
}
if (content === "last_triggered") { if (content === "last_triggered") {
return html` return html`
<ha-relative-time <ha-relative-time

View File

@ -146,6 +146,7 @@ export class HuiCardLayoutEditor extends LitElement {
this._defaultLayoutOptions = this._defaultLayoutOptions =
this._cardElement?.getElementLayoutOptions(); this._cardElement?.getElementLayoutOptions();
}); });
this._cardElement.load();
this._defaultLayoutOptions = this._cardElement.getElementLayoutOptions(); this._defaultLayoutOptions = this._cardElement.getElementLayoutOptions();
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console

View File

@ -201,6 +201,12 @@ export class HuiTileCardEditor
), ),
value: "last-changed", value: "last-changed",
}, },
{
label: localize(
`ui.panel.lovelace.editor.card.tile.state_content_options.last-updated`
),
value: "last-updated",
},
...Object.keys(stateObj?.attributes ?? {}) ...Object.keys(stateObj?.attributes ?? {})
.filter((a) => !HIDDEN_ATTRIBUTES.includes(a)) .filter((a) => !HIDDEN_ATTRIBUTES.includes(a))
.map((attribute) => ({ .map((attribute) => ({

View File

@ -64,6 +64,7 @@ export class HuiSection extends ReactiveElement {
ev.stopPropagation(); ev.stopPropagation();
this._cards = [...this._cards]; this._cards = [...this._cards];
}); });
element.load();
return element; return element;
} }

View File

@ -82,6 +82,7 @@ export class HUIView extends ReactiveElement {
ev.stopPropagation(); ev.stopPropagation();
this._cards = [...this._cards]; this._cards = [...this._cards];
}); });
element.load();
return element; return element;
} }

View File

@ -5987,7 +5987,8 @@
"state_content": "State content", "state_content": "State content",
"state_content_options": { "state_content_options": {
"state": "State", "state": "State",
"last-changed": "Last changed" "last-changed": "Last changed",
"last-updated": "Last updated"
} }
}, },
"vertical-stack": { "vertical-stack": {

View File

@ -4036,10 +4036,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/chromecast-caf-receiver@npm:6.0.15": "@types/chromecast-caf-receiver@npm:6.0.16":
version: 6.0.15 version: 6.0.16
resolution: "@types/chromecast-caf-receiver@npm:6.0.15" resolution: "@types/chromecast-caf-receiver@npm:6.0.16"
checksum: 10/532c926d01b8173013c0aa96fad3b4e3e8b8f02c993b52cbc654b4263c7e396cc7a1497e00561428cccdbe6bb2014824577e7fdf44f1ebd63e68a3815500fd86 checksum: 10/8d60a8fb0a7c4c90d8d8bb7fd55007ef0b2367f0a2129b83895bd857dfdc21296934cb57829248806093d92f66a2fc05475c616b7656ebc27c9498011e2f1f01
languageName: node languageName: node
linkType: hard linkType: hard
@ -8986,7 +8986,7 @@ __metadata:
"@rollup/plugin-replace": "npm:5.0.7" "@rollup/plugin-replace": "npm:5.0.7"
"@thomasloven/round-slider": "npm:0.6.0" "@thomasloven/round-slider": "npm:0.6.0"
"@types/babel__plugin-transform-runtime": "npm:7.9.5" "@types/babel__plugin-transform-runtime": "npm:7.9.5"
"@types/chromecast-caf-receiver": "npm:6.0.15" "@types/chromecast-caf-receiver": "npm:6.0.16"
"@types/chromecast-caf-sender": "npm:1.0.10" "@types/chromecast-caf-sender": "npm:1.0.10"
"@types/color-name": "npm:1.1.4" "@types/color-name": "npm:1.1.4"
"@types/glob": "npm:8.1.0" "@types/glob": "npm:8.1.0"