mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-28 17:07:12 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa10b9b715 |
+6
-2
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { UNAVAILABLE } from "./entity/entity";
|
||||
|
||||
export enum AssistSatelliteEntityFeature {
|
||||
ANNOUNCE = 1,
|
||||
START_CONVERSATION = 2,
|
||||
}
|
||||
|
||||
export interface WakeWordInterceptMessage {
|
||||
|
||||
+6
-8
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -64,7 +64,6 @@ export type ClimateEntity = HassEntityBase & {
|
||||
swing_modes?: string[];
|
||||
swing_horizontal_mode?: string;
|
||||
swing_horizontal_modes?: string[];
|
||||
aux_heat?: "on" | "off";
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum WaterHeaterEntityFeature {
|
||||
TARGET_TEMPERATURE = 1,
|
||||
OPERATION_MODE = 2,
|
||||
AWAY_MODE = 4,
|
||||
ON_OFF = 8,
|
||||
}
|
||||
|
||||
export const OPERATION_MODES = [
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user