UNPKG

homebridge-freeathome-local-api

Version:

Control your free@home setup using the local API provided by your System Access Point

790 lines (757 loc) 24.4 kB
import { Configuration, Logger as FahLogger, SystemAccessPoint, WebSocketMessage, } from "freeathome-local-api-client"; import { Logger, PlatformAccessory, PlatformConfig } from "homebridge"; import { HomebridgeAPI } from "homebridge/api"; import { Subject, Subscription } from "rxjs"; import { FreeAtHomeAccessory } from "../src/freeAtHomeAccessory.js"; import { FreeAtHomeContext, isFreeAtHomeAccessory, } from "../src/freeAtHomeContext.js"; import { FreeAtHomeHomebridgePlatform } from "../src/platform.js"; import { SwitchActuatorAccessory } from "../src/switchActuatorAccessory.js"; import { AccessoryType } from "../src/typeMappings.js"; import { TestAccessory } from "./TestAccessory.mock.js"; import { createPlatformAccessory } from "./platform.mock.js"; const configuration: Configuration = { "00000000-0000-0000-0000-000000000000": { sysapName: "Gerke", devices: { EXCLUDED0001: { floor: "1", room: "1", channels: { ch0001: { functionID: "7d", }, }, }, FFFF48000001: {}, ABB700000000: {}, ABB700000001: { channels: { ch0000: {}, ch0001: { functionID: "never", }, ch0002: { functionID: "7", }, ch0003: { functionID: "7", floor: "1", room: "1", }, ch0004: { displayName: "Dimmer", functionID: "12", floor: "1", room: "1", outputs: { odp0000: { pairingID: 256, }, odp0001: { pairingID: 272, }, }, inputs: { idp0000: { pairingID: 1, }, idp0001: { pairingID: 17, }, }, }, }, }, ABB700000002: { floor: "1", room: "1", channels: { ch0000: { functionID: "23", outputs: { odp0000: { pairingID: 48, }, odp0001: { pairingID: 50, }, odp0006: { pairingID: 51, }, odp0008: { pairingID: 56, }, odp0010: { pairingID: 304, }, }, inputs: { idp0012: { pairingID: 66 }, idp0016: { pairingID: 320 }, }, }, ch0001: { functionID: "20", }, ch0002: { functionID: "7D", }, ch0003: { functionID: "11", outputs: { odp0123: { pairingID: 6, }, }, }, ch0004: { functionID: "1a", }, ch0005: { functionID: "1a", }, }, }, ABB700000003: { channels: { ch0003: { functionID: "7", floor: "1", room: "1", }, ch0004: { displayName: "Dimmer", functionID: "12", floor: "1", room: "1", outputs: { odp0000: { pairingID: 256, }, odp0001: { pairingID: 272, }, }, inputs: { idp0000: { pairingID: 1, }, idp0001: { pairingID: 17, }, }, }, ch0005: { functionID: "9", }, }, }, ABB700000004: { floor: "1", room: "1", channels: { ch0005: { functionID: "9", }, }, }, ABB700000005: { floor: "1", room: "1", channels: { ch0001: { functionID: "7", }, ch0002: { functionID: "4800", }, ch0006: { functionID: "3e", outputs: { odp0000: { pairingID: 331, }, odp0006: { pairingID: 51, }, odp0008: { pairingID: 56, }, odp0010: { pairingID: 304, }, }, inputs: { idp0012: { pairingID: 66 }, idp0016: { pairingID: 320 }, }, }, }, }, ABB700000006: { floor: "1", room: "1", channels: { ch0001: { functionID: "0", }, ch0002: { functionID: "F", outputs: { dp0: { pairingID: 53, }, }, }, ch0003: { functionID: "6", outputs: { dp0: { pairingID: 4, }, }, }, ch0004: { functionID: "4", outputs: { dp0: { pairingID: 2, }, }, }, ch0005: { functionID: "45", inputs: { dp0: { pairingID: 2, }, }, }, ch0006: { functionID: "43", outputs: { dp0: { pairingID: 1024, }, }, }, ch0007: { functionID: "41", outputs: { dp0: { pairingID: 1027, }, }, }, ch0008: { functionID: "5E", outputs: { dp0: { pairingID: 2, }, }, }, }, }, E11000000001: { floor: "1", room: "1", channels: { ch0001: { functionID: "7d", }, }, }, }, floorplan: { floors: {}, }, users: {}, }, }; describe("free@home Homebridge Platform", () => { let api: HomebridgeAPI; let config: PlatformConfig; let logger: Logger; beforeEach(() => { api = new HomebridgeAPI(); config = { name: "Test Platform Configuration", platform: "free@home Unit Testing Platform", ignoredChannels: ["ABB700000002/ch0005", "ABB700000003/*"], }; logger = { debug: jasmine.createSpy(), error: jasmine.createSpy(), info: jasmine.createSpy(), warn: jasmine.createSpy(), log: jasmine.createSpy(), success: jasmine.createSpy(), }; }); it("should be created", () => { const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); expect(platform).toBeTruthy(); }); it("should handle API lifecycle events", () => { const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { webSocketSubscription: Subscription; discoverDevices: () => Promise<void>; }; const spyWsConnect = spyOn(platform.sysap, "connectWebSocket"); const spyUnsubscribe = spyOn(instance.webSocketSubscription, "unsubscribe"); const spyDiscovery = spyOn(instance, "discoverDevices").and.resolveTo(); api.emit("didFinishLaunching"); expect(spyWsConnect).toHaveBeenCalled(); expect(spyDiscovery).toHaveBeenCalled(); api.emit("shutdown"); expect(spyUnsubscribe).toHaveBeenCalled(); }); it("should handle device discovery failure", () => { const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { discoverDevices: () => Promise<void>; }; spyOn(platform.sysap, "connectWebSocket"); const spyDiscovery = spyOn(instance, "discoverDevices").and.rejectWith( "Test Error" ); api.emit("didFinishLaunching"); expect(spyDiscovery).toHaveBeenCalled(); }); it("should reset connection attempt count when websocket is connected", () => { const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { wsConnectionAttempt: number; sysap: SystemAccessPoint; }; instance.wsConnectionAttempt = 3; instance.sysap.emit("websocket-open"); expect(instance.wsConnectionAttempt).toBe(0); }); it("should handle expected websocket closure", () => { const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const spy = spyOn(platform.sysap, "connectWebSocket"); platform.sysap.emit("websocket-close", 1000, Buffer.from("Bye")); expect(spy).not.toHaveBeenCalled(); }); it("should not attempt to reconnect after reconnection attempt limit was reached", () => { config.maxWsRetryCount = 3; const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { wsConnectionAttempt: number; }; instance.wsConnectionAttempt = 3; const spy = spyOn(platform.sysap, "connectWebSocket"); platform.sysap.emit( "websocket-close", 1234, Buffer.from("Disconnect Test") ); expect(spy).not.toHaveBeenCalled(); }); it("should attempt to reconnect if reconnection attempt limit is not reached", () => { jasmine.clock().install(); config.maxWsRetryCount = 3; const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { wsConnectionAttempt: number; }; const spy = spyOn(platform.sysap, "connectWebSocket"); platform.sysap.emit( "websocket-close", 1234, Buffer.from("Disconnect Test") ); jasmine.clock().tick(230); expect(spy).toHaveBeenCalled(); expect(instance.wsConnectionAttempt).toBe(1); jasmine.clock().uninstall(); }); it("should receive message from SysAP websocket", () => { const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { readonly sysap: { readonly webSocketMessageSubject: Subject<WebSocketMessage>; }; processWebSocketMesage: (message: WebSocketMessage) => void; }; const spy = spyOn(instance, "processWebSocketMesage"); instance.sysap.webSocketMessageSubject.next({}); expect(spy).toHaveBeenCalledWith({}); }); it("should forward log messages from SysAP to Homebridge logger", () => { const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { readonly sysap: { readonly logger: FahLogger; }; }; /* eslint-disable @typescript-eslint/unbound-method */ instance.sysap.logger.debug("Debug"); expect(logger.debug).toHaveBeenCalledWith("Debug"); instance.sysap.logger.error("Error"); expect(logger.error).toHaveBeenCalledWith("Error"); instance.sysap.logger.log("Log"); expect(logger.info).toHaveBeenCalledWith("Log"); instance.sysap.logger.warn("Warn"); expect(logger.warn).toHaveBeenCalledWith("Warn"); /* eslint-enable @typescript-eslint/unbound-method */ }); it("should configure free@home accessory", () => { const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { readonly sysap: { readonly logger: FahLogger; }; }; expect(platform.accessories.length).toBe(0); const platformAccessory = createPlatformAccessory("Test Accessory"); platformAccessory.context = { channel: {}, channelId: "ch1234", device: {}, deviceSerial: "ABB7xxxxxxxx", }; if (!isFreeAtHomeAccessory(platformAccessory, instance.sysap.logger)) { fail(); return; } platform.configureAccessory(platformAccessory); expect(platform.accessories.length).toBe(1); expect(platform.accessories[0]).toBe(platformAccessory); }); it("should not configure non-free@home accessory", () => { const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); expect(platform.accessories.length).toBe(0); const platformAccessory = createPlatformAccessory("Test Accessory"); platformAccessory.context = {}; platform.configureAccessory(platformAccessory); expect(platform.accessories.length).toBe(0); }); it("should configure only allowed accessory", () => { config.allowedChannels = ["ABB700000001/*", "ABB700000002/ch0000"]; const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); expect(platform.accessories.length).toBe(0); const platformAccessory = createPlatformAccessory("Test Accessory"); platformAccessory.context = { channel: {}, channelId: "ch0005", device: {}, deviceSerial: "ABB700000002", }; platform.configureAccessory(platformAccessory); expect(platform.accessories.length).toBe(0); }); it("should not configure ignored channel from allowed device", () => { config.allowedChannels = ["ABB700000001/*"]; config.ignoredChannels = ["ABB700000001/ch0005"]; const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); expect(platform.accessories.length).toBe(0); const platformAccessory = createPlatformAccessory("Test Accessory"); platformAccessory.context = { channel: {}, channelId: "ch0005", device: {}, deviceSerial: "ABB700000002", }; platform.configureAccessory(platformAccessory); expect(platform.accessories.length).toBe(0); }); it("should not configure ignored accessory", () => { const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); expect(platform.accessories.length).toBe(0); const platformAccessory = createPlatformAccessory("Test Accessory"); platformAccessory.context = { channel: {}, channelId: "ch0005", device: {}, deviceSerial: "ABB700000002", }; platform.configureAccessory(platformAccessory); expect(platform.accessories.length).toBe(0); }); it("should discover devices from the system access point", async () => { const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { readonly fahAccessories: Map<string, FreeAtHomeAccessory>; readonly sysap: { readonly logger: FahLogger; }; discoverDevices(): Promise<void>; }; const knownAccessory = createPlatformAccessory( "Known Device", api.hap.uuid.generate("ABB700000002_ch0000") ); knownAccessory.context = { channel: { functionID: "23", }, channelId: "ch0000", device: { floor: "1", room: "1", channels: { ch0000: { functionID: "23", }, }, }, deviceSerial: "ABB700000002", }; if (!isFreeAtHomeAccessory(knownAccessory, instance.sysap.logger)) { fail(); return; } platform.accessories.push(knownAccessory); spyOn(platform.sysap, "getConfiguration").and.resolveTo(configuration); await instance.discoverDevices(); expect(instance.fahAccessories.size).toBe(15); }); it("should discover devices from the system access point with allow list", async () => { config.allowedChannels = ["ABB700000001/*", "ABB700000002/ch0000"]; const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { readonly fahAccessories: Map<string, FreeAtHomeAccessory>; readonly sysap: { readonly logger: FahLogger; }; discoverDevices(): Promise<void>; }; spyOn(platform.sysap, "getConfiguration").and.resolveTo(configuration); await instance.discoverDevices(); expect(instance.fahAccessories.size).toBe(3); }); it("should discover devices from the system access point without ignore list", async () => { delete config.ignoredChannels; const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { readonly fahAccessories: Map<string, FreeAtHomeAccessory>; readonly sysap: { readonly logger: FahLogger; }; discoverDevices(): Promise<void>; }; spyOn(platform.sysap, "getConfiguration").and.resolveTo(configuration); await instance.discoverDevices(); expect(instance.fahAccessories.size).toBe(18); }); it("should discover devices from the system access point in experimental mode", async () => { config.experimental = true; const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); // eslint-disable-next-line @typescript-eslint/unbound-method expect(logger.warn).toHaveBeenCalledWith("Experimental Mode enabled!"); const instance = platform as unknown as { readonly fahAccessories: Map<string, FreeAtHomeAccessory>; readonly sysap: { readonly logger: FahLogger; }; discoverDevices(): Promise<void>; }; spyOn(platform.sysap, "getConfiguration").and.resolveTo(configuration); await instance.discoverDevices(); expect(instance.fahAccessories.size).toBe(20); }); it("should log an error if channel configuration fails during discovery", async () => { config.experimental = true; const testConfig: Configuration = { "00000000-0000-0000-0000-000000000000": { sysapName: "Gerke", devices: { ABB700000001: { channels: { ch0000: { displayName: "Scene Sensor", functionID: "6", floor: "1", room: "1", }, }, }, }, floorplan: { floors: {}, }, users: {}, }, }; const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { readonly fahAccessories: Map<string, FreeAtHomeAccessory>; readonly sysap: { readonly logger: FahLogger; }; discoverDevices(): Promise<void>; }; spyOn(platform.sysap, "getConfiguration").and.resolveTo(testConfig); await instance.discoverDevices(); expect(instance.fahAccessories.size).toBe(0); // eslint-disable-next-line @typescript-eslint/unbound-method expect(logger.error).toHaveBeenCalledWith( "Error processing discovered channel ABB700000001/ch0000", new Error("Data point object is undefined") ); }); it("should not create an accessory for an unknown function ID", () => { const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { readonly fahAccessories: Map<string, FreeAtHomeAccessory>; readonly sysap: { readonly logger: FahLogger; }; createAccessory: ( serial: string, functionID: string, channelId: string, accessory: PlatformAccessory<FreeAtHomeContext> ) => void; }; const platformAccessory = createPlatformAccessory( "Known Device", api.hap.uuid.generate("ABB700000002_ch0000") ); platformAccessory.context = { channel: { functionID: "23", }, channelId: "ch0000", device: { floor: "1", room: "1", channels: { ch0000: { functionID: "23", }, }, }, deviceSerial: "ABB700000002", }; if (!isFreeAtHomeAccessory(platformAccessory, instance.sysap.logger)) { fail(); return; } const spy = spyOn(instance.fahAccessories, "set"); instance.createAccessory("", "never", "", platformAccessory); expect(spy).not.toHaveBeenCalled(); }); it("should process web socket message", () => { const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { readonly fahAccessories: Map<string, FreeAtHomeAccessory>; readonly sysap: { readonly logger: FahLogger; }; processWebSocketMesage: (message: WebSocketMessage) => void; }; const platformAccessory = createPlatformAccessory( "Known Device", api.hap.uuid.generate("ABB700000002_ch0000") ); platformAccessory.context = { channel: { functionID: "23", }, channelId: "ch0000", device: { floor: "1", room: "1", channels: { ch0000: { functionID: "23", }, }, }, deviceSerial: "ABB700000002", }; if (!isFreeAtHomeAccessory(platformAccessory, instance.sysap.logger)) { fail(); return; } const accessory = new TestAccessory(platform, platformAccessory); const spy = spyOn(accessory, "updateDatapoint"); instance.fahAccessories.set("ABB700000002_ch0000", accessory); const message: WebSocketMessage = { "00000000-0000-0000-0000-000000000000": { datapoints: { "ABB700000002/ch0000/idp0000": "1", "ABB700000002/ch0000/odp0000": "2", "ABB700000000/ch0000/odp0000": "3", "ABB700000000/ch0000/xdp0000": "0", }, devices: {}, devicesAdded: [], devicesRemoved: [], scenesTriggered: {}, }, }; instance.processWebSocketMesage(message); expect(spy.calls.count()).toBe(2); expect(spy).toHaveBeenCalledWith("idp0000", "1"); expect(spy).toHaveBeenCalledWith("odp0000", "2"); }); it("should resolve to mapped type", async () => { config.typeMappings = [{ channel: "ABB700000005/ch0001", type: "Outlet" }]; const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { readonly fahAccessories: Map<string, FreeAtHomeAccessory>; discoverDevices(): Promise<void>; }; spyOn(platform.sysap, "getConfiguration").and.resolveTo(configuration); await instance.discoverDevices(); const accessory = instance.fahAccessories.get("ABB700000005_ch0001"); expect(accessory).toBeDefined(); expect(accessory).toBeInstanceOf(SwitchActuatorAccessory); expect((accessory as SwitchActuatorAccessory).accessoryType).toBe( AccessoryType.Outlet ); }); it("should resolve to unknown mapped types to Undefined", async () => { config.typeMappings = [{ channel: "ABB700000005/ch0001", type: "outlet" }]; const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { readonly fahAccessories: Map<string, FreeAtHomeAccessory>; discoverDevices(): Promise<void>; }; spyOn(platform.sysap, "getConfiguration").and.resolveTo(configuration); await instance.discoverDevices(); const accessory = instance.fahAccessories.get("ABB700000005_ch0001"); expect(accessory).toBeDefined(); expect(accessory).toBeInstanceOf(SwitchActuatorAccessory); expect((accessory as SwitchActuatorAccessory).accessoryType).toBe( AccessoryType.Undefined ); }); it("should use the first mapping rule to resolve the mapped types if a channel has multiple mappings", async () => { config.typeMappings = [ { channel: "ABB700000005/ch0001", type: "Outlet" }, { channel: "ABB700000005/ch0001", type: "outlet" }, ]; const platform = new FreeAtHomeHomebridgePlatform(logger, config, api); const instance = platform as unknown as { readonly fahAccessories: Map<string, FreeAtHomeAccessory>; discoverDevices(): Promise<void>; }; spyOn(platform.sysap, "getConfiguration").and.resolveTo(configuration); await instance.discoverDevices(); const accessory = instance.fahAccessories.get("ABB700000005_ch0001"); // eslint-disable-next-line @typescript-eslint/unbound-method expect(logger.warn).toHaveBeenCalledWith( "Multiple type mappings are defined for channel 'ABB700000005/CH0001'. The first mapping is used." ); expect(accessory).toBeDefined(); expect(accessory).toBeInstanceOf(SwitchActuatorAccessory); expect((accessory as SwitchActuatorAccessory).accessoryType).toBe( AccessoryType.Outlet ); }); });