@homebridge-plugins/homebridge-august 3.3.0-beta.0 → 3.3.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +19 -14
  2. package/CLAUDE.md +48 -0
  3. package/README.md +39 -27
  4. package/config.schema.json +48 -3
  5. package/dist/Platform.HAP.d.ts +139 -20
  6. package/dist/Platform.HAP.d.ts.map +1 -1
  7. package/dist/Platform.HAP.js +732 -46
  8. package/dist/Platform.HAP.js.map +1 -1
  9. package/dist/connectivity-manager.d.ts +3 -3
  10. package/dist/connectivity-manager.d.ts.map +1 -1
  11. package/dist/connectivity-manager.js +14 -57
  12. package/dist/connectivity-manager.js.map +1 -1
  13. package/dist/devices/device.d.ts +16 -5
  14. package/dist/devices/device.d.ts.map +1 -1
  15. package/dist/devices/device.js +75 -18
  16. package/dist/devices/device.js.map +1 -1
  17. package/dist/devices/device.test.d.ts +2 -0
  18. package/dist/devices/device.test.d.ts.map +1 -0
  19. package/dist/devices/device.test.js +294 -0
  20. package/dist/devices/device.test.js.map +1 -0
  21. package/dist/devices/lock.d.ts +3 -3
  22. package/dist/devices/lock.d.ts.map +1 -1
  23. package/dist/devices/lock.js +41 -41
  24. package/dist/devices/lock.js.map +1 -1
  25. package/dist/index.js +4 -4
  26. package/dist/index.js.map +1 -1
  27. package/dist/platform.matter.d.ts +26 -18
  28. package/dist/platform.matter.d.ts.map +1 -1
  29. package/dist/platform.matter.js +95 -76
  30. package/dist/platform.matter.js.map +1 -1
  31. package/dist/settings.d.ts +1 -0
  32. package/dist/settings.d.ts.map +1 -1
  33. package/package.json +37 -32
  34. package/test/connectivity-manager.test.ts +592 -0
  35. package/test/platform.matter.test.ts +660 -0
  36. package/test/platform.polling.test.ts +5 -5
  37. package/test/{platform.hap.test.ts → platform.test.ts} +23 -23
  38. package/.github/FUNDING.yml +0 -4
  39. package/.github/ISSUE_TEMPLATE/bug-report.yml +0 -97
  40. package/.github/ISSUE_TEMPLATE/config.yml +0 -8
  41. package/.github/ISSUE_TEMPLATE/feature-request.yml +0 -38
  42. package/.github/ISSUE_TEMPLATE/support-request.yml +0 -85
  43. package/.github/ISSUE_TEMPLATE.md +0 -48
  44. package/.github/PULL_REQUEST_TEMPLATE/pull_request.md +0 -27
  45. package/.github/copilot-instructions.md +0 -200
  46. package/.github/dependabot.yml +0 -17
  47. package/.github/labeler.yml +0 -38
  48. package/.github/npm-version-script-esm.js +0 -97
  49. package/.github/workflows/release.yml +0 -169
  50. package/.github/workflows/stale.yml +0 -12
  51. package/SECURITY.md +0 -19
  52. package/branding/Homebridge_x_August.svg +0 -32
  53. package/branding/icon.png +0 -0
  54. package/dist/Platform.Base.d.ts +0 -288
  55. package/dist/Platform.Base.d.ts.map +0 -1
  56. package/dist/Platform.Base.js +0 -797
  57. package/dist/Platform.Base.js.map +0 -1
  58. package/dist/package.json +0 -85
  59. package/docs/.nojekyll +0 -1
  60. package/docs/assets/hierarchy.js +0 -1
  61. package/docs/assets/highlight.css +0 -22
  62. package/docs/assets/icons.js +0 -18
  63. package/docs/assets/icons.svg +0 -1
  64. package/docs/assets/main.js +0 -60
  65. package/docs/assets/navigation.js +0 -1
  66. package/docs/assets/search.js +0 -1
  67. package/docs/assets/style.css +0 -1633
  68. package/docs/hierarchy.html +0 -1
  69. package/docs/index.html +0 -44
  70. package/docs/modules.html +0 -1
  71. package/docs/variables/default.html +0 -1
  72. package/eslint.config.js +0 -47
  73. package/test/device.test.ts +0 -384
  74. package/vitest.config.ts +0 -8
