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 > & { bluetooth_proxy?: Partial; zwave_proxy?: Partial; } = {} ): 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 => ({ 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", ]); }); });