diff --git a/packages/eufy-security-scrypted/src/utils/device-utils.ts b/packages/eufy-security-scrypted/src/utils/device-utils.ts index 2e74515..d0ea71c 100644 --- a/packages/eufy-security-scrypted/src/utils/device-utils.ts +++ b/packages/eufy-security-scrypted/src/utils/device-utils.ts @@ -282,13 +282,15 @@ export class DeviceUtils { ScryptedInterface.Refresh, ]; - // Talkback requires both a microphone (to receive) and speaker (to play) - // on the device. Without these the camera will reject startTalkback and - // Scrypted would surface a non-functional talk button. - if ( - properties.microphone !== undefined && - properties.speaker !== undefined - ) { + // Talkback support varies by device. Ask the server (which consults the + // upstream eufy-security-client DeviceCommands table) rather than inferring + // it from the presence of `microphone`/`speaker` properties — many models + // that support talkback (e.g. BATTERY_DOORBELL_2 / S220) don't expose those + // properties at all, which previously hid the talk button in HomeKit. + // The argument is the upstream CommandName enum value (camelCase), not the + // snake_case WS protocol command name. + const { exists: hasTalkback } = await api.hasCommand("deviceStartTalkback"); + if (hasTalkback) { interfaces.push(ScryptedInterface.Intercom); } diff --git a/packages/eufy-security-scrypted/tests/unit/device-utils.test.ts b/packages/eufy-security-scrypted/tests/unit/device-utils.test.ts index c87a334..d7b551d 100644 --- a/packages/eufy-security-scrypted/tests/unit/device-utils.test.ts +++ b/packages/eufy-security-scrypted/tests/unit/device-utils.test.ts @@ -66,16 +66,17 @@ jest.mock("../../src/utils/scrypted-device-detection", () => ({ getScryptedDeviceType: jest.fn().mockReturnValue("Camera"), })); -const makeApi = (properties: any) => ({ +const makeApi = (properties: any, hasTalkback = false) => ({ getProperties: jest.fn().mockResolvedValue({ properties }), getPropertiesMetadata: jest.fn().mockResolvedValue({ properties: {} }), + hasCommand: jest.fn().mockResolvedValue({ exists: hasTalkback }), }); const baseProps = { type: 1, name: "Test Cam", serialNumber: "CAM001" }; -const makeWsClient = (properties: any) => ({ +const makeWsClient = (properties: any, hasTalkback = false) => ({ commands: { - device: jest.fn().mockReturnValue(makeApi(properties)), + device: jest.fn().mockReturnValue(makeApi(properties, hasTalkback)), }, }); @@ -102,33 +103,28 @@ describe("DeviceUtils", () => { expect(result.interfaces).toContain("Refresh"); }); - it("adds Intercom when microphone AND speaker are present", async () => { - const wsClient = makeWsClient({ - ...baseProps, - microphone: true, - speaker: true, - }) as any; + it("adds Intercom when the device supports deviceStartTalkback", async () => { + const wsClient = makeWsClient(baseProps, true) as any; const result = await DeviceUtils.createDeviceManifest(wsClient, "CAM001"); expect(result.interfaces).toContain("Intercom"); }); - it("does NOT add Intercom when microphone is missing", async () => { - const wsClient = makeWsClient({ ...baseProps, speaker: true }) as any; + it("adds Intercom even when microphone/speaker properties are absent (e.g. S220)", async () => { + // BATTERY_DOORBELL_2 (S220) supports talkback but does not expose + // microphone/speaker boolean properties; the server's hasCommand check + // is authoritative. + const wsClient = makeWsClient(baseProps, true) as any; const result = await DeviceUtils.createDeviceManifest(wsClient, "CAM001"); - expect(result.interfaces).not.toContain("Intercom"); - }); - - it("does NOT add Intercom when speaker is missing", async () => { - const wsClient = makeWsClient({ ...baseProps, microphone: true }) as any; - const result = await DeviceUtils.createDeviceManifest(wsClient, "CAM001"); - - expect(result.interfaces).not.toContain("Intercom"); + expect(result.interfaces).toContain("Intercom"); }); - it("does NOT add Intercom when neither microphone nor speaker is present", async () => { - const wsClient = makeWsClient(baseProps) as any; + it("does NOT add Intercom when the device does not support startTalkback", async () => { + const wsClient = makeWsClient( + { ...baseProps, microphone: true, speaker: true }, + false, + ) as any; const result = await DeviceUtils.createDeviceManifest(wsClient, "CAM001"); expect(result.interfaces).not.toContain("Intercom");