@@ -0,0 +1,660 @@
1
+ /* Copyright(C) 2021-2024, donavanbecker (https://github.com/donavanbecker). All rights reserved.
2
+ *
3
+ * platform.matter.test.ts: homebridge-august Matter platform tests.
4
+ */
5
+ import type { API, Logging, MatterAccessory, PlatformAccessory } from 'homebridge'
6
+
7
+ import type { AugustPlatformConfig } from '../src/settings.js'
8
+
9
+ import { readFileSync } from 'node:fs'
10
+
11
+ import August from 'august-yale'
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
13
+
14
+ import { AugustMatterPlatform } from '../src/platform.matter.js'
15
+
16
+ // Mock the august-yale module
17
+ vi.mock('august-yale', () => {
18
+ // eslint-disable-next-line prefer-arrow-callback
19
+ const MockAugust = vi.fn().mockImplementation(function () {
20
+ return {
21
+ end: vi.fn(),
22
+ lock: vi.fn().mockResolvedValue(undefined),
23
+ unlock: vi.fn().mockResolvedValue(undefined),
24
+ details: vi.fn(),
25
+ }
26
+ })
27
+
28
+ const MockConstructor = MockAugust as any
29
+ MockConstructor.details = vi.fn()
30
+ MockConstructor.authorize = vi.fn()
31
+ MockConstructor.validate = vi.fn()
32
+ MockConstructor.subscribe = vi.fn().mockResolvedValue(() => {}) // returns unsubscribe fn by default
33
+
34
+ return {
35
+ default: MockConstructor,
36
+ }
37
+ })
38
+
39
+ // Mock file system
40
+ vi.mock('node:fs', () => ({
41
+ readFileSync: vi.fn(),
42
+ writeFileSync: vi.fn(),
43
+ }))
44
+
45
+ describe('augustMatterPlatform', () => {
46
+ let platform: AugustMatterPlatform
47
+ let mockApi: API
48
+ let mockLog: Logging
49
+ let mockConfig: AugustPlatformConfig
50
+ let mockMatterApi: any
51
+
52
+ beforeEach(() => {
53
+ vi.clearAllMocks()
54
+
55
+ mockMatterApi = {
56
+ uuid: { generate: vi.fn((id: string) => `matter-uuid-${id}`) },
57
+ deviceTypes: { DoorLock: { deviceType: 11 } },
58
+ types: {
59
+ DoorLock: {
60
+ LockState: {
61
+ NotFullyLocked: 0,
62
+ Locked: 1,
63
+ Unlocked: 2,
64
+ },
65
+ },
66
+ },
67
+ registerPlatformAccessories: vi.fn().mockResolvedValue(undefined),
68
+ updatePlatformAccessories: vi.fn().mockResolvedValue(undefined),
69
+ unregisterPlatformAccessories: vi.fn().mockResolvedValue(undefined),
70
+ updateAccessoryState: vi.fn().mockResolvedValue(undefined),
71
+ getAccessoryState: vi.fn().mockResolvedValue(undefined),
72
+ }
73
+
74
+ mockApi = {
75
+ hap: {
76
+ uuid: { generate: vi.fn(() => 'hap-uuid') },
77
+ },
78
+ user: {
79
+ configPath: vi.fn(() => '/mock/config.json'),
80
+ },
81
+ on: vi.fn(),
82
+ registerPlatformAccessories: vi.fn(),
83
+ updatePlatformAccessories: vi.fn(),
84
+ unregisterPlatformAccessories: vi.fn(),
85
+ publishExternalAccessories: vi.fn(),
86
+ isMatterAvailable: vi.fn(() => true),
87
+ isMatterEnabled: vi.fn(() => true),
88
+ matter: mockMatterApi,
89
+ } as unknown as API
90
+
91
+ mockLog = {
92
+ info: vi.fn(),
93
+ warn: vi.fn(),
94
+ error: vi.fn(),
95
+ debug: vi.fn(),
96
+ success: vi.fn(),
97
+ } as any
98
+
99
+ mockConfig = {
100
+ platform: 'August',
101
+ name: 'Test August',
102
+ credentials: {
103
+ augustId: 'test@example.com',
104
+ password: 'testpassword',
105
+ countryCode: 'US',
106
+ installId: 'test-install-id',
107
+ isValidated: true,
108
+ },
109
+ options: {
110
+ logging: 'debug',
111
+ },
112
+ }
113
+
114
+ vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
115
+ platforms: [mockConfig],
116
+ }))
117
+ })
118
+
119
+ afterEach(() => {
120
+ vi.restoreAllMocks()
121
+ })
122
+
123
+ describe('configureAccessory', () => {
124
+ it('should stage (not immediately unregister) cached HAP accessories for deferred cleanup', async () => {
125
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
126
+
127
+ const hapAccessory = {
128
+ UUID: 'hap-uuid-1',
129
+ displayName: 'Front Door',
130
+ } as unknown as PlatformAccessory
131
+
132
+ await platform.configureAccessory(hapAccessory)
133
+
134
+ // HAP accessory should NOT be unregistered immediately; deferred until Matter registration succeeds
135
+ expect(mockApi.unregisterPlatformAccessories).not.toHaveBeenCalled()
136
+ })
137
+
138
+ it('should unregister staged HAP accessory after successful Matter registration', async () => {
139
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
140
+
141
+ const device = {
142
+ lockId: 'lock-abc',
143
+ LockName: 'Front Door',
144
+ SerialNumber: 'SN123',
145
+ skuNumber: 'AUG-SL05',
146
+ currentFirmwareVersion: '1.2.3',
147
+ hide_device: false,
148
+ homeKitEnabled: false,
149
+ } as any
150
+
151
+ // Stage a HAP accessory with UUID matching what matterApi.uuid.generate will return for this lockId
152
+ const hapAccessory = {
153
+ UUID: 'matter-uuid-lock-abc',
154
+ displayName: 'Front Door',
155
+ } as unknown as PlatformAccessory
156
+ await platform.configureAccessory(hapAccessory)
157
+
158
+ await (platform as any).Lock(device)
159
+
160
+ // After Matter registration succeeds, the staged HAP accessory should be unregistered
161
+ expect(mockApi.unregisterPlatformAccessories).toHaveBeenCalledWith(
162
+ 'homebridge-august',
163
+ 'August',
164
+ [hapAccessory],
165
+ )
166
+ })
167
+
168
+ it('should not add HAP accessories to the accessories cache', async () => {
169
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
170
+
171
+ const hapAccessory = {
172
+ UUID: 'hap-uuid-2',
173
+ displayName: 'Back Door',
174
+ } as unknown as PlatformAccessory
175
+
176
+ await platform.configureAccessory(hapAccessory)
177
+
178
+ // accessories array from the HAP base class should remain empty
179
+ expect(platform.accessories).toHaveLength(0)
180
+ })
181
+ })
182
+
183
+ describe('configureMatterAccessory', () => {
184
+ it('should store Matter accessories in the matterAccessories map', () => {
185
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
186
+
187
+ const matterAccessory = {
188
+ UUID: 'matter-uuid-lock1',
189
+ displayName: 'Front Door',
190
+ deviceType: { deviceType: 11 },
191
+ serialNumber: 'ABC123',
192
+ manufacturer: 'August Home Inc.',
193
+ model: 'AUG-SL05',
194
+ context: { lockId: 'lock1' },
195
+ } as unknown as MatterAccessory
196
+
197
+ platform.configureMatterAccessory(matterAccessory)
198
+
199
+ expect(platform.matterAccessories.has('matter-uuid-lock1')).toBe(true)
200
+ expect(platform.matterAccessories.get('matter-uuid-lock1')).toBe(matterAccessory)
201
+ })
202
+ })
203
+
204
+ describe('lock (Matter accessory registration)', () => {
205
+ it('should register a new Matter DoorLock accessory when Matter API is available', async () => {
206
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
207
+
208
+ const device = {
209
+ lockId: 'lock-abc',
210
+ LockName: 'Front Door',
211
+ SerialNumber: 'SN123',
212
+ skuNumber: 'AUG-SL05',
213
+ currentFirmwareVersion: '1.2.3',
214
+ hide_device: false,
215
+ homeKitEnabled: false,
216
+ } as any
217
+
218
+ // Expose the protected Lock method for testing
219
+ await (platform as any).Lock(device)
220
+
221
+ expect(mockMatterApi.registerPlatformAccessories).toHaveBeenCalledOnce()
222
+ const [pluginName, platformName, accessories] = mockMatterApi.registerPlatformAccessories.mock.calls[0]
223
+ expect(pluginName).toBe('homebridge-august')
224
+ expect(platformName).toBe('August')
225
+ expect(accessories).toHaveLength(1)
226
+ expect(accessories[0].deviceType).toBe(mockMatterApi.deviceTypes.DoorLock)
227
+ expect(accessories[0].manufacturer).toBe('August Home Inc.')
228
+ expect(accessories[0].serialNumber).toBe('SN123')
229
+ })
230
+
231
+ it('should restore an existing cached Matter accessory', async () => {
232
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
233
+
234
+ const uuid = 'matter-uuid-lock-abc'
235
+ const cachedAccessory: MatterAccessory = {
236
+ UUID: uuid,
237
+ displayName: 'Front Door',
238
+ deviceType: { deviceType: 11 } as any,
239
+ serialNumber: 'SN123',
240
+ manufacturer: 'August Home Inc.',
241
+ model: 'AUG-SL05',
242
+ context: { lockId: 'lock-abc' },
243
+ }
244
+ platform.configureMatterAccessory(cachedAccessory)
245
+
246
+ const device = {
247
+ lockId: 'lock-abc',
248
+ LockName: 'Front Door',
249
+ SerialNumber: 'SN123',
250
+ skuNumber: 'AUG-SL05',
251
+ currentFirmwareVersion: '1.2.3',
252
+ hide_device: false,
253
+ homeKitEnabled: false,
254
+ } as any
255
+
256
+ await (platform as any).Lock(device)
257
+
258
+ // re-registers to update handlers
259
+ expect(mockMatterApi.registerPlatformAccessories).toHaveBeenCalledOnce()
260
+ // Should have logged the "Restoring" message
261
+ expect(mockLog.info).toHaveBeenCalledWith(expect.stringContaining('Restoring existing Matter accessory'))
262
+ })
263
+
264
+ it('should unregister stale Matter accessory when device is hidden', async () => {
265
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
266
+
267
+ const uuid = 'matter-uuid-hidden-lock'
268
+ mockMatterApi.uuid.generate.mockReturnValueOnce(uuid)
269
+
270
+ const cachedAccessory: MatterAccessory = {
271
+ UUID: uuid,
272
+ displayName: 'Hidden Lock',
273
+ deviceType: { deviceType: 11 } as any,
274
+ serialNumber: 'SN456',
275
+ manufacturer: 'August Home Inc.',
276
+ model: 'AUG-SL05',
277
+ context: { lockId: 'hidden-lock' },
278
+ }
279
+ platform.configureMatterAccessory(cachedAccessory)
280
+
281
+ const device = {
282
+ lockId: 'hidden-lock',
283
+ LockName: 'Hidden Lock',
284
+ SerialNumber: 'SN456',
285
+ hide_device: true, // hidden device
286
+ homeKitEnabled: false,
287
+ } as any
288
+
289
+ await (platform as any).Lock(device)
290
+
291
+ // Should unregister the stale accessory
292
+ expect(mockMatterApi.unregisterPlatformAccessories).toHaveBeenCalledWith(
293
+ 'homebridge-august',
294
+ 'August',
295
+ [cachedAccessory],
296
+ )
297
+ expect(platform.matterAccessories.has(uuid)).toBe(false)
298
+ expect(mockMatterApi.registerPlatformAccessories).not.toHaveBeenCalled()
299
+ })
300
+
301
+ it('should not register when Matter API is unavailable', async () => {
302
+ const apiWithoutMatter = {
303
+ ...mockApi,
304
+ matter: null as any,
305
+ }
306
+ platform = new AugustMatterPlatform(mockLog, mockConfig, apiWithoutMatter as any)
307
+
308
+ const device = {
309
+ lockId: 'lock-xyz',
310
+ LockName: 'Some Lock',
311
+ hide_device: false,
312
+ homeKitEnabled: false,
313
+ } as any
314
+
315
+ await (platform as any).Lock(device)
316
+
317
+ expect(mockMatterApi.registerPlatformAccessories).not.toHaveBeenCalled()
318
+ })
319
+
320
+ it('should set initial lockState to 0 (NotFullyLocked) until first poll resolves', async () => {
321
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
322
+
323
+ const device = {
324
+ lockId: 'lock-nullstate',
325
+ LockName: 'Null State Lock',
326
+ SerialNumber: 'SN789',
327
+ hide_device: false,
328
+ homeKitEnabled: false,
329
+ } as any
330
+
331
+ await (platform as any).Lock(device)
332
+
333
+ const registeredAccessory = mockMatterApi.registerPlatformAccessories.mock.calls[0][2][0] as MatterAccessory
334
+ expect(registeredAccessory.clusters?.doorLock?.lockState).toBe(0)
335
+ })
336
+ })
337
+
338
+ describe('fetchAndUpdateMatterLockState', () => {
339
+ // Helper: install a mock connectivity manager whose execute() runs
340
+ // the supplied fn against a mock August client. This mirrors the
341
+ // production path (platform.connectivity.execute(label, fn) ->
342
+ // fn(client)) without spinning up the real ConnectivityManager.
343
+ function installConnectivityMock(p: AugustMatterPlatform, client: any) {
344
+ ;(p as any).connectivity = {
345
+ getState: () => 'healthy',
346
+ execute: vi.fn(async (_label: string, fn: (c: any) => Promise<any>) => {
347
+ try {
348
+ return await fn(client)
349
+ } catch {
350
+ // execute() returns undefined on failure rather than
351
+ // rethrowing (unless throwOnOffline:true). Match that.
352
+ return undefined
353
+ }
354
+ }),
355
+ }
356
+ }
357
+
358
+ it('should update lockState to 1 (Locked) when lock is locked', async () => {
359
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
360
+
361
+ const mockDetailsResult = { LockStatus: { state: { locked: true, unlocked: false } } }
362
+ installConnectivityMock(platform, { details: vi.fn().mockResolvedValue(mockDetailsResult) })
363
+
364
+ const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
365
+
366
+ await platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi)
367
+
368
+ expect(mockMatterApi.updateAccessoryState).toHaveBeenCalledWith('test-uuid', 'doorLock', { lockState: 1 })
369
+ })
370
+
371
+ it('should update lockState to 2 (Unlocked) when lock is unlocked', async () => {
372
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
373
+
374
+ const mockDetailsResult = { LockStatus: { state: { locked: false, unlocked: true } } }
375
+ installConnectivityMock(platform, { details: vi.fn().mockResolvedValue(mockDetailsResult) })
376
+
377
+ const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
378
+
379
+ await platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi)
380
+
381
+ expect(mockMatterApi.updateAccessoryState).toHaveBeenCalledWith('test-uuid', 'doorLock', { lockState: 2 })
382
+ })
383
+
384
+ it('should update lockState to 0 (NotFullyLocked) for ambiguous state', async () => {
385
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
386
+
387
+ const mockDetailsResult = { LockStatus: { state: { locked: false, unlocked: false } } }
388
+ installConnectivityMock(platform, { details: vi.fn().mockResolvedValue(mockDetailsResult) })
389
+
390
+ const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
391
+
392
+ await platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi)
393
+
394
+ expect(mockMatterApi.updateAccessoryState).toHaveBeenCalledWith('test-uuid', 'doorLock', { lockState: 0 })
395
+ })
396
+
397
+ it('should not throw when August API details call fails', async () => {
398
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
399
+
400
+ installConnectivityMock(platform, { details: vi.fn().mockRejectedValue(new Error('API error')) })
401
+
402
+ const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
403
+
404
+ await expect(
405
+ platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi),
406
+ ).resolves.toBeUndefined()
407
+
408
+ expect(mockMatterApi.updateAccessoryState).not.toHaveBeenCalled()
409
+ })
410
+
411
+ it('should not call updateAccessoryState when connectivity is not initialized', async () => {
412
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
413
+ // connectivity is not set
414
+
415
+ const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
416
+
417
+ await expect(
418
+ platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi),
419
+ ).resolves.toBeUndefined()
420
+
421
+ expect(mockMatterApi.updateAccessoryState).not.toHaveBeenCalled()
422
+ })
423
+
424
+ it('should not throw when execute returns undefined (offline / failed)', async () => {
425
+ // The previous "retry with session refresh" test asserted that the
426
+ // matter path called refreshAugustSession() after a 502. That
427
+ // explicit retry layer is gone — ConnectivityManager handles
428
+ // retry/probe/rebuild internally. From the matter path's
429
+ // perspective, the only observable is: execute() either returned
430
+ // a result, or it returned undefined.
431
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
432
+ ;(platform as any).connectivity = {
433
+ getState: () => 'offline',
434
+ execute: vi.fn().mockResolvedValue(undefined),
435
+ }
436
+
437
+ const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
438
+
439
+ await expect(
440
+ platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi),
441
+ ).resolves.toBeUndefined()
442
+
443
+ expect(mockMatterApi.updateAccessoryState).not.toHaveBeenCalled()
444
+ })
445
+ })
446
+
447
+ describe('pubNub subscription lifecycle (Matter path equivalent of PR #206)', () => {
448
+ it('should capture unsubscribe function from August.subscribe()', async () => {
449
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
450
+
451
+ const mockUnsubscribe = vi.fn()
452
+ vi.mocked(August.subscribe).mockResolvedValueOnce(mockUnsubscribe)
453
+
454
+ const device = {
455
+ lockId: 'lock-sub',
456
+ LockName: 'Sub Lock',
457
+ SerialNumber: 'SN001',
458
+ hide_device: false,
459
+ homeKitEnabled: false,
460
+ } as any
461
+
462
+ await (platform as any).Lock(device)
463
+
464
+ // The unsubscribe should be stored in the internal map
465
+ const uuid = mockMatterApi.uuid.generate('lock-sub')
466
+ const stored = (platform as any).matterPubNubUnsubscribes.get(uuid)
467
+ expect(stored).toBe(mockUnsubscribe)
468
+ })
469
+
470
+ it('should tear down PubNub subscription when device is hidden (stale accessory removal)', async () => {
471
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
472
+
473
+ const uuid = 'matter-uuid-hidden-sub'
474
+ mockMatterApi.uuid.generate.mockReturnValueOnce(uuid)
475
+
476
+ const mockUnsubscribe = vi.fn()
477
+ ;(platform as any).matterPubNubUnsubscribes.set(uuid, mockUnsubscribe)
478
+
479
+ const cachedAccessory: MatterAccessory = {
480
+ UUID: uuid,
481
+ displayName: 'Hidden Lock',
482
+ deviceType: { deviceType: 11 } as any,
483
+ serialNumber: 'SN002',
484
+ manufacturer: 'August Home Inc.',
485
+ model: 'AUG-SL05',
486
+ context: { lockId: 'hidden-sub' },
487
+ }
488
+ platform.configureMatterAccessory(cachedAccessory)
489
+
490
+ const device = {
491
+ lockId: 'hidden-sub',
492
+ LockName: 'Hidden Lock',
493
+ hide_device: true,
494
+ homeKitEnabled: false,
495
+ } as any
496
+
497
+ await (platform as any).Lock(device)
498
+
499
+ expect(mockUnsubscribe).toHaveBeenCalledOnce()
500
+ expect((platform as any).matterPubNubUnsubscribes.has(uuid)).toBe(false)
501
+ })
502
+ })
503
+
504
+ describe('polling subscription lifecycle', () => {
505
+ it('should track the polling subscription so it can be disposed', async () => {
506
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
507
+
508
+ const device = {
509
+ lockId: 'lock-poll-track',
510
+ LockName: 'Tracked Lock',
511
+ SerialNumber: 'SN100',
512
+ hide_device: false,
513
+ homeKitEnabled: false,
514
+ } as any
515
+
516
+ await (platform as any).Lock(device)
517
+
518
+ const uuid = mockMatterApi.uuid.generate('lock-poll-track')
519
+ const stored = (platform as any).matterPollingSubscriptions.get(uuid)
520
+ expect(stored).toBeDefined()
521
+ expect(typeof stored.unsubscribe).toBe('function')
522
+ })
523
+
524
+ it('should dispose previous polling subscription before starting a new one (idempotent)', async () => {
525
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
526
+
527
+ const device = {
528
+ lockId: 'lock-poll-idem',
529
+ LockName: 'Idempotent Lock',
530
+ SerialNumber: 'SN101',
531
+ hide_device: false,
532
+ homeKitEnabled: false,
533
+ } as any
534
+
535
+ await (platform as any).Lock(device)
536
+
537
+ const uuid = mockMatterApi.uuid.generate('lock-poll-idem')
538
+ const firstSub = (platform as any).matterPollingSubscriptions.get(uuid)
539
+ const firstUnsubSpy = vi.spyOn(firstSub, 'unsubscribe')
540
+
541
+ // Call polling again (simulating re-registration)
542
+ ;(platform as any).startMatterStatusPolling(device, uuid, mockMatterApi)
543
+
544
+ // Previous subscription should have been disposed
545
+ expect(firstUnsubSpy).toHaveBeenCalledOnce()
546
+ // A new subscription should be stored
547
+ const secondSub = (platform as any).matterPollingSubscriptions.get(uuid)
548
+ expect(secondSub).toBeDefined()
549
+ expect(secondSub).not.toBe(firstSub)
550
+ })
551
+
552
+ it('should dispose polling subscription when device is hidden', async () => {
553
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
554
+
555
+ const uuid = 'matter-uuid-hidden-poll'
556
+ mockMatterApi.uuid.generate.mockReturnValueOnce(uuid)
557
+
558
+ const fakeSubscription = { unsubscribe: vi.fn() }
559
+ ;(platform as any).matterPollingSubscriptions.set(uuid, fakeSubscription)
560
+
561
+ const device = {
562
+ lockId: 'hidden-poll',
563
+ LockName: 'Hidden Poll Lock',
564
+ hide_device: true,
565
+ homeKitEnabled: false,
566
+ } as any
567
+
568
+ await (platform as any).Lock(device)
569
+
570
+ expect(fakeSubscription.unsubscribe).toHaveBeenCalledOnce()
571
+ expect((platform as any).matterPollingSubscriptions.has(uuid)).toBe(false)
572
+ })
573
+
574
+ it('should not start polling when refreshRate is 0', async () => {
575
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
576
+ ;(platform as any).platformRefreshRate = 0
577
+
578
+ const device = {
579
+ lockId: 'lock-no-poll',
580
+ LockName: 'No Poll Lock',
581
+ SerialNumber: 'SN102',
582
+ hide_device: false,
583
+ homeKitEnabled: false,
584
+ } as any
585
+
586
+ await (platform as any).Lock(device)
587
+
588
+ const uuid = mockMatterApi.uuid.generate('lock-no-poll')
589
+ expect((platform as any).matterPollingSubscriptions.has(uuid)).toBe(false)
590
+ })
591
+ })
592
+
593
+ describe('pendingHapCleanup sweep after discovery', () => {
594
+ it('should unregister staged HAP accessories that were not handled during discovery', async () => {
595
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
596
+
597
+ // Stage two HAP accessories from a previous session
598
+ const orphanedHap1 = { UUID: 'hap-orphan-1', displayName: 'Removed Lock 1' } as unknown as PlatformAccessory
599
+ const orphanedHap2 = { UUID: 'hap-orphan-2', displayName: 'Removed Lock 2' } as unknown as PlatformAccessory
600
+ await platform.configureAccessory(orphanedHap1)
601
+ await platform.configureAccessory(orphanedHap2)
602
+
603
+ // Stub super.discoverDevices so it's a no-op (doesn't touch pendingHapCleanup)
604
+ const baseProto = Object.getPrototypeOf(Object.getPrototypeOf(platform))
605
+ vi.spyOn(baseProto, 'discoverDevices').mockResolvedValue(undefined as never)
606
+
607
+ await platform.discoverDevices()
608
+
609
+ // Both staged HAP accessories should have been unregistered since neither
610
+ // had a matching Lock() call during discovery
611
+ expect(mockApi.unregisterPlatformAccessories).toHaveBeenCalledWith(
612
+ 'homebridge-august',
613
+ 'August',
614
+ expect.arrayContaining([orphanedHap1, orphanedHap2]),
615
+ )
616
+ // Map should be empty after the sweep
617
+ expect((platform as any).pendingHapCleanup.size).toBe(0)
618
+ })
619
+
620
+ it('should leave handled HAP accessories alone (those already removed by Lock())', async () => {
621
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
622
+
623
+ // Simulate a successful Lock() having already cleaned its entry
624
+ // (pendingHapCleanup is empty)
625
+ const baseProto = Object.getPrototypeOf(Object.getPrototypeOf(platform))
626
+ vi.spyOn(baseProto, 'discoverDevices').mockResolvedValue(undefined as never)
627
+
628
+ await platform.discoverDevices()
629
+
630
+ // unregisterPlatformAccessories should not be called — nothing to sweep
631
+ expect(mockApi.unregisterPlatformAccessories).not.toHaveBeenCalled()
632
+ })
633
+
634
+ it('should only sweep the orphans, not the locks that were successfully migrated', async () => {
635
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
636
+
637
+ // One lock that will be migrated (has matching active lock in account)
638
+ // and one orphan that no longer exists in the account
639
+ const migrated = { UUID: 'hap-migrated', displayName: 'Still Active' } as unknown as PlatformAccessory
640
+ const orphan = { UUID: 'hap-orphan', displayName: 'Deleted From Account' } as unknown as PlatformAccessory
641
+ await platform.configureAccessory(migrated)
642
+ await platform.configureAccessory(orphan)
643
+
644
+ // Simulate a successful Lock() call for the 'migrated' UUID by removing
645
+ // that entry from pendingHapCleanup directly (mimicking what Lock() does).
646
+ const baseProto = Object.getPrototypeOf(Object.getPrototypeOf(platform))
647
+ vi.spyOn(baseProto, 'discoverDevices').mockImplementation(async () => {
648
+ (platform as any).pendingHapCleanup.delete('hap-migrated')
649
+ })
650
+
651
+ await platform.discoverDevices()
652
+
653
+ // Only the orphan should have been unregistered in the sweep
654
+ expect(mockApi.unregisterPlatformAccessories).toHaveBeenCalledOnce()
655
+ const swept = (mockApi.unregisterPlatformAccessories as any).mock.calls[0][2]
656
+ expect(swept).toContain(orphan)
657
+ expect(swept).not.toContain(migrated)
658
+ })
659
+ })
660
+ })
@@ -33,7 +33,7 @@ import { readFileSync } from 'node:fs'
33
33
 
