Compare commits

...
Author SHA1 Message Date
Petar Petrov f275980851 Find ESPHome setup switches through the device wizard
Match the Bluetooth, Sendspin and unpaired access switches from the device wizard pages, offer a pairing code after Music Assistant is set up, and count Music Assistant as set up only when it has a config entry.
2026-10-23 08:46:37 +03:00
8 changed files with 536 additions and 281 deletions

No files matched your search

+25
View File
@@ -28,6 +28,22 @@ export interface ESPHomeDeviceCapabilities {
serial_proxies: ESPHomeSerialProxy[];
}
export interface ESPHomeWizardEntity {
/** Null when Home Assistant has no entity for it. */
entity_id: string | null;
description: string;
}
export interface ESPHomeWizardPage {
title: string;
description: string;
entities: ESPHomeWizardEntity[];
}
export interface ESPHomeDeviceWizard {
pages: ESPHomeWizardPage[];
}
export const fetchESPHomeEncryptionKey = (
hass: HomeAssistant,
entry_id: string
@@ -45,3 +61,12 @@ export const fetchESPHomeDeviceCapabilities = (
type: "esphome/get_device_capabilities",
device_id: deviceId,
});
export const fetchESPHomeDeviceWizard = (
hass: { callWS: CallWS },
deviceId: string
): Promise<ESPHomeDeviceWizard> =>
hass.callWS({
type: "esphome/get_device_wizard",
device_id: deviceId,
});
+91 -71
View File
@@ -5,10 +5,14 @@ import {
mdiSwapHorizontal,
} from "@mdi/js";
import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id";
import type { LocalizeKeys } from "../common/translations/localize";
import type { ConfigEntry } from "./config_entries";
import type { DataEntryFlowProgress } from "./data_entry_flow";
import type { ESPHomeDeviceCapabilities, ESPHomeSerialProxy } from "./esphome";
import type {
ESPHomeDeviceCapabilities,
ESPHomeDeviceWizard,
ESPHomeSerialProxy,
} from "./esphome";
import type { ESPHomeFrontendUserData } from "./frontend";
import type { SerialPortUsage } from "./usb";
@@ -40,8 +44,8 @@ export interface ESPHomeEntityStateRef {
export interface ESPHomeAudioControls {
supported: boolean;
sendspinEntityId?: string;
/** True when the Sendspin client switch is on. On means the client is running. */
sendspinOn: boolean;
/** Undefined when the Sendspin switch cannot be switched now, e.g. while offline. */
sendspinOn?: boolean;
}
export const CAPABILITY_ORDER: ESPHomeCapabilityId[] = [
@@ -77,49 +81,59 @@ export const ESPHOME_CAPABILITY_TITLE_KEYS: Record<
serial: "ui.panel.config.devices.esphome.setup_capability_serial_title",
};
/**
* Object id of the Sendspin client switch.
*
* The platform does not set a default name, translation_key, or device class
* (https://github.com/esphome/esphome/pull/19361). Home Assistant slugs the
* YAML name, so this matches `sendspin_enabled` or a device-prefixed
* `*_sendspin_enabled` only. The published docs and tests use
* "Sendspin Enabled". A different YAML name, or a renamed entity id, does
* not match. On starts the client (the restore default); off stops it.
* Guest mode has no entity yet.
*/
const SENDSPIN_ENABLED_OBJECT_ID = "sendspin_enabled";
/** Switches the device's wizard lists for parts of this checklist. */
export interface ESPHomeWizardSwitches {
bluetooth?: string;
sendspin?: string;
/** On lets anyone on the network stream without a pairing code. */
sendspinUnpairedAccess?: string;
}
const wizardPageEntityIds = (
wizard: ESPHomeDeviceWizard | undefined,
title: string
): (string | undefined)[] =>
wizard?.pages
.find((page) => page.title.trim().toLowerCase() === title)
?.entities.map((entity) =>
entity.entity_id && computeDomain(entity.entity_id) === "switch"
? entity.entity_id
: undefined
) ?? [];
/**
* Object id of the Bluetooth switch, a template switch named "Bluetooth"
* that runs ble.enable and ble.disable. Device capabilities do not report it.
* Find the switches in the device's wizard. Its pages have no ids yet, so they
* are matched by title. The Sendspin page lists the Sendspin switch first and
* the unpaired access switch second.
*/
const BLUETOOTH_OBJECT_ID = "bluetooth";
export const getESPHomeWizardSwitches = (
wizard: ESPHomeDeviceWizard | undefined
): ESPHomeWizardSwitches => {
const [sendspin, sendspinUnpairedAccess] = wizardPageEntityIds(
wizard,
"sendspin"
);
return {
bluetooth: wizardPageEntityIds(wizard, "bluetooth")[0],
sendspin,
sendspinUnpairedAccess,
};
};
const findESPHomeSwitch = (
deviceId: string,
entities: Iterable<ESPHomeDeviceEntityRef>,
objectId: string
): string | undefined => {
// The device page passes full registry entries, including disabled ones
const isEnabledEntity = (
entityId: string | undefined,
entities: Iterable<ESPHomeDeviceEntityRef>
): entityId is string => {
if (!entityId) {
return false;
}
for (const entity of entities) {
// The device page passes full registry entries, including disabled ones
if (
entity.device_id !== deviceId ||
entity.disabled_by ||
computeDomain(entity.entity_id) !== "switch" ||
(entity.platform && entity.platform !== "esphome")
) {
continue;
}
const entityObjectId = computeObjectId(entity.entity_id);
if (
entityObjectId === objectId ||
entityObjectId.endsWith(`_${objectId}`)
) {
return entity.entity_id;
if (entity.entity_id === entityId) {
return !entity.disabled_by;
}
}
return undefined;
return false;
};
export const deviceHasMediaPlayerEntity = (
@@ -137,19 +151,26 @@ export const deviceHasMediaPlayerEntity = (
return false;
};
/** A wizard switch, if it can be switched now. */
export const getESPHomeSwitch = (
entityId: string | undefined,
states: Readonly<Record<string, ESPHomeEntityStateRef>> = {}
): { entityId: string; on: boolean } | undefined => {
// Disabled has no state and unavailable cannot be switched, so neither counts as off
const state = entityId ? states[entityId]?.state : undefined;
return entityId && (state === "on" || state === "off")
? { entityId, on: state === "on" }
: undefined;
};
export const getESPHomeAudioControls = (
deviceId: string,
entities: Iterable<ESPHomeDeviceEntityRef>,
states: Readonly<Record<string, ESPHomeEntityStateRef>> = {}
states: Readonly<Record<string, ESPHomeEntityStateRef>> = {},
wizardSendspinEntityId?: string
): ESPHomeAudioControls => {
const sendspinEntityId = findESPHomeSwitch(
deviceId,
entities,
SENDSPIN_ENABLED_OBJECT_ID
);
const sendspinState = sendspinEntityId
? states[sendspinEntityId]?.state
const sendspinEntityId = isEnabledEntity(wizardSendspinEntityId, entities)
? wizardSendspinEntityId
: undefined;
return {
@@ -157,30 +178,15 @@ export const getESPHomeAudioControls = (
deviceHasMediaPlayerEntity(deviceId, entities) ||
Boolean(sendspinEntityId),
sendspinEntityId,
sendspinOn: sendspinState === "on",
sendspinOn: getESPHomeSwitch(sendspinEntityId, states)?.on,
};
};
/** Whether the device has an enabled Bluetooth switch, whatever its state. */
/** Whether the wizard's Bluetooth switch is enabled, whatever its state. */
export const hasESPHomeBluetoothSwitch = (
deviceId: string,
wizardBluetoothEntityId: string | undefined,
entities: Iterable<ESPHomeDeviceEntityRef>
): boolean =>
findESPHomeSwitch(deviceId, entities, BLUETOOTH_OBJECT_ID) !== undefined;
/** The device's Bluetooth switch, if it exposes one that can be switched now. */
export const getESPHomeBluetoothSwitch = (
deviceId: string,
entities: Iterable<ESPHomeDeviceEntityRef>,
states: Readonly<Record<string, ESPHomeEntityStateRef>> = {}
): { entityId: string; on: boolean } | undefined => {
const entityId = findESPHomeSwitch(deviceId, entities, BLUETOOTH_OBJECT_ID);
// Disabled has no state and unavailable cannot be switched, so neither counts as off
const state = entityId ? states[entityId]?.state : undefined;
return entityId && (state === "on" || state === "off")
? { entityId, on: state === "on" }
: undefined;
};
): boolean => isEnabledEntity(wizardBluetoothEntityId, entities);
/**
* Whether the device page should show the setup wizard: the device supports
@@ -200,6 +206,19 @@ export const hasESPHomeSetupCapabilities = (
capabilities.serial_proxies.length > 0)
);
/**
* Whether Music Assistant is set up. Its integration stays loaded after the
* entry is deleted, until Home Assistant restarts.
*/
export const isMusicAssistantConfigured = (
entries: readonly Pick<ConfigEntry, "domain" | "source">[] | undefined
): boolean =>
Boolean(
entries?.some(
(entry) => entry.domain === "music_assistant" && entry.source !== "ignore"
)
);
export const isESPHomeSerialConfigured = (
serialProxies: readonly Pick<ESPHomeSerialProxy, "url">[],
ports: readonly Pick<SerialPortUsage, "device" | "consumers">[]
@@ -212,10 +231,11 @@ export const deriveESPHomeSetupStatus = (
capabilities: ESPHomeDeviceCapabilities,
options: {
mediaPlayerSupported: boolean;
musicAssistantLoaded: boolean;
musicAssistantConfigured: boolean;
/** Undefined when the device has no Bluetooth switch to turn it off. */
bluetoothEnabled?: boolean;
sendspinSupported?: boolean;
/** Undefined when the Sendspin switch cannot be read, e.g. while offline. */
sendspinEnabled?: boolean;
serialConfigured?: boolean;
zwaveFlowInProgress?: boolean;
@@ -232,8 +252,8 @@ export const deriveESPHomeSetupStatus = (
// ESPHome does not always expose the Sendspin switch. When it is missing,
// Music Assistant is enough to finish the row.
status.audio =
options.musicAssistantLoaded &&
(!options.sendspinSupported || options.sendspinEnabled)
options.musicAssistantConfigured &&
(!options.sendspinSupported || options.sendspinEnabled !== false)
? "completed"
: "not-started";
}
@@ -1,9 +1,10 @@
import type { ContextType } from "@lit/context";
import { mdiCheck, mdiChevronDown, mdiOpenInNew } from "@mdi/js";
import { mdiCheck, mdiChevronDown, mdiOpenInNew, mdiShieldLock } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { live } from "lit/directives/live";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { consume } from "../../common/decorators/consume";
import { transform } from "../../common/decorators/transform";
@@ -16,8 +17,11 @@ import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-domain-icon";
import "../../components/ha-icon-button-prev";
import "../../components/ha-settings-row";
import "../../components/ha-spinner";
import "../../components/ha-svg-icon";
import "../../components/ha-switch";
import type { HaSwitch } from "../../components/ha-switch";
import { fetchConfigFlowInProgress } from "../../data/config_flow";
import {
apiContext,
@@ -34,7 +38,9 @@ import type { DataEntryFlowProgress } from "../../data/data_entry_flow";
import { UNAVAILABLE } from "../../data/entity/entity";
import {
fetchESPHomeDeviceCapabilities,
fetchESPHomeDeviceWizard,
type ESPHomeDeviceCapabilities,
type ESPHomeDeviceWizard,
type ESPHomeSerialPortType,
} from "../../data/esphome";
import {
@@ -44,9 +50,11 @@ import {
ESPHOME_CAPABILITY_TITLE_KEYS,
findESPHomeZWaveFlow,
getESPHomeAudioControls,
getESPHomeBluetoothSwitch,
getESPHomeSetupCapabilityIds,
getESPHomeSwitch,
getESPHomeWizardSwitches,
isESPHomeSerialConfigured,
isMusicAssistantConfigured,
MUSIC_ASSISTANT_DOCS_URL,
type ESPHomeAudioControls,
type ESPHomeCapabilityId,
@@ -70,7 +78,8 @@ import {
import { DialogMixin } from "../dialog-mixin";
import type { ESPHomeDeviceSetupDialogParams } from "./show-dialog-esphome-device-setup";
type SetupView = "checklist" | "audio-offer" | "audio-working";
type SetupView =
"checklist" | "audio-offer" | "audio-working" | "audio-protect";
const SERIAL_PORT_TYPE_LABELS: Record<ESPHomeSerialPortType, LocalizeKeys> = {
TTL: "ui.panel.config.devices.esphome.setup_serial_port_ttl",
@@ -153,6 +162,8 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
@state() private _capabilities?: ESPHomeDeviceCapabilities;
@state() private _wizard?: ESPHomeDeviceWizard;
/** Last successful USB usage scan. Undefined until a scan completes. */
@state() private _serialPorts?: SerialPortUsage[];
@@ -174,8 +185,8 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
@state() private _switchingBluetooth = false;
/** True until the Sendspin switch state catches up after we turn it on. */
@state() private _sendspinOptimisticOn = false;
/** Switches we just set, until their state catches up. */
@state() private _pendingSwitches: Readonly<Record<string, boolean>> = {};
/** Bumps when the user leaves the audio flow, so an in-flight step stops. */
private _audioFlowId = 0;
@@ -191,11 +202,12 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (this._sendspinOptimisticOn && changedProps.has("_states")) {
const entityId = this._audioControls().sendspinEntityId;
if (changedProps.has("_states")) {
const oldStates = changedProps.get("_states") as typeof this._states;
if (entityId && this._states?.[entityId] !== oldStates?.[entityId]) {
this._sendspinOptimisticOn = false;
for (const entityId of Object.keys(this._pendingSwitches)) {
if (this._states?.[entityId] !== oldStates?.[entityId]) {
this._clearPendingSwitch(entityId);
}
}
}
if (
@@ -207,6 +219,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
) {
this._loadStarted = true;
this._capabilities = this.params.capabilities;
this._wizard = this.params.wizard;
this._load();
} else if (this._loadStarted && this._capabilities?.serial_proxies.length) {
if (changedProps.has("_configEntries")) {
@@ -271,6 +284,9 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
if (this._view === "audio-offer") {
return this._renderAudioOffer();
}
if (this._view === "audio-protect") {
return this._renderAudioProtect();
}
return this._renderChecklist();
}
@@ -420,7 +436,16 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
private _renderAudioActions(status: ESPHomeCapabilityStatus) {
const localize = this._i18n!.localize;
if (status === "completed") {
return nothing;
if (!this._unpairedAccessSwitch()) {
return nothing;
}
return html`
<div class="actions">
<ha-button appearance="outlined" @click=${this._showAudioProtect}>
${localize("ui.panel.config.devices.esphome.setup_manage_sendspin")}
</ha-button>
</div>
`;
}
return html`
<div class="actions">
@@ -473,6 +498,43 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
`;
}
private _renderAudioProtect() {
const localize = this._i18n!.localize;
const unpairedAccess = this._unpairedAccessSwitch();
const label = localize(
"ui.panel.config.devices.esphome.setup_protect_require_pairing_code"
);
return html`
<div
class="protect"
style="--capability-accent: ${ESPHOME_CAPABILITY_ACCENTS.audio}"
>
<span class="icon-chip">
<ha-svg-icon .path=${mdiShieldLock}></ha-svg-icon>
</span>
<h2>
${localize("ui.panel.config.devices.esphome.setup_protect_heading")}
</h2>
<p>${localize("ui.panel.config.devices.esphome.setup_protect_body")}</p>
<ha-settings-row>
<span slot="heading">${label}</span>
<span slot="description">
${localize(
"ui.panel.config.devices.esphome.setup_protect_require_pairing_code_description"
)}
</span>
<ha-switch
.checked=${live(unpairedAccess ? !unpairedAccess.on : false)}
.disabled=${!unpairedAccess}
.accessibleLabel=${label}
haptic
@change=${this._toggleRequirePairingCode}
></ha-switch>
</ha-settings-row>
</div>
`;
}
private _renderBenefit(key: LocalizeKeys) {
return html`
<div class="ma-benefit">
@@ -496,6 +558,15 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
return nothing;
}
const localize = this._i18n.localize;
if (this._view === "audio-protect") {
return html`
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" @click=${this._showChecklist}>
${localize("ui.panel.config.devices.esphome.setup_finish")}
</ha-button>
</ha-dialog-footer>
`;
}
if (this._view === "audio-offer" && !this._installingAudio) {
const hassio = isComponentLoaded(this._hassConfig.config, "hassio");
return html`
@@ -726,6 +797,9 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
"ui.panel.config.devices.esphome.setup_audio_offer_title"
);
}
if (this._view === "audio-protect") {
return localize("ui.panel.config.devices.esphome.setup_protect_title");
}
return localize("ui.panel.config.devices.esphome.setup_title");
}
@@ -740,10 +814,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
Boolean(this.params.mediaPlayerSupported) || audio.supported,
sendspinSupported: Boolean(audio.sendspinEntityId),
sendspinEnabled: this._sendspinIsOn(audio),
musicAssistantLoaded: isComponentLoaded(
this._hassConfig.config,
"music_assistant"
),
musicAssistantConfigured: isMusicAssistantConfigured(this._configEntries),
serialConfigured:
this._serialPorts === undefined
? undefined
@@ -773,14 +844,15 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
this._fetching = true;
this._error = undefined;
try {
const capabilities = await fetchESPHomeDeviceCapabilities(
this._api,
this.params.deviceId
);
const [capabilities, wizard] = await Promise.all([
fetchESPHomeDeviceCapabilities(this._api, this.params.deviceId),
fetchESPHomeDeviceWizard(this._api, this.params.deviceId),
]);
if (!this.isConnected) {
return;
}
this._capabilities = capabilities;
this._wizard = wizard;
if (capabilities.zwave_proxy.supported) {
await this._refreshZWaveFlows();
if (!this.isConnected) {
@@ -889,12 +961,8 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
};
private _bluetoothSwitch() {
if (!this.params) {
return undefined;
}
return getESPHomeBluetoothSwitch(
this.params.deviceId,
this._entities ? Object.values(this._entities) : [],
return getESPHomeSwitch(
getESPHomeWizardSwitches(this._wizard).bluetooth,
this._states ?? {}
);
}
@@ -921,15 +989,13 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
private _audioControls(): ESPHomeAudioControls {
if (!this.params) {
return {
supported: false,
sendspinOn: false,
};
return { supported: false };
}
return getESPHomeAudioControls(
this.params.deviceId,
this._entities ? Object.values(this._entities) : [],
this._states ?? {}
this._states ?? {},
getESPHomeWizardSwitches(this._wizard).sendspin
);
}
@@ -938,8 +1004,70 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
return key ? this._i18n!.localize(key) : portType;
}
private _sendspinIsOn(audio: ESPHomeAudioControls): boolean {
return this._sendspinOptimisticOn || audio.sendspinOn;
private _sendspinIsOn(audio: ESPHomeAudioControls): boolean | undefined {
const pending = audio.sendspinEntityId
? this._pendingSwitches[audio.sendspinEntityId]
: undefined;
return pending ?? audio.sendspinOn;
}
/** On lets anyone on the network stream without a pairing code. */
private _unpairedAccessSwitch() {
const unpairedAccess = getESPHomeSwitch(
getESPHomeWizardSwitches(this._wizard).sendspinUnpairedAccess,
this._states ?? {}
);
return (
unpairedAccess && {
...unpairedAccess,
on: this._pendingSwitches[unpairedAccess.entityId] ?? unpairedAccess.on,
}
);
}
private _showAudioProtect = (ev: Event) => {
ev.stopPropagation();
this._view = "audio-protect";
};
private _toggleRequirePairingCode = (
ev: HASSDomCurrentTargetEvent<HaSwitch>
) => {
const unpairedAccess = this._unpairedAccessSwitch();
if (!unpairedAccess || !this._api) {
return;
}
void this._setSwitch(unpairedAccess.entityId, !ev.currentTarget.checked);
};
/** Shows the new state right away, like ha-entity-toggle. */
private async _setSwitch(entityId: string, on: boolean): Promise<boolean> {
const stateBefore = this._states?.[entityId];
this._pendingSwitches = { ...this._pendingSwitches, [entityId]: on };
try {
await this._api!.callService("switch", on ? "turn_on" : "turn_off", {
entity_id: entityId,
});
} catch {
// callService already shows the failure toast.
this._clearPendingSwitch(entityId);
return false;
}
window.setTimeout(() => {
if (
this._pendingSwitches[entityId] === on &&
this._states?.[entityId] === stateBefore
) {
this._clearPendingSwitch(entityId);
}
}, SWITCH_RESYNC_MS);
return true;
}
private _clearPendingSwitch(entityId: string) {
const pending = { ...this._pendingSwitches };
delete pending[entityId];
this._pendingSwitches = pending;
}
private _audioFlowCurrent(flowId: number): boolean {
@@ -948,7 +1076,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
private _startAudioFlow = (ev: Event) => {
ev.stopPropagation();
if (!isComponentLoaded(this._hassConfig!.config, "music_assistant")) {
if (!isMusicAssistantConfigured(this._configEntries)) {
this._view = "audio-offer";
return;
}
@@ -960,13 +1088,16 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
void this._continueAfterMusicAssistant(this._audioFlowId);
};
/** After Music Assistant is installed or skipped, turn Sendspin on. */
/**
* After Music Assistant is installed or skipped, turn Sendspin on, then
* offer a pairing code.
*/
private async _continueAfterMusicAssistant(flowId: number) {
if (!this._audioFlowCurrent(flowId)) {
return;
}
const audio = this._audioControls();
if (audio.sendspinEntityId && !this._sendspinIsOn(audio)) {
if (audio.sendspinEntityId && this._sendspinIsOn(audio) === false) {
const enabled = await this._enableSendspin(
flowId,
audio.sendspinEntityId
@@ -978,7 +1109,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
if (!this._audioFlowCurrent(flowId)) {
return;
}
this._view = "checklist";
this._view = this._unpairedAccessSwitch() ? "audio-protect" : "checklist";
this._installStatus = undefined;
}
@@ -994,37 +1125,16 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
this._installStatus = this._i18n.localize(
"ui.panel.config.devices.esphome.setup_enabling_sendspin"
);
const stateBefore = this._states?.[entityId];
this._sendspinOptimisticOn = true;
try {
await this._api.callService("switch", "turn_on", {
entity_id: entityId,
});
if (!this._audioFlowCurrent(flowId)) {
return false;
}
window.setTimeout(() => {
if (
this._states?.[entityId] === stateBefore &&
this._sendspinOptimisticOn
) {
this._sendspinOptimisticOn = false;
}
}, SWITCH_RESYNC_MS);
return true;
} catch {
// callService already shows the failure toast.
this._sendspinOptimisticOn = false;
if (this._audioFlowCurrent(flowId)) {
this._view = "checklist";
this._installStatus = undefined;
}
const enabled = await this._setSwitch(entityId, true);
if (!this._audioFlowCurrent(flowId)) {
return false;
} finally {
if (this._audioFlowCurrent(flowId)) {
this._installingAudio = false;
}
}
this._installingAudio = false;
if (!enabled) {
this._view = "checklist";
this._installStatus = undefined;
}
return enabled;
}
/** The integration's config flow installs and starts the Music Assistant app. */
@@ -1386,6 +1496,37 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
.ports ha-button {
flex-shrink: 0;
}
.protect {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-4);
text-align: center;
}
.protect .icon-chip {
width: 64px;
height: 64px;
}
.protect .icon-chip ha-svg-icon {
--mdc-icon-size: 32px;
}
.protect h2 {
margin: 0;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
}
.protect p {
margin: 0;
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
}
.protect ha-settings-row {
align-self: stretch;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-lg);
text-align: start;
}
`,
];
}
@@ -1,5 +1,8 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { ESPHomeDeviceCapabilities } from "../../data/esphome";
import type {
ESPHomeDeviceCapabilities,
ESPHomeDeviceWizard,
} from "../../data/esphome";
export const loadESPHomeDeviceSetupDialog = () =>
import("./dialog-esphome-device-setup");
@@ -8,6 +11,7 @@ export interface ESPHomeDeviceSetupDialogParams {
deviceId: string;
deviceName?: string;
capabilities?: ESPHomeDeviceCapabilities;
wizard?: ESPHomeDeviceWizard;
mediaPlayerSupported?: boolean;
dialogClosedCallback?: () => void;
}
@@ -2,22 +2,28 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { html, nothing } from "lit";
import { isComponentLoaded } from "../../../../../../common/config/is_component_loaded";
import { ContextSubscriptionController } from "../../../../../../common/decorators/consume";
import { computeDeviceNameDisplay } from "../../../../../../common/entity/compute_device_name";
import { configEntriesContext } from "../../../../../../data/context";
import {
fetchESPHomeDeviceCapabilities,
fetchESPHomeDeviceWizard,
type ESPHomeDeviceCapabilities,
type ESPHomeDeviceWizard,
} from "../../../../../../data/esphome";
import {
countRemainingESPHomeCapabilities,
type ESPHomeDeviceEntityRef,
deriveESPHomeSetupStatus,
getESPHomeAudioControls,
getESPHomeBluetoothSwitch,
hasESPHomeBluetoothSwitch,
getESPHomeSwitch,
getESPHomeSetupCapabilityIds,
getESPHomeWizardSwitches,
hasESPHomeBluetoothSwitch,
hasESPHomeSetupCapabilities,
isESPHomeSerialConfigured,
isESPHomeSetupDeferred,
isMusicAssistantConfigured,
withDeferredESPHomeDevice,
} from "../../../../../../data/esphome_setup";
import {
@@ -47,6 +53,8 @@ export class ESPHomeSetupController implements ReactiveController {
private _capabilities?: ESPHomeDeviceCapabilities;
private _wizard?: ESPHomeDeviceWizard;
/** Undefined until a USB usage scan succeeds. Failures keep the previous value. */
private _serialConfigured?: boolean;
@@ -64,11 +72,24 @@ export class ESPHomeSetupController implements ReactiveController {
private _capabilitiesRequest = 0;
private _musicAssistantConfigured = false;
constructor(
private readonly _host: ESPHomeSetupHost,
private readonly _getEntities: () => ESPHomeDeviceEntityRef[]
) {
this._host.addController(this);
new ContextSubscriptionController(
this._host,
configEntriesContext,
(entries) => {
const configured = isMusicAssistantConfigured(entries);
if (configured !== this._musicAssistantConfigured) {
this._musicAssistantConfigured = configured;
this._host.requestUpdate();
}
}
);
}
public hostConnected(): void {
@@ -101,7 +122,10 @@ export class ESPHomeSetupController implements ReactiveController {
this._subscribeUserData();
const hass = this._host.hass;
try {
const capabilities = await fetchESPHomeDeviceCapabilities(hass, deviceId);
const [capabilities, wizard] = await Promise.all([
fetchESPHomeDeviceCapabilities(hass, deviceId),
fetchESPHomeDeviceWizard(hass, deviceId),
]);
if (!stillCurrent()) {
return;
}
@@ -132,6 +156,7 @@ export class ESPHomeSetupController implements ReactiveController {
serialConfigured = false;
}
this._capabilities = capabilities;
this._wizard = wizard;
this._serialConfigured = serialConfigured;
this._serialError = serialError;
this._host.requestUpdate();
@@ -184,15 +209,17 @@ export class ESPHomeSetupController implements ReactiveController {
return undefined;
}
const hass = this._host.hass;
const switches = getESPHomeWizardSwitches(this._wizard);
const audio = getESPHomeAudioControls(
deviceId,
this._getEntities(),
hass.states
hass.states,
switches.sendspin
);
if (
!hasESPHomeSetupCapabilities(this._capabilities, {
bluetoothSwitch: hasESPHomeBluetoothSwitch(
deviceId,
switches.bluetooth,
this._getEntities()
),
mediaPlayerSupported: audio.supported,
@@ -201,15 +228,11 @@ export class ESPHomeSetupController implements ReactiveController {
return undefined;
}
let status = deriveESPHomeSetupStatus(this._capabilities, {
bluetoothEnabled: getESPHomeBluetoothSwitch(
deviceId,
this._getEntities(),
hass.states
)?.on,
bluetoothEnabled: getESPHomeSwitch(switches.bluetooth, hass.states)?.on,
mediaPlayerSupported: audio.supported,
sendspinSupported: Boolean(audio.sendspinEntityId),
sendspinEnabled: audio.sendspinOn,
musicAssistantLoaded: isComponentLoaded(hass.config, "music_assistant"),
musicAssistantConfigured: this._musicAssistantConfigured,
serialConfigured: this._serialConfigured,
});
// Unknown usage is not a completed setup. List the row so the banner stays,
@@ -230,6 +253,7 @@ export class ESPHomeSetupController implements ReactiveController {
private _clearCapabilities() {
this._capabilities = undefined;
this._wizard = undefined;
this._serialConfigured = undefined;
this._serialError = undefined;
this._host.requestUpdate();
@@ -290,10 +314,12 @@ export class ESPHomeSetupController implements ReactiveController {
? computeDeviceNameDisplay(device, hass.localize)
: undefined,
capabilities: this._capabilities,
wizard: this._wizard,
mediaPlayerSupported: getESPHomeAudioControls(
deviceId,
this._getEntities(),
hass.states
hass.states,
getESPHomeWizardSwitches(this._wizard).sendspin
).supported,
dialogClosedCallback: () => {
if (this._deviceId === deviceId) {
@@ -1089,8 +1089,10 @@ export class HaConfigDevicePage extends LitElement {
if (request !== this._esphomeSetupRequest || this.deviceId !== deviceId) {
return;
}
this._esphomeSetup ??= new esphomeSetup.ESPHomeSetupController(this, () =>
this._entities(this.deviceId, this._entityReg, this.hass.devices)
// The wizard may list entities of sub-devices
this._esphomeSetup ??= new esphomeSetup.ESPHomeSetupController(
this,
() => this._entityReg
);
}
this._esphomeSetup.refresh(deviceId);
+7
View File
@@ -7458,6 +7458,13 @@
"setup_audio_benefit_lossless": "Lossless, high-resolution audio",
"setup_audio_benefit_album_art": "Album art on every screen",
"setup_install_music_assistant": "Install Music Assistant",
"setup_manage_sendspin": "Manage Sendspin",
"setup_protect_title": "Protect this speaker",
"setup_protect_heading": "Require a pairing code to play audio",
"setup_protect_body": "Without a pairing code, anyone on your network can stream to this speaker. Require one so only people you share it with can play audio here.",
"setup_protect_require_pairing_code": "Require a pairing code to play",
"setup_protect_require_pairing_code_description": "Guests enter a code before they can stream",
"setup_finish": "Finish",
"setup_error_capabilities": "Couldn't load device capabilities",
"setup_error_defer": "Couldn't save setup for later",
"setup_error_zwave": "Z-Wave setup for this device isn't available right now. Try again in a moment.",
+161 -131
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import type {
ESPHomeBluetoothProxyCapabilities,
ESPHomeDeviceCapabilities,
ESPHomeWizardPage,
ESPHomeZWaveProxyCapabilities,
} from "../../src/data/esphome";
import {
@@ -11,13 +12,15 @@ import {
deviceHasMediaPlayerEntity,
findESPHomeZWaveFlow,
getESPHomeAudioControls,
getESPHomeBluetoothSwitch,
getESPHomeSetupBannerState,
getESPHomeSetupCapabilityIds,
getESPHomeSwitch,
getESPHomeWizardSwitches,
hasESPHomeBluetoothSwitch,
hasESPHomeSetupCapabilities,
hasStartedNonBluetoothESPHomeSetup,
isESPHomeSerialConfigured,
isMusicAssistantConfigured,
isESPHomeSetupDeferred,
withDeferredESPHomeDevice,
} from "../../src/data/esphome_setup";
@@ -49,7 +52,7 @@ const capabilities = (
const deriveOptions = (
overrides: Partial<{
mediaPlayerSupported: boolean;
musicAssistantLoaded: boolean;
musicAssistantConfigured: boolean;
sendspinSupported: boolean;
sendspinEnabled: boolean;
serialConfigured: boolean;
@@ -57,7 +60,7 @@ const deriveOptions = (
}> = {}
) => ({
mediaPlayerSupported: false,
musicAssistantLoaded: false,
musicAssistantConfigured: false,
serialConfigured: false,
...overrides,
});
@@ -215,7 +218,7 @@ describe("deriveESPHomeSetupStatus", () => {
caps,
deriveOptions({
mediaPlayerSupported: true,
musicAssistantLoaded: true,
musicAssistantConfigured: true,
})
).audio
).toBe("completed");
@@ -232,7 +235,7 @@ describe("deriveESPHomeSetupStatus", () => {
caps,
deriveOptions({
mediaPlayerSupported: true,
musicAssistantLoaded: true,
musicAssistantConfigured: true,
sendspinSupported: true,
sendspinEnabled: false,
})
@@ -243,7 +246,7 @@ describe("deriveESPHomeSetupStatus", () => {
caps,
deriveOptions({
mediaPlayerSupported: true,
musicAssistantLoaded: true,
musicAssistantConfigured: true,
sendspinSupported: true,
sendspinEnabled: true,
})
@@ -251,6 +254,18 @@ describe("deriveESPHomeSetupStatus", () => {
).toBe("completed");
});
it("keeps audio completed while the Sendspin switch is unavailable", () => {
expect(
deriveESPHomeSetupStatus(
capabilities(),
deriveOptions({
musicAssistantConfigured: true,
sendspinSupported: true,
})
).audio
).toBe("completed");
});
it("derives connectivity from home_id and a matching zwave_js entry", () => {
expect(
deriveESPHomeSetupStatus(
@@ -317,7 +332,7 @@ describe("deriveESPHomeSetupStatus", () => {
expect(
deriveESPHomeSetupStatus(caps, {
mediaPlayerSupported: false,
musicAssistantLoaded: false,
musicAssistantConfigured: false,
}).serial
).toBeUndefined();
});
@@ -419,6 +434,21 @@ describe("isESPHomeSerialConfigured", () => {
});
});
describe("isMusicAssistantConfigured", () => {
it.each([
["no entries", undefined, false],
["another integration", [{ domain: "esphome", source: "user" }], false],
[
"an ignored discovery",
[{ domain: "music_assistant", source: "ignore" }],
false,
],
["an entry", [{ domain: "music_assistant", source: "hassio" }], true],
])("handles %s", (_name, entries, expected) => {
expect(isMusicAssistantConfigured(entries)).toBe(expected);
});
});
describe("remaining capabilities and continue-setup", () => {
it("counts incomplete rows, with Bluetooth completed when it has no switch", () => {
const status = deriveESPHomeSetupStatus(
@@ -482,7 +512,7 @@ describe("remaining capabilities and continue-setup", () => {
}),
deriveOptions({
mediaPlayerSupported: true,
musicAssistantLoaded: true,
musicAssistantConfigured: true,
})
);
expect(countRemainingESPHomeCapabilities(withoutSwitch)).toBe(0);
@@ -494,7 +524,7 @@ describe("remaining capabilities and continue-setup", () => {
}),
deriveOptions({
mediaPlayerSupported: true,
musicAssistantLoaded: true,
musicAssistantConfigured: true,
sendspinSupported: true,
sendspinEnabled: false,
})
@@ -508,7 +538,7 @@ describe("remaining capabilities and continue-setup", () => {
}),
deriveOptions({
mediaPlayerSupported: true,
musicAssistantLoaded: true,
musicAssistantConfigured: true,
sendspinSupported: true,
sendspinEnabled: true,
})
@@ -562,150 +592,150 @@ describe("remaining capabilities and continue-setup", () => {
});
});
describe("getESPHomeBluetoothSwitch", () => {
// Another device's switch comes first, so only the device filter skips it
const entities = [
{
entity_id: "switch.other_bluetooth",
device_id: "dev-2",
platform: "esphome",
},
{
entity_id: "switch.garage_proxy_bluetooth",
device_id: "dev-1",
platform: "esphome",
},
];
it("finds the device's Bluetooth switch and its state", () => {
expect(
getESPHomeBluetoothSwitch("dev-1", entities, {
"switch.garage_proxy_bluetooth": { state: "off" },
"switch.other_bluetooth": { state: "on" },
})
).toEqual({ entityId: "switch.garage_proxy_bluetooth", on: false });
describe("getESPHomeWizardSwitches", () => {
const page = (
title: string,
entityIds: (string | null)[]
): ESPHomeWizardPage => ({
title,
description: "",
entities: entityIds.map((entity_id) => ({ entity_id, description: "" })),
});
it("counts an offline switch as present, but not a disabled one", () => {
expect(hasESPHomeBluetoothSwitch("dev-1", entities)).toBe(true);
it("takes the switches from the Bluetooth and Sendspin pages by title", () => {
expect(
hasESPHomeBluetoothSwitch("dev-3", [
{
entity_id: "switch.disabled_bluetooth",
device_id: "dev-3",
platform: "esphome",
disabled_by: "integration",
},
])
).toBe(false);
getESPHomeWizardSwitches({
pages: [
page("Sendspin", [
"switch.proxy_sendspin",
"switch.proxy_sendspin_unpaired_access",
]),
page(" bluetooth ", ["switch.proxy_bluetooth"]),
page("Lights", ["switch.proxy_light"]),
],
})
).toEqual({
bluetooth: "switch.proxy_bluetooth",
sendspin: "switch.proxy_sendspin",
sendspinUnpairedAccess: "switch.proxy_sendspin_unpaired_access",
});
});
it("skips entities that are not switches, keeping the order", () => {
expect(
getESPHomeWizardSwitches({
pages: [
page("Sendspin", [
"media_player.proxy_sendspin",
"switch.proxy_sendspin_unpaired_access",
]),
],
})
).toEqual({
bluetooth: undefined,
sendspin: undefined,
sendspinUnpairedAccess: "switch.proxy_sendspin_unpaired_access",
});
});
it.each([
["a disabled switch without a state", {}],
["no wizard", undefined],
["no matching pages", { pages: [page("Lights", ["switch.proxy_light"])] }],
[
"entities without an entity id",
{ pages: [page("Bluetooth", [null]), page("Sendspin", [])] },
],
])("finds no switches with %s", (_name, wizard) => {
expect(getESPHomeWizardSwitches(wizard)).toEqual({
bluetooth: undefined,
sendspin: undefined,
sendspinUnpairedAccess: undefined,
});
});
});
describe("getESPHomeSwitch", () => {
it("reads the switch state", () => {
expect(
getESPHomeSwitch("switch.proxy_bluetooth", {
"switch.proxy_bluetooth": { state: "off" },
})
).toEqual({ entityId: "switch.proxy_bluetooth", on: false });
});
it.each([
["no wizard switch", undefined, {}],
["a disabled switch without a state", "switch.proxy_bluetooth", {}],
[
"an unavailable switch",
{ "switch.garage_proxy_bluetooth": { state: "unavailable" } },
"switch.proxy_bluetooth",
{ "switch.proxy_bluetooth": { state: "unavailable" } },
],
])("ignores %s", (_name, states) => {
])("ignores %s", (_name, entityId, states) => {
expect(getESPHomeSwitch(entityId, states)).toBeUndefined();
});
it("counts an enabled switch as present, but not a disabled one", () => {
const entities = [
{ entity_id: "switch.proxy_bluetooth", disabled_by: null },
{ entity_id: "switch.disabled_bluetooth", disabled_by: "integration" },
];
expect(hasESPHomeBluetoothSwitch("switch.proxy_bluetooth", entities)).toBe(
true
);
expect(
getESPHomeBluetoothSwitch("dev-1", entities, states)
).toBeUndefined();
hasESPHomeBluetoothSwitch("switch.disabled_bluetooth", entities)
).toBe(false);
expect(hasESPHomeBluetoothSwitch(undefined, entities)).toBe(false);
});
});
describe("getESPHomeAudioControls", () => {
it("maps the Sendspin Enabled switch without inverting it", () => {
const sendspin = {
entity_id: "switch.proxy_sendspin",
device_id: "dev-1",
platform: "esphome",
};
it("uses the wizard's Sendspin switch without inverting it", () => {
expect(
getESPHomeAudioControls(
"dev-1",
[
{
entity_id: "media_player.proxy",
device_id: "dev-1",
platform: "esphome",
},
{
entity_id: "switch.proxy_sendspin_enabled",
device_id: "dev-1",
platform: "esphome",
},
{
entity_id: "switch.other_sendspin_enabled",
device_id: "dev-2",
platform: "esphome",
},
],
{
"switch.proxy_sendspin_enabled": { state: "on" },
"switch.other_sendspin_enabled": { state: "off" },
}
[sendspin],
{ "switch.proxy_sendspin": { state: "on" } },
"switch.proxy_sendspin"
)
).toMatchObject({
).toEqual({
supported: true,
sendspinEntityId: "switch.proxy_sendspin_enabled",
sendspinEntityId: "switch.proxy_sendspin",
sendspinOn: true,
});
});
it("treats Sendspin Enabled off as the client stopped", () => {
expect(
getESPHomeAudioControls(
"dev-1",
[
{
entity_id: "switch.sendspin_enabled",
device_id: "dev-1",
platform: "esphome",
},
],
{ "switch.sendspin_enabled": { state: "off" } }
)
).toMatchObject({
supported: true,
sendspinEntityId: "switch.sendspin_enabled",
sendspinOn: false,
});
});
it("ignores label matches that are not the Sendspin Enabled object id", () => {
expect(
getESPHomeAudioControls(
"dev-1",
[
{
entity_id: "switch.guest_room_night_light",
device_id: "dev-1",
platform: "esphome",
},
{
entity_id: "switch.guest_room_sendspin",
device_id: "dev-1",
platform: "esphome",
},
{
entity_id: "switch.guest_room_require_pin_to_stream",
device_id: "dev-1",
platform: "esphome",
},
{
entity_id: "switch.proxy_sendspin_guest_mode",
device_id: "dev-1",
platform: "esphome",
},
],
{
"switch.guest_room_night_light": { state: "on" },
"switch.guest_room_sendspin": { state: "off" },
"switch.guest_room_require_pin_to_stream": { state: "on" },
"switch.proxy_sendspin_guest_mode": { state: "on" },
}
)
).toMatchObject({
supported: false,
sendspinEntityId: undefined,
sendspinOn: false,
});
});
it.each([
["without the wizard", [sendspin], undefined],
[
"for a disabled switch",
[{ ...sendspin, disabled_by: "user" }],
"switch.proxy_sendspin",
],
])(
"finds no Sendspin switch %s",
(_name, entities, wizardSendspinEntityId) => {
expect(
getESPHomeAudioControls(
"dev-1",
entities,
{ "switch.proxy_sendspin": { state: "on" } },
wizardSendspinEntityId
)
).toEqual({
supported: false,
sendspinEntityId: undefined,
sendspinOn: undefined,
});
}
);
});
describe("Later persistence helpers", () => {