Compare commits

..

1 Commits

Author SHA1 Message Date
Claude aa10b9b715 Align frontend API types with the backend
Audited the TypeScript types under src/data against the core websocket/REST
serializers and the Supervisor REST API, and corrected the mismatches.

Core:
- ConfigEntry: add the unload_in_progress state, and created_at/modified_at
- SubEntry.unique_id and DeviceRegistryEntry config_entry_id/config_subentry_id
- removeConfigEntryFromDevice can resolve to null
- config_entries/flow/subscribe: removed messages carry no flow, so model the
  message as a discriminated union
- Entity registry: original_name/translation_key are always sent (nullable),
  options is never null, hidden_by is only user/integration, and the extended
  entry's capabilities/device_class/original_device_class/original_icon are
  always present and nullable
- CategoryRegistryEntry gains the registry timestamps
- updateFloorRegistryEntry returned an area entry
- search/related can return floor, label and person results
- Manifest zeroconf entries can be objects, and integration_type can be virtual
- LoggedError.message holds up to five deduplicated messages
- StatisticsMetaData: add display_unit_of_measurement and has_mean, name is
  always present
- Backup: add the blocked manager state, the event reason, the core_restart
  restore state, nullable homeassistant_version/addon names, and the
  agent_errors on backup details
- Energy validation placeholders are nullable
- Entity feature flags: add MEDIA_ANNOUNCE/MEDIA_ENQUEUE/SEARCH_MEDIA,
  water heater ON_OFF and assist satellite START_CONVERSATION, and drop the
  removed climate AUX_HEAT along with the stale aux_heat attribute

Supervisor:
- Host info nullability, plus the fields the API reports that were missing
- OS info gains boot_slots/version_pending, data_disk is nullable
- Resolution issues and suggestions are objects, not strings, and checks was
  missing entirely
- Supervisor info: addons and addons_repositories are object lists, plus
  auto_update/country/detect_blocking_io/feature_flags
- Add-on details: services and uart were named services_role and auto_uart,
  and boot_config/dns/host_uts/ingress_port/udev/usb/video were missing
