Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions packages/eufy-security-scrypted/src/utils/device-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
38 changes: 17 additions & 21 deletions packages/eufy-security-scrypted/tests/unit/device-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
},
});

Expand All @@ -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");
Expand Down