Compare commits

...

14 Commits

Author SHA1 Message Date
Aidan Timson 74a967fdf7 Test generated dashboard initial readiness 2026-07-28 15:47:36 +01:00
Aidan Timson 4210afb95d Delay generated panel readiness until content 2026-07-28 15:47:36 +01:00
Aidan Timson cde453dceb Add fade in delay to correct dashboard loading 2026-07-28 15:47:36 +01:00
Aidan Timson 0cb12c3fee Test dashboard initial readiness 2026-07-28 15:47:36 +01:00
Aidan Timson 115e8abb02 Wait for generated panel readiness 2026-07-28 15:47:36 +01:00
Aidan Timson f0104b5be1 Aggregate dashboard view readiness 2026-07-28 15:47:36 +01:00
Aidan Timson 28624d1c49 Signal initial Lovelace view readiness 2026-07-28 15:47:36 +01:00
Aidan Timson 1aa5877052 Wait for dashboard initial readiness 2026-07-28 15:47:36 +01:00
Aidan Timson 4d0734538d Expose initial view render completion 2026-07-28 15:47:36 +01:00
Aidan Timson 541d871a1b Wrap dashboard loading spinner in a delayed fade in component 2026-07-28 15:47:36 +01:00
Aidan Timson af966cf3c2 Move panel readiness setup to constructor 2026-07-28 15:47:35 +01:00
Aidan Timson 99597d7cce Wait for settings overview readiness 2026-07-28 15:47:35 +01:00
Bram Kragten 6667d0e57e Surface replaced composite devices in device and target pickers (#53169)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 15:59:17 +02:00
Aidan Timson 70ec53ff43 Wait for initial panel readiness (#53351)
* Wait for initial panel readiness

* Keep panel readiness internal
2026-07-28 14:03:04 +01:00
39 changed files with 1744 additions and 169 deletions
@@ -0,0 +1,23 @@
---
title: Replaced device selectors
subtitle: How device and target selectors surface devices that were split into separate devices
---
A device that used to belong to multiple config entries is split into one
device per config entry. The original composite device is removed from the
registry, so existing references to it (targets in automations, device
selectors) point at a device that no longer exists.
When a selector holds such a reference, it shows a **replaced** state instead of
a plain "not found", and offers to point the reference at the replacement
device(s). The candidate replacements are filtered through the selector's own
filters, so in practice usually a single device matches:
- **Target selector** — the replaced device row offers **Replace**, which adds
every replacement device that matches the target filters and removes the old
reference.
- **Device selector** — when exactly one replacement matches, **Replace** swaps
to it in one click; when several match, a dialog lets you pick one.
All samples below reference the removed composite device `old_composite`, which
was split into a light device and a switch device.
@@ -0,0 +1,318 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import "../../../../src/components/ha-target-picker";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
import type { Selector } from "../../../../src/data/selector";
import {
showDialog,
type ShowDialogParams,
} from "../../../../src/dialogs/make-dialog-manager";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
import type { HomeAssistant } from "../../../../src/types";
import "../../components/demo-black-white-row";
// The composite device "old_composite" is intentionally NOT in the registry:
// it was split into "device_light" and "device_switch". References to the old
// id (targets, device selectors) should surface a "replaced" state.
const DEVICES: DeviceRegistryEntry[] = [
{
area_id: "bedroom",
configuration_url: null,
config_entries: ["config_entry_light"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_light",
identifiers: [["demo", "light"] as [string, string]],
manufacturer: null,
model: null,
model_id: null,
name_by_user: null,
name: "Living room lamp",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
},
{
area_id: "backyard",
configuration_url: null,
config_entries: ["config_entry_switch"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_switch",
identifiers: [["demo", "switch"] as [string, string]],
manufacturer: null,
model: null,
model_id: null,
name_by_user: null,
name: "Garden socket",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
},
];
const ENTITIES = [
{
entity_id: "light.living_room_lamp",
state: "on",
attributes: { friendly_name: "Living room lamp" },
},
{
entity_id: "switch.garden_socket",
state: "off",
attributes: { friendly_name: "Garden socket" },
},
];
// Registry display entries link the demo entities to the split devices so the
// pickers can filter split candidates by domain.
const ENTITY_REGISTRY: Record<string, EntityRegistryDisplayEntry> = {
"light.living_room_lamp": {
entity_id: "light.living_room_lamp",
name: "Living room lamp",
device_id: "device_light",
area_id: "bedroom",
platform: "demo",
labels: [],
},
"switch.garden_socket": {
entity_id: "switch.garden_socket",
name: "Garden socket",
device_id: "device_switch",
area_id: "backyard",
platform: "demo",
labels: [],
},
};
const AREAS: AreaRegistryEntry[] = [
{
area_id: "backyard",
floor_id: null,
name: "Backyard",
icon: null,
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "bedroom",
floor_id: null,
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
// Maps the removed composite device to the devices that replaced it.
const COMPOSITE_SPLITS = {
old_composite: {
split_ids: ["device_light", "device_switch"],
primary_id: "device_light",
},
};
interface Sample {
name: string;
description: string;
selector: Selector;
value: unknown;
// Render ha-target-picker directly in compact (chip) mode instead of the
// ha-selector, which does not expose the compact option.
compact?: boolean;
}
const SAMPLES: Sample[] = [
{
name: "Target",
description:
"Migrate adds every replacement device that matches the target filters (here both).",
selector: { target: {} },
value: { device_id: ["old_composite"] },
},
{
name: "Target (compact)",
description:
"In compact mode the replaced reference is shown as a warning chip.",
selector: { target: {} },
value: { device_id: ["old_composite"] },
compact: true,
},
{
name: "Device (unfiltered, multiple matches)",
description:
"Both replacement devices qualify, so Replace opens a dialog to pick one.",
selector: { device: {} },
value: "old_composite",
},
{
name: "Device (filtered to lights, single match)",
description:
"Only the light device passes the filter, so Replace swaps to it in one click.",
selector: { device: { entity: [{ domain: "light" }] } },
value: "old_composite",
},
{
name: "Device (multiple)",
description: "Each slot resolves independently to a matching replacement.",
selector: { device: { multiple: true } },
value: ["old_composite"],
},
];
@customElement("demo-components-ha-selector-replaced-device")
class DemoHaSelectorReplacedDevice
extends LitElement
implements ProvideHassElement
{
@state() public hass!: HomeAssistant;
private _values = SAMPLES.map((sample) => sample.value);
constructor() {
super();
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.updateTranslations("config", "en");
hass.addEntities(ENTITIES);
mockEntityRegistry(hass);
mockDeviceRegistry(hass, DEVICES);
mockConfigEntries(hass);
mockHassioSupervisor(hass);
// Provide the demo areas and link the demo entities to the split devices.
// Set them directly via updateHass (typed against the real registry types)
// instead of the area stub, whose demo-specific type differs.
const areas: Record<string, AreaRegistryEntry> = {};
AREAS.forEach((area) => {
areas[area.area_id] = area;
});
hass.updateHass({ areas, entities: ENTITY_REGISTRY });
hass.mockWS(
"config/device_registry/list_composite_splits",
() => COMPOSITE_SPLITS
);
hass.mockWS("extract_from_target", () => ({
referenced_entities: [],
referenced_devices: [],
referenced_areas: [],
}));
hass.mockWS("auth/sign_path", (params) => params);
}
public provideHass(el) {
el.hass = this.hass;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("show-dialog", this._dialogManager);
}
private _dialogManager = (e: HASSDomEvent<ShowDialogParams<unknown>>) => {
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
e.detail;
showDialog(
this,
dialogTag,
dialogParams,
dialogImport,
parentElement,
addHistory
);
};
protected render(): TemplateResult {
return html`
${SAMPLES.map(
(sample, idx) => html`
<demo-black-white-row .title=${sample.name}>
${["light", "dark"].map(
(slot) => html`
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${sample.name}</span>
<span slot="description">${sample.description}</span>
${
sample.compact
? html`<ha-target-picker
compact
.hass=${this.hass}
.value=${this._values[idx] as HassServiceTarget}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-target-picker>`
: html`<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>`
}
</ha-settings-row>
`
)}
</demo-black-white-row>
`
)}
`;
}
private _handleValueChanged(ev) {
const idx = ev.target.sampleIdx;
this._values[idx] = ev.detail.value;
this.requestUpdate();
}
static styles = css`
ha-settings-row {
--settings-row-content-width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-selector-replaced-device": DemoHaSelectorReplacedDevice;
}
}
@@ -0,0 +1,142 @@
import { mdiDevices } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../../dialogs/make-dialog-manager";
import "../ha-dialog";
import "../ha-svg-icon";
import "../item/ha-list-item-button";
import "../list/ha-list-base";
import type { DeviceReplacedDialogParams } from "./show-dialog-device-replaced";
@customElement("dialog-device-replaced")
export class DialogDeviceReplaced
extends LitElement
implements HassDialog<DeviceReplacedDialogParams>
{
@state() private _params?: DeviceReplacedDialogParams;
@state() private _open = false;
@property({ attribute: false }) public hass!: HomeAssistant;
public async showDialog(params: DeviceReplacedDialogParams): Promise<void> {
this._params = params;
this._open = true;
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _pick(ev: Event): void {
const item = (ev.target as HTMLElement).closest("ha-list-item-button") as
(HTMLElement & { deviceId?: string }) | null;
if (!item?.deviceId) {
return;
}
this._params?.onResolved(item.deviceId);
this.closeDialog();
}
private _items = memoizeOne(
(
candidates: string[],
primaryId: string | null,
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"]
) =>
candidates.map((deviceId) => {
const device = devices[deviceId];
const area = device ? getDeviceArea(device, areas) : undefined;
return {
deviceId,
name: device ? computeDeviceName(device) : deviceId,
secondary: area ? computeAreaName(area) : undefined,
isPrimary: deviceId === primaryId,
};
})
);
protected render() {
if (!this._params || !this.hass) {
return nothing;
}
return html`
<ha-dialog
.open=${this._open}
.headerTitle=${this.hass.localize(
"ui.components.device-picker.replaced_dialog.title"
)}
@closed=${this._dialogClosed}
>
<p class="description">
${this.hass.localize(
"ui.components.device-picker.replaced_dialog.description"
)}
</p>
<ha-list-base @click=${this._pick}>
${this._items(
this._params.candidates,
this._params.primaryId,
this.hass.devices,
this.hass.areas
).map(
(item) => html`
<ha-list-item-button .deviceId=${item.deviceId}>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<span slot="headline">${item.name}</span>
${
item.secondary || item.isPrimary
? html`<span slot="supporting-text">
${[
item.secondary,
item.isPrimary
? this.hass.localize(
"ui.components.device-picker.replaced_dialog.recommended"
)
: undefined,
]
.filter(Boolean)
.join(" • ")}
</span>`
: nothing
}
</ha-list-item-button>
`
)}
</ha-list-base>
</ha-dialog>
`;
}
static styles = css`
ha-dialog {
--dialog-content-padding: 0;
--ha-row-item-padding-inline: var(--ha-space-6);
}
.description {
margin: 0;
padding: 0 var(--ha-space-6) var(--ha-space-4);
color: var(--secondary-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-device-replaced": DialogDeviceReplaced;
}
}
@@ -12,6 +12,7 @@ import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
deviceAutomationsEqual,
deviceAutomationsSimilar,
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -201,12 +202,22 @@ export abstract class HaDeviceAutomationPicker<
: // No device, clear the list of automations
[];
// If there is no value, or if we have changed the device ID, reset the value.
// If there is no value, or if we have changed the device ID, reset the
// value. When the device changed (for example after replacing a removed
// device), try to keep the same automation type/subtype on the new device
// before falling back to the first available automation.
if (!this.value || this.value.device_id !== this.deviceId) {
const equivalent =
this.value && this.deviceId
? this._automations.find((automation) =>
deviceAutomationsSimilar(automation, this.value!)
)
: undefined;
this._setValue(
this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId)
equivalent ||
(this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId))
);
}
this._renderEmpty = true;
+244 -46
View File
@@ -1,6 +1,7 @@
import { mdiAlertOutline } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
@@ -13,12 +14,20 @@ import {
getDevices,
type DevicePickerItem,
} from "../../data/device/device_picker";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
type DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-alert";
import "../ha-button";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-svg-icon";
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry
@@ -95,6 +104,10 @@ export class HaDevicePicker extends LitElement {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
private _getDevicesMemoized = memoizeOne(
(
_devices: HomeAssistant["devices"],
@@ -123,6 +136,29 @@ export class HaDevicePicker extends LitElement {
this._loadConfigEntries();
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (
!this.hass ||
!this.value ||
this._compositeSplits !== undefined ||
this._loadingCompositeSplits
) {
return;
}
const oldHass = changedProperties.get("hass") as HomeAssistant | undefined;
const devicesChanged =
changedProperties.has("hass") && this.hass.devices !== oldHass?.devices;
if (
(changedProperties.has("value") || devicesChanged) &&
!this.hass.devices[this.value]
) {
// The selected device is not in the registry; it might be a legacy
// composite device that was split into separate devices. Fetch the
// split map so we can offer to replace the reference.
this._loadCompositeSplits();
}
}
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
@@ -130,6 +166,43 @@ export class HaDevicePicker extends LitElement {
);
}
private async _loadCompositeSplits() {
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
private _getReplacement = memoizeOne(
(
value: string | undefined,
_devices: HomeAssistant["devices"],
compositeSplits: DeviceCompositeSplits | undefined,
items: (DevicePickerItem | string)[]
) => {
if (!value || !compositeSplits || this.hass.devices[value]) {
return undefined;
}
const split = compositeSplits[value];
if (!split) {
return undefined;
}
// Keep only the split devices that pass this picker's filters. In
// practice usually exactly one of the split devices matches.
const selectableIds = new Set(
items
.filter((item): item is DevicePickerItem => typeof item !== "string")
.map((item) => item.id)
);
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
return { candidates, primaryId: split.primary_id };
}
);
private _getItems = () =>
this._getDevicesMemoized(
this.hass.devices,
@@ -144,49 +217,66 @@ export class HaDevicePicker extends LitElement {
);
private _valueRenderer = memoizeOne(
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
(
configEntriesLookup: Record<string, ConfigEntry>,
replacementName: string | undefined
) =>
(value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
if (!device) {
return html`<span slot="headline">${deviceId}</span>`;
}
const area = getDeviceArea(device, this.hass.areas);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = deviceName;
const secondary = areaName;
const configEntry = device.primary_config_entry
? configEntriesLookup[device.primary_config_entry]
: undefined;
return html`
${
configEntry
? html`<img
if (!device) {
// When the device was replaced and a replacement is available, show
// the replacement device's name. Otherwise fall back to the normal
// "not found" display of the raw id.
if (replacementName) {
return html`
<ha-svg-icon
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: nothing
style="color: var(--warning-color)"
.path=${mdiAlertOutline}
></ha-svg-icon>
<span slot="headline">${replacementName}</span>
`;
}
return html`<span slot="headline">${deviceId}</span>`;
}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
}
const area = getDeviceArea(device, this.hass.areas);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = deviceName;
const secondary = areaName;
const configEntry = device.primary_config_entry
? configEntriesLookup[device.primary_config_entry]
: undefined;
return html`
${
configEntry
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: nothing
}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
}
);
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
@@ -235,7 +325,39 @@ export class HaDevicePicker extends LitElement {
this.placeholder ??
this.hass.localize("ui.components.device-picker.placeholder");
const valueRenderer = this._valueRenderer(this._configEntryLookup);
// Only resolve a replacement (which needs the full item list) when the
// value is a missing device that we know was replaced, to avoid computing
// the item list on every render for the common case.
const replacement =
this.value &&
!this.hass.devices[this.value] &&
this._compositeSplits?.[this.value]
? this._getReplacement(
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
)
: undefined;
// Only treat the value as "replaced" when there is an available
// replacement device; otherwise fall back to normal "not found" behavior.
const canReplace = !!replacement?.candidates.length;
const replacementName = canReplace
? computeDeviceName(
this.hass.devices[
replacement!.primaryId &&
replacement!.candidates.includes(replacement!.primaryId)
? replacement!.primaryId
: replacement!.candidates[0]
]
)
: undefined;
const valueRenderer = this._valueRenderer(
this._configEntryLookup,
replacementName
);
return html`
<ha-generic-picker
@@ -256,15 +378,84 @@ export class HaDevicePicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${this.hass.localize(
"ui.components.device-picker.unknown"
)}
.unknownItemText=${
replacement?.candidates.length
? this.hass.localize(
"ui.components.device-picker.device_replaced_count",
{ count: replacement.candidates.length }
)
: this.hass.localize("ui.components.device-picker.unknown")
}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
${canReplace ? this._renderReplacedAlert(replacement!) : nothing}
`;
}
private _renderReplacedAlert(replacement: {
candidates: string[];
primaryId: string | null;
}) {
const { candidates } = replacement;
const replacementName =
candidates.length === 1
? computeDeviceName(this.hass.devices[candidates[0]])
: undefined;
return html`
<ha-alert alert-type="warning">
${
replacementName
? this.hass.localize(
"ui.components.device-picker.device_replaced_by_one",
{ device: replacementName }
)
: this.hass.localize(
"ui.components.device-picker.device_replaced_by_multiple",
{ count: candidates.length }
)
}
<ha-button
slot="action"
appearance="plain"
@click=${this._handleReplace}
>
${this.hass.localize("ui.components.device-picker.replace_device")}
</ha-button>
</ha-alert>
`;
}
private _handleReplace = () => {
const replacement = this._getReplacement(
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
);
if (!replacement?.candidates.length) {
return;
}
const { candidates, primaryId } = replacement;
if (candidates.length === 1) {
this._setValue(candidates[0]);
return;
}
showDeviceReplacedDialog(this, {
originalDeviceId: this.value!,
candidates,
primaryId,
onResolved: (deviceId) => this._setValue(deviceId),
});
};
private _setValue(value: string) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
public async open() {
await this.updateComplete;
await this._picker?.open();
@@ -281,6 +472,13 @@ export class HaDevicePicker extends LitElement {
this.hass.localize("ui.components.device-picker.no_match", {
term: html`<b>${search}</b>`,
});
static styles = css`
ha-alert {
display: block;
margin-top: 8px;
}
`;
}
declare global {
@@ -0,0 +1,22 @@
import { fireEvent } from "../../common/dom/fire_event";
export interface DeviceReplacedDialogParams {
/** The removed composite device that is being referenced. */
originalDeviceId: string;
/** The split devices the reference can be pointed at. */
candidates: string[];
/** The split device that took over the composite's primary config entry. */
primaryId: string | null;
/** Called with the device the user picked as replacement. */
onResolved: (deviceId: string) => void;
}
export const showDeviceReplacedDialog = (
element: HTMLElement,
params: DeviceReplacedDialogParams
) =>
fireEvent(element, "show-dialog", {
dialogTag: "dialog-device-replaced",
dialogImport: () => import("./dialog-device-replaced"),
dialogParams: params,
});
+55
View File
@@ -27,6 +27,10 @@ import {
getDevices,
type DevicePickerItem,
} from "../data/device/device_picker";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import {
entityComboBoxKeys,
@@ -122,6 +126,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: labelsContext, subscribe: true })
private _labelRegistry!: LabelRegistryEntry[];
@@ -211,6 +219,24 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._loadConfigEntries();
}
const devicesChanged =
changedProps.has("hass") &&
this.hass.devices !== changedProps.get("hass")?.devices;
if (
(changedProps.has("value") || devicesChanged) &&
this.hass &&
this._compositeSplits === undefined &&
!this._loadingCompositeSplits &&
this.value?.device_id &&
ensureArray(this.value.device_id).some(
(deviceId) => !this.hass.devices[deviceId]
)
) {
// A referenced device is missing from the registry; it might be a legacy
// composite device that was split. Fetch the split map to offer a fix.
this._loadCompositeSplits();
}
if (
this._pendingEntityId &&
changedProps.has("hass") &&
@@ -297,6 +323,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass}
type="device"
.itemId=${device_id}
.compositeSplits=${this._compositeSplits}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
@@ -390,6 +417,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
@replace-target-item=${this._handleReplace}
@migrate-target-item=${this._handleMigrate}
type="device"
.hass=${this.hass}
.items=${{ device: deviceIds }}
@@ -398,6 +426,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
.compositeSplits=${this._compositeSplits}
>
</ha-target-picker-item-group>
`
@@ -1175,6 +1204,31 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
);
}
private async _loadCompositeSplits() {
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
private _handleMigrate(
ev: HASSDomEvent<HASSDomEvents["migrate-target-item"]>
) {
const { id, replacements } = ev.detail;
let value = this._removeItem(this.value, "device", id);
for (const replacement of replacements) {
value = this._addTargetToValue(value, {
type: "device",
id: replacement,
});
}
fireEvent(this, "value-changed", { value });
}
private _renderRow = (
item:
| PickerComboBoxItem
@@ -1357,6 +1411,7 @@ declare global {
"remove-target-item": TargetItem;
"expand-target-item": TargetItem;
"replace-target-item": TargetItem;
"migrate-target-item": { id: string; replacements: string[] };
"remove-target-group": string;
}
}
@@ -1,5 +1,6 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { TargetType, TargetTypeFloorless } from "../../data/target";
import type { HomeAssistant } from "../../types";
@@ -52,6 +53,9 @@ export class HaTargetPickerItemGroup extends LitElement {
@property({ type: Boolean, attribute: "primary-entities-only" })
public primaryEntitiesOnly?: boolean;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
protected render() {
let count = 0;
Object.values(this.items).forEach((items) => {
@@ -87,6 +91,7 @@ export class HaTargetPickerItemGroup extends LitElement {
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
.compositeSplits=${this.compositeSplits}
></ha-target-picker-item-row>`
)
: nothing
@@ -34,7 +34,10 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import type {
DeviceCompositeSplits,
DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { FloorRegistryEntry } from "../../data/floor_registry";
import { domainToName } from "../../data/integration";
@@ -54,6 +57,7 @@ import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-button";
import "../ha-icon-button";
import "../ha-state-icon";
import "../ha-svg-icon";
@@ -108,6 +112,9 @@ export class HaTargetPickerItemRow extends LitElement {
@property({ type: Boolean, attribute: "primary-entities-only" })
public primaryEntitiesOnly?: boolean;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
@state() private _iconImg?: string;
@state() private _domainName?: string;
@@ -128,6 +135,15 @@ export class HaTargetPickerItemRow extends LitElement {
const { name, context, iconPath, fallbackIconPath, stateObject, notFound } =
this._itemData(this.type, this.itemId);
const replacement =
this.type === "device" && notFound
? this._getReplacement(this.itemId)
: undefined;
// Only surface the "replaced" state when there is at least one available
// replacement device to migrate to. If every replacement device was
// deleted (or filtered out), fall back to the plain "not found" state.
const canMigrate = !!replacement?.candidates.length;
const showEntities = this.type !== "entity" && !notFound;
const entries = this.parentEntries || this._entries;
@@ -174,15 +190,20 @@ export class HaTargetPickerItemRow extends LitElement {
}
</div>
<div slot="headline">${name}</div>
<div slot="headline">${(canMigrate && replacement?.name) || name}</div>
${
notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${
notFound
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
? canMigrate
? this.hass.localize(
"ui.components.target-picker.device_replaced",
{ count: replacement!.candidates.length }
)
: this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context
}</span
>`
@@ -229,6 +250,24 @@ export class HaTargetPickerItemRow extends LitElement {
`
: nothing
}
${
canMigrate
? html`
<ha-button
class="migrate"
slot="end"
appearance="plain"
variant="warning"
size="s"
@click=${this._migrate}
>
${this.hass.localize(
"ui.components.target-picker.replace_device"
)}
</ha-button>
`
: nothing
}
${
!this.expand && !this.subEntry
? html`
@@ -707,6 +746,51 @@ export class HaTargetPickerItemRow extends LitElement {
});
}
// Returns the split devices that replaced a removed composite device and
// pass this row's filters, or undefined if the item is not a replaced device.
private _getReplacement(item: string) {
const split = this.compositeSplits?.[item];
if (!split || this.hass.devices[item]) {
return undefined;
}
const candidates = split.split_ids.filter((id) => {
const device = this.hass.devices[id];
return (
device &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
);
});
// Display the replaced reference using the primary replacement device's
// name instead of the removed composite device id. Fall back to the first
// available candidate if the primary device itself was deleted.
const nameDevice =
(split.primary_id && this.hass.devices[split.primary_id]) ||
(candidates.length ? this.hass.devices[candidates[0]] : undefined);
const name = nameDevice ? computeDeviceName(nameDevice) : undefined;
return { candidates, name };
}
private _migrate = (ev: MouseEvent) => {
ev.stopPropagation();
const replacement = this._getReplacement(this.itemId);
if (!replacement?.candidates.length) {
return;
}
fireEvent(this, "migrate-target-item", {
id: this.itemId,
replacements: replacement.candidates,
});
};
private _openDetails(ev: MouseEvent) {
ev.stopPropagation();
showTargetDetailsDialog(this, {
@@ -746,6 +830,11 @@ export class HaTargetPickerItemRow extends LitElement {
color: var(--ha-color-on-warning-normal);
}
.migrate {
align-self: center;
white-space: nowrap;
}
.replaceable {
cursor: pointer;
}
@@ -1,6 +1,7 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { consume } from "@lit/context";
import {
mdiAlertOutline,
mdiDevices,
mdiHome,
mdiLabel,
@@ -9,6 +10,7 @@ import {
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../common/color/compute-color";
import { hex2rgb } from "../../common/color/convert-color";
@@ -19,6 +21,7 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { slugify } from "../../common/string/slugify";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { TargetType } from "../../data/target";
@@ -39,6 +42,9 @@ export class HaTargetPickerValueChip extends LitElement {
@property({ attribute: "item-id" }) public itemId!: string;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
@state() private _domainName?: string;
@state() private _iconImg?: string;
@@ -51,38 +57,74 @@ export class HaTargetPickerValueChip extends LitElement {
const { name, iconPath, fallbackIconPath, stateObject, color } =
this._itemData(this.type, this.itemId);
const split =
this.type === "device" && !this.hass.devices?.[this.itemId]
? this.compositeSplits?.[this.itemId]
: undefined;
// Show the replaced reference using the primary replacement device's name,
// falling back to the first still-existing split device if the primary
// device itself was deleted. If no replacement device exists at all, fall
// back to the normal "not found" display.
const replacementDevice = split
? (split.primary_id && this.hass.devices?.[split.primary_id]) ||
split.split_ids
.map((id) => this.hass.devices?.[id])
.find((device) => device)
: undefined;
const replaced = !!replacementDevice;
const replacedName = replacementDevice
? computeDeviceNameDisplay(
replacementDevice,
this.hass.localize,
this.hass.states
)
: undefined;
return html`
<wa-tag
pill
with-remove
class=${this.type}
class=${classMap({ [this.type]: true, replaced })}
style=${color ? `--color: rgb(${color});` : ""}
@wa-remove=${this._removeItem}
>
<div class="icon">
${
iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.stateObj=${stateObject}
></ha-state-icon>`
: nothing
replaced
? html`<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>`
: iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon
.path=${fallbackIconPath}
></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.stateObj=${stateObject}
></ha-state-icon>`
: nothing
}
</div>
<span class="name"> ${name} </span>
<span class="name">
${
replaced
? replacedName ||
this.hass.localize(
"ui.components.target-picker.replaced_device"
)
: name
}
</span>
${
this.type === "entity"
this.type === "entity" || replaced
? nothing
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
>${this.hass.localize(
@@ -125,7 +167,7 @@ export class HaTargetPickerValueChip extends LitElement {
if (type === "device") {
const device = this.hass.devices?.[itemId];
if (device.primary_config_entry) {
if (device?.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
@@ -240,6 +282,11 @@ export class HaTargetPickerValueChip extends LitElement {
--background-color: var(--color);
--icon-primary-color: var(--primary-text-color);
}
wa-tag.replaced {
border-color: var(--ha-color-border-warning-normal, var(--warning-color));
--background-color: var(--warning-color);
color: var(--ha-color-on-warning-normal, var(--warning-color));
}
.name {
overflow: hidden;
+47 -1
View File
@@ -2,9 +2,10 @@ import type { HassEntities } from "home-assistant-js-websocket";
import { computeStateName } from "../../common/entity/compute_state_name";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { HaFormSchema } from "../../components/ha-form/types";
import type { CallWS } from "../../types";
import type { CallWS, HomeAssistant } from "../../types";
import type { BaseTrigger } from "../automation";
import { migrateAutomationTrigger } from "../automation";
import type { DeviceCompositeSplits } from "./device_registry";
import type { EntityRegistryEntry } from "../entity/entity_registry";
import {
entityRegistryByEntityId,
@@ -158,6 +159,51 @@ export const deviceAutomationsEqual = (
return true;
};
// Decides how a device automation editor should handle its referenced device.
// A missing device that was replaced by a split device stays editable so the
// device picker can offer to fix the reference; a genuinely unknown device
// cannot be edited visually. Returns "loading" while the split map is unknown.
export const deviceAutomationEditorMode = (
hass: HomeAssistant,
deviceId: string | undefined,
compositeSplits: DeviceCompositeSplits | undefined
): "editable" | "loading" | "unknown-device" => {
if (!deviceId || deviceId in hass.devices) {
return "editable";
}
if (compositeSplits === undefined) {
return "loading";
}
// Only editable if at least one of the split (replacement) devices still
// exists; otherwise the reference is stale and cannot be fixed here.
const split = compositeSplits[deviceId];
return split?.split_ids.some((id) => id in hass.devices)
? "editable"
: "unknown-device";
};
// Like deviceAutomationsEqual, but ignores device_id and entity_id so an
// automation can be matched to the equivalent one on a different device (for
// example when a referenced device was replaced by a split device).
export const deviceAutomationsSimilar = (
a: DeviceAutomation,
b: DeviceAutomation
) => {
if (typeof a !== typeof b) {
return false;
}
return deviceAutomationIdentifiers
.filter((property) => property !== "device_id" && property !== "entity_id")
.every((property) => {
const inA = property in a;
const inB = property in b;
if (!inA && !inB) {
return true;
}
return Object.is(a[property], b[property]);
});
};
const compareEntityIdWithEntityRegId = (
entityRegistry: EntityRegistryEntry[],
entityIdA?: string,
+47
View File
@@ -1,3 +1,4 @@
import type { Connection } from "home-assistant-js-websocket";
import { computeStateName } from "../../common/entity/compute_state_name";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { HomeAssistant } from "../../types";
@@ -54,6 +55,52 @@ export interface DeviceRegistryEntryMutableParams {
labels?: string[];
}
/**
* Describes how a legacy composite device (that lived on multiple config
* entries) was split into separate devices. The composite device no longer
* exists in the registry; references to it (in automations, targets, ...) now
* need to point at one or more of the split devices instead.
*/
export interface DeviceCompositeSplit {
/** Ids of the devices that replaced the composite device. */
split_ids: string[];
/** The split device that took over the composite's primary config entry. */
primary_id: string | null;
}
/** Map of removed composite device id -> its split information. */
export type DeviceCompositeSplits = Record<string, DeviceCompositeSplit>;
// The composite split migration in core is a one-time operation, so the split
// map is static for the lifetime of the connection. Cache the request per
// connection so it is fetched once and shared across all pickers, instead of
// being requested again by every device/target picker.
const compositeSplitsCache = new WeakMap<
Connection,
Promise<DeviceCompositeSplits>
>();
export const fetchDeviceCompositeSplits = (
hass: Pick<HomeAssistant, "connection" | "callWS">
): Promise<DeviceCompositeSplits> => {
const conn = hass.connection;
let request = compositeSplitsCache.get(conn);
if (!request) {
request = hass
.callWS<DeviceCompositeSplits>({
type: "config/device_registry/list_composite_splits",
})
.catch((err) => {
// Don't cache failures so the next caller retries.
compositeSplitsCache.delete(conn);
throw err;
});
compositeSplitsCache.set(conn, request);
}
return request;
};
export const fallbackDeviceName = (
hass: HomeAssistant,
entities: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
+1
View File
@@ -23,6 +23,7 @@ export interface LovelaceViewElement extends HTMLElement {
badges?: HuiBadge[];
sections?: HuiSection[];
isStrategy: boolean;
initialRenderComplete?: Promise<void>;
setConfig(config: LovelaceViewConfig): void;
}
+4 -1
View File
@@ -1,6 +1,7 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../components/animation/ha-fade-in";
import "../components/ha-top-app-bar-fixed";
import "../components/ha-spinner";
import type { HomeAssistant } from "../types";
@@ -36,7 +37,9 @@ class HassLoadingScreen extends LitElement {
private _renderContent(): TemplateResult {
return html`
<div class="content">
<ha-spinner></ha-spinner>
<ha-fade-in .delay=${500}>
<ha-spinner></ha-spinner>
</ha-fade-in>
${
this.message
? html`<div id="loading-text">${this.message}</div>`
+18 -1
View File
@@ -5,6 +5,7 @@ import memoizeOne from "memoize-one";
import { navigate } from "../common/navigate";
import { computeRouteTail } from "../common/url/route";
import type { Route } from "../types";
import { PanelReady } from "./panel-ready";
const extractPage = (path: string, defaultPage: string) => {
if (path === "") {
@@ -22,6 +23,7 @@ export interface RouteOptions {
// Function to load the page.
load?: () => Promise<unknown>;
cache?: boolean;
waitForReady?: boolean;
}
export interface RouterOptions {
@@ -53,6 +55,8 @@ export class HassRouterPage extends ReactiveElement {
private _currentLoadProm?: Promise<void>;
private _panelReady = new PanelReady();
private _cache = {};
private _initialLoadDone = false;
@@ -180,6 +184,10 @@ export class HassRouterPage extends ReactiveElement {
// If we don't show loading screen, just show the panel.
// It will be automatically upgraded when loading done.
if (!routerOptions.showLoading) {
const loadComplete = () => {
this._currentLoadProm = undefined;
};
this._currentLoadProm = loadProm.then(loadComplete, loadComplete);
this._createPanel(routerOptions, newPage, routeOptions);
return;
}
@@ -287,7 +295,15 @@ export class HassRouterPage extends ReactiveElement {
* Promise that resolves when the page has rendered.
*/
protected get pageRendered(): Promise<void> {
return this.updateComplete.then(() => this._currentLoadProm);
return this.updateComplete
.then(() => this._currentLoadProm)
.then(() => {
const page = this.lastElementChild;
return Promise.all([
this._panelReady.ready,
page instanceof HassRouterPage ? page.pageRendered : undefined,
]).then(() => undefined);
});
}
protected createElement(tag: string) {
@@ -312,6 +328,7 @@ export class HassRouterPage extends ReactiveElement {
}
const panelEl = this._cache[page] || this.createElement(routeOptions.tag);
this._panelReady.track(panelEl, routeOptions.waitForReady);
this.updatePageEl(panelEl);
this.appendChild(panelEl);
+4 -14
View File
@@ -17,10 +17,7 @@ import { HassElement } from "../state/hass-element";
import QuickBarMixin from "../state/quick-bar-mixin";
import type { HomeAssistant, Route } from "../types";
import { storeState } from "../util/ha-pref-storage";
import {
removeLaunchScreen,
renderLaunchScreenContent,
} from "../util/launch-screen";
import { renderLaunchScreenContent } from "../util/launch-screen";
import { checkOnboardingSurveyToast } from "../util/onboarding-survey";
import {
registerServiceWorker,
@@ -136,16 +133,9 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
) {
this.render = this.renderHass;
this.update = super.update;
// Apps with a native splash screen keep covering the frontend until
// frontend/loaded, so the launch screen stays up (invisibly) until the
// first panel has rendered and partial-panel-resolver removes it and
// fires frontend/loaded.
if (
!this.hass.auth.external?.config.hasSplashscreen &&
removeLaunchScreen()
) {
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
// partial-panel-resolver removes the launch screen after the first panel
// is ready. Native apps request instant removal because their own splash
// screen covers the frontend until frontend/loaded is sent.
}
super.update(changedProps);
}
+69
View File
@@ -0,0 +1,69 @@
import { ContextProvider, createContext } from "@lit/context";
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { ReactiveElement } from "lit";
import { fireEvent } from "../common/dom/fire_event";
declare global {
interface HASSDomEvents {
"hass-panel-ready": undefined;
}
}
export const panelIsReady = async (element: HTMLElement) => {
if (element instanceof ReactiveElement) {
// Ensure pending Lit changes are rendered before revealing the panel.
await element.updateComplete;
}
fireEvent(element, "hass-panel-ready");
};
export type RegisterChildPanelReady = (ready: Promise<void>) => void;
export const childPanelReadyContext =
createContext<RegisterChildPanelReady>("child-panel-ready");
export class ChildPanelReady implements ReactiveController {
private _promises: Promise<void>[] = [];
private _host: ReactiveControllerHost & HTMLElement;
private _resolveReady?: () => void;
public ready = new Promise<void>((resolve) => {
this._resolveReady = resolve;
});
public constructor(host: ReactiveControllerHost & HTMLElement) {
this._host = host;
host.addController(this);
new ContextProvider(host, {
context: childPanelReadyContext,
initialValue: (ready) => this._promises.push(ready),
});
}
public hostUpdated() {
Promise.all(this._promises).then(
() => {
this._resolveReady?.();
return panelIsReady(this._host);
},
() => undefined
);
this._host.removeController(this);
}
}
export class PanelReady {
public ready?: Promise<void>;
public track(element: HTMLElement, waitForReady = false) {
this.ready = waitForReady
? new Promise((resolve) => {
element.addEventListener("hass-panel-ready", () => resolve(), {
once: true,
});
})
: undefined;
}
}
+67 -27
View File
@@ -8,6 +8,7 @@ import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { deepActiveElement } from "../common/dom/deep-active-element";
import { deepEqual } from "../common/util/deep-equal";
import { promiseTimeout } from "../common/util/promise-timeout";
import { narrowViewportContext } from "../data/context";
import { getDefaultPanel } from "../data/panel";
import type { CustomPanelInfo } from "../data/panel_custom";
@@ -17,29 +18,63 @@ import type { RouteOptions, RouterOptions } from "./hass-router-page";
import { HassRouterPage } from "./hass-router-page";
const CACHE_URL_PATHS = ["lovelace", "home", "config"];
const PANEL_READY_TIMEOUT = 2000;
const DASHBOARD_READY_TIMEOUT = 5000;
const COMPONENTS = {
app: () => import("../panels/app/ha-panel-app"),
energy: () => import("../panels/energy/ha-panel-energy"),
calendar: () => import("../panels/calendar/ha-panel-calendar"),
config: () => import("../panels/config/ha-panel-config"),
custom: () => import("../panels/custom/ha-panel-custom"),
lovelace: () => import("../panels/lovelace/ha-panel-lovelace"),
history: () => import("../panels/history/ha-panel-history"),
iframe: () => import("../panels/iframe/ha-panel-iframe"),
logbook: () => import("../panels/logbook/ha-panel-logbook"),
map: () => import("../panels/map/ha-panel-map"),
my: () => import("../panels/my/ha-panel-my"),
profile: () => import("../panels/profile/ha-panel-profile"),
todo: () => import("../panels/todo/ha-panel-todo"),
"media-browser": () =>
import("../panels/media-browser/ha-panel-media-browser"),
light: () => import("../panels/light/ha-panel-light"),
security: () => import("../panels/security/ha-panel-security"),
climate: () => import("../panels/climate/ha-panel-climate"),
maintenance: () => import("../panels/maintenance/ha-panel-maintenance"),
home: () => import("../panels/home/ha-panel-home"),
notfound: () => import("../panels/notfound/ha-panel-notfound"),
};
app: { load: () => import("../panels/app/ha-panel-app") },
energy: {
load: () => import("../panels/energy/ha-panel-energy"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
calendar: { load: () => import("../panels/calendar/ha-panel-calendar") },
config: { load: () => import("../panels/config/ha-panel-config") },
custom: { load: () => import("../panels/custom/ha-panel-custom") },
lovelace: {
load: () => import("../panels/lovelace/ha-panel-lovelace"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
history: { load: () => import("../panels/history/ha-panel-history") },
iframe: { load: () => import("../panels/iframe/ha-panel-iframe") },
logbook: { load: () => import("../panels/logbook/ha-panel-logbook") },
map: { load: () => import("../panels/map/ha-panel-map") },
my: { load: () => import("../panels/my/ha-panel-my") },
profile: { load: () => import("../panels/profile/ha-panel-profile") },
todo: { load: () => import("../panels/todo/ha-panel-todo") },
"media-browser": {
load: () => import("../panels/media-browser/ha-panel-media-browser"),
},
light: {
load: () => import("../panels/light/ha-panel-light"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
security: {
load: () => import("../panels/security/ha-panel-security"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
climate: {
load: () => import("../panels/climate/ha-panel-climate"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
maintenance: {
load: () => import("../panels/maintenance/ha-panel-maintenance"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
home: {
load: () => import("../panels/home/ha-panel-home"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
notfound: { load: () => import("../panels/notfound/ha-panel-notfound") },
} satisfies Record<
string,
Pick<RouteOptions, "load" | "waitForReady"> & { readyTimeout?: number }
>;
@customElement("partial-panel-resolver")
class PartialPanelResolver extends HassRouterPage {
@@ -124,13 +159,12 @@ class PartialPanelResolver extends HassRouterPage {
private _getRoutes(panels: Panels): RouterOptions {
const routes: RouterOptions["routes"] = {};
Object.values(panels).forEach((panel) => {
const component = COMPONENTS[panel.component_name];
const data: RouteOptions = {
tag: `ha-panel-${panel.component_name}`,
cache: CACHE_URL_PATHS.includes(panel.url_path),
...component,
};
if (panel.component_name in COMPONENTS) {
data.load = COMPONENTS[panel.component_name];
}
routes[panel.url_path] = data;
});
@@ -219,9 +253,15 @@ class PartialPanelResolver extends HassRouterPage {
)
) {
await this.rebuild();
await this.pageRendered;
const component =
COMPONENTS[this.hass.panels[this._currentPage].component_name];
await promiseTimeout(
component?.readyTimeout ?? PANEL_READY_TIMEOUT,
this.pageRendered
).catch(() => undefined);
// Only fire frontend/loaded when this call actually removed the launch
// screen, so later panel updates do not fire it again.
// screen, so later panel updates do not fire it again. Native apps remove
// it instantly because their own splash screen is still visible.
if (
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen)
) {
+4
View File
@@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
@@ -31,6 +32,8 @@ class PanelClimate extends LitElement {
@state() private _searchParams = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -127,6 +130,7 @@ class PanelClimate extends LitElement {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
@@ -13,11 +13,16 @@ import type {
DeviceCapabilities,
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
deviceAutomationsEqual,
fetchDeviceActionCapabilities,
localizeExtraFieldsComputeHelperCallback,
localizeExtraFieldsComputeLabelCallback,
} from "../../../../../data/device/device_automation";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
@@ -33,6 +38,10 @@ export class HaDeviceAction extends LitElement {
@state() private _capabilities?: DeviceCapabilities;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -59,14 +68,19 @@ export class HaDeviceAction extends LitElement {
}
);
public shouldUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("action")) {
return true;
public shouldUpdate(_changedProperties: PropertyValues<this>) {
const mode = deviceAutomationEditorMode(
this.hass,
this.action.device_id,
this._compositeSplits
);
if (mode === "loading") {
// The device is missing; wait for the composite split map before deciding
// whether it is a replaced device (editable) or genuinely unknown (YAML).
this._loadCompositeSplits();
return false;
}
if (
this.action.device_id &&
!(this.action.device_id in this.hass.devices)
) {
if (mode === "unknown-device") {
fireEvent(
this,
"ui-mode-not-available",
@@ -81,6 +95,20 @@ export class HaDeviceAction extends LitElement {
return true;
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
protected render() {
const deviceId = this._deviceId || this.action.device_id;
@@ -13,11 +13,16 @@ import type {
DeviceCondition,
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
deviceAutomationsEqual,
fetchDeviceConditionCapabilities,
localizeExtraFieldsComputeHelperCallback,
localizeExtraFieldsComputeLabelCallback,
} from "../../../../../data/device/device_automation";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
@@ -33,6 +38,10 @@ export class HaDeviceCondition extends LitElement {
@state() private _capabilities?: DeviceCapabilities;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -60,14 +69,19 @@ export class HaDeviceCondition extends LitElement {
}
);
public shouldUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("condition")) {
return true;
public shouldUpdate(_changedProperties: PropertyValues<this>) {
const mode = deviceAutomationEditorMode(
this.hass,
this.condition.device_id,
this._compositeSplits
);
if (mode === "loading") {
// The device is missing; wait for the composite split map before deciding
// whether it is a replaced device (editable) or genuinely unknown (YAML).
this._loadCompositeSplits();
return false;
}
if (
this.condition.device_id &&
!(this.condition.device_id in this.hass.devices)
) {
if (mode === "unknown-device") {
fireEvent(
this,
"ui-mode-not-available",
@@ -82,6 +96,20 @@ export class HaDeviceCondition extends LitElement {
return true;
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
protected render() {
const deviceId = this._deviceId || this.condition.device_id;
@@ -33,11 +33,16 @@ import type { ConfigEntry } from "../../../../data/config_entries";
import {
apiContext,
configEntriesContext,
connectionContext,
internationalizationContext,
labelsContext,
registriesContext,
statesContext,
} from "../../../../data/context";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../../data/device/device_registry";
import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
import {
deviceMeetsTargetSelector,
@@ -89,9 +94,16 @@ export class HaAutomationRowTargets extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
private _countCache = new Map<
string,
Promise<number | undefined> | number | undefined
@@ -108,6 +120,43 @@ export class HaAutomationRowTargets extends LitElement {
) {
this._rerenderCount = true;
}
if (
(changedProps.has("target") || changedProps.has("_registries")) &&
this._compositeSplits === undefined &&
!this._loadingCompositeSplits &&
this._hasMissingDevice()
) {
// A referenced device is missing from the registry; it might be a legacy
// composite device that was split. Fetch the split map so we can flag it.
this._loadCompositeSplits();
}
}
private _hasMissingDevice(): boolean {
const deviceIds = this.target?.device_id
? ensureArray(this.target.device_id)
: [];
return deviceIds.some(
(id) => !isTemplate(id) && !this._registries?.devices?.[id]
);
}
private async _loadCompositeSplits() {
if (!this._api || !this._connection) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits({
connection: this._connection.connection,
callWS: this._api.callWS,
});
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
protected updated(changedProps: PropertyValues) {
@@ -342,7 +391,8 @@ export class HaAutomationRowTargets extends LitElement {
error = false,
targetId?: string,
targetType?: string,
countTemplate: unknown = nothing
countTemplate: unknown = nothing,
title?: string
) {
if (!this.interactive || !targetId || !targetType) {
return html`<div
@@ -351,6 +401,7 @@ export class HaAutomationRowTargets extends LitElement {
warning,
error,
})}
title=${title ?? nothing}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
@@ -366,6 +417,7 @@ export class HaAutomationRowTargets extends LitElement {
warning,
error,
})}
title=${title ?? nothing}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
@@ -385,6 +437,7 @@ export class HaAutomationRowTargets extends LitElement {
let icon: string | undefined;
let label: string;
let warning = false;
let title: string | undefined;
let badgeTargetId: string | undefined = targetId;
let badgeTargetType: string | undefined = targetType;
let countTemplate: unknown = nothing;
@@ -408,17 +461,28 @@ export class HaAutomationRowTargets extends LitElement {
const exists = this._checkTargetExists(targetType, targetId);
if (!exists) {
icon = mdiAlert;
label = getTargetText(
this._registries,
this._states,
this._i18n.localize,
targetType,
targetId,
this._getLabel
);
warning = true;
badgeTargetId = undefined;
badgeTargetType = undefined;
if (targetType === "device" && this._compositeSplits?.[targetId]) {
// The device was replaced by one or more split devices; make clear
// this reference needs to be updated, distinct from "unknown device".
label = this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.device_replaced"
);
title = this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.device_replaced_description"
);
} else {
label = getTargetText(
this._registries,
this._states,
this._i18n.localize,
targetType,
targetId,
this._getLabel
);
}
} else {
label = getTargetText(
this._registries,
@@ -456,6 +520,7 @@ export class HaAutomationRowTargets extends LitElement {
targetType: badgeTargetType,
label,
}}
title=${title ?? nothing}
class=${classMap({
warning,
})}
@@ -470,7 +535,8 @@ export class HaAutomationRowTargets extends LitElement {
false,
badgeTargetId,
badgeTargetType,
countTemplate
countTemplate,
title
);
}
@@ -15,11 +15,16 @@ import type {
DeviceTrigger,
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
deviceAutomationsEqual,
fetchDeviceTriggerCapabilities,
localizeExtraFieldsComputeHelperCallback,
localizeExtraFieldsComputeLabelCallback,
} from "../../../../../data/device/device_automation";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
@@ -35,6 +40,10 @@ export class HaDeviceTrigger extends LitElement {
@state() private _capabilities?: DeviceCapabilities;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -64,14 +73,19 @@ export class HaDeviceTrigger extends LitElement {
}
);
public shouldUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("trigger")) {
return true;
public shouldUpdate(_changedProperties: PropertyValues<this>) {
const mode = deviceAutomationEditorMode(
this.hass,
this.trigger.device_id,
this._compositeSplits
);
if (mode === "loading") {
// The device is missing; wait for the composite split map before deciding
// whether it is a replaced device (editable) or genuinely unknown (YAML).
this._loadCompositeSplits();
return false;
}
if (
this.trigger.device_id &&
!(this.trigger.device_id in this.hass.devices)
) {
if (mode === "unknown-device") {
fireEvent(
this,
"ui-mode-not-available",
@@ -86,6 +100,20 @@ export class HaDeviceTrigger extends LitElement {
return true;
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
protected render() {
const deviceId = this._deviceId || this.trigger.device_id;
@@ -36,6 +36,7 @@ import { showQuickBar } from "../../../dialogs/quick-bar/show-dialog-quick-bar";
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
import { showShortcutsDialog } from "../../../dialogs/shortcuts/show-shortcuts-dialog";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import { ChildPanelReady } from "../../../layouts/panel-ready";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@@ -157,6 +158,11 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
total: 0,
};
public constructor() {
super();
new ChildPanelReady(this);
}
private _pages = memoizeOne(
(
cloudStatus,
@@ -1,4 +1,5 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { consume } from "@lit/context";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { filterNavigationPages } from "../../../common/config/filter_navigation_pages";
@@ -7,6 +8,10 @@ import "../../../components/ha-icon-next";
import type { CloudStatus } from "../../../data/cloud";
import { getConfigEntries } from "../../../data/config_entries";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import {
childPanelReadyContext,
type RegisterChildPanelReady,
} from "../../../layouts/panel-ready";
import type { HomeAssistant } from "../../../types";
import "../components/ha-config-navigation-list";
@@ -18,21 +23,20 @@ class HaConfigNavigation extends LitElement {
@property({ attribute: false }) public pages!: PageNavigation[];
@state() private _hasBluetoothConfigEntries = false;
@state() private _visiblePages?: PageNavigation[];
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
getConfigEntries(this.hass, {
domain: "bluetooth",
}).then((bluetoothEntries) => {
this._hasBluetoothConfigEntries = bluetoothEntries.length > 0;
});
private _hasBluetoothConfigEntries = false;
@consume({ context: childPanelReadyContext })
private _registerChildPanelReady?: RegisterChildPanelReady;
public connectedCallback() {
super.connectedCallback();
this._registerChildPanelReady?.(this._resolveVisiblePages());
}
protected render(): TemplateResult {
const pages = filterNavigationPages(this.hass, this.pages, {
hasBluetoothConfigEntries: this._hasBluetoothConfigEntries,
}).map((page) => ({
const pages = (this._visiblePages ?? []).map((page) => ({
...page,
name:
page.name ||
@@ -75,6 +79,20 @@ class HaConfigNavigation extends LitElement {
`;
}
private async _resolveVisiblePages(): Promise<void> {
if (this.pages.some((page) => page.component === "bluetooth")) {
const entries = await getConfigEntries(this.hass, {
domain: "bluetooth",
});
this._hasBluetoothConfigEntries = entries.length > 0;
}
this._visiblePages = filterNavigationPages(this.hass, this.pages, {
hasBluetoothConfigEntries: this._hasBluetoothConfigEntries,
});
await this.updateComplete;
}
static styles: CSSResultGroup = css`
/* Accessibility */
.visually-hidden {
+2
View File
@@ -82,12 +82,14 @@ class HaPanelConfig extends HassRouterPage {
info: {
tag: "ha-config-info",
load: () => import("./info/ha-config-info"),
waitForReady: true,
},
// customize was removed in 2021.12, fallback to dashboard
customize: "dashboard",
dashboard: {
tag: "ha-config-dashboard",
load: () => import("./dashboard/ha-config-dashboard"),
waitForReady: true,
},
entities: {
tag: "ha-config-entities",
+2
View File
@@ -24,6 +24,7 @@ import { fetchHassioInfo } from "../../../data/hassio/supervisor";
import { subscribeSystemHealthInfo } from "../../../data/system_health";
import { showShortcutsDialog } from "../../../dialogs/shortcuts/show-shortcuts-dialog";
import "../../../layouts/hass-subpage";
import { panelIsReady } from "../../../layouts/panel-ready";
import { mdiHomeAssistant } from "../../../resources/home-assistant-logo-svg";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
@@ -289,6 +290,7 @@ class HaConfigInfo extends LitElement {
if (info?.homeassistant) {
this._installationMethod = info.homeassistant.info.installation_type;
unsubSystemHealth.then((unsub) => unsub());
panelIsReady(this);
}
});
}
+4
View File
@@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
@@ -31,6 +32,8 @@ class PanelLight extends LitElement {
@state() private _searchParms = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -127,6 +130,7 @@ class PanelLight extends LitElement {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
@@ -3,6 +3,7 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/animation/ha-fade-in";
import "../../../components/ha-card";
import "../../../components/ha-spinner";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
@@ -38,7 +39,9 @@ export class HuiStartingCard extends LitElement implements LovelaceCard {
return html`
<div class="content">
<ha-spinner></ha-spinner>
<ha-fade-in .delay=${500}>
<ha-spinner></ha-spinner>
</ha-fade-in>
${this.hass.localize("ui.panel.lovelace.cards.starting.description")}
</div>
`;
+10 -3
View File
@@ -75,6 +75,7 @@ import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-di
import { showQuickBar } from "../../dialogs/quick-bar/show-dialog-quick-bar";
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
import { haStyle } from "../../resources/styles";
import { ChildPanelReady } from "../../layouts/panel-ready";
import type { HomeAssistant, PanelInfo } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { isMac } from "../../util/is_mac";
@@ -163,6 +164,8 @@ class HUIRoot extends LitElement {
private _restoreScroll = false;
private _childPanelReady?: ChildPanelReady;
private _undoRedoController = new UndoRedoController<UndoStackItem>(this, {
apply: (config) => this._applyUndoRedo(config),
currentConfig: () => ({
@@ -776,7 +779,7 @@ class HUIRoot extends LitElement {
huiView.narrow = this.narrow;
}
let newSelectView;
let newSelectView: HUIRoot["_curView"];
let viewPath: string | undefined = this.route!.path.split("/")[1];
viewPath = viewPath ? decodeURI(viewPath) : undefined;
@@ -1254,7 +1257,7 @@ class HUIRoot extends LitElement {
return;
}
let view;
let view: HUIView;
const viewConfig = this.config.views[viewIndex];
if (!viewConfig) {
@@ -1265,12 +1268,16 @@ class HUIRoot extends LitElement {
if (this._viewCache[viewIndex]) {
view = this._viewCache[viewIndex];
} else {
if (!this._childPanelReady) {
this._childPanelReady = new ChildPanelReady(this);
this.requestUpdate();
}
view = document.createElement("hui-view");
view.index = viewIndex;
this._viewCache[viewIndex] = view;
}
view.lovelace = this.lovelace;
view.lovelace = this.lovelace!;
view.hass = this.hass;
view.narrow = this.narrow;
+17 -1
View File
@@ -58,6 +58,12 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
private _mqlListenerRef?: () => void;
private _resolveInitialRender?: () => void;
public initialRenderComplete = new Promise<void>((resolve) => {
this._resolveInitialRender = resolve;
});
public connectedCallback() {
super.connectedCallback();
this._initMqls();
@@ -166,7 +172,13 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
root.removeChild(root.lastChild);
}
columns.forEach((column) => root.appendChild(column));
columns.forEach((column) => {
root.appendChild(column);
});
if (this.cards.length === 0 || columns.some((column) => column.lastChild)) {
this._resolveInitialRender?.();
this._resolveInitialRender = undefined;
}
}
private async _createColumns() {
@@ -234,6 +246,10 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
index,
this.lovelace!.editMode
);
if (columnElements.some((column) => column.isConnected)) {
this._resolveInitialRender?.();
this._resolveInitialRender = undefined;
}
}
// Remove empty columns
+26
View File
@@ -1,4 +1,5 @@
import deepClone from "deep-clone-simple";
import { consume } from "@lit/context";
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -19,6 +20,10 @@ import type {
} from "../../../data/lovelace/config/view";
import { isStrategyView } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import {
childPanelReadyContext,
type RegisterChildPanelReady,
} from "../../../layouts/panel-ready";
import "../badges/hui-badge";
import type { HuiBadge } from "../badges/hui-badge";
import "../cards/hui-card";
@@ -95,6 +100,15 @@ export class HUIView extends ReactiveElement {
private _config?: LovelaceViewConfig;
private _resolveInitialRender?: () => void;
private _initialRenderComplete = new Promise<void>((resolve) => {
this._resolveInitialRender = resolve;
});
@consume({ context: childPanelReadyContext })
private _registerChildPanelReady?: RegisterChildPanelReady;
@storage({
key: "dashboardCardClipboard",
state: false,
@@ -152,6 +166,11 @@ export class HUIView extends ReactiveElement {
return this;
}
public connectedCallback(): void {
super.connectedCallback();
this._registerChildPanelReady?.(this._initialRenderComplete);
}
public willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
@@ -324,6 +343,13 @@ export class HUIView extends ReactiveElement {
const viewConfig = await this._generateConfig(rawConfig);
this._setConfig(viewConfig, isStrategy);
await customElements.whenDefined(this._layoutElement!.localName);
if (this._layoutElement instanceof ReactiveElement) {
await this._layoutElement.updateComplete;
}
await this._layoutElement!.initialRenderComplete;
this._resolveInitialRender?.();
this._resolveInitialRender = undefined;
}
private _createLayoutElement(config: LovelaceViewConfig): void {
@@ -5,6 +5,7 @@ import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-top-app-bar-fixed";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
@@ -31,6 +32,8 @@ class PanelMaintenance extends LitElement {
@state() private _searchParams = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -127,6 +130,7 @@ class PanelMaintenance extends LitElement {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
+4
View File
@@ -5,6 +5,7 @@ import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-top-app-bar-fixed";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
@@ -31,6 +32,8 @@ class PanelSecurity extends LitElement {
@state() private _searchParms = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -127,6 +130,7 @@ class PanelSecurity extends LitElement {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
+15 -1
View File
@@ -804,6 +804,9 @@
"device_not_found": "Device not found",
"entity_not_found": "Entity not found",
"label_not_found": "Label not found",
"replaced_device": "Replaced device",
"device_replaced": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
"replace_device": "Replace",
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
"entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
"target_details": "Target details",
@@ -916,7 +919,16 @@
"unnamed_device": "Unnamed device",
"no_area": "No area",
"placeholder": "Select a device",
"unknown": "Unknown device selected"
"unknown": "Unknown device selected",
"device_replaced_count": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
"device_replaced_by_one": "This device was replaced by {device}.",
"device_replaced_by_multiple": "This device was replaced by {count} devices.",
"replace_device": "Replace",
"replaced_dialog": {
"title": "Replace device",
"description": "This device was replaced by multiple devices. Choose which one to use.",
"recommended": "Recommended"
}
},
"category-picker": {
"clear": "Clear",
@@ -5208,6 +5220,8 @@
"all_entities": "All entities",
"none_entities": "No entities",
"template": "Template",
"device_replaced": "Replaced device",
"device_replaced_description": "This device was replaced by one or more new devices. Update this automation to use them.",
"types": {
"entity": "[%key:ui::components::target-picker::type::entities%]",
"device": "[%key:ui::components::target-picker::type::devices%]",
+34
View File
@@ -166,6 +166,40 @@ defineRouteSmokeTests(appRouteSmokeGroups);
// ---------------------------------------------------------------------------
test.describe("Lovelace dashboard", () => {
test("keeps the launch screen until generated content renders", async ({
page,
}) => {
await goToPanel(page, "/?scenario=delayed-generated-dashboard#/climate");
const launchScreen = page.locator("#ha-launch-screen");
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(page.locator("hui-view")).not.toBeAttached();
await page.evaluate(() => window.resolveGeneratedDashboard?.());
await expect(page.locator("hui-view")).toBeAttached({
timeout: PANEL_TIMEOUT,
});
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
});
test("keeps the launch screen until initial content renders", async ({
page,
}) => {
await goToPanel(page, "/?scenario=delayed-lovelace#/lovelace");
const launchScreen = page.locator("#ha-launch-screen");
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(page.locator("hui-card")).not.toBeAttached();
await page.evaluate(() => window.resolveLovelaceConfig?.());
await expect(page.locator("hui-card").first()).toBeAttached({
timeout: PANEL_TIMEOUT,
});
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
});
test("renders cards", async ({ page }) => {
await goToPanel(page, "/lovelace");
// At least one card should appear
+2
View File
@@ -92,6 +92,8 @@ declare global {
interface Window {
__assistRun?: unknown;
__mockHass: MockHomeAssistant;
resolveGeneratedDashboard?: () => void;
resolveLovelaceConfig?: () => void;
}
}
+45
View File
@@ -1,5 +1,6 @@
import type { ExtEntityRegistryEntry } from "../../../../../src/data/entity/entity_registry";
import type { AssistPipeline } from "../../../../../src/data/assist_pipeline";
import type { LovelaceRawConfig } from "../../../../../src/data/lovelace/config/types";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
@@ -124,6 +125,48 @@ const quickSearchAssistScenario: Scenario = async (hass) => {
});
};
const delayedLovelaceScenario: Scenario = (hass) => {
const launchScreen = document.createElement("div");
launchScreen.id = "ha-launch-screen";
document.body.prepend(launchScreen);
const config: LovelaceRawConfig = {
views: [
{
title: "Home",
cards: [{ type: "markdown", content: "Dashboard ready" }],
},
],
};
let resolveConfig: ((config: LovelaceRawConfig) => void) | undefined;
const configPromise = new Promise<LovelaceRawConfig>((resolve) => {
resolveConfig = resolve;
});
window.resolveLovelaceConfig = () => resolveConfig?.(config);
hass.mockWS("lovelace/config", () => configPromise);
};
const delayedGeneratedDashboardScenario: Scenario = (hass) => {
const launchScreen = document.createElement("div");
launchScreen.id = "ha-launch-screen";
document.body.prepend(launchScreen);
const loadFragmentTranslation = hass.loadFragmentTranslation;
let resolveTranslation: (() => void) | undefined;
const translationReady = new Promise<void>((resolve) => {
resolveTranslation = resolve;
});
hass.loadFragmentTranslation = async (fragment) => {
if (fragment === "lovelace") {
await translationReady;
}
return loadFragmentTranslation(fragment);
};
window.resolveGeneratedDashboard = resolveTranslation;
};
// ── Registry ──────────────────────────────────────────────────────────────
export const scenarios: Record<string, Scenario> = {
@@ -131,6 +174,8 @@ export const scenarios: Record<string, Scenario> = {
"non-admin": nonAdminScenario,
"dark-theme": darkThemeScenario,
"custom-theme": customThemeScenario,
"delayed-generated-dashboard": delayedGeneratedDashboardScenario,
"light-more-info": lightMoreInfoScenario,
"quick-search-assist": quickSearchAssistScenario,
"delayed-lovelace": delayedLovelaceScenario,
};
+111
View File
@@ -0,0 +1,111 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ReactiveElement } from "lit";
import { state } from "lit/decorators";
import type { RouterOptions } from "../../src/layouts/hass-router-page";
import { HassRouterPage } from "../../src/layouts/hass-router-page";
import { panelIsReady } from "../../src/layouts/panel-ready";
class TestRouter extends HassRouterPage {
protected routerOptions: RouterOptions = {
routes: {
immediate: { tag: "test-immediate-panel" },
deferred: { tag: "test-deferred-panel", waitForReady: true },
loaded: {
tag: "test-loaded-panel",
load: () => loadedPanelImport,
waitForReady: true,
},
},
};
public get rendered() {
return this.pageRendered;
}
}
class ImmediatePanel extends HTMLElement {}
class DeferredPanel extends ReactiveElement {
@state() public loaded = false;
}
let resolveLoadedPanelImport: () => void;
const loadedPanelImport = new Promise<void>((resolve) => {
resolveLoadedPanelImport = resolve;
});
class LoadedPanel extends HTMLElement {}
customElements.define("test-router", TestRouter);
customElements.define("test-immediate-panel", ImmediatePanel);
customElements.define("test-deferred-panel", DeferredPanel);
let router: TestRouter | undefined;
const mountRouter = async (path: string) => {
router = document.createElement("test-router") as TestRouter;
router.route = { prefix: "", path };
document.body.append(router);
await router.updateComplete;
return router;
};
afterEach(() => {
router?.remove();
router = undefined;
});
describe("HassRouterPage panel readiness", () => {
it("resolves when a routed panel has no readiness promise", async () => {
const element = await mountRouter("/immediate");
await expect(element.rendered).resolves.toBeUndefined();
});
it("waits for the routed panel readiness promise", async () => {
const element = await mountRouter("/deferred");
let ready = false;
element.rendered.then(() => {
ready = true;
});
await Promise.resolve();
expect(ready).toBe(false);
const panel = element.lastElementChild as DeferredPanel;
panel.loaded = true;
panelIsReady(panel);
await Promise.resolve();
expect(ready).toBe(false);
await expect(element.rendered).resolves.toBeUndefined();
expect(panel.loaded).toBe(true);
});
it("waits for a dynamically loaded panel before reading its readiness", async () => {
const element = await mountRouter("/loaded");
let ready = false;
element.rendered.then(() => {
ready = true;
});
resolveLoadedPanelImport();
customElements.define("test-loaded-panel", LoadedPanel);
await vi.waitFor(() => {
expect(element.lastElementChild).toBeInstanceOf(LoadedPanel);
});
expect(ready).toBe(false);
panelIsReady(element.lastElementChild as HTMLElement);
await expect(element.rendered).resolves.toBeUndefined();
});
});
declare global {
interface HTMLElementTagNameMap {
"test-router": TestRouter;
"test-immediate-panel": ImmediatePanel;
"test-deferred-panel": DeferredPanel;
"test-loaded-panel": LoadedPanel;
}
}