mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-30 11:50:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
863125d9b0 | ||
|
|
0531888064 | ||
|
|
24e26b398e |
+34
-1
@@ -1,9 +1,33 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { CallWS, HomeAssistant } from "../types";
|
||||
|
||||
export interface ESPHomeEncryptionKey {
|
||||
encryption_key: string;
|
||||
}
|
||||
|
||||
export type ESPHomeSerialPortType = "TTL" | "RS232" | "RS485";
|
||||
|
||||
export interface ESPHomeBluetoothProxyCapabilities {
|
||||
supported: boolean;
|
||||
}
|
||||
|
||||
export interface ESPHomeZWaveProxyCapabilities {
|
||||
supported: boolean;
|
||||
home_id: number;
|
||||
}
|
||||
|
||||
export interface ESPHomeSerialProxy {
|
||||
name: string;
|
||||
port_type: ESPHomeSerialPortType | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ESPHomeDeviceCapabilities {
|
||||
available: boolean;
|
||||
bluetooth_proxy: ESPHomeBluetoothProxyCapabilities;
|
||||
zwave_proxy: ESPHomeZWaveProxyCapabilities;
|
||||
serial_proxies: ESPHomeSerialProxy[];
|
||||
}
|
||||
|
||||
export const fetchESPHomeEncryptionKey = (
|
||||
hass: HomeAssistant,
|
||||
entry_id: string
|
||||
@@ -12,3 +36,12 @@ export const fetchESPHomeEncryptionKey = (
|
||||
type: "esphome/get_encryption_key",
|
||||
entry_id,
|
||||
});
|
||||
|
||||
export const fetchESPHomeDeviceCapabilities = (
|
||||
hass: { callWS: CallWS },
|
||||
deviceId: string
|
||||
): Promise<ESPHomeDeviceCapabilities> =>
|
||||
hass.callWS({
|
||||
type: "esphome/get_device_capabilities",
|
||||
device_id: deviceId,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import type { ConfigEntry } from "./config_entries";
|
||||
import type { DeviceRegistryEntry } from "./device/device_registry";
|
||||
import type { ESPHomeDeviceCapabilities, ESPHomeSerialProxy } from "./esphome";
|
||||
import type { ESPHomeFrontendUserData } from "./frontend";
|
||||
import type { SerialPortUsage } from "./usb";
|
||||
|
||||
export const MUSIC_ASSISTANT_ADDON_SLUG = "d5369777_music_assistant";
|
||||
|
||||
export const MUSIC_ASSISTANT_DOCS_URL = "https://music-assistant.io/";
|
||||
|
||||
export const ESPHOME_SERIAL_INTEGRATIONS = [
|
||||
"anthemav",
|
||||
"monoprice",
|
||||
"russound_rio",
|
||||
"jvc_projector",
|
||||
"elkm1",
|
||||
] as const;
|
||||
|
||||
export type ESPHomeSerialIntegrationDomain =
|
||||
(typeof ESPHOME_SERIAL_INTEGRATIONS)[number];
|
||||
|
||||
export type ESPHomeCapabilityId =
|
||||
"bluetooth" | "audio" | "connectivity" | "serial";
|
||||
|
||||
export type ESPHomeCapabilityStatus =
|
||||
"not-started" | "active" | "detected" | "completed";
|
||||
|
||||
export interface ESPHomeSetupStatus {
|
||||
bluetooth?: "completed";
|
||||
audio?: "active" | "completed";
|
||||
connectivity?: "not-started" | "detected" | "completed";
|
||||
serial?: "not-started" | "completed";
|
||||
}
|
||||
|
||||
export const CAPABILITY_ORDER: ESPHomeCapabilityId[] = [
|
||||
"bluetooth",
|
||||
"audio",
|
||||
"connectivity",
|
||||
"serial",
|
||||
];
|
||||
|
||||
/** Capability accents from the ESPHome device setup prototype. */
|
||||
export const ESPHOME_CAPABILITY_ACCENTS: Record<ESPHomeCapabilityId, string> = {
|
||||
bluetooth: "#2962ff",
|
||||
audio: "#53c22b",
|
||||
connectivity: "#00acc1",
|
||||
serial: "#8353d1",
|
||||
};
|
||||
|
||||
export const deviceHasMediaPlayerEntity = (
|
||||
deviceId: string,
|
||||
entities: Iterable<{ entity_id: string; device_id?: string | null }>
|
||||
): boolean => {
|
||||
for (const entity of entities) {
|
||||
if (
|
||||
entity.device_id === deviceId &&
|
||||
computeDomain(entity.entity_id) === "media_player"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const hasESPHomeSetupCapabilities = (
|
||||
capabilities: ESPHomeDeviceCapabilities | undefined | null,
|
||||
options: { mediaPlayerSupported?: boolean } = {}
|
||||
): boolean =>
|
||||
Boolean(
|
||||
capabilities &&
|
||||
(capabilities.bluetooth_proxy.supported ||
|
||||
options.mediaPlayerSupported ||
|
||||
capabilities.zwave_proxy.supported ||
|
||||
capabilities.serial_proxies.length > 0)
|
||||
);
|
||||
|
||||
export const hasZWaveJSEntryForDevice = (
|
||||
deviceId: string,
|
||||
devices: Record<string, DeviceRegistryEntry>,
|
||||
entries: ConfigEntry[]
|
||||
): boolean => {
|
||||
const zwaveEntryIds = new Set(
|
||||
entries
|
||||
.filter((entry) => entry.domain === "zwave_js" && !entry.disabled_by)
|
||||
.map((entry) => entry.entry_id)
|
||||
);
|
||||
if (!zwaveEntryIds.size) {
|
||||
return false;
|
||||
}
|
||||
const device = devices[deviceId];
|
||||
if (device?.config_entries.some((entryId) => zwaveEntryIds.has(entryId))) {
|
||||
return true;
|
||||
}
|
||||
return Object.values(devices).some(
|
||||
(candidate) =>
|
||||
candidate.via_device_id === deviceId &&
|
||||
candidate.config_entries.some((entryId) => zwaveEntryIds.has(entryId))
|
||||
);
|
||||
};
|
||||
|
||||
export const isESPHomeSerialConfigured = (
|
||||
serialProxies: readonly Pick<ESPHomeSerialProxy, "url">[],
|
||||
ports: readonly Pick<SerialPortUsage, "device" | "consumers">[]
|
||||
): boolean =>
|
||||
serialProxies.some((proxy) =>
|
||||
ports.some((port) => port.device === proxy.url && port.consumers.length > 0)
|
||||
);
|
||||
|
||||
export const deriveESPHomeSetupStatus = (
|
||||
capabilities: ESPHomeDeviceCapabilities,
|
||||
options: {
|
||||
mediaPlayerSupported: boolean;
|
||||
musicAssistantLoaded: boolean;
|
||||
zwaveJsEntryExists: boolean;
|
||||
serialConfigured?: boolean;
|
||||
}
|
||||
): ESPHomeSetupStatus => {
|
||||
const status: ESPHomeSetupStatus = {};
|
||||
|
||||
if (capabilities.bluetooth_proxy.supported) {
|
||||
status.bluetooth = "completed";
|
||||
}
|
||||
|
||||
if (options.mediaPlayerSupported) {
|
||||
status.audio = options.musicAssistantLoaded ? "completed" : "active";
|
||||
}
|
||||
|
||||
if (capabilities.zwave_proxy.supported) {
|
||||
if (options.zwaveJsEntryExists) {
|
||||
status.connectivity = "completed";
|
||||
} else if (capabilities.zwave_proxy.home_id !== 0) {
|
||||
status.connectivity = "detected";
|
||||
} else {
|
||||
status.connectivity = "not-started";
|
||||
}
|
||||
}
|
||||
|
||||
if (capabilities.serial_proxies.length > 0) {
|
||||
status.serial = options.serialConfigured ? "completed" : "not-started";
|
||||
}
|
||||
|
||||
return status;
|
||||
};
|
||||
|
||||
export const getESPHomeSetupCapabilityIds = (
|
||||
status: ESPHomeSetupStatus
|
||||
): ESPHomeCapabilityId[] =>
|
||||
CAPABILITY_ORDER.filter((id) => status[id] !== undefined);
|
||||
|
||||
export const countRemainingESPHomeCapabilities = (
|
||||
status: ESPHomeSetupStatus
|
||||
): number =>
|
||||
getESPHomeSetupCapabilityIds(status).filter(
|
||||
(id) => status[id] !== "completed"
|
||||
).length;
|
||||
|
||||
export const hasStartedNonBluetoothESPHomeSetup = (
|
||||
status: ESPHomeSetupStatus
|
||||
): boolean =>
|
||||
status.audio === "completed" ||
|
||||
status.connectivity === "completed" ||
|
||||
status.serial === "completed";
|
||||
|
||||
export const isESPHomeSetupDeferred = (
|
||||
data: ESPHomeFrontendUserData | null | undefined,
|
||||
deviceId: string
|
||||
): boolean => Boolean(data?.setupDeferred?.includes(deviceId));
|
||||
|
||||
export const withDeferredESPHomeDevice = (
|
||||
data: ESPHomeFrontendUserData | null | undefined,
|
||||
deviceId: string
|
||||
): ESPHomeFrontendUserData => ({
|
||||
...data,
|
||||
setupDeferred: [...new Set([...(data?.setupDeferred ?? []), deviceId])],
|
||||
});
|
||||
@@ -18,6 +18,10 @@ export interface SidebarFrontendUserData {
|
||||
hiddenPanels?: string[];
|
||||
}
|
||||
|
||||
export interface ESPHomeFrontendUserData {
|
||||
setupDeferred?: string[];
|
||||
}
|
||||
|
||||
export interface CoreFrontendSystemData {
|
||||
default_panel?: string;
|
||||
onboarded_version?: string;
|
||||
@@ -46,6 +50,7 @@ declare global {
|
||||
interface FrontendUserData {
|
||||
core: CoreFrontendUserData;
|
||||
sidebar: SidebarFrontendUserData;
|
||||
esphome: ESPHomeFrontendUserData;
|
||||
}
|
||||
interface FrontendSystemData {
|
||||
core: CoreFrontendSystemData;
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { CallWS, HomeAssistant } from "../types";
|
||||
|
||||
export interface SerialPort {
|
||||
device: string;
|
||||
@@ -41,7 +41,7 @@ export const scanUSBDevices = (hass: HomeAssistant) =>
|
||||
export const listSerialPorts = (hass: HomeAssistant) =>
|
||||
hass.callWS<SerialPort[]>({ type: "usb/list_serial_ports" });
|
||||
|
||||
export const listSerialPortsWithUsage = (hass: HomeAssistant) =>
|
||||
export const listSerialPortsWithUsage = (hass: { callWS: CallWS }) =>
|
||||
hass.callWS<SerialPortUsage[]>({
|
||||
type: "usb/list_serial_ports",
|
||||
include_usage: true,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { ESPHomeDeviceCapabilities } from "../../data/esphome";
|
||||
|
||||
export const loadESPHomeDeviceSetupDialog = () =>
|
||||
import("./dialog-esphome-device-setup");
|
||||
|
||||
export interface ESPHomeDeviceSetupDialogParams {
|
||||
deviceId: string;
|
||||
deviceName?: string;
|
||||
capabilities?: ESPHomeDeviceCapabilities;
|
||||
mediaPlayerSupported?: boolean;
|
||||
dialogClosedCallback?: () => void;
|
||||
}
|
||||
|
||||
export const showESPHomeDeviceSetupDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: ESPHomeDeviceSetupDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-esphome-device-setup",
|
||||
dialogImport: loadESPHomeDeviceSetupDialog,
|
||||
dialogParams,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import {
|
||||
mdiAccessPoint,
|
||||
mdiBluetooth,
|
||||
mdiCheck,
|
||||
mdiMusic,
|
||||
mdiSwapHorizontal,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomEvent,
|
||||
} from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { internationalizationContext } from "../../../../data/context";
|
||||
import {
|
||||
ESPHOME_CAPABILITY_ACCENTS,
|
||||
getESPHomeSetupCapabilityIds,
|
||||
type ESPHomeCapabilityId,
|
||||
type ESPHomeSetupStatus,
|
||||
} from "../../../../data/esphome_setup";
|
||||
|
||||
const CAPABILITY_ICONS: Record<ESPHomeCapabilityId, string> = {
|
||||
bluetooth: mdiBluetooth,
|
||||
audio: mdiMusic,
|
||||
connectivity: mdiAccessPoint,
|
||||
serial: mdiSwapHorizontal,
|
||||
};
|
||||
|
||||
const CAPABILITY_TITLE_KEYS: Record<ESPHomeCapabilityId, LocalizeKeys> = {
|
||||
bluetooth: "ui.panel.config.devices.esphome.setup_capability_bluetooth_title",
|
||||
audio: "ui.panel.config.devices.esphome.setup_capability_audio_title",
|
||||
connectivity:
|
||||
"ui.panel.config.devices.esphome.setup_capability_connectivity_title",
|
||||
serial: "ui.panel.config.devices.esphome.setup_capability_serial_title",
|
||||
};
|
||||
|
||||
@customElement("ha-esphome-setup-banner")
|
||||
export class HaESPHomeSetupBanner extends LitElement {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ attribute: false }) public deviceName!: string;
|
||||
|
||||
@property({ attribute: false }) public status: ESPHomeSetupStatus = {};
|
||||
|
||||
@property({ type: Boolean }) public started = false;
|
||||
|
||||
protected render() {
|
||||
if (!this._i18n) {
|
||||
return nothing;
|
||||
}
|
||||
const localize = this._i18n.localize;
|
||||
const ids = getESPHomeSetupCapabilityIds(this.status);
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<div class="content">
|
||||
<div class="text">
|
||||
<h2>
|
||||
${
|
||||
this.started
|
||||
? localize(
|
||||
"ui.panel.config.devices.esphome.setup_continue_title",
|
||||
{ name: this.deviceName }
|
||||
)
|
||||
: localize("ui.panel.config.devices.esphome.setup_title")
|
||||
}
|
||||
</h2>
|
||||
<p>
|
||||
${localize("ui.panel.config.devices.esphome.setup_intro", {
|
||||
count: ids.length,
|
||||
})}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<ha-button @click=${this._setup}>
|
||||
${localize("ui.panel.config.devices.esphome.setup_action")}
|
||||
</ha-button>
|
||||
<ha-button appearance="plain" @click=${this._later}>
|
||||
${localize("ui.panel.config.devices.esphome.setup_later")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
ids.length
|
||||
? html`
|
||||
<div class="pips">
|
||||
${ids.map((id) => this._renderPip(id))}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPip(id: ESPHomeCapabilityId) {
|
||||
const localize = this._i18n!.localize;
|
||||
const completed = this.status[id] === "completed";
|
||||
return html`
|
||||
<div class="pip">
|
||||
<span
|
||||
class="pip-chip"
|
||||
style="--capability-accent: ${ESPHOME_CAPABILITY_ACCENTS[id]}"
|
||||
>
|
||||
<ha-svg-icon .path=${CAPABILITY_ICONS[id]}></ha-svg-icon>
|
||||
${
|
||||
completed
|
||||
? html`
|
||||
<span
|
||||
class="pip-check"
|
||||
role="img"
|
||||
aria-label=${localize(
|
||||
"ui.panel.config.devices.esphome.setup_status_completed"
|
||||
)}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiCheck}></ha-svg-icon>
|
||||
</span>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</span>
|
||||
<span class="pip-label"> ${localize(CAPABILITY_TITLE_KEYS[id])} </span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _setup() {
|
||||
fireEvent(this, "esphome-setup");
|
||||
}
|
||||
|
||||
private _later() {
|
||||
fireEvent(this, "esphome-setup-later");
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
display: block;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-5);
|
||||
padding: var(--ha-space-5);
|
||||
}
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--ha-space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-2xl);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
max-width: 48ch;
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-1);
|
||||
margin-block-start: var(--ha-space-2);
|
||||
}
|
||||
.pips {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--ha-space-3) var(--ha-space-6);
|
||||
align-content: center;
|
||||
}
|
||||
.pip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
.pip-chip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
background: color-mix(in srgb, var(--capability-accent) 12%, transparent);
|
||||
color: var(--capability-accent);
|
||||
}
|
||||
.pip-chip ha-svg-icon {
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
.pip-check {
|
||||
position: absolute;
|
||||
inset-inline-end: -2px;
|
||||
bottom: -2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
background: var(--success-color);
|
||||
color: var(--ha-color-on-success-loud);
|
||||
border: 2px solid var(--card-background-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.pip-check ha-svg-icon {
|
||||
--mdc-icon-size: 10px;
|
||||
}
|
||||
.pip-label {
|
||||
min-width: 0;
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.content {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-8);
|
||||
}
|
||||
.text {
|
||||
flex: 1.05 1 16rem;
|
||||
}
|
||||
.pips {
|
||||
flex: 1 1 12rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-esphome-setup-banner": HaESPHomeSetupBanner;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"esphome-setup": undefined;
|
||||
"esphome-setup-later": undefined;
|
||||
}
|
||||
|
||||
interface HTMLElementEventMap {
|
||||
"esphome-setup": HASSDomEvent<HASSDomEvents["esphome-setup"]>;
|
||||
"esphome-setup-later": HASSDomEvent<HASSDomEvents["esphome-setup-later"]>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import { internationalizationContext } from "../../../../data/context";
|
||||
|
||||
@customElement("ha-esphome-setup-reminder")
|
||||
export class HaESPHomeSetupReminder extends LitElement {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ type: Number }) public remaining = 0;
|
||||
|
||||
@property({ type: Number }) public count = 0;
|
||||
|
||||
protected render() {
|
||||
if (!this._i18n) {
|
||||
return nothing;
|
||||
}
|
||||
const localize = this._i18n.localize;
|
||||
const title =
|
||||
this.remaining > 0
|
||||
? localize("ui.panel.config.devices.esphome.setup_reminder_remaining", {
|
||||
count: this.remaining,
|
||||
})
|
||||
: localize("ui.panel.config.devices.esphome.setup_reminder_done");
|
||||
const lead =
|
||||
this.remaining > 0
|
||||
? localize("ui.panel.config.devices.esphome.setup_reminder_intro", {
|
||||
count: this.count,
|
||||
})
|
||||
: localize(
|
||||
"ui.panel.config.devices.esphome.setup_reminder_done_intro",
|
||||
{ count: this.count }
|
||||
);
|
||||
return html`
|
||||
<ha-card outlined .header=${title}>
|
||||
<div class="card-content">
|
||||
<p>${lead}</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button appearance="plain" @click=${this._setup}>
|
||||
${localize("ui.panel.config.devices.esphome.setup_action")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _setup() {
|
||||
fireEvent(this, "esphome-setup");
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
.card-content {
|
||||
padding-block-end: var(--ha-space-6);
|
||||
}
|
||||
/* Same as Device info slot="actions" on ha-config-device-page
|
||||
(and ha-device-entities-card). */
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--ha-space-1) var(--ha-space-4) var(--ha-space-1)
|
||||
var(--ha-space-1);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-esphome-setup-reminder": HaESPHomeSetupReminder;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
mdiTextureBox,
|
||||
mdiTools,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -83,12 +83,35 @@ import {
|
||||
findBatteryEntity,
|
||||
updateEntityRegistryEntry,
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import {
|
||||
fetchESPHomeDeviceCapabilities,
|
||||
type ESPHomeDeviceCapabilities,
|
||||
} from "../../../data/esphome";
|
||||
import {
|
||||
countRemainingESPHomeCapabilities,
|
||||
deriveESPHomeSetupStatus,
|
||||
deviceHasMediaPlayerEntity,
|
||||
getESPHomeSetupCapabilityIds,
|
||||
hasESPHomeSetupCapabilities,
|
||||
hasStartedNonBluetoothESPHomeSetup,
|
||||
hasZWaveJSEntryForDevice,
|
||||
isESPHomeSerialConfigured,
|
||||
isESPHomeSetupDeferred,
|
||||
withDeferredESPHomeDevice,
|
||||
} from "../../../data/esphome_setup";
|
||||
import {
|
||||
saveFrontendUserData,
|
||||
subscribeFrontendUserData,
|
||||
type ESPHomeFrontendUserData,
|
||||
} from "../../../data/frontend";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import { domainToName } from "../../../data/integration";
|
||||
import { listSerialPortsWithUsage } from "../../../data/usb";
|
||||
import { regenerateEntityIds } from "../../../data/regenerate_entity_ids";
|
||||
import type { RelatedResult } from "../../../data/search";
|
||||
import { findRelated } from "../../../data/search";
|
||||
import { filterAddToSceneEntityIds } from "../../../dialogs/add-to/add-to";
|
||||
import { showESPHomeDeviceSetupDialog } from "../../../dialogs/esphome-device-setup/show-dialog-esphome-device-setup";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
@@ -110,6 +133,8 @@ import "../../logbook/ha-logbook";
|
||||
import "./device-detail/ha-device-child-devices-card";
|
||||
import "./device-detail/ha-device-entities-card";
|
||||
import "./device-detail/ha-device-info-card";
|
||||
import "./device-detail/ha-esphome-setup-banner";
|
||||
import "./device-detail/ha-esphome-setup-reminder";
|
||||
import "./device-detail/ha-device-linked-devices-card";
|
||||
import "./device-detail/ha-device-via-devices-card";
|
||||
import { showDeviceAddToDialog } from "./device-detail/show-dialog-device-add-to";
|
||||
@@ -206,8 +231,20 @@ export class HaConfigDevicePage extends LitElement {
|
||||
|
||||
@state() private _deviceAlerts: DeviceAlert[] = [];
|
||||
|
||||
@state() private _esphomeCapabilities?: ESPHomeDeviceCapabilities;
|
||||
|
||||
@state() private _esphomeSerialConfigured = false;
|
||||
|
||||
@state() private _esphomeUserData: ESPHomeFrontendUserData | null = null;
|
||||
|
||||
@state() private _esphomeUserDataReady = false;
|
||||
|
||||
private _deviceAlertsActionsTimeout?: number;
|
||||
|
||||
private _unsubEsphomeUserData?: UnsubscribeFunc;
|
||||
|
||||
private _esphomeUserDataSubGeneration = 0;
|
||||
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityReg: EntityRegistryEntry[] = [];
|
||||
@@ -367,6 +404,8 @@ export class HaConfigDevicePage extends LitElement {
|
||||
this._deviceAlerts = [];
|
||||
this._deleteButtons = [];
|
||||
this._diagnosticDownloadLinks = [];
|
||||
this._esphomeCapabilities = undefined;
|
||||
this._esphomeSerialConfigured = false;
|
||||
}
|
||||
|
||||
if (changedProps.has("deviceId") || changedProps.has("entries")) {
|
||||
@@ -377,6 +416,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
loadDeviceRegistryDetailDialog();
|
||||
this._subscribeESPHomeUserData();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
@@ -390,9 +430,19 @@ export class HaConfigDevicePage extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated) {
|
||||
this._subscribeESPHomeUserData();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
clearTimeout(this._deviceAlertsActionsTimeout);
|
||||
this._esphomeUserDataSubGeneration += 1;
|
||||
this._unsubEsphomeUserData?.();
|
||||
this._unsubEsphomeUserData = undefined;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -446,6 +496,44 @@ export class HaConfigDevicePage extends LitElement {
|
||||
: undefined;
|
||||
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
|
||||
|
||||
const mediaPlayerSupported = deviceHasMediaPlayerEntity(
|
||||
this.deviceId,
|
||||
entities
|
||||
);
|
||||
const showESPHomeSetup =
|
||||
this._esphomeUserDataReady &&
|
||||
hasESPHomeSetupCapabilities(this._esphomeCapabilities, {
|
||||
mediaPlayerSupported,
|
||||
});
|
||||
const esphomeDeferred = isESPHomeSetupDeferred(
|
||||
this._esphomeUserData,
|
||||
this.deviceId
|
||||
);
|
||||
const esphomeStatus = this._esphomeCapabilities
|
||||
? deriveESPHomeSetupStatus(this._esphomeCapabilities, {
|
||||
mediaPlayerSupported,
|
||||
musicAssistantLoaded: isComponentLoaded(
|
||||
this.hass.config,
|
||||
"music_assistant"
|
||||
),
|
||||
zwaveJsEntryExists: hasZWaveJSEntryForDevice(
|
||||
this.deviceId,
|
||||
this.hass.devices,
|
||||
this.entries
|
||||
),
|
||||
serialConfigured: this._esphomeSerialConfigured,
|
||||
})
|
||||
: undefined;
|
||||
const esphomeRemaining = esphomeStatus
|
||||
? countRemainingESPHomeCapabilities(esphomeStatus)
|
||||
: 0;
|
||||
const esphomeCapabilityCount = esphomeStatus
|
||||
? getESPHomeSetupCapabilityIds(esphomeStatus).length
|
||||
: 0;
|
||||
const esphomeStarted = esphomeStatus
|
||||
? hasStartedNonBluetoothESPHomeSetup(esphomeStatus)
|
||||
: false;
|
||||
|
||||
const deviceInfo: TemplateResult[] = integrations.length
|
||||
? [
|
||||
html`<ha-list-nav slot="actions">
|
||||
@@ -901,6 +989,15 @@ export class HaConfigDevicePage extends LitElement {
|
||||
: ""
|
||||
}
|
||||
</ha-device-info-card>
|
||||
${
|
||||
showESPHomeSetup && esphomeDeferred
|
||||
? html`<ha-esphome-setup-reminder
|
||||
.remaining=${esphomeRemaining}
|
||||
.count=${esphomeCapabilityCount}
|
||||
@esphome-setup=${this._showESPHomeSetup}
|
||||
></ha-esphome-setup-reminder>`
|
||||
: nothing
|
||||
}
|
||||
<ha-device-child-devices-card
|
||||
.hass=${this.hass}
|
||||
.deviceId=${this.deviceId}
|
||||
@@ -1115,6 +1212,20 @@ export class HaConfigDevicePage extends LitElement {
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
showESPHomeSetup && !esphomeDeferred
|
||||
? html`
|
||||
<ha-esphome-setup-banner
|
||||
class="fullwidth"
|
||||
.deviceName=${deviceName}
|
||||
.status=${esphomeStatus}
|
||||
.started=${esphomeStarted}
|
||||
@esphome-setup=${this._showESPHomeSetup}
|
||||
@esphome-setup-later=${this._deferESPHomeSetup}
|
||||
></ha-esphome-setup-banner>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${columnContents.map(
|
||||
(contents) => html`<div class="column">${contents}</div>`
|
||||
)}
|
||||
@@ -1129,6 +1240,124 @@ export class HaConfigDevicePage extends LitElement {
|
||||
clearTimeout(this._deviceAlertsActionsTimeout);
|
||||
this._getDeviceActions();
|
||||
this._getDeviceAlerts();
|
||||
this._fetchESPHomeCapabilities();
|
||||
}
|
||||
}
|
||||
|
||||
private async _subscribeESPHomeUserData() {
|
||||
const generation = this._esphomeUserDataSubGeneration;
|
||||
try {
|
||||
const unsub = await subscribeFrontendUserData(
|
||||
this.hass.connection,
|
||||
"esphome",
|
||||
({ value }) => {
|
||||
if (generation !== this._esphomeUserDataSubGeneration) {
|
||||
return;
|
||||
}
|
||||
this._esphomeUserData = value;
|
||||
this._esphomeUserDataReady = true;
|
||||
}
|
||||
);
|
||||
if (generation !== this._esphomeUserDataSubGeneration) {
|
||||
unsub();
|
||||
return;
|
||||
}
|
||||
this._unsubEsphomeUserData = unsub;
|
||||
} catch (_err) {
|
||||
if (generation !== this._esphomeUserDataSubGeneration) {
|
||||
return;
|
||||
}
|
||||
this._esphomeUserData = null;
|
||||
this._esphomeUserDataReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchESPHomeCapabilities() {
|
||||
const deviceId = this.deviceId;
|
||||
const device = this.hass.devices[deviceId];
|
||||
if (!device) {
|
||||
this._esphomeCapabilities = undefined;
|
||||
this._esphomeSerialConfigured = false;
|
||||
return;
|
||||
}
|
||||
const domains = this._integrations(
|
||||
device,
|
||||
this.entries,
|
||||
this.manifests
|
||||
).map((entry) => entry.domain);
|
||||
if (!domains.includes("esphome")) {
|
||||
this._esphomeCapabilities = undefined;
|
||||
this._esphomeSerialConfigured = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const capabilities = await fetchESPHomeDeviceCapabilities(
|
||||
this.hass,
|
||||
deviceId
|
||||
);
|
||||
let serialConfigured = false;
|
||||
if (
|
||||
capabilities.serial_proxies.length > 0 &&
|
||||
isComponentLoaded(this.hass.config, "usb")
|
||||
) {
|
||||
try {
|
||||
const ports = await listSerialPortsWithUsage(this.hass);
|
||||
serialConfigured = isESPHomeSerialConfigured(
|
||||
capabilities.serial_proxies,
|
||||
ports
|
||||
);
|
||||
} catch (_err) {
|
||||
serialConfigured = false;
|
||||
}
|
||||
}
|
||||
if (this.deviceId !== deviceId) {
|
||||
return;
|
||||
}
|
||||
this._esphomeCapabilities = capabilities;
|
||||
this._esphomeSerialConfigured = serialConfigured;
|
||||
} catch (_err) {
|
||||
if (this.deviceId !== deviceId) {
|
||||
return;
|
||||
}
|
||||
this._esphomeCapabilities = undefined;
|
||||
this._esphomeSerialConfigured = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _showESPHomeSetup = () => {
|
||||
const device = this.hass.devices[this.deviceId];
|
||||
showESPHomeDeviceSetupDialog(this, {
|
||||
deviceId: this.deviceId,
|
||||
deviceName: device
|
||||
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
|
||||
: undefined,
|
||||
capabilities: this._esphomeCapabilities,
|
||||
mediaPlayerSupported: deviceHasMediaPlayerEntity(
|
||||
this.deviceId,
|
||||
this._entities(this.deviceId, this._entityReg, this.hass.devices)
|
||||
),
|
||||
dialogClosedCallback: () => {
|
||||
this._fetchESPHomeCapabilities();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private async _deferESPHomeSetup() {
|
||||
const previous = this._esphomeUserData;
|
||||
const next = withDeferredESPHomeDevice(previous, this.deviceId);
|
||||
this._esphomeUserData = next;
|
||||
try {
|
||||
await saveFrontendUserData(this.hass.connection, "esphome", next);
|
||||
} catch (err: unknown) {
|
||||
this._esphomeUserData = previous;
|
||||
await showAlertDialog(this, {
|
||||
text:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.devices.esphome.setup_error_defer"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6956,7 +6956,63 @@
|
||||
"esphome": {
|
||||
"show_encryption_key": "Show encryption key",
|
||||
"encryption_key_title": "ESPHome Encryption Key",
|
||||
"encryption_key_description": "This is the encryption key for your ESPHome device. Keep it in a safe place, as you may need it when transferring devices between Home Assistant instances."
|
||||
"encryption_key_description": "This is the encryption key for your ESPHome device. Keep it in a safe place, as you may need it when transferring devices between Home Assistant instances.",
|
||||
"setup_title": "Set up your ESPHome proxy",
|
||||
"setup_continue_title": "Continue setup for {name}",
|
||||
"setup_intro": "This adapter unlocks {count} simultaneous {count, plural,\n one {capability}\n other {capabilities}\n} in Home Assistant. Set them up now, or come back to this checklist any time.",
|
||||
"setup_action": "Set up",
|
||||
"setup_later": "Later",
|
||||
"setup_learn_more": "Learn more",
|
||||
"setup_reminder_remaining": "Set up {count} more {count, plural,\n one {capability}\n other {capabilities}\n}",
|
||||
"setup_reminder_done": "Everything is set up",
|
||||
"setup_reminder_intro": "This adapter unlocks {count} simultaneous {count, plural,\n one {capability}\n other {capabilities}\n} in Home Assistant.",
|
||||
"setup_reminder_done_intro": "All {count} simultaneous {count, plural,\n one {capability is}\n other {capabilities are}\n} configured.",
|
||||
"setup_capability_bluetooth_title": "Bluetooth proxy",
|
||||
"setup_capability_bluetooth_short": "Extend Bluetooth range",
|
||||
"setup_capability_bluetooth_description": "Add nearby Bluetooth devices into Home Assistant. Adding multiple Bluetooth proxies allows for better connectivity with devices across your home.",
|
||||
"setup_capability_audio_title": "Stream audio via Sendspin",
|
||||
"setup_capability_audio_short": "Multi-room sound, recommended with Music Assistant",
|
||||
"setup_capability_audio_description": "Stream audio from Home Assistant (including announcements, media, or radio); just note this audio will be unsynchronized with other players. For synchronized whole-home audio, use Music Assistant.",
|
||||
"setup_capability_connectivity_title": "Connect a Zigbee or Z-Wave adapter",
|
||||
"setup_capability_connectivity_short": "Network connectivity for ZBT-2 or ZWA-2",
|
||||
"setup_capability_connectivity_description": "Plug a Connect ZBT-2 or ZWA-2 into the USB port to enable Zigbee or Z-Wave connectivity over Ethernet or Wi-Fi. Get your antenna in an optimal location for better device responsiveness.",
|
||||
"setup_capability_serial_title": "Control a device over serial",
|
||||
"setup_capability_serial_short": "AV receivers, amplifiers, and more",
|
||||
"setup_capability_serial_description": "Add serial devices into Home Assistant, allowing for control of A/V receivers and multi-zone amplifiers.",
|
||||
"setup_audio_home_assistant": "Home Assistant",
|
||||
"setup_audio_builtin_playback": "Built-in playback",
|
||||
"setup_audio_music_assistant": "Music Assistant",
|
||||
"setup_audio_music_assistant_meta": "Synchronized multi-room audio",
|
||||
"setup_audio_music_assistant_upsell": "The better way to stream with a free app",
|
||||
"setup_audio_benefit_multiroom": "Synchronized multi-room playback",
|
||||
"setup_audio_benefit_lossless": "Lossless, high-resolution audio",
|
||||
"setup_audio_benefit_album_art": "Album art on every screen",
|
||||
"setup_audio_active": "Active",
|
||||
"setup_audio_recommended": "Recommended",
|
||||
"setup_install_music_assistant": "Install Music Assistant",
|
||||
"setup_open_music_assistant": "Open Music Assistant",
|
||||
"setup_installing_music_assistant": "Installing Music Assistant",
|
||||
"setup_starting_music_assistant": "Starting Music Assistant",
|
||||
"setup_discovering_music_assistant": "Waiting for Music Assistant discovery",
|
||||
"setup_error_music_assistant": "Couldn't install Music Assistant",
|
||||
"setup_error_capabilities": "Couldn't load device capabilities",
|
||||
"setup_error_defer": "Couldn't save setup for later",
|
||||
"setup_error_serial_integration": "Couldn't start this integration",
|
||||
"setup_status_completed": "Completed",
|
||||
"setup_status_active": "Active",
|
||||
"setup_zwave": "Set up Z-Wave",
|
||||
"setup_what_are_adapters": "What are these adapters?",
|
||||
"setup_adapters_title": "Add Zigbee or Z-Wave",
|
||||
"setup_adapters_intro": "Plug a Home Assistant Connect adapter into your proxy's USB port to add wireless radios:",
|
||||
"setup_adapter_zbt2": "Connect ZBT-2",
|
||||
"setup_adapter_zbt2_description": "Adds a Zigbee radio.",
|
||||
"setup_adapter_zwa2": "Connect ZWA-2",
|
||||
"setup_adapter_zwa2_description": "Adds a Z-Wave radio.",
|
||||
"setup_serial_choose": "Choose an integration",
|
||||
"setup_serial_intro": "Choose an integration to set up a serial device on this ESPHome device. The integration will let you pick the serial port.",
|
||||
"setup_serial_ports": "Ports on this device",
|
||||
"setup_serial_configured": "Configured",
|
||||
"setup_serial_integrations": "Serial integrations"
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ConfigEntry } from "../../src/data/config_entries";
|
||||
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
|
||||
import type { ESPHomeDeviceCapabilities } from "../../src/data/esphome";
|
||||
import {
|
||||
countRemainingESPHomeCapabilities,
|
||||
deriveESPHomeSetupStatus,
|
||||
deviceHasMediaPlayerEntity,
|
||||
getESPHomeSetupCapabilityIds,
|
||||
hasESPHomeSetupCapabilities,
|
||||
hasStartedNonBluetoothESPHomeSetup,
|
||||
hasZWaveJSEntryForDevice,
|
||||
isESPHomeSerialConfigured,
|
||||
isESPHomeSetupDeferred,
|
||||
withDeferredESPHomeDevice,
|
||||
} from "../../src/data/esphome_setup";
|
||||
import type { SerialPortUsage } from "../../src/data/usb";
|
||||
|
||||
const capabilities = (
|
||||
overrides: Partial<ESPHomeDeviceCapabilities> = {}
|
||||
): ESPHomeDeviceCapabilities => ({
|
||||
available: true,
|
||||
bluetooth_proxy: { supported: false },
|
||||
zwave_proxy: {
|
||||
supported: false,
|
||||
home_id: 0,
|
||||
},
|
||||
serial_proxies: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const deriveOptions = (
|
||||
overrides: Partial<{
|
||||
mediaPlayerSupported: boolean;
|
||||
musicAssistantLoaded: boolean;
|
||||
zwaveJsEntryExists: boolean;
|
||||
serialConfigured: boolean;
|
||||
}> = {}
|
||||
) => ({
|
||||
mediaPlayerSupported: false,
|
||||
musicAssistantLoaded: false,
|
||||
zwaveJsEntryExists: false,
|
||||
serialConfigured: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const serialProxy = (
|
||||
url = "esphome-hass://esphome/entry?port_name=uart0",
|
||||
name = "UART"
|
||||
) => ({
|
||||
name,
|
||||
port_type: "TTL" as const,
|
||||
url,
|
||||
});
|
||||
|
||||
const serialUsage = (
|
||||
device: string,
|
||||
consumerCount = 0
|
||||
): Pick<SerialPortUsage, "device" | "consumers"> => ({
|
||||
device,
|
||||
consumers: Array.from({ length: consumerCount }, (_, index) => ({
|
||||
kind: "config_entry" as const,
|
||||
title: `Consumer ${index + 1}`,
|
||||
active: true,
|
||||
domain: "monoprice",
|
||||
config_entry_id: `entry-${index}`,
|
||||
slug: null,
|
||||
})),
|
||||
});
|
||||
|
||||
const entry = (overrides: Partial<ConfigEntry>): ConfigEntry =>
|
||||
({
|
||||
entry_id: "entry",
|
||||
domain: "esphome",
|
||||
title: "Device",
|
||||
source: "user",
|
||||
state: "loaded",
|
||||
supports_options: true,
|
||||
supports_remove_device: false,
|
||||
supports_unload: true,
|
||||
supports_reconfigure: false,
|
||||
supported_subentry_types: {},
|
||||
num_subentries: 0,
|
||||
pref_disable_new_entities: false,
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
...overrides,
|
||||
}) as ConfigEntry;
|
||||
|
||||
const device = (
|
||||
id: string,
|
||||
overrides: Partial<DeviceRegistryEntry> = {}
|
||||
): DeviceRegistryEntry =>
|
||||
({
|
||||
id,
|
||||
config_entries: [],
|
||||
via_device_id: null,
|
||||
...overrides,
|
||||
}) as DeviceRegistryEntry;
|
||||
|
||||
describe("hasESPHomeSetupCapabilities", () => {
|
||||
it("is false when capabilities are missing", () => {
|
||||
expect(hasESPHomeSetupCapabilities(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when advertised even if the device is unavailable", () => {
|
||||
expect(
|
||||
hasESPHomeSetupCapabilities(
|
||||
capabilities({
|
||||
available: false,
|
||||
bluetooth_proxy: { supported: true },
|
||||
}),
|
||||
{ mediaPlayerSupported: true }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a device with no advertised capabilities", () => {
|
||||
expect(hasESPHomeSetupCapabilities(capabilities())).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when any capability is supported", () => {
|
||||
expect(
|
||||
hasESPHomeSetupCapabilities(
|
||||
capabilities({
|
||||
bluetooth_proxy: { supported: true },
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasESPHomeSetupCapabilities(capabilities(), {
|
||||
mediaPlayerSupported: true,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasESPHomeSetupCapabilities(
|
||||
capabilities({ zwave_proxy: { supported: true, home_id: 0 } })
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasESPHomeSetupCapabilities(
|
||||
capabilities({
|
||||
serial_proxies: [
|
||||
{
|
||||
name: "UART",
|
||||
port_type: "TTL",
|
||||
url: "esphome-hass://proxy/uart",
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deviceHasMediaPlayerEntity", () => {
|
||||
it("is true when the device has a media_player entity", () => {
|
||||
expect(
|
||||
deviceHasMediaPlayerEntity("dev-1", [
|
||||
{ entity_id: "sensor.proxy_rssi", device_id: "dev-1" },
|
||||
{ entity_id: "media_player.proxy", device_id: "dev-1" },
|
||||
])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores media players on other devices and non-media entities", () => {
|
||||
expect(
|
||||
deviceHasMediaPlayerEntity("dev-1", [
|
||||
{ entity_id: "media_player.other", device_id: "dev-2" },
|
||||
{ entity_id: "switch.proxy", device_id: "dev-1" },
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveESPHomeSetupStatus", () => {
|
||||
it("omits rows that are not supported", () => {
|
||||
expect(deriveESPHomeSetupStatus(capabilities(), deriveOptions())).toEqual(
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it("marks Bluetooth completed when the proxy is compiled in", () => {
|
||||
const status = deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
bluetooth_proxy: { supported: true },
|
||||
}),
|
||||
deriveOptions()
|
||||
);
|
||||
|
||||
expect(status.bluetooth).toBe("completed");
|
||||
expect(status.audio).toBeUndefined();
|
||||
expect(status.connectivity).toBeUndefined();
|
||||
expect(status.serial).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks audio from media_player entities, not capabilities", () => {
|
||||
const caps = capabilities();
|
||||
|
||||
expect(
|
||||
deriveESPHomeSetupStatus(
|
||||
caps,
|
||||
deriveOptions({ mediaPlayerSupported: true })
|
||||
).audio
|
||||
).toBe("active");
|
||||
expect(
|
||||
deriveESPHomeSetupStatus(
|
||||
caps,
|
||||
deriveOptions({
|
||||
mediaPlayerSupported: true,
|
||||
musicAssistantLoaded: true,
|
||||
})
|
||||
).audio
|
||||
).toBe("completed");
|
||||
expect(
|
||||
deriveESPHomeSetupStatus(caps, deriveOptions()).audio
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("derives connectivity from home_id and a zwave_js entry", () => {
|
||||
const supported = capabilities({
|
||||
zwave_proxy: { supported: true, home_id: 0 },
|
||||
});
|
||||
|
||||
expect(
|
||||
deriveESPHomeSetupStatus(supported, deriveOptions()).connectivity
|
||||
).toBe("not-started");
|
||||
|
||||
expect(
|
||||
deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
zwave_proxy: { supported: true, home_id: 123456 },
|
||||
}),
|
||||
deriveOptions()
|
||||
).connectivity
|
||||
).toBe("detected");
|
||||
|
||||
expect(
|
||||
deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
zwave_proxy: { supported: true, home_id: 123456 },
|
||||
}),
|
||||
deriveOptions({ zwaveJsEntryExists: true })
|
||||
).connectivity
|
||||
).toBe("completed");
|
||||
});
|
||||
|
||||
it("marks serial completed only when an advertised UART is in use", () => {
|
||||
const caps = capabilities({
|
||||
serial_proxies: [serialProxy()],
|
||||
});
|
||||
|
||||
expect(deriveESPHomeSetupStatus(caps, deriveOptions()).serial).toBe(
|
||||
"not-started"
|
||||
);
|
||||
expect(
|
||||
deriveESPHomeSetupStatus(caps, deriveOptions({ serialConfigured: true }))
|
||||
.serial
|
||||
).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isESPHomeSerialConfigured", () => {
|
||||
const url = "esphome-hass://esphome/entry?port_name=uart0";
|
||||
|
||||
it("is true when a matching port has consumers", () => {
|
||||
expect(
|
||||
isESPHomeSerialConfigured(
|
||||
[serialProxy(url)],
|
||||
[serialUsage(url, 1), serialUsage("/dev/ttyUSB0", 1)]
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when the matching port has no consumers", () => {
|
||||
expect(
|
||||
isESPHomeSerialConfigured(
|
||||
[serialProxy(url)],
|
||||
[serialUsage(url), serialUsage("/dev/ttyUSB0", 1)]
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when consumers are on a different device URL", () => {
|
||||
expect(
|
||||
isESPHomeSerialConfigured(
|
||||
[serialProxy(url)],
|
||||
[serialUsage("esphome-hass://esphome/other?port_name=uart0", 1)]
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remaining capabilities and continue-setup", () => {
|
||||
it("counts incomplete rows and ignores Bluetooth", () => {
|
||||
const status = deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
bluetooth_proxy: { supported: true },
|
||||
zwave_proxy: { supported: true, home_id: 0 },
|
||||
serial_proxies: [
|
||||
{ name: "UART", port_type: "TTL", url: "esphome-hass://proxy/uart" },
|
||||
],
|
||||
}),
|
||||
deriveOptions({ mediaPlayerSupported: true })
|
||||
);
|
||||
|
||||
expect(getESPHomeSetupCapabilityIds(status)).toEqual([
|
||||
"bluetooth",
|
||||
"audio",
|
||||
"connectivity",
|
||||
"serial",
|
||||
]);
|
||||
expect(getESPHomeSetupCapabilityIds(status).length).toBe(4);
|
||||
expect(countRemainingESPHomeCapabilities(status)).toBe(3);
|
||||
expect(hasStartedNonBluetoothESPHomeSetup(status)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not count Bluetooth when the proxy is unsupported", () => {
|
||||
const status = deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
zwave_proxy: { supported: true, home_id: 0 },
|
||||
serial_proxies: [
|
||||
{ name: "UART", port_type: "TTL", url: "esphome-hass://proxy/uart" },
|
||||
],
|
||||
}),
|
||||
deriveOptions({ mediaPlayerSupported: true })
|
||||
);
|
||||
|
||||
expect(getESPHomeSetupCapabilityIds(status)).toEqual([
|
||||
"audio",
|
||||
"connectivity",
|
||||
"serial",
|
||||
]);
|
||||
expect(getESPHomeSetupCapabilityIds(status).length).toBe(3);
|
||||
});
|
||||
|
||||
it("does not treat Bluetooth-only setup as started", () => {
|
||||
const status = deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
bluetooth_proxy: { supported: true },
|
||||
}),
|
||||
deriveOptions()
|
||||
);
|
||||
|
||||
expect(countRemainingESPHomeCapabilities(status)).toBe(0);
|
||||
expect(hasStartedNonBluetoothESPHomeSetup(status)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports everything set up when remaining is zero", () => {
|
||||
const status = deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
bluetooth_proxy: { supported: true },
|
||||
}),
|
||||
deriveOptions({
|
||||
mediaPlayerSupported: true,
|
||||
musicAssistantLoaded: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(countRemainingESPHomeCapabilities(status)).toBe(0);
|
||||
expect(hasStartedNonBluetoothESPHomeSetup(status)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a configured serial port as remaining-zero and started", () => {
|
||||
const status = deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
bluetooth_proxy: { supported: true },
|
||||
serial_proxies: [serialProxy()],
|
||||
}),
|
||||
deriveOptions({ serialConfigured: true })
|
||||
);
|
||||
|
||||
expect(status.serial).toBe("completed");
|
||||
expect(countRemainingESPHomeCapabilities(status)).toBe(0);
|
||||
expect(hasStartedNonBluetoothESPHomeSetup(status)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasZWaveJSEntryForDevice", () => {
|
||||
it("detects a zwave_js entry on the ESPHome device itself", () => {
|
||||
const devices = {
|
||||
esphome: device("esphome", {
|
||||
config_entries: ["esphome-entry", "zwave-entry"],
|
||||
}),
|
||||
};
|
||||
const entries = [
|
||||
entry({ entry_id: "esphome-entry", domain: "esphome" }),
|
||||
entry({ entry_id: "zwave-entry", domain: "zwave_js" }),
|
||||
];
|
||||
|
||||
expect(hasZWaveJSEntryForDevice("esphome", devices, entries)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects a child device via this ESPHome device", () => {
|
||||
const devices = {
|
||||
esphome: device("esphome", { config_entries: ["esphome-entry"] }),
|
||||
controller: device("controller", {
|
||||
config_entries: ["zwave-entry"],
|
||||
via_device_id: "esphome",
|
||||
}),
|
||||
};
|
||||
const entries = [
|
||||
entry({ entry_id: "esphome-entry", domain: "esphome" }),
|
||||
entry({ entry_id: "zwave-entry", domain: "zwave_js" }),
|
||||
];
|
||||
|
||||
expect(hasZWaveJSEntryForDevice("esphome", devices, entries)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores disabled zwave_js entries and unrelated devices", () => {
|
||||
const devices = {
|
||||
esphome: device("esphome", { config_entries: ["esphome-entry"] }),
|
||||
other: device("other", {
|
||||
config_entries: ["zwave-disabled"],
|
||||
via_device_id: "someone-else",
|
||||
}),
|
||||
};
|
||||
const entries = [
|
||||
entry({ entry_id: "esphome-entry", domain: "esphome" }),
|
||||
entry({
|
||||
entry_id: "zwave-disabled",
|
||||
domain: "zwave_js",
|
||||
disabled_by: "user",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(hasZWaveJSEntryForDevice("esphome", devices, entries)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Later persistence helpers", () => {
|
||||
it("tracks deferred device ids per user data", () => {
|
||||
expect(isESPHomeSetupDeferred(undefined, "dev-1")).toBe(false);
|
||||
expect(isESPHomeSetupDeferred({ setupDeferred: ["dev-1"] }, "dev-1")).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
const next = withDeferredESPHomeDevice(
|
||||
{ setupDeferred: ["dev-1"] },
|
||||
"dev-2"
|
||||
);
|
||||
expect(next.setupDeferred).toEqual(["dev-1", "dev-2"]);
|
||||
expect(withDeferredESPHomeDevice(next, "dev-1").setupDeferred).toEqual([
|
||||
"dev-1",
|
||||
"dev-2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user