34
34
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
35
35
 
36
- import { AugustPlatformHAP } from '../src/Platform.HAP.js'
36
+ import { AugustPlatform } from '../src/Platform.HAP.js'
37
37
 
38
38
  // Same mock conventions as the rest of the suite.
39
39
  vi.mock('august-yale', () => {
@@ -57,7 +57,7 @@ vi.mock('node:fs', () => ({
57
57
  // execute() runs the supplied fn against a fake "client" passed through
58
58
  // from the test. Returns the underlying mock execute fn for assertions.
59
59
  function installConnectivity(
60
- platform: AugustPlatformHAP,
60
+ platform: AugustPlatform,
61
61
  client: any,
62
62
  state: 'healthy' | 'degraded' | 'offline' | 'recovering' = 'healthy',
63
63
  ) {
@@ -78,7 +78,7 @@ function installConnectivity(
78
78
  // Helper: register N stub LockMechanism objects on the platform. Each
79
79
  // has its own lockId and a vi.fn() applyRefresh. Returns the array of
80
80
  // stubs in registration order.
81
- function registerLocks(platform: AugustPlatformHAP, count: number) {
81
+ function registerLocks(platform: AugustPlatform, count: number) {
82
82
  const locks: any[] = []
83
83
  for (let i = 0; i < count; i++) {
84
84
  const lock = {
@@ -94,7 +94,7 @@ function registerLocks(platform: AugustPlatformHAP, count: number) {
94
94
  }
95
95
 
96
96
  describe('augustPlatform.startPolling', () => {
97
- let platform: AugustPlatformHAP
97
+ let platform: AugustPlatform
98
98
  let mockApi: API
99
99
  let mockLog: Logging
100
100
  let mockConfig: AugustPlatformConfig
@@ -137,7 +137,7 @@ describe('augustPlatform.startPolling', () => {
137
137
  options: { logging: 'debug' },
138
138
  }
139
139
 
140
- platform = new AugustPlatformHAP(mockLog, mockConfig, mockApi)
140
+ platform = new AugustPlatform(mockLog, mockConfig, mockApi)
141
141
  // Force a deterministic poll interval (1s) so tests can advance
142
142
  // time precisely.
143
143
  ;(platform as any).platformRefreshRate = 1