UNPKG

@homebridge-plugins/homebridge-august

Version:

The August plugin allows you to access your August & Yale device(s) from HomeKit.

661 lines (529 loc) 24.1 kB
/* Copyright(C) 2021-2024, donavanbecker (https://github.com/donavanbecker). All rights reserved. * * platform.matter.test.ts: homebridge-august Matter platform tests. */ import type { API, Logging, MatterAccessory, PlatformAccessory } from 'homebridge' import type { AugustPlatformConfig } from '../src/settings.js' import { readFileSync } from 'node:fs' import August from 'august-yale' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AugustMatterPlatform } from '../src/platform.matter.js' // Mock the august-yale module vi.mock('august-yale', () => { // eslint-disable-next-line prefer-arrow-callback const MockAugust = vi.fn().mockImplementation(function () { return { end: vi.fn(), lock: vi.fn().mockResolvedValue(undefined), unlock: vi.fn().mockResolvedValue(undefined), details: vi.fn(), } }) const MockConstructor = MockAugust as any MockConstructor.details = vi.fn() MockConstructor.authorize = vi.fn() MockConstructor.validate = vi.fn() MockConstructor.subscribe = vi.fn().mockResolvedValue(() => {}) // returns unsubscribe fn by default return { default: MockConstructor, } }) // Mock file system vi.mock('node:fs', () => ({ readFileSync: vi.fn(), writeFileSync: vi.fn(), })) describe('augustMatterPlatform', () => { let platform: AugustMatterPlatform let mockApi: API let mockLog: Logging let mockConfig: AugustPlatformConfig let mockMatterApi: any beforeEach(() => { vi.clearAllMocks() mockMatterApi = { uuid: { generate: vi.fn((id: string) => `matter-uuid-${id}`) }, deviceTypes: { DoorLock: { deviceType: 11 } }, types: { DoorLock: { LockState: { NotFullyLocked: 0, Locked: 1, Unlocked: 2, }, }, }, registerPlatformAccessories: vi.fn().mockResolvedValue(undefined), updatePlatformAccessories: vi.fn().mockResolvedValue(undefined), unregisterPlatformAccessories: vi.fn().mockResolvedValue(undefined), updateAccessoryState: vi.fn().mockResolvedValue(undefined), getAccessoryState: vi.fn().mockResolvedValue(undefined), } mockApi = { hap: { uuid: { generate: vi.fn(() => 'hap-uuid') }, }, user: { configPath: vi.fn(() => '/mock/config.json'), }, on: vi.fn(), registerPlatformAccessories: vi.fn(), updatePlatformAccessories: vi.fn(), unregisterPlatformAccessories: vi.fn(), publishExternalAccessories: vi.fn(), isMatterAvailable: vi.fn(() => true), isMatterEnabled: vi.fn(() => true), matter: mockMatterApi, } as unknown as API mockLog = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), success: vi.fn(), } as any mockConfig = { platform: 'August', name: 'Test August', credentials: { augustId: 'test@example.com', password: 'testpassword', countryCode: 'US', installId: 'test-install-id', isValidated: true, }, options: { logging: 'debug', }, } vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ platforms: [mockConfig], })) }) afterEach(() => { vi.restoreAllMocks() }) describe('configureAccessory', () => { it('should stage (not immediately unregister) cached HAP accessories for deferred cleanup', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) const hapAccessory = { UUID: 'hap-uuid-1', displayName: 'Front Door', } as unknown as PlatformAccessory await platform.configureAccessory(hapAccessory) // HAP accessory should NOT be unregistered immediately; deferred until Matter registration succeeds expect(mockApi.unregisterPlatformAccessories).not.toHaveBeenCalled() }) it('should unregister staged HAP accessory after successful Matter registration', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) const device = { lockId: 'lock-abc', LockName: 'Front Door', SerialNumber: 'SN123', skuNumber: 'AUG-SL05', currentFirmwareVersion: '1.2.3', hide_device: false, homeKitEnabled: false, } as any // Stage a HAP accessory with UUID matching what matterApi.uuid.generate will return for this lockId const hapAccessory = { UUID: 'matter-uuid-lock-abc', displayName: 'Front Door', } as unknown as PlatformAccessory await platform.configureAccessory(hapAccessory) await (platform as any).Lock(device) // After Matter registration succeeds, the staged HAP accessory should be unregistered expect(mockApi.unregisterPlatformAccessories).toHaveBeenCalledWith( 'homebridge-august', 'August', [hapAccessory], ) }) it('should not add HAP accessories to the accessories cache', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) const hapAccessory = { UUID: 'hap-uuid-2', displayName: 'Back Door', } as unknown as PlatformAccessory await platform.configureAccessory(hapAccessory) // accessories array from the HAP base class should remain empty expect(platform.accessories).toHaveLength(0) }) }) describe('configureMatterAccessory', () => { it('should store Matter accessories in the matterAccessories map', () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) const matterAccessory = { UUID: 'matter-uuid-lock1', displayName: 'Front Door', deviceType: { deviceType: 11 }, serialNumber: 'ABC123', manufacturer: 'August Home Inc.', model: 'AUG-SL05', context: { lockId: 'lock1' }, } as unknown as MatterAccessory platform.configureMatterAccessory(matterAccessory) expect(platform.matterAccessories.has('matter-uuid-lock1')).toBe(true) expect(platform.matterAccessories.get('matter-uuid-lock1')).toBe(matterAccessory) }) }) describe('lock (Matter accessory registration)', () => { it('should register a new Matter DoorLock accessory when Matter API is available', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) const device = { lockId: 'lock-abc', LockName: 'Front Door', SerialNumber: 'SN123', skuNumber: 'AUG-SL05', currentFirmwareVersion: '1.2.3', hide_device: false, homeKitEnabled: false, } as any // Expose the protected Lock method for testing await (platform as any).Lock(device) expect(mockMatterApi.registerPlatformAccessories).toHaveBeenCalledOnce() const [pluginName, platformName, accessories] = mockMatterApi.registerPlatformAccessories.mock.calls[0] expect(pluginName).toBe('homebridge-august') expect(platformName).toBe('August') expect(accessories).toHaveLength(1) expect(accessories[0].deviceType).toBe(mockMatterApi.deviceTypes.DoorLock) expect(accessories[0].manufacturer).toBe('August Home Inc.') expect(accessories[0].serialNumber).toBe('SN123') }) it('should restore an existing cached Matter accessory', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) const uuid = 'matter-uuid-lock-abc' const cachedAccessory: MatterAccessory = { UUID: uuid, displayName: 'Front Door', deviceType: { deviceType: 11 } as any, serialNumber: 'SN123', manufacturer: 'August Home Inc.', model: 'AUG-SL05', context: { lockId: 'lock-abc' }, } platform.configureMatterAccessory(cachedAccessory) const device = { lockId: 'lock-abc', LockName: 'Front Door', SerialNumber: 'SN123', skuNumber: 'AUG-SL05', currentFirmwareVersion: '1.2.3', hide_device: false, homeKitEnabled: false, } as any await (platform as any).Lock(device) // re-registers to update handlers expect(mockMatterApi.registerPlatformAccessories).toHaveBeenCalledOnce() // Should have logged the "Restoring" message expect(mockLog.info).toHaveBeenCalledWith(expect.stringContaining('Restoring existing Matter accessory')) }) it('should unregister stale Matter accessory when device is hidden', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) const uuid = 'matter-uuid-hidden-lock' mockMatterApi.uuid.generate.mockReturnValueOnce(uuid) const cachedAccessory: MatterAccessory = { UUID: uuid, displayName: 'Hidden Lock', deviceType: { deviceType: 11 } as any, serialNumber: 'SN456', manufacturer: 'August Home Inc.', model: 'AUG-SL05', context: { lockId: 'hidden-lock' }, } platform.configureMatterAccessory(cachedAccessory) const device = { lockId: 'hidden-lock', LockName: 'Hidden Lock', SerialNumber: 'SN456', hide_device: true, // hidden device homeKitEnabled: false, } as any await (platform as any).Lock(device) // Should unregister the stale accessory expect(mockMatterApi.unregisterPlatformAccessories).toHaveBeenCalledWith( 'homebridge-august', 'August', [cachedAccessory], ) expect(platform.matterAccessories.has(uuid)).toBe(false) expect(mockMatterApi.registerPlatformAccessories).not.toHaveBeenCalled() }) it('should not register when Matter API is unavailable', async () => { const apiWithoutMatter = { ...mockApi, matter: null as any, } platform = new AugustMatterPlatform(mockLog, mockConfig, apiWithoutMatter as any) const device = { lockId: 'lock-xyz', LockName: 'Some Lock', hide_device: false, homeKitEnabled: false, } as any await (platform as any).Lock(device) expect(mockMatterApi.registerPlatformAccessories).not.toHaveBeenCalled() }) it('should set initial lockState to 0 (NotFullyLocked) until first poll resolves', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) const device = { lockId: 'lock-nullstate', LockName: 'Null State Lock', SerialNumber: 'SN789', hide_device: false, homeKitEnabled: false, } as any await (platform as any).Lock(device) const registeredAccessory = mockMatterApi.registerPlatformAccessories.mock.calls[0][2][0] as MatterAccessory expect(registeredAccessory.clusters?.doorLock?.lockState).toBe(0) }) }) describe('fetchAndUpdateMatterLockState', () => { // Helper: install a mock connectivity manager whose execute() runs // the supplied fn against a mock August client. This mirrors the // production path (platform.connectivity.execute(label, fn) -> // fn(client)) without spinning up the real ConnectivityManager. function installConnectivityMock(p: AugustMatterPlatform, client: any) { ;(p as any).connectivity = { getState: () => 'healthy', execute: vi.fn(async (_label: string, fn: (c: any) => Promise<any>) => { try { return await fn(client) } catch { // execute() returns undefined on failure rather than // rethrowing (unless throwOnOffline:true). Match that. return undefined } }), } } it('should update lockState to 1 (Locked) when lock is locked', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) const mockDetailsResult = { LockStatus: { state: { locked: true, unlocked: false } } } installConnectivityMock(platform, { details: vi.fn().mockResolvedValue(mockDetailsResult) }) const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any await platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi) expect(mockMatterApi.updateAccessoryState).toHaveBeenCalledWith('test-uuid', 'doorLock', { lockState: 1 }) }) it('should update lockState to 2 (Unlocked) when lock is unlocked', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) const mockDetailsResult = { LockStatus: { state: { locked: false, unlocked: true } } } installConnectivityMock(platform, { details: vi.fn().mockResolvedValue(mockDetailsResult) }) const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any await platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi) expect(mockMatterApi.updateAccessoryState).toHaveBeenCalledWith('test-uuid', 'doorLock', { lockState: 2 }) }) it('should update lockState to 0 (NotFullyLocked) for ambiguous state', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) const mockDetailsResult = { LockStatus: { state: { locked: false, unlocked: false } } } installConnectivityMock(platform, { details: vi.fn().mockResolvedValue(mockDetailsResult) }) const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any await platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi) expect(mockMatterApi.updateAccessoryState).toHaveBeenCalledWith('test-uuid', 'doorLock', { lockState: 0 }) }) it('should not throw when August API details call fails', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) installConnectivityMock(platform, { details: vi.fn().mockRejectedValue(new Error('API error')) }) const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any await expect( platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi), ).resolves.toBeUndefined() expect(mockMatterApi.updateAccessoryState).not.toHaveBeenCalled() }) it('should not call updateAccessoryState when connectivity is not initialized', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) // connectivity is not set const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any await expect( platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi), ).resolves.toBeUndefined() expect(mockMatterApi.updateAccessoryState).not.toHaveBeenCalled() }) it('should not throw when execute returns undefined (offline / failed)', async () => { // The previous "retry with session refresh" test asserted that the // matter path called refreshAugustSession() after a 502. That // explicit retry layer is gone — ConnectivityManager handles // retry/probe/rebuild internally. From the matter path's // perspective, the only observable is: execute() either returned // a result, or it returned undefined. platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) ;(platform as any).connectivity = { getState: () => 'offline', execute: vi.fn().mockResolvedValue(undefined), } const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any await expect( platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi), ).resolves.toBeUndefined() expect(mockMatterApi.updateAccessoryState).not.toHaveBeenCalled() }) }) describe('pubNub subscription lifecycle (Matter path equivalent of PR #206)', () => { it('should capture unsubscribe function from August.subscribe()', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) const mockUnsubscribe = vi.fn() vi.mocked(August.subscribe).mockResolvedValueOnce(mockUnsubscribe) const device = { lockId: 'lock-sub', LockName: 'Sub Lock', SerialNumber: 'SN001', hide_device: false, homeKitEnabled: false, } as any await (platform as any).Lock(device) // The unsubscribe should be stored in the internal map const uuid = mockMatterApi.uuid.generate('lock-sub') const stored = (platform as any).matterPubNubUnsubscribes.get(uuid) expect(stored).toBe(mockUnsubscribe) }) it('should tear down PubNub subscription when device is hidden (stale accessory removal)', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) const uuid = 'matter-uuid-hidden-sub' mockMatterApi.uuid.generate.mockReturnValueOnce(uuid) const mockUnsubscribe = vi.fn() ;(platform as any).matterPubNubUnsubscribes.set(uuid, mockUnsubscribe) const cachedAccessory: MatterAccessory = { UUID: uuid, displayName: 'Hidden Lock', deviceType: { deviceType: 11 } as any, serialNumber: 'SN002', manufacturer: 'August Home Inc.', model: 'AUG-SL05', context: { lockId: 'hidden-sub' }, } platform.configureMatterAccessory(cachedAccessory) const device = { lockId: 'hidden-sub', LockName: 'Hidden Lock', hide_device: true, homeKitEnabled: false, } as any await (platform as any).Lock(device) expect(mockUnsubscribe).toHaveBeenCalledOnce() expect((platform as any).matterPubNubUnsubscribes.has(uuid)).toBe(false) }) }) describe('polling subscription lifecycle', () => { it('should track the polling subscription so it can be disposed', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) const device = { lockId: 'lock-poll-track', LockName: 'Tracked Lock', SerialNumber: 'SN100', hide_device: false, homeKitEnabled: false, } as any await (platform as any).Lock(device) const uuid = mockMatterApi.uuid.generate('lock-poll-track') const stored = (platform as any).matterPollingSubscriptions.get(uuid) expect(stored).toBeDefined() expect(typeof stored.unsubscribe).toBe('function') }) it('should dispose previous polling subscription before starting a new one (idempotent)', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) const device = { lockId: 'lock-poll-idem', LockName: 'Idempotent Lock', SerialNumber: 'SN101', hide_device: false, homeKitEnabled: false, } as any await (platform as any).Lock(device) const uuid = mockMatterApi.uuid.generate('lock-poll-idem') const firstSub = (platform as any).matterPollingSubscriptions.get(uuid) const firstUnsubSpy = vi.spyOn(firstSub, 'unsubscribe') // Call polling again (simulating re-registration) ;(platform as any).startMatterStatusPolling(device, uuid, mockMatterApi) // Previous subscription should have been disposed expect(firstUnsubSpy).toHaveBeenCalledOnce() // A new subscription should be stored const secondSub = (platform as any).matterPollingSubscriptions.get(uuid) expect(secondSub).toBeDefined() expect(secondSub).not.toBe(firstSub) }) it('should dispose polling subscription when device is hidden', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) const uuid = 'matter-uuid-hidden-poll' mockMatterApi.uuid.generate.mockReturnValueOnce(uuid) const fakeSubscription = { unsubscribe: vi.fn() } ;(platform as any).matterPollingSubscriptions.set(uuid, fakeSubscription) const device = { lockId: 'hidden-poll', LockName: 'Hidden Poll Lock', hide_device: true, homeKitEnabled: false, } as any await (platform as any).Lock(device) expect(fakeSubscription.unsubscribe).toHaveBeenCalledOnce() expect((platform as any).matterPollingSubscriptions.has(uuid)).toBe(false) }) it('should not start polling when refreshRate is 0', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) ;(platform as any).platformRefreshRate = 0 const device = { lockId: 'lock-no-poll', LockName: 'No Poll Lock', SerialNumber: 'SN102', hide_device: false, homeKitEnabled: false, } as any await (platform as any).Lock(device) const uuid = mockMatterApi.uuid.generate('lock-no-poll') expect((platform as any).matterPollingSubscriptions.has(uuid)).toBe(false) }) }) describe('pendingHapCleanup sweep after discovery', () => { it('should unregister staged HAP accessories that were not handled during discovery', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) // Stage two HAP accessories from a previous session const orphanedHap1 = { UUID: 'hap-orphan-1', displayName: 'Removed Lock 1' } as unknown as PlatformAccessory const orphanedHap2 = { UUID: 'hap-orphan-2', displayName: 'Removed Lock 2' } as unknown as PlatformAccessory await platform.configureAccessory(orphanedHap1) await platform.configureAccessory(orphanedHap2) // Stub super.discoverDevices so it's a no-op (doesn't touch pendingHapCleanup) const baseProto = Object.getPrototypeOf(Object.getPrototypeOf(platform)) vi.spyOn(baseProto, 'discoverDevices').mockResolvedValue(undefined as never) await platform.discoverDevices() // Both staged HAP accessories should have been unregistered since neither // had a matching Lock() call during discovery expect(mockApi.unregisterPlatformAccessories).toHaveBeenCalledWith( 'homebridge-august', 'August', expect.arrayContaining([orphanedHap1, orphanedHap2]), ) // Map should be empty after the sweep expect((platform as any).pendingHapCleanup.size).toBe(0) }) it('should leave handled HAP accessories alone (those already removed by Lock())', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) // Simulate a successful Lock() having already cleaned its entry // (pendingHapCleanup is empty) const baseProto = Object.getPrototypeOf(Object.getPrototypeOf(platform)) vi.spyOn(baseProto, 'discoverDevices').mockResolvedValue(undefined as never) await platform.discoverDevices() // unregisterPlatformAccessories should not be called — nothing to sweep expect(mockApi.unregisterPlatformAccessories).not.toHaveBeenCalled() }) it('should only sweep the orphans, not the locks that were successfully migrated', async () => { platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi) // One lock that will be migrated (has matching active lock in account) // and one orphan that no longer exists in the account const migrated = { UUID: 'hap-migrated', displayName: 'Still Active' } as unknown as PlatformAccessory const orphan = { UUID: 'hap-orphan', displayName: 'Deleted From Account' } as unknown as PlatformAccessory await platform.configureAccessory(migrated) await platform.configureAccessory(orphan) // Simulate a successful Lock() call for the 'migrated' UUID by removing // that entry from pendingHapCleanup directly (mimicking what Lock() does). const baseProto = Object.getPrototypeOf(Object.getPrototypeOf(platform)) vi.spyOn(baseProto, 'discoverDevices').mockImplementation(async () => { (platform as any).pendingHapCleanup.delete('hap-migrated') }) await platform.discoverDevices() // Only the orphan should have been unregistered in the sweep expect(mockApi.unregisterPlatformAccessories).toHaveBeenCalledOnce() const swept = (mockApi.unregisterPlatformAccessories as any).mock.calls[0][2] expect(swept).toContain(orphan) expect(swept).not.toContain(migrated) }) }) })