mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-23 06:04:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f275980851 |
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
+38
-12
@@ -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);
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user