Files
frontend/test/data/esphome_setup.test.ts

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",
]);
});
});