Compare commits

...
Author SHA1 Message Date
Petar Petrov 863125d9b0 Mark ESPHome serial setup completed when a UART port is in use. 2026-08-27 14:22:40 +03:00
Petar Petrov 0531888064 Harden the ESPHome setup wizard after review feedback.
Show cached capabilities while offline, wait for user data before the banner, and expose setup status to assistive tech.
2026-08-25 16:37:23 +03:00
Petar Petrov 24e26b398e Add an ESPHome device setup checklist on the device page.
Surface advertised proxy capabilities in a banner and dialog so users can continue Bluetooth, audio, Z-Wave, and serial setup after the device is added.
2026-08-25 15:46:52 +03:00
11 changed files with 2611 additions and 5 deletions
+34 -1
View File
@@ -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,
});
+176
View File
@@ -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])],
});
+5
View File
@@ -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
View File
@@ -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"
),
});
}
}
+57 -1
View File
@@ -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": {
+452
View File
@@ -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",
]);
});
});