mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-02 01:38:57 +01:00
656 lines
18 KiB
TypeScript
656 lines
18 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import type {
|
|
ESPHomeBluetoothProxyCapabilities,
|
|
ESPHomeDeviceCapabilities,
|
|
ESPHomeZWaveProxyCapabilities,
|
|
} from "../../src/data/esphome";
|
|
import {
|
|
countRemainingESPHomeCapabilities,
|
|
deriveESPHomeSetupStatus,
|
|
deviceHasMediaPlayerEntity,
|
|
findESPHomeZWaveFlow,
|
|
getESPHomeAudioControls,
|
|
getESPHomeSetupBannerState,
|
|
getESPHomeSetupCapabilityIds,
|
|
hasESPHomeSetupCapabilities,
|
|
hasStartedNonBluetoothESPHomeSetup,
|
|
isESPHomeSerialConfigured,
|
|
isESPHomeSetupDeferred,
|
|
withDeferredESPHomeDevice,
|
|
} from "../../src/data/esphome_setup";
|
|
import type { DataEntryFlowProgress } from "../../src/data/data_entry_flow";
|
|
import type { SerialPortUsage } from "../../src/data/usb";
|
|
|
|
const capabilities = (
|
|
overrides: Partial<
|
|
Omit<ESPHomeDeviceCapabilities, "bluetooth_proxy" | "zwave_proxy">
|
|
> & {
|
|
bluetooth_proxy?: Partial<ESPHomeBluetoothProxyCapabilities>;
|
|
zwave_proxy?: Partial<ESPHomeZWaveProxyCapabilities>;
|
|
} = {}
|
|
): ESPHomeDeviceCapabilities => ({
|
|
serial_proxies: [],
|
|
...overrides,
|
|
bluetooth_proxy: {
|
|
supported: false,
|
|
...overrides.bluetooth_proxy,
|
|
},
|
|
zwave_proxy: {
|
|
supported: false,
|
|
home_id: 0,
|
|
config_entry_id: null,
|
|
...overrides.zwave_proxy,
|
|
},
|
|
});
|
|
|
|
const deriveOptions = (
|
|
overrides: Partial<{
|
|
mediaPlayerSupported: boolean;
|
|
musicAssistantLoaded: boolean;
|
|
sendspinSupported: boolean;
|
|
sendspinEnabled: boolean;
|
|
serialConfigured: boolean;
|
|
zwaveFlowInProgress: boolean;
|
|
}> = {}
|
|
) => ({
|
|
mediaPlayerSupported: false,
|
|
musicAssistantLoaded: 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,
|
|
})),
|
|
});
|
|
|
|
describe("hasESPHomeSetupCapabilities", () => {
|
|
it("is false when capabilities are missing", () => {
|
|
expect(hasESPHomeSetupCapabilities(undefined)).toBe(false);
|
|
});
|
|
|
|
it("is false for Bluetooth alone", () => {
|
|
expect(
|
|
hasESPHomeSetupCapabilities(
|
|
capabilities({
|
|
bluetooth_proxy: { supported: true },
|
|
})
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
it("is false for a device with no advertised capabilities", () => {
|
|
expect(hasESPHomeSetupCapabilities(capabilities())).toBe(false);
|
|
});
|
|
|
|
it("is true when music, Z-Wave, or serial is supported", () => {
|
|
expect(
|
|
hasESPHomeSetupCapabilities(
|
|
capabilities({
|
|
bluetooth_proxy: { supported: true },
|
|
}),
|
|
{ mediaPlayerSupported: 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("not-started");
|
|
expect(
|
|
deriveESPHomeSetupStatus(
|
|
caps,
|
|
deriveOptions({
|
|
mediaPlayerSupported: true,
|
|
musicAssistantLoaded: true,
|
|
})
|
|
).audio
|
|
).toBe("completed");
|
|
expect(
|
|
deriveESPHomeSetupStatus(caps, deriveOptions()).audio
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("keeps audio incomplete until Sendspin is on", () => {
|
|
const caps = capabilities();
|
|
|
|
expect(
|
|
deriveESPHomeSetupStatus(
|
|
caps,
|
|
deriveOptions({
|
|
mediaPlayerSupported: true,
|
|
musicAssistantLoaded: true,
|
|
sendspinSupported: true,
|
|
sendspinEnabled: false,
|
|
})
|
|
).audio
|
|
).toBe("not-started");
|
|
expect(
|
|
deriveESPHomeSetupStatus(
|
|
caps,
|
|
deriveOptions({
|
|
mediaPlayerSupported: true,
|
|
musicAssistantLoaded: true,
|
|
sendspinSupported: true,
|
|
sendspinEnabled: true,
|
|
})
|
|
).audio
|
|
).toBe("completed");
|
|
});
|
|
|
|
it("derives connectivity from home_id and a matching zwave_js entry", () => {
|
|
expect(
|
|
deriveESPHomeSetupStatus(
|
|
capabilities({
|
|
zwave_proxy: { supported: true, home_id: 0 },
|
|
}),
|
|
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,
|
|
config_entry_id: "zwave-entry",
|
|
},
|
|
}),
|
|
deriveOptions()
|
|
).connectivity
|
|
).toBe("completed");
|
|
});
|
|
|
|
it("marks connectivity detected when a discovery flow exists despite home_id 0", () => {
|
|
expect(
|
|
deriveESPHomeSetupStatus(
|
|
capabilities({
|
|
zwave_proxy: { supported: true, home_id: 0 },
|
|
}),
|
|
deriveOptions({ zwaveFlowInProgress: true })
|
|
).connectivity
|
|
).toBe("detected");
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
it("leaves serial unset when USB usage was not loaded", () => {
|
|
const caps = capabilities({
|
|
serial_proxies: [serialProxy()],
|
|
});
|
|
|
|
expect(
|
|
deriveESPHomeSetupStatus(caps, {
|
|
mediaPlayerSupported: false,
|
|
musicAssistantLoaded: false,
|
|
}).serial
|
|
).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("findESPHomeZWaveFlow", () => {
|
|
const flow = (
|
|
context: DataEntryFlowProgress["context"],
|
|
handler = "zwave_js"
|
|
): DataEntryFlowProgress => ({
|
|
flow_id: `${handler}-${JSON.stringify(context)}`,
|
|
handler,
|
|
step_id: "installation_type",
|
|
context,
|
|
});
|
|
const fromDevice = flow({
|
|
source: "esphome",
|
|
unique_id: "3551671779",
|
|
discovery_key: { domain: "esphome", key: "20:F8:3B:17:08:A8", version: 1 },
|
|
});
|
|
|
|
it("matches the flow by the device MAC, ignoring case and separators", () => {
|
|
expect(findESPHomeZWaveFlow([fromDevice], "20:f8:3b:17:08:a8")).toBe(
|
|
fromDevice
|
|
);
|
|
const rawKey = flow({
|
|
...fromDevice.context,
|
|
discovery_key: { domain: "esphome", key: "20F83B1708A8", version: 1 },
|
|
});
|
|
expect(findESPHomeZWaveFlow([rawKey], "20:f8:3b:17:08:a8")).toBe(rawKey);
|
|
});
|
|
|
|
it("ignores flows from other devices", () => {
|
|
expect(
|
|
findESPHomeZWaveFlow(
|
|
[
|
|
fromDevice,
|
|
flow({ ...fromDevice.context, source: "usb" }),
|
|
flow(fromDevice.context, "zha"),
|
|
],
|
|
"aa:bb:cc:dd:ee:ff"
|
|
)
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("does not match flows without an ESPHome discovery key", () => {
|
|
const legacy = flow({ source: "esphome", unique_id: "3551671779" });
|
|
const otherKey = flow({
|
|
source: "esphome",
|
|
discovery_key: { domain: "usb", key: "20:F8:3B:17:08:A8", version: 1 },
|
|
});
|
|
expect(
|
|
findESPHomeZWaveFlow([legacy, otherKey], "20:F8:3B:17:08:A8")
|
|
).toBeUndefined();
|
|
expect(findESPHomeZWaveFlow([fromDevice], undefined)).toBeUndefined();
|
|
});
|
|
|
|
it("ignores same-MAC flows from other sources and integrations", () => {
|
|
expect(
|
|
findESPHomeZWaveFlow(
|
|
[
|
|
flow({ ...fromDevice.context, source: "usb" }),
|
|
flow(fromDevice.context, "zha"),
|
|
],
|
|
"20:F8:3B:17:08:A8"
|
|
)
|
|
).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
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);
|
|
expect(getESPHomeSetupBannerState(status)).toBe("setup");
|
|
});
|
|
|
|
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("completes audio without a Sendspin switch once Music Assistant is installed", () => {
|
|
const withoutSwitch = deriveESPHomeSetupStatus(
|
|
capabilities({
|
|
bluetooth_proxy: { supported: true },
|
|
}),
|
|
deriveOptions({
|
|
mediaPlayerSupported: true,
|
|
musicAssistantLoaded: true,
|
|
})
|
|
);
|
|
expect(countRemainingESPHomeCapabilities(withoutSwitch)).toBe(0);
|
|
expect(hasStartedNonBluetoothESPHomeSetup(withoutSwitch)).toBe(true);
|
|
|
|
const switchOff = deriveESPHomeSetupStatus(
|
|
capabilities({
|
|
bluetooth_proxy: { supported: true },
|
|
}),
|
|
deriveOptions({
|
|
mediaPlayerSupported: true,
|
|
musicAssistantLoaded: true,
|
|
sendspinSupported: true,
|
|
sendspinEnabled: false,
|
|
})
|
|
);
|
|
expect(countRemainingESPHomeCapabilities(switchOff)).toBe(1);
|
|
expect(getESPHomeSetupBannerState(switchOff)).toBe("setup");
|
|
|
|
const switchOn = deriveESPHomeSetupStatus(
|
|
capabilities({
|
|
bluetooth_proxy: { supported: true },
|
|
}),
|
|
deriveOptions({
|
|
mediaPlayerSupported: true,
|
|
musicAssistantLoaded: true,
|
|
sendspinSupported: true,
|
|
sendspinEnabled: true,
|
|
})
|
|
);
|
|
expect(countRemainingESPHomeCapabilities(switchOn)).toBe(0);
|
|
expect(hasStartedNonBluetoothESPHomeSetup(switchOn)).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);
|
|
});
|
|
|
|
it("treats a detected Z-Wave controller as started setup", () => {
|
|
const status = deriveESPHomeSetupStatus(
|
|
capabilities({
|
|
zwave_proxy: { supported: true, home_id: 123456 },
|
|
}),
|
|
deriveOptions()
|
|
);
|
|
|
|
expect(status.connectivity).toBe("detected");
|
|
expect(hasStartedNonBluetoothESPHomeSetup(status)).toBe(true);
|
|
expect(getESPHomeSetupBannerState(status)).toBe("continue");
|
|
});
|
|
|
|
it("keeps the continue headline while other non-Bluetooth work remains", () => {
|
|
const status = deriveESPHomeSetupStatus(
|
|
capabilities({
|
|
zwave_proxy: {
|
|
supported: true,
|
|
home_id: 123456,
|
|
config_entry_id: "zwave-entry",
|
|
},
|
|
serial_proxies: [serialProxy()],
|
|
}),
|
|
deriveOptions()
|
|
);
|
|
|
|
expect(countRemainingESPHomeCapabilities(status)).toBe(1);
|
|
expect(getESPHomeSetupBannerState(status)).toBe("continue");
|
|
});
|
|
});
|
|
|
|
describe("getESPHomeAudioControls", () => {
|
|
it("maps the Sendspin Enabled 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" },
|
|
}
|
|
)
|
|
).toMatchObject({
|
|
supported: true,
|
|
sendspinEntityId: "switch.proxy_sendspin_enabled",
|
|
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,
|
|
});
|
|
});
|
|
});
|
|
|
|
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",
|
|
]);
|
|
});
|
|
});
|