@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
text/typescript
/* 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)
})
})
})