- Store add-on details apparmor is a profile name, long_description is nullable
- Mounts: the state enum mirrors the systemd unit state, read_only and
  user_path were missing, and port is optional

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKb38CdFmbwAwfy8xbci8S
2026-07-28 13:19:46 +00:00
82 changed files with 449 additions and 2290 deletions
+6 -2
View File
@@ -151,7 +151,9 @@ export class HaDemo extends HomeAssistantAppEl {
entity_category: null,
has_entity_name: false,
unique_id: "co2_intensity",
options: null,
original_name: null,
translation_key: null,
options: {},
created_at: 0,
modified_at: 0,
},
@@ -172,7 +174,9 @@ export class HaDemo extends HomeAssistantAppEl {
entity_category: null,
has_entity_name: false,
unique_id: "grid_fossil_fuel_percentage",
options: null,
original_name: null,
translation_key: null,
options: {},
created_at: 0,
modified_at: 0,
},
+2
View File
@@ -21,6 +21,8 @@ const baseEntry = {
reason: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
created_at: 0,
modified_at: 0,
};
// Each entry is tagged with its integration type so we can honor the
+2
View File
@@ -14,6 +14,8 @@ const baseDevice = {
name_by_user: null,
disabled_by: null,
configuration_url: null,
config_entry_id: null,
config_subentry_id: null,
created_at: 0,
modified_at: 0,
};
+8 -1
View File
@@ -16,7 +16,14 @@ export const mockEntityRegistry = (
for (const entityId of msg.entity_ids) {
const entry = data.find((e) => e.entity_id === entityId);
if (entry) {
result[entityId] = { ...entry, capabilities: {}, aliases: [] };
result[entityId] = {
...entry,
capabilities: {},
original_icon: null,
device_class: null,
original_device_class: null,
aliases: [],
};
}
}
return result;
+31 -2
View File
@@ -114,13 +114,14 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
audio_output: null,
audio: false,
auth_api: false,
auto_uart: false,
auto_update: false,
boot: "auto",
boot_config: "auto",
changelog: false,
devices: [],
devicetree: false,
discovery: [],
dns: [],
docker_api: false,
documentation: false,
full_access: false,
@@ -133,8 +134,10 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
host_ipc: false,
host_network: false,
host_pid: false,
host_uts: false,
ingress_entry: null,
ingress_panel: false,
ingress_port: null,
ingress_url: null,
ingress: false,
ip_address: "172.30.33.2",
@@ -148,13 +151,17 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
protected: true,
rating: 6,
schema: CONFIG_SCHEMAS[addon.slug] ?? null,
services_role: [],
services: [],
signed: false,
startup: "application",
stdin: false,
system_managed: false,
system_managed_config_entry: null,
translations: {},
uart: false,
udev: false,
usb: false,
video: false,
watchdog: true,
webui: null,
});
@@ -212,6 +219,13 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
debug: false,
debug_block: false,
diagnostics: true,
auto_update: true,
country: "NL",
detect_blocking_io: false,
feature_flags: {
supervisor_v2_api: false,
supervisor_websocket_v2_api: false,
},
addons: DEMO_ADDONS as any,
addons_repositories: [
"https://github.com/music-assistant/home-assistant-addon",
@@ -264,6 +278,7 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
hostname: "homeassistant",
logging: "info",
machine: "green",
machine_id: "0123456789abcdef0123456789abcdef",
state: "running",
operating_system: "Home Assistant OS 18.2",
supervisor: "2026.07.1",
@@ -277,6 +292,9 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
if (msg.endpoint === "/host/info") {
const data: HassioHostInfo = {
agent_version: "1.8.0",
apparmor_version: "3.0.7",
broadcast_llmnr: true,
broadcast_mdns: true,
chassis: "embedded",
cpe: "cpe:2.3:o:home-assistant:haos:18.2:*:production:*:*:*:aarch64:*",
deployment: "production",
@@ -284,12 +302,18 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
disk_free: 22.3,
disk_total: 31.2,
disk_used: 8.9,
dt_synchronized: true,
dt_utc: "2026-07-28T10:00:00+00:00",
features: ["reboot", "shutdown", "network", "hostname", "os_agent"],
hostname: "homeassistant",
kernel: "6.12.48-haos",
llmnr_hostname: "homeassistant",
operating_system: "Home Assistant OS 18.2",
boot_timestamp: 1751932800000000,
startup_time: 12.4,
timezone: "Europe/Amsterdam",
use_ntp: true,
virtualization: "",
};
return data;
}
@@ -298,9 +322,14 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
const data: HassioHassOSInfo = {
board: "green",
boot: "A",
boot_slots: {
A: { state: "booted", status: "good", version: "18.2" },
B: { state: "inactive", status: "good", version: "18.1" },
},
update_available: false,
version: "18.2",
version_latest: "18.2",
version_pending: null,
data_disk: "Home Assistant Green (mmcblk0)",
};
return data;
+6
View File
@@ -69,6 +69,8 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
@@ -93,6 +95,8 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_2"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
@@ -117,6 +121,8 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_3"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
@@ -1,23 +0,0 @@
---
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.
@@ -1,318 +0,0 @@
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;
}
}
@@ -82,6 +82,8 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
@@ -106,6 +108,8 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_2"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
@@ -130,6 +134,8 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_3"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
+7 -1
View File
@@ -29,6 +29,8 @@ const createConfigEntry = (
title,
source: "zeroconf",
state: "loaded",
created_at: 0,
modified_at: 0,
supports_options: false,
supports_remove_device: false,
supports_unload: true,
@@ -204,7 +206,9 @@ const createEntityRegistryEntries = (
platform: "updater",
has_entity_name: false,
unique_id: "updater",
options: null,
original_name: null,
translation_key: null,
options: {},
labels: [],
categories: {},
created_at: 0,
@@ -219,6 +223,8 @@ const createDeviceRegistryEntries = (
entry_type: null,
config_entries: [item.entry_id],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
manufacturer: "ESPHome",
model: "Mock Device",
@@ -1,15 +0,0 @@
import type { EntityIdPart } from "../../data/entity_id_format";
import { slugify } from "../string/slugify";
export const computeEntityIdFormatExample = (
format: EntityIdPart[],
examples: Record<EntityIdPart, string>
): string => {
const parts = format
.map((item) => examples[item])
.filter(Boolean)
.map((part) => slugify(part, "_"))
.filter(Boolean);
return parts.join("_") || "unknown";
};
+1 -1
View File
@@ -126,7 +126,7 @@ export class HaProgressButton extends LitElement {
visibility: hidden;
}
.progress ha-svg-icon {
:host([appearance="brand"]) ha-svg-icon {
color: var(--white-color);
}
`;
-1
View File
@@ -27,7 +27,6 @@ export class HaInputChip extends InputChip {
);
--ha-input-chip-selected-container-opacity: 1;
--md-input-chip-label-text-font: Roboto, sans-serif;
--md-input-chip-label-text-weight: 400;
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]) {
@@ -1,142 +0,0 @@
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,7 +12,6 @@ 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";
@@ -202,22 +201,12 @@ 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. 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 there is no value, or if we have changed the device ID, reset the value.
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(
equivalent ||
(this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId))
this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId)
);
}
this._renderEmpty = true;
+47 -245
View File
@@ -1,7 +1,6 @@
import { mdiAlertOutline } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { 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";
@@ -14,20 +13,12 @@ import {
getDevices,
type DevicePickerItem,
} from "../../data/device/device_picker";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
type DeviceRegistryEntry,
} from "../../data/device/device_registry";
import 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
@@ -104,10 +95,6 @@ export class HaDevicePicker extends LitElement {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
private _getDevicesMemoized = memoizeOne(
(
_devices: HomeAssistant["devices"],
@@ -136,29 +123,6 @@ 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(
@@ -166,43 +130,6 @@ 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,
@@ -217,66 +144,49 @@ export class HaDevicePicker extends LitElement {
);
private _valueRenderer = memoizeOne(
(
configEntriesLookup: Record<string, ConfigEntry>,
replacementName: string | undefined
) =>
(value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
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"
style="color: var(--warning-color)"
.path=${mdiAlertOutline}
></ha-svg-icon>
<span slot="headline">${replacementName}</span>
`;
}
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
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>
`;
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
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`
@@ -325,39 +235,7 @@ export class HaDevicePicker extends LitElement {
this.placeholder ??
this.hass.localize("ui.components.device-picker.placeholder");
// 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
);
const valueRenderer = this._valueRenderer(this._configEntryLookup);
return html`
<ha-generic-picker
@@ -378,84 +256,15 @@ export class HaDevicePicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.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")
}
.unknownItemText=${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();
@@ -472,13 +281,6 @@ 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 {
@@ -1,22 +0,0 @@
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,10 +27,6 @@ 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,
@@ -126,10 +122,6 @@ 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[];
@@ -219,24 +211,6 @@ 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") &&
@@ -323,7 +297,6 @@ 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>
@@ -417,7 +390,6 @@ 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 }}
@@ -426,7 +398,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
.compositeSplits=${this._compositeSplits}
>
</ha-target-picker-item-group>
`
@@ -1204,31 +1175,6 @@ 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
@@ -1411,7 +1357,6 @@ 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,6 +1,5 @@
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";
@@ -53,9 +52,6 @@ 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) => {
@@ -91,7 +87,6 @@ export class HaTargetPickerItemGroup extends LitElement {
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
.compositeSplits=${this.compositeSplits}
></ha-target-picker-item-row>`
)
: nothing
@@ -34,10 +34,7 @@ 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 {
DeviceCompositeSplits,
DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { 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";
@@ -57,7 +54,6 @@ 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";
@@ -112,9 +108,6 @@ 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;
@@ -135,15 +128,6 @@ 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;
@@ -190,20 +174,15 @@ export class HaTargetPickerItemRow extends LitElement {
}
</div>
<div slot="headline">${(canMigrate && replacement?.name) || name}</div>
<div slot="headline">${name}</div>
${
notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${
notFound
? 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`
)
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context
}</span
>`
@@ -250,24 +229,6 @@ 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`
@@ -746,51 +707,6 @@ 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, {
@@ -830,11 +746,6 @@ export class HaTargetPickerItemRow extends LitElement {
color: var(--ha-color-on-warning-normal);
}
.migrate {
align-self: center;
white-space: nowrap;
}
.replaceable {
cursor: pointer;
}
@@ -1,7 +1,6 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { consume } from "@lit/context";
import {
mdiAlertOutline,
mdiDevices,
mdiHome,
mdiLabel,
@@ -10,7 +9,6 @@ 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";
@@ -21,7 +19,6 @@ 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";
@@ -42,9 +39,6 @@ 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;
@@ -57,74 +51,38 @@ 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=${classMap({ [this.type]: true, replaced })}
class=${this.type}
style=${color ? `--color: rgb(${color});` : ""}
@wa-remove=${this._removeItem}
>
<div class="icon">
${
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
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">
${
replaced
? replacedName ||
this.hass.localize(
"ui.components.target-picker.replaced_device"
)
: name
}
</span>
<span class="name"> ${name} </span>
${
this.type === "entity" || replaced
this.type === "entity"
? nothing
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
>${this.hass.localize(
@@ -167,7 +125,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);
}
@@ -282,11 +240,6 @@ 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;
+1
View File
@@ -5,6 +5,7 @@ import { UNAVAILABLE } from "./entity/entity";
export enum AssistSatelliteEntityFeature {
ANNOUNCE = 1,
START_CONVERSATION = 2,
}
export interface WakeWordInterceptMessage {
+6 -8
View File
@@ -121,22 +121,19 @@ export interface BackupContent {
"supervisor.addon_update"?: string;
"supervisor.app_update"?: string;
};
with_automatic_settings: boolean;
with_automatic_settings: boolean | null;
}
export interface BackupData {
addons: BackupAddon[];
database_included: boolean;
folders: string[];
homeassistant_version: string;
// null when homeassistant_included is false
homeassistant_version: string | null;
homeassistant_included: boolean;
}
export interface BackupAddon {
name: string;
slug: string;
version: string;
}
export type BackupAddon = AddonInfo;
export interface BackupContentExtended extends BackupContent, BackupData {}
@@ -152,7 +149,8 @@ export interface BackupInfo {
}
export interface BackupDetails {
backup: BackupContentExtended;
backup: BackupContentExtended | null;
agent_errors: Record<string, string>;
}
export interface BackupAgentsInfo {
+16 -5
View File
@@ -1,7 +1,7 @@
import type { HomeAssistant } from "../types";
export type BackupManagerState =
"idle" | "create_backup" | "receive_backup" | "restore_backup";
"idle" | "blocked" | "create_backup" | "receive_backup" | "restore_backup";
export type CreateBackupStage =
| "addon_repositories"
@@ -39,26 +39,34 @@ export type RestoreBackupStage =
| "remove_delta_addons"
| "remove_delta_apps";
export type RestoreBackupState = "completed" | "failed" | "in_progress";
export type RestoreBackupState =
"completed" | "core_restart" | "failed" | "in_progress";
interface IdleEvent {
manager_state: "idle";
}
interface BlockedEvent {
manager_state: "blocked";
}
interface CreateBackupEvent {
manager_state: "create_backup";
reason: string | null;
stage: CreateBackupStage | null;
state: CreateBackupState;
}
interface ReceiveBackupEvent {
manager_state: "receive_backup";
reason: string | null;
stage: ReceiveBackupStage | null;
state: ReceiveBackupState;
}
interface RestoreBackupEvent {
manager_state: "restore_backup";
reason: string | null;
stage: RestoreBackupStage | null;
state: RestoreBackupState;
}
@@ -70,11 +78,14 @@ export interface UploadBackupEvent {
total_bytes: number;
}
export type ManagerState =
"idle" | "create_backup" | "receive_backup" | "restore_backup";
export type ManagerState = BackupManagerState;
export type ManagerStateEvent =
IdleEvent | CreateBackupEvent | ReceiveBackupEvent | RestoreBackupEvent;
| IdleEvent
| BlockedEvent
| CreateBackupEvent
| ReceiveBackupEvent
| RestoreBackupEvent;
export type BackupSubscriptionEvent = ManagerStateEvent | UploadBackupEvent;
+2 -1
View File
@@ -4,8 +4,9 @@ import type { Store } from "home-assistant-js-websocket/dist/store";
import { stringCompare } from "../common/string/compare";
import type { HomeAssistant } from "../types";
import { debounce } from "../common/util/debounce";
import type { RegistryEntry } from "./registry";
export interface CategoryRegistryEntry {
export interface CategoryRegistryEntry extends RegistryEntry {
category_id: string;
name: string;
icon: string | null;
-1
View File
@@ -64,7 +64,6 @@ export type ClimateEntity = HassEntityBase & {
swing_modes?: string[];
swing_horizontal_mode?: string;
swing_horizontal_modes?: string[];
aux_heat?: "on" | "off";
};
};
+5 -3
View File
@@ -1,8 +1,9 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import type { IntegrationType } from "./integration";
import type { RegistryEntry } from "./registry";
export interface ConfigEntry {
export interface ConfigEntry extends RegistryEntry {
entry_id: string;
domain: string;
title: string;
@@ -14,7 +15,8 @@ export interface ConfigEntry {
| "setup_retry"
| "not_loaded"
| "failed_unload"
| "setup_in_progress";
| "setup_in_progress"
| "unload_in_progress";
supports_options: boolean;
supports_remove_device: boolean;
supports_unload: boolean;
@@ -33,7 +35,7 @@ export interface SubEntry {
subentry_id: string;
subentry_type: string;
title: string;
unique_id: string;
unique_id: string | null;
}
export const getSubEntries = (hass: HomeAssistant, entry_id: string) =>
+10 -2
View File
@@ -91,12 +91,20 @@ export const fetchConfigFlowInProgress = (
type: "config_entries/flow/progress",
});
export interface ConfigFlowInProgressMessage {
type: null | "added" | "removed";
interface ConfigFlowInProgressAddedMessage {
type: null | "added";
flow_id: string;
flow: DataEntryFlowProgress;
}
interface ConfigFlowInProgressRemovedMessage {
type: "removed";
flow_id: string;
}
export type ConfigFlowInProgressMessage =
ConfigFlowInProgressAddedMessage | ConfigFlowInProgressRemovedMessage;
export const subscribeConfigFlowInProgress = (
hass: HomeAssistant,
onChange: (update: ConfigFlowInProgressMessage[]) => void
+1 -47
View File
@@ -2,10 +2,9 @@ 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, HomeAssistant } from "../../types";
import type { CallWS } 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,
@@ -159,51 +158,6 @@ 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,
+3 -48
View File
@@ -1,4 +1,3 @@
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";
@@ -19,6 +18,8 @@ export interface DeviceRegistryEntry extends RegistryEntry {
id: string;
config_entries: string[];
config_entries_subentries: Record<string, (string | null)[]>;
config_entry_id: string | null;
config_subentry_id: string | null;
connections: [string, string][];
identifiers: [string, string][];
manufacturer: string | null;
@@ -55,52 +56,6 @@ 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[]
@@ -134,7 +89,7 @@ export const removeConfigEntryFromDevice = (
deviceId: string,
configEntryId: string
) =>
hass.callWS<DeviceRegistryEntry>({
hass.callWS<DeviceRegistryEntry | null>({
type: "config/device_registry/remove_config_entry",
device_id: deviceId,
config_entry_id: configEntryId,
+1 -1
View File
@@ -243,7 +243,7 @@ export interface EnergyInfo {
export interface EnergyValidationIssue {
type: string;
affected_entities: [string, unknown][];
translation_placeholders: Record<string, string>;
translation_placeholders: Record<string, string> | null;
}
export interface EnergyPreferencesValidation {
+9 -9
View File
@@ -37,7 +37,7 @@ export interface EntityRegistryDisplayEntryResponse {
ec?: number;
en?: string;
ic?: string;
pl?: string;
pl: string;
tk?: string;
hb?: boolean;
dp?: number;
@@ -58,21 +58,21 @@ export interface EntityRegistryEntry extends RegistryEntry {
area_id: string | null;
labels: string[];
disabled_by: "user" | "device" | "integration" | "config_entry" | null;
hidden_by: Exclude<EntityRegistryEntry["disabled_by"], "config_entry">;
hidden_by: "user" | "integration" | null;
entity_category: EntityCategory | null;
has_entity_name: boolean;
original_name?: string;
original_name: string | null;
unique_id: string;
translation_key?: string;
options: EntityRegistryOptions | null;
translation_key: string | null;
options: EntityRegistryOptions;
categories: Record<string, string>;
}
export interface ExtEntityRegistryEntry extends EntityRegistryEntry {
capabilities: Record<string, unknown>;
original_icon?: string;
device_class?: string;
original_device_class?: string;
capabilities: Record<string, unknown> | null;
original_icon: string | null;
device_class: string | null;
original_device_class: string | null;
aliases: (string | null)[];
}
@@ -1,22 +0,0 @@
import type { HomeAssistantApi } from "../../types";
import type { EntityIdFormat } from "../entity_id_format";
export interface EntityRegistrySettings {
entity_id_parts: EntityIdFormat | null;
}
export const fetchEntityRegistrySettings = (
api: HomeAssistantApi
): Promise<EntityRegistrySettings> =>
api.callWS<EntityRegistrySettings>({
type: "config/entity_registry/settings/get",
});
export const updateEntityRegistrySettings = (
api: HomeAssistantApi,
updates: Partial<EntityRegistrySettings>
): Promise<EntityRegistrySettings> =>
api.callWS<EntityRegistrySettings>({
type: "config/entity_registry/settings/update",
...updates,
});
-12
View File
@@ -1,12 +0,0 @@
export type EntityIdPart = "area" | "device" | "entity" | "floor";
export type EntityIdFormat = EntityIdPart[];
export const DEFAULT_ENTITY_ID_FORMAT: EntityIdFormat = [
"area",
"device",
"entity",
];
export const isDefaultEntityIdFormat = (format: EntityIdFormat): boolean =>
JSON.stringify(format) === JSON.stringify(DEFAULT_ENTITY_ID_FORMAT);
@@ -5,7 +5,6 @@ export enum ClimateEntityFeature {
FAN_MODE = 8,
PRESET_MODE = 16,
SWING_MODE = 32,
AUX_HEAT = 64,
TURN_OFF = 128,
TURN_ON = 256,
SWING_HORIZONTAL_MODE = 512,
@@ -19,4 +19,7 @@ export enum MediaPlayerEntityFeature {
BROWSE_MEDIA = 131072,
REPEAT_SET = 262144,
GROUPING = 524288,
MEDIA_ANNOUNCE = 1048576,
MEDIA_ENQUEUE = 2097152,
SEARCH_MEDIA = 4194304,
}
+1 -1
View File
@@ -35,7 +35,7 @@ export const updateFloorRegistryEntry = (
floorId: string,
updates: Partial<FloorRegistryEntryMutableParams>
) =>
hass.callWS<AreaRegistryEntry>({
hass.callWS<FloorRegistryEntry>({
type: "config/floor_registry/update",
floor_id: floorId,
...updates,
+12 -4
View File
@@ -17,6 +17,7 @@ export type AddonCapability = Exclude<
>;
export type AddonStage = "stable" | "experimental" | "deprecated";
export type AddonAppArmour = "disable" | "default" | "profile";
export type AddonBootConfig = "auto" | "manual" | "manual_only";
export type AddonRole = "default" | "homeassistant" | "manager" | "admin";
export type AddonStartup =
"initialize" | "system" | "services" | "application" | "once";
@@ -63,13 +64,14 @@ export interface HassioAddonDetails extends HassioAddonInfo {
audio_output: null | string;
audio: boolean;
auth_api: boolean;
auto_uart: boolean;
auto_update: boolean;
boot: "auto" | "manual";
boot_config: AddonBootConfig;
changelog: boolean;
devices: string[];
devicetree: boolean;
discovery: string[];
dns: string[];
docker_api: boolean;
documentation: boolean;
full_access: boolean;
@@ -82,22 +84,24 @@ export interface HassioAddonDetails extends HassioAddonInfo {
host_ipc: boolean;
host_network: boolean;
host_pid: boolean;
host_uts: boolean;
ingress_entry: null | string;
ingress_panel: boolean;
ingress_port: number | null;
ingress_url: null | string;
ingress: boolean;
ip_address: string;
kernel_modules: boolean;
long_description: null | string;
machine: any;
machine: string[];
network_description: null | Record<string, string>;
network: null | Record<string, number>;
options: Record<string, unknown>;
privileged: any;
privileged: string[];
protected: boolean;
rating: number;
schema: HaFormSchema[] | null;
services_role: string[];
services: string[];
signed: boolean;
slug: string;
startup: AddonStartup;
@@ -105,6 +109,10 @@ export interface HassioAddonDetails extends HassioAddonInfo {
system_managed: boolean;
system_managed_config_entry: string | null;
translations: Record<string, AddonTranslations>;
uart: boolean;
udev: boolean;
usb: boolean;
video: boolean;
watchdog: null | boolean;
webui: null | string;
}
+27 -10
View File
@@ -1,29 +1,46 @@
import type { HomeAssistant } from "../../types";
export interface HassioHostInfo {
agent_version: string;
chassis: string;
cpe: string;
deployment: string;
agent_version: string | null;
apparmor_version: string | null;
boot_timestamp: number | null;
broadcast_llmnr: boolean | null;
broadcast_mdns: boolean | null;
chassis: string | null;
cpe: string | null;
deployment: string | null;
disk_life_time: number | null;
disk_free: number;
disk_total: number;
disk_used: number;
dt_synchronized: boolean | null;
dt_utc: string;
features: string[];
hostname: string;
kernel: string;
operating_system: string;
boot_timestamp: number;
startup_time: number;
hostname: string | null;
kernel: string | null;
llmnr_hostname: string | null;
operating_system: string | null;
startup_time: number | null;
timezone: string | null;
use_ntp: boolean | null;
virtualization: string | null;
}
export interface HassioHassOSBootSlot {
state: string | null;
status: string | null;
version: string | null;
}
export interface HassioHassOSInfo {
board: string | null;
boot: string | null;
boot_slots: Record<string, HassioHassOSBootSlot>;
update_available: boolean;
version_latest: string | null;
version_pending: string | null;
version: string | null;
data_disk: string;
data_disk: string | null;
}
export interface Datadisk {
+20 -2
View File
@@ -1,10 +1,28 @@
import type { HomeAssistant, TranslationDict } from "../../types";
export interface ResolutionIssue {
type: string;
context: string;
reference: string | null;
reference_extra: Record<string, unknown> | null;
uuid: string;
}
export interface ResolutionSuggestion extends ResolutionIssue {
auto: boolean;
}
export interface ResolutionCheck {
enabled: boolean;
slug: string;
}
export interface HassioResolution {
unsupported: (keyof TranslationDict["ui"]["dialogs"]["unsupported"]["reasons"])[];
unhealthy: (keyof TranslationDict["ui"]["dialogs"]["unhealthy"]["reasons"])[];
issues: string[];
suggestions: string[];
issues: ResolutionIssue[];
suggestions: ResolutionSuggestion[];
checks: ResolutionCheck[];
}
export const fetchHassioResolution = async (
+31 -5
View File
@@ -1,5 +1,6 @@
import type { HomeAssistant, PanelInfo } from "../../types";
import type { SupervisorArch } from "../supervisor/supervisor";
import type { AddonState } from "./addon";
import type { HassioResponse } from "./common";
export interface HassioHomeAssistantInfo {
@@ -19,14 +20,37 @@ export interface HassioHomeAssistantInfo {
watchdog: boolean;
}
export interface HassioSupervisorAddonInfo {
name: string;
slug: string;
version: string | null;
version_latest: string;
update_available: boolean;
state: AddonState;
repository: string;
icon: boolean;
}
export interface HassioSupervisorRepositoryInfo {
name: string;
slug: string;
}
export type SupervisorFeatureFlag =
"supervisor_v2_api" | "supervisor_websocket_v2_api";
export interface HassioSupervisorInfo {
addons: string[];
addons_repositories: string[];
addons: HassioSupervisorAddonInfo[];
addons_repositories: HassioSupervisorRepositoryInfo[];
arch: SupervisorArch;
auto_update: boolean;
channel: string;
country: string | null;
debug: boolean;
debug_block: boolean;
detect_blocking_io: boolean;
diagnostics: boolean | null;
feature_flags: Record<SupervisorFeatureFlag, boolean>;
healthy: boolean;
ip_address: string;
logging: string;
@@ -35,6 +59,7 @@ export interface HassioSupervisorInfo {
update_available: boolean;
version: string;
version_latest: string;
/** @deprecated No longer used by the Supervisor */
wait_boot: number;
}
@@ -43,11 +68,12 @@ export interface HassioInfo {
channel: string;
docker: string;
features: string[];
hassos: null;
hassos: string | null;
homeassistant: string;
hostname: string;
hostname: string | null;
logging: string;
machine: string;
machine_id: string | null;
state:
| "initialize"
| "setup"
@@ -57,7 +83,7 @@ export interface HassioInfo {
| "shutdown"
| "stopping"
| "close";
operating_system: string;
operating_system: string | null;
supervisor: string;
supported: boolean;
supported_arch: SupervisorArch[];
+8 -2
View File
@@ -20,6 +20,12 @@ export const integrationsWithPanel = {
export type IntegrationType =
"device" | "helper" | "hub" | "service" | "hardware" | "entity" | "system";
/**
* `integration_type` as reported by manifests and brand data. Virtual
* integrations only exist as metadata, they never have config entries.
*/
export type ManifestIntegrationType = IntegrationType | "virtual";
export type DomainManifestLookup = Record<string, IntegrationManifest>;
export interface IntegrationManifest {
@@ -35,9 +41,9 @@ export interface IntegrationManifest {
codeowners?: string[];
requirements?: string[];
ssdp?: { manufacturer?: string; modelName?: string; st?: string }[];
zeroconf?: string[];
zeroconf?: (string | Record<string, string>)[];
homekit?: { models: string[] };
integration_type?: IntegrationType;
integration_type?: ManifestIntegrationType;
loggers?: string[];
quality_scale?:
| "bronze"
+2 -2
View File
@@ -1,10 +1,10 @@
import type { HomeAssistant } from "../types";
import type { IntegrationType } from "./integration";
import type { ManifestIntegrationType } from "./integration";
export type IotStandards = "zwave" | "zigbee" | "homekit" | "matter";
export interface Integration {
integration_type: IntegrationType;
integration_type: ManifestIntegrationType;
name?: string;
config_flow?: boolean;
iot_standards?: IotStandards[];
+4 -1
View File
@@ -47,9 +47,12 @@ export enum StatisticMeanType {
export interface StatisticsMetaData {
statistics_unit_of_measurement: string | null;
display_unit_of_measurement: string | null;
statistic_id: string;
source: string;
name?: string | null;
name: string | null;
/** @deprecated Use `mean_type` instead, removed in HA Core 2026.4 */
has_mean: boolean;
has_sum: boolean;
mean_type: StatisticMeanType;
unit_class: string | null;
+5
View File
@@ -7,8 +7,11 @@ export interface RelatedResult {
config_entry?: string[];
device?: string[];
entity?: string[];
floor?: string[];
group?: string[];
integration?: string[];
label?: string[];
person?: string[];
scene?: string[];
script?: string[];
script_blueprint?: string[];
@@ -29,7 +32,9 @@ export type ItemType =
| "entity"
| "floor"
| "group"
| "integration"
| "label"
| "person"
| "scene"
| "script"
| "automation_blueprint"
+27 -12
View File
@@ -12,49 +12,64 @@ export enum SupervisorMountUsage {
SHARE = "share",
}
/** Mirrors the systemd unit active state the Supervisor reports. */
export enum SupervisorMountState {
ACTIVE = "active",
ACTIVATING = "activating",
DEACTIVATING = "deactivating",
FAILED = "failed",
UNKNOWN = "unknown",
INACTIVE = "inactive",
MAINTENANCE = "maintenance",
RELOADING = "reloading",
}
interface MountOptions {
default_backup_mount?: string | null;
}
/** `auto` is a UI-only value, it is stripped before the mount is submitted. */
export type CIFSVersion = "auto" | "1.0" | "2.0";
interface SupervisorMountBase {
name: string;
usage: SupervisorMountUsage;
type: SupervisorMountType;
read_only?: boolean;
server: string;
port: number;
port?: number;
}
export interface SupervisorMountResponse extends SupervisorMountBase {
state: SupervisorMountState | null;
}
export interface SupervisorNFSMount extends SupervisorMountResponse {
interface SupervisorNFSMountBase extends SupervisorMountBase {
type: SupervisorMountType.NFS;
path: string;
}
export interface SupervisorCIFSMount extends SupervisorMountResponse {
interface SupervisorCIFSMountBase extends SupervisorMountBase {
type: SupervisorMountType.CIFS;
share: string;
version?: CIFSVersion;
version?: CIFSVersion | null;
}
/** Fields the Supervisor adds to a configured mount when reporting it. */
export interface SupervisorMountResponse {
read_only: boolean;
state: SupervisorMountState | null;
user_path: string | null;
}
export type SupervisorNFSMount = SupervisorNFSMountBase &
SupervisorMountResponse;
export type SupervisorCIFSMount = SupervisorCIFSMountBase &
SupervisorMountResponse;
export type SupervisorMount = SupervisorNFSMount | SupervisorCIFSMount;
export type SupervisorNFSMountRequestParams = SupervisorNFSMount;
export type SupervisorNFSMountRequestParams = SupervisorNFSMountBase;
export interface SupervisorCIFSMountRequestParams extends SupervisorCIFSMount {
export interface SupervisorCIFSMountRequestParams extends SupervisorCIFSMountBase {
username?: string;
password?: string;
version?: CIFSVersion;
}
export type SupervisorMountRequestParams =
+9 -6
View File
@@ -1,13 +1,15 @@
import type { HomeAssistant } from "../../types";
import type { AddonRole, AddonStage } from "../hassio/addon";
import type { AddonAppArmour, AddonRole, AddonStage } from "../hassio/addon";
import { supervisorApiCall } from "./common";
import type { SupervisorArch } from "./supervisor";
export interface StoreAddon {
advanced: boolean;
arch: SupervisorArch[];
available: boolean;
build: boolean;
description: string;
documentation: boolean;
homeassistant: string | null;
icon: boolean;
installed: boolean;
@@ -17,19 +19,20 @@ export interface StoreAddon {
slug: string;
stage: AddonStage;
update_available: boolean;
url: string;
url: string | null;
version_latest: string;
// The Supervisor reports the installed version here, but the frontend only
// ever loads store details for add-ons that are not installed. Kept as `null`
// so it discriminates `StoreAddon` from `HassioAddonInfo`.
version: null;
}
export interface StoreAddonDetails extends StoreAddon {
apparmor: boolean;
arch: SupervisorArch[];
apparmor: AddonAppArmour;
auth_api: boolean;
changelog: boolean;
detached: boolean;
docker_api: boolean;
documentation: boolean;
full_access: boolean;
hassio_api: boolean;
hassio_role: AddonRole;
@@ -37,7 +40,7 @@ export interface StoreAddonDetails extends StoreAddon {
host_network: boolean;
host_pid: boolean;
ingress: boolean;
long_description: string;
long_description: string | null;
rating: number;
signed: boolean;
}
+2 -1
View File
@@ -5,7 +5,8 @@ export type SystemLogLevel =
export interface LoggedError {
name: string;
message: [string];
// The backend keeps up to 5 deduplicated messages per entry
message: string[];
level: SystemLogLevel;
source: [string, number];
exception: string;
+1
View File
@@ -7,6 +7,7 @@ export enum WaterHeaterEntityFeature {
TARGET_TEMPERATURE = 1,
OPERATION_MODE = 2,
AWAY_MODE = 4,
ON_OFF = 8,
}
export const OPERATION_MODES = [
-13
View File
@@ -619,19 +619,6 @@ export const demoServices: HassServices = {
},
},
climate: {
set_aux_heat: {
description: "Turn auxiliary heater on/off for climate device.",
fields: {
entity_id: {
description: "Name(s) of entities to change.",
example: "climate.kitchen",
},
aux_heat: {
description: "New value of axillary heater.",
example: "true",
},
},
},
set_temperature: {
description: "Set target temperature of climate device.",
fields: {
-10
View File
@@ -100,11 +100,6 @@ export class MockClimateEntity extends MockBaseEntity {
return;
}
if (service === "set_aux_heat") {
this.update({ attributes: { aux_heat: data.aux_heat } });
return;
}
super.handleService(domain, service, data);
}
@@ -227,11 +222,6 @@ export class MockClimateEntity extends MockBaseEntity {
: (attrs.swing_horizontal_mode ?? null);
}
// Aux heat
if (supportsFeatureFromAttributes(attrs, ClimateEntityFeature.AUX_HEAT)) {
stateAttrs.aux_heat = isOff ? null : (attrs.aux_heat ?? null);
}
return stateAttrs;
}
+1 -18
View File
@@ -5,7 +5,6 @@ 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 === "") {
@@ -23,7 +22,6 @@ export interface RouteOptions {
// Function to load the page.
load?: () => Promise<unknown>;
cache?: boolean;
waitForReady?: boolean;
}
export interface RouterOptions {
@@ -55,8 +53,6 @@ export class HassRouterPage extends ReactiveElement {
private _currentLoadProm?: Promise<void>;
private _panelReady = new PanelReady();
private _cache = {};
private _initialLoadDone = false;
@@ -184,10 +180,6 @@ 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;
}
@@ -295,15 +287,7 @@ export class HassRouterPage extends ReactiveElement {
* Promise that resolves when the page has rendered.
*/
protected get pageRendered(): Promise<void> {
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);
});
return this.updateComplete.then(() => this._currentLoadProm);
}
protected createElement(tag: string) {
@@ -328,7 +312,6 @@ 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);
+14 -4
View File
@@ -17,7 +17,10 @@ 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 { renderLaunchScreenContent } from "../util/launch-screen";
import {
removeLaunchScreen,
renderLaunchScreenContent,
} from "../util/launch-screen";
import { checkOnboardingSurveyToast } from "../util/onboarding-survey";
import {
registerServiceWorker,
@@ -133,9 +136,16 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
) {
this.render = this.renderHass;
this.update = super.update;
// 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.
// 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" });
}
}
super.update(changedProps);
}
-30
View File
@@ -1,30 +0,0 @@
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 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;
}
}
+2 -7
View File
@@ -8,7 +8,6 @@ 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";
@@ -18,7 +17,6 @@ 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 COMPONENTS = {
app: () => import("../panels/app/ha-panel-app"),
energy: () => import("../panels/energy/ha-panel-energy"),
@@ -221,12 +219,9 @@ class PartialPanelResolver extends HassRouterPage {
)
) {
await this.rebuild();
await promiseTimeout(PANEL_READY_TIMEOUT, this.pageRendered).catch(
() => undefined
);
await this.pageRendered;
// Only fire frontend/loaded when this call actually removed the launch
// screen, so later panel updates do not fire it again. Native apps remove
// it instantly because their own splash screen is still visible.
// screen, so later panel updates do not fire it again.
if (
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen)
) {
@@ -13,16 +13,11 @@ 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";
@@ -38,10 +33,6 @@ export class HaDeviceAction extends LitElement {
@state() private _capabilities?: DeviceCapabilities;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -68,19 +59,14 @@ export class HaDeviceAction extends LitElement {
}
);
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;
public shouldUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("action")) {
return true;
}
if (mode === "unknown-device") {
if (
this.action.device_id &&
!(this.action.device_id in this.hass.devices)
) {
fireEvent(
this,
"ui-mode-not-available",
@@ -95,20 +81,6 @@ 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,16 +13,11 @@ 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";
@@ -38,10 +33,6 @@ export class HaDeviceCondition extends LitElement {
@state() private _capabilities?: DeviceCapabilities;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -69,19 +60,14 @@ export class HaDeviceCondition extends LitElement {
}
);
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;
public shouldUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("condition")) {
return true;
}
if (mode === "unknown-device") {
if (
this.condition.device_id &&
!(this.condition.device_id in this.hass.devices)
) {
fireEvent(
this,
"ui-mode-not-available",
@@ -96,20 +82,6 @@ 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,16 +33,11 @@ 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,
@@ -94,16 +89,9 @@ 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
@@ -120,43 +108,6 @@ 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) {
@@ -391,8 +342,7 @@ export class HaAutomationRowTargets extends LitElement {
error = false,
targetId?: string,
targetType?: string,
countTemplate: unknown = nothing,
title?: string
countTemplate: unknown = nothing
) {
if (!this.interactive || !targetId || !targetType) {
return html`<div
@@ -401,7 +351,6 @@ export class HaAutomationRowTargets extends LitElement {
warning,
error,
})}
title=${title ?? nothing}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
@@ -417,7 +366,6 @@ export class HaAutomationRowTargets extends LitElement {
warning,
error,
})}
title=${title ?? nothing}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
@@ -437,7 +385,6 @@ 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;
@@ -461,28 +408,17 @@ 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,
@@ -520,7 +456,6 @@ export class HaAutomationRowTargets extends LitElement {
targetType: badgeTargetType,
label,
}}
title=${title ?? nothing}
class=${classMap({
warning,
})}
@@ -535,8 +470,7 @@ export class HaAutomationRowTargets extends LitElement {
false,
badgeTargetId,
badgeTargetType,
countTemplate,
title
countTemplate
);
}
@@ -15,16 +15,11 @@ 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";
@@ -40,10 +35,6 @@ export class HaDeviceTrigger extends LitElement {
@state() private _capabilities?: DeviceCapabilities;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -73,19 +64,14 @@ export class HaDeviceTrigger extends LitElement {
}
);
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;
public shouldUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("trigger")) {
return true;
}
if (mode === "unknown-device") {
if (
this.trigger.device_id &&
!(this.trigger.device_id in this.hass.devices)
) {
fireEvent(
this,
"ui-mode-not-available",
@@ -100,20 +86,6 @@ 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;
@@ -91,7 +91,7 @@ export class HaBackupDataPicker extends LitElement {
`ui.panel.${this.translationKeyPanel}.data_picker.${data.database_included ? "settings_and_history" : "settings"}`
),
id: "config",
version: data.homeassistant_version,
version: data.homeassistant_version ?? undefined,
});
}
items.push(
@@ -133,9 +133,9 @@ export class HaBackupDataPicker extends LitElement {
addonIcons: Record<string, boolean>
) =>
data.addons.map<BackupAddonItem>((addon) => ({
name: addon.name,
name: addon.name || addon.slug,
slug: addon.slug,
version: addon.version,
version: addon.version ?? undefined,
icon: addonIcons[addon.slug],
}))
);
@@ -85,7 +85,7 @@ export class HaBackupOverviewProgress extends LitElement {
private get _heading() {
const managerState = this.manager.manager_state;
if (managerState === "idle") {
if (managerState === "idle" || managerState === "blocked") {
return "";
}
return this.hass.localize(
@@ -94,7 +94,10 @@ export class HaBackupOverviewProgress extends LitElement {
}
private get _isUploadStage(): boolean {
if (this.manager.manager_state === "idle") {
if (
this.manager.manager_state === "idle" ||
this.manager.manager_state === "blocked"
) {
return false;
}
return this.manager.stage === "upload_to_agents";
@@ -314,7 +314,7 @@ class HaConfigBackupDetails extends LitElement {
try {
const response = await fetchBackupDetails(this.hass, this.backupId);
this._backup = response.backup;
this._agents = computeAgents(response.backup);
this._agents = response.backup ? computeAgents(response.backup) : [];
} catch (err: any) {
this._error =
err?.message ||
-9
View File
@@ -22,7 +22,6 @@ import {
mdiPuzzle,
mdiRadioTower,
mdiRemote,
mdiRenameOutline,
mdiRobot,
mdiScrewdriver,
mdiScriptText,
@@ -495,14 +494,6 @@ export const configSections: Record<string, PageNavigation[]> = {
component: "backup",
adminOnly: true,
},
{
path: "/config/entity-id-format",
translationKey: "entity_id_format",
iconPath: mdiRenameOutline,
iconColor: "#24a5cb",
core: true,
adminOnly: true,
},
{
path: "/config/analytics",
translationKey: "analytics",
@@ -1,229 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import { mdiRestore } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { computeEntityIdFormatExample } from "../../../common/entity/compute_entity_id_format_example";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-svg-icon";
import { apiContext, configContext } from "../../../data/context";
import {
fetchEntityRegistrySettings,
updateEntityRegistrySettings,
} from "../../../data/entity/entity_registry_settings";
import {
DEFAULT_ENTITY_ID_FORMAT,
isDefaultEntityIdFormat,
type EntityIdFormat,
type EntityIdPart,
} from "../../../data/entity_id_format";
import { haStyle } from "../../../resources/styles";
import { documentationUrl } from "../../../util/documentation-url";
import "./ha-entity-id-format-editor";
const EXAMPLE_DOMAIN = "sensor";
@customElement("ha-config-entity-id-format")
export class HaConfigEntityIdFormat extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@state() private _format?: EntityIdFormat;
@state() private _error?: string;
protected async firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
try {
const settings = await fetchEntityRegistrySettings(this._api);
this._format = settings.entity_id_parts ?? DEFAULT_ENTITY_ID_FORMAT;
} catch (err: any) {
this._error = err.message;
}
}
private _examples = memoizeOne(
(localize: LocalizeFunc): Record<EntityIdPart, string> => ({
area: localize(
"ui.panel.config.entity_id_format.card.editor.examples.area"
),
device: localize(
"ui.panel.config.entity_id_format.card.editor.examples.device"
),
entity: localize(
"ui.panel.config.entity_id_format.card.editor.examples.entity"
),
floor: localize(
"ui.panel.config.entity_id_format.card.editor.examples.floor"
),
})
);
protected render() {
return html`
<ha-card
outlined
.header=${this._localize(
"ui.panel.config.entity_id_format.card.header"
)}
>
<div class="card-content">
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<p class="description">
${this._localize(
"ui.panel.config.entity_id_format.card.description"
)}
<a
href=${documentationUrl(
this._config,
"/docs/configuration/customizing-devices/"
)}
target="_blank"
rel="noreferrer"
>${this._localize(
"ui.panel.config.entity_id_format.card.learn_more"
)}</a
>
</p>
${
this._format
? html`
<ha-entity-id-format-editor
.value=${this._format}
.label=${this._localize(
"ui.panel.config.entity_id_format.card.editor.label"
)}
@value-changed=${this._formatChanged}
></ha-entity-id-format-editor>
${this._renderPreview()}
`
: nothing
}
</div>
<div class="card-actions">
<ha-button
appearance="plain"
@click=${this._reset}
.disabled=${!this._format || isDefaultEntityIdFormat(this._format)}
>
<ha-svg-icon slot="start" .path=${mdiRestore}></ha-svg-icon>
${this._localize("ui.panel.config.entity_id_format.card.reset")}
</ha-button>
<ha-progress-button
appearance="filled"
@click=${this._save}
.disabled=${!this._format}
>
${this._localize("ui.common.save")}
</ha-progress-button>
</div>
</ha-card>
`;
}
private _renderPreview() {
const example = computeEntityIdFormatExample(
this._format!,
this._examples(this._localize)
);
return html`
<div class="preview">
<span class="preview-label">
${this._localize("ui.panel.config.entity_id_format.card.preview")}
</span>
<code>${EXAMPLE_DOMAIN}.${example}</code>
</div>
`;
}
private _formatChanged(ev: CustomEvent) {
this._format = ev.detail.value;
}
private _reset() {
this._format = [...DEFAULT_ENTITY_ID_FORMAT];
}
private async _save(ev: Event) {
const button = ev.currentTarget as HaProgressButton;
if (button.progress) {
return;
}
button.progress = true;
this._error = undefined;
try {
const format = this._format!;
const settings = await updateEntityRegistrySettings(this._api, {
entity_id_parts: isDefaultEntityIdFormat(format) ? null : format,
});
this._format = settings.entity_id_parts ?? DEFAULT_ENTITY_ID_FORMAT;
button.actionSuccess();
} catch (err: any) {
button.actionError();
this._error = err.message;
} finally {
button.progress = false;
}
}
static styles = [
haStyle,
css`
:host {
display: block;
}
.description {
margin-top: 0;
color: var(--secondary-text-color);
}
.preview {
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
margin-top: var(--ha-space-5);
}
.preview-label {
font-weight: 500;
}
.preview code {
display: block;
padding: var(--ha-space-3);
border-radius: var(--ha-border-radius-md);
background-color: var(--secondary-background-color);
font-family: var(--ha-font-family-code);
overflow-wrap: anywhere;
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-entity-id-format": HaConfigEntityIdFormat;
}
}
@@ -1,51 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import "./ha-config-entity-id-format";
@customElement("ha-config-section-entity-id-format")
export class HaConfigSectionEntityIdFormat extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
protected render() {
return html`
<hass-subpage
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.entity_id_format.caption"
)}
>
<div class="content">
<ha-config-entity-id-format></ha-config-entity-id-format>
</div>
</hass-subpage>
`;
}
static styles = css`
.content {
padding: var(--ha-space-7) var(--ha-space-5) 0;
max-width: 1040px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--ha-space-5);
}
ha-config-entity-id-format {
max-width: 600px;
margin: 0 auto;
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-section-entity-id-format": HaConfigSectionEntityIdFormat;
}
}
@@ -1,348 +0,0 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/chips/ha-assist-chip";
import "../../../components/chips/ha-chip-set";
import "../../../components/chips/ha-input-chip";
import "../../../components/ha-combo-box-item";
import "../../../components/ha-generic-picker";
import type { HaGenericPicker } from "../../../components/ha-generic-picker";
import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type {
EntityIdFormat,
EntityIdPart,
} from "../../../data/entity_id_format";
import type { ValueChangedEvent } from "../../../types";
const STRUCTURAL_TYPES = ["area", "device", "entity", "floor"] as const;
const REQUIRED_TYPES: readonly EntityIdPart[] = ["device", "entity"];
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button" compact>
<span slot="headline">${item.primary}</span>
</ha-combo-box-item>
`;
const encodeType = (type: EntityIdPart) => `___${type}___`;
const decodeType = (value: string): EntityIdPart | undefined => {
const type =
value.startsWith("___") && value.endsWith("___")
? value.slice(3, -3)
: value;
return (STRUCTURAL_TYPES as readonly string[]).includes(type)
? (type as EntityIdPart)
: undefined;
};
@customElement("ha-entity-id-format-editor")
export class HaEntityIdFormatEditor extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public value: EntityIdFormat = [];
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _editIndex?: number;
protected render() {
return html`
<div class="container">
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.disabled=${this.disabled}
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
@value-changed=${this._pickerValueChanged}
.searchLabel=${this._localize(
"ui.panel.config.entity_id_format.card.editor.search"
)}
>
<div slot="field" class="field">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this.value,
(item) => item,
(item: EntityIdPart, idx) => {
const label = this._formatType(item);
if (REQUIRED_TYPES.includes(item)) {
return html`
<ha-assist-chip
filled
class="required"
.label=${label}
.disabled=${this.disabled}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-assist-chip>
`;
}
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-input-chip>
`;
}
)}
${
this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this._localize(
"ui.panel.config.entity_id_format.card.editor.add"
)}
class="add"
>
<ha-svg-icon
slot="icon"
.path=${mdiPlus}
></ha-svg-icon>
</ha-assist-chip>
`
}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
</div>
`;
}
private async _addItem(ev: Event) {
ev.stopPropagation();
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx || "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
}
private _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const newValue = this.value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private _removeItem(ev: Event) {
ev.stopPropagation();
const value = [...this.value];
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const type = decodeType(value);
if (!type) {
return;
}
const newValue = [...this.value];
if (this._editIndex != null) {
newValue[this._editIndex] = type;
this._editIndex = undefined;
} else {
newValue.push(type);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: EntityIdFormat) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
private _formatType = (type: EntityIdPart) =>
this._localize(`ui.components.entity.entity-name-picker.types.${type}`);
private _getItems = memoizeOne(
(localize: LocalizeFunc): PickerComboBoxItem[] =>
STRUCTURAL_TYPES.map((type) => {
const primary = localize(
`ui.components.entity.entity-name-picker.types.${type}`
);
const id = encodeType(type);
return {
id,
primary,
search_labels: { primary, id },
sorting_label: primary,
};
})
);
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
const item = this.value[this._editIndex];
return item ? encodeType(item) : undefined;
}
return undefined;
}
private _getFilteredItems = (): PickerComboBoxItem[] => {
const items = this._getItems(this._localize);
const currentValue =
this._editIndex != null
? encodeType(this.value[this._editIndex])
: undefined;
const usedValues = new Set(this.value.map((item) => encodeType(item)));
return items.filter(
(item) => !usedValues.has(item.id) || item.id === currentValue
);
};
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
label {
display: block;
font-weight: 500;
}
ha-generic-picker {
width: 100%;
}
.field {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.field:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
}
:host([disabled]) .field:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.field:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
ha-chip-set {
padding: var(--ha-space-3);
}
ha-assist-chip.required {
--ha-assist-chip-filled-container-color: rgba(
var(--rgb-primary-text-color),
0.15
);
}
.add {
order: 1;
}
.sortable-fallback {
display: none;
opacity: 0;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-drag {
cursor: grabbing;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-entity-id-format-editor": HaEntityIdFormatEditor;
}
}
@@ -1206,7 +1206,7 @@ ${rejected
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
confirm: async () => {
const proms: Promise<DeviceRegistryEntry>[] = [];
const proms: Promise<DeviceRegistryEntry | null>[] = [];
this._selectedCanDelete.forEach((deviceId) => {
const entries = this.hass!.devices[deviceId]?.config_entries;
entries.forEach((entryId) => {
@@ -27,7 +27,7 @@ class EnergyValidationMessage extends LitElement {
>
${this.hass.localize(
`component.energy.issues.${issue.type}.description`,
issue.translation_placeholders
issue.translation_placeholders ?? undefined
)}
${
issue.type === "recorder_untracked"
@@ -264,7 +264,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._name = this.entry.name || "";
this._icon = this.entry.icon || "";
this._deviceClass =
this.entry.device_class || this.entry.original_device_class;
this.entry.device_class || this.entry.original_device_class || undefined;
this._origEntityId = this.entry.entity_id;
this._areaId = this.entry.area_id;
this._labels = this.entry.labels;
@@ -1231,6 +1231,8 @@ export class HaConfigEntities extends LitElement {
selectable: false,
entity_category: null,
has_entity_name: false,
original_name: null,
translation_key: null,
options: this._getExposedEntitySettingsAsOptions(entityId),
labels: [],
categories: {},
-5
View File
@@ -82,7 +82,6 @@ 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",
@@ -171,10 +170,6 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-section-ai-tasks",
load: () => import("./core/ha-config-section-ai-tasks"),
},
"entity-id-format": {
tag: "ha-config-section-entity-id-format",
load: () => import("./core/ha-config-section-entity-id-format"),
},
zha: {
tag: "zha-config-dashboard-router",
load: () =>
-2
View File
@@ -24,7 +24,6 @@ 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";
@@ -290,7 +289,6 @@ class HaConfigInfo extends LitElement {
if (info?.homeassistant) {
this._installationMethod = info.homeassistant.info.installation_type;
unsubSystemHealth.then((unsub) => unsub());
panelIsReady(this);
}
});
}
@@ -54,6 +54,7 @@ import {
import type {
IntegrationLogInfo,
IntegrationManifest,
IntegrationType,
} from "../../../data/integration";
import {
LogSeverity,
@@ -166,6 +167,14 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
private _subEntriesFetchId = 0;
/** Virtual integrations never have config entries, so they have no strings. */
private get _configEntryIntegrationType(): IntegrationType | undefined {
const integrationType = this._manifest?.integration_type;
return integrationType && integrationType !== "virtual"
? integrationType
: undefined;
}
@state() private _domainEntities: Record<string, string[]> = {};
@queryAll("ha-config-entry-row")
@@ -683,9 +692,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this.hass.localize(
`component.${this.domain}.config.initiate_flow.user`
) ||
(this._manifest?.integration_type
(this._configEntryIntegrationType
? this.hass.localize(
`ui.panel.config.integrations.integration_page.add_${this._manifest.integration_type}`
`ui.panel.config.integrations.integration_page.add_${this._configEntryIntegrationType}`
)
: this.hass.localize(
`ui.panel.config.integrations.integration_page.add_entry`
@@ -834,9 +843,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
<div class="section">
<h3 class="section-header">
${
this._manifest?.integration_type
this._configEntryIntegrationType
? this.hass.localize(
`ui.panel.config.integrations.integration_page.entries_${this._manifest.integration_type}`
`ui.panel.config.integrations.integration_page.entries_${this._configEntryIntegrationType}`
)
: this.hass.localize(
`ui.panel.config.integrations.integration_page.entries`
@@ -251,6 +251,9 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
reason: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
// Synthetic entry for a YAML-only integration, it has no registry timestamps
created_at: 0,
modified_at: 0,
})
);
@@ -32,7 +32,7 @@ export class HassioHostname extends LitElement {
private async _fetchHostInfo() {
const hostInfo = await fetchHassioHostInfo(this.hass);
this._hostname = hostInfo.hostname;
this._hostname = hostInfo.hostname ?? undefined;
}
protected render() {
@@ -37,7 +37,7 @@ interface MoveDatadiskFormState {
const calculateMoveTime = memoizeOne((hostInfo: HassioHostInfo): number => {
// Assume a speed of 30 MB/s.
const moveTime = (hostInfo.disk_used * 1000) / 60 / 30;
const rebootTime = (hostInfo.startup_time * 4) / 60;
const rebootTime = ((hostInfo.startup_time ?? 0) * 4) / 60;
return Math.ceil((moveTime + rebootTime) / 10) * 10;
});
@@ -746,10 +746,13 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
this._data.push({
statistic_id: statisticId,
statistics_unit_of_measurement: "",
display_unit_of_measurement: "",
source: "",
name: null,
state: this._states[statisticId],
issues: issues[statisticId],
mean_type: StatisticMeanType.NONE,
has_mean: false,
has_sum: false,
unit_class: null,
});
+5 -40
View File
@@ -804,9 +804,6 @@
"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",
@@ -919,16 +916,7 @@
"unnamed_device": "Unnamed device",
"no_area": "No area",
"placeholder": "Select a device",
"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"
}
"unknown": "Unknown device selected"
},
"category-picker": {
"clear": "Clear",
@@ -5220,8 +5208,6 @@
"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%]",
@@ -6424,7 +6410,7 @@
"account": {
"thank_you_title": "Thank you!",
"getting_renewal_date": "Getting renewal date…",
"funding_note": "Every Cloud subscription funds the Open Home Foundation, paying full-time developers to work on and keep the Home Assistant project independent. No private equity, no ads, no data harvesting.",
"funding_note": "Every subscription funds Home Assistant and the Open Home Foundation, paying full-time developers and keeping the project independent. No private equity, no ads, and no data harvesting.",
"email": "Email",
"show_email": "Show email",
"hide_email": "Hide email",
@@ -6556,7 +6542,7 @@
"reset_data_confirm_text": "This will reset all your cloud settings. This includes your remote connection, Google Assistant, and Amazon Alexa integrations. This action cannot be undone.",
"reset": "Reset",
"reset_data_failed": "Failed to reset cloud data",
"thank_you_note": "It's because of people like you that we are able to make a great home automation experience for everyone.",
"thank_you_note": "Thank you for being part of Home Assistant Cloud. It's because of people like you that we are able to make a great home automation experience for everyone. Thank you!",
"nabu_casa_account": "Nabu Casa account",
"connection_status": "Cloud connection status",
"manage_account": "Manage account",
@@ -7184,7 +7170,8 @@
"setup_retry": "Failed setup, will retry",
"not_loaded": "Not loaded",
"failed_unload": "Failed to unload",
"setup_in_progress": "Initializing"
"setup_in_progress": "Initializing",
"unload_in_progress": "Unloading"
},
"open_configuration_url": "Visit device",
"bronze_quality": "Bronze quality",
@@ -8632,28 +8619,6 @@
"caption": "AI tasks",
"description": "Configure AI suggestions and task preferences"
},
"entity_id_format": {
"caption": "Entity ID format",
"description": "Manage the format of newly created entity IDs",
"card": {
"header": "Entity ID format for new entities",
"description": "Entity IDs are used to reference entities in automations, scripts, and dashboards. This format only applies when a new entity is created. Using it is optional, and you can still rename each entity ID afterwards in its settings",
"learn_more": "Learn more",
"preview": "Preview",
"reset": "Reset to default",
"editor": {
"label": "Format",
"add": "Add",
"search": "Search part",
"examples": {
"area": "Living room",
"device": "Thermostat",
"entity": "Temperature",
"floor": "Ground floor"
}
}
}
},
"labs": {
"caption": "Labs",
"custom_integration": "Custom integration",
@@ -22,8 +22,10 @@ const buildMetadata = (
{
statistic_id: id,
statistics_unit_of_measurement: unit,
display_unit_of_measurement: unit,
source: "recorder",
name: null,
has_mean: !hasSum,
has_sum: hasSum,
mean_type: hasSum
? StatisticMeanType.NONE
+5 -1
View File
@@ -47,7 +47,9 @@ export const mockEntityEntry = (
has_entity_name: false,
unique_id: "",
id: "",
options: null,
original_name: null,
translation_key: null,
options: {},
categories: {},
created_at: 0,
modified_at: 0,
@@ -60,6 +62,8 @@ export const mockDevice = (
id: "",
config_entries: [],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
identifiers: [],
manufacturer: null,
@@ -28,8 +28,10 @@ const buildMetadata = (
{
statistic_id: id,
statistics_unit_of_measurement: unit,
display_unit_of_measurement: unit,
source: "recorder",
name: null,
has_mean: !hasSum,
has_sum: hasSum,
mean_type: hasSum
? StatisticMeanType.NONE
-111
View File
@@ -1,111 +0,0 @@
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;
}
}