@homebridge-plugins/homebridge-august 3.1.2 → 3.1.4
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.
- package/.github/scripts/normalize-changelog-header.cjs +86 -0
- package/CHANGELOG.md +7 -2
- package/dist/connectivity-manager.d.ts +89 -0
- package/dist/connectivity-manager.d.ts.map +1 -0
- package/dist/connectivity-manager.js +264 -0
- package/dist/connectivity-manager.js.map +1 -0
- package/dist/devices/device.d.ts +0 -9
- package/dist/devices/device.d.ts.map +1 -1
- package/dist/devices/device.js +0 -32
- package/dist/devices/device.js.map +1 -1
- package/dist/devices/lock.d.ts +20 -1
- package/dist/devices/lock.d.ts.map +1 -1
- package/dist/devices/lock.js +133 -103
- package/dist/devices/lock.js.map +1 -1
- package/dist/package.json +9 -9
- package/dist/platform.d.ts +26 -19
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +101 -52
- package/dist/platform.js.map +1 -1
- package/dist/platform.matter.d.ts +6 -9
- package/dist/platform.matter.d.ts.map +1 -1
- package/dist/platform.matter.js +25 -31
- package/dist/platform.matter.js.map +1 -1
- package/docs/variables/default.html +1 -1
- package/package.json +9 -9
- package/test/connectivity-manager.test.ts +380 -0
- package/test/platform.matter.test.ts +660 -0
- package/test/platform.polling.test.ts +313 -0
- package/test/platform.test.ts +642 -0
- package/test/settings.test.ts +148 -0
- package/vitest.config.ts +2 -1
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/* Copyright(C) 2026, homebridge-plugins (https://github.com/homebridge-plugins). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* platform.polling.test.ts: tests for AugustPlatform.startPolling().
|
|
4
|
+
*
|
|
5
|
+
* The platform-level serial poller is what turns the ConnectivityManager
|
|
6
|
+
* state machine into observable behavior. Critical properties:
|
|
7
|
+
*
|
|
8
|
+
* - SERIAL iteration so a network outage produces ONE timeout per
|
|
9
|
+
* cycle, not N (one per registered lock). This is the behavior
|
|
10
|
+
* change that fixes the log-spam cascade after router restarts.
|
|
11
|
+
*
|
|
12
|
+
* - Short-circuit on the first failure in a cycle. Once execute()
|
|
13
|
+
* returns undefined, the rest of the locks in the cycle are not
|
|
14
|
+
* polled — the network is clearly bad and there's no value in
|
|
15
|
+
* hammering N more endpoints.
|
|
16
|
+
*
|
|
17
|
+
* - Skip entirely when the manager reports 'offline'. The manager's
|
|
18
|
+
* probe schedule (with backoff + jitter) drives recovery — the
|
|
19
|
+
* poll interval should not.
|
|
20
|
+
*
|
|
21
|
+
* - Skip locks mid-push (lockUpdateInProgress) so a stale poll result
|
|
22
|
+
* doesn't race the user's lock/unlock command.
|
|
23
|
+
*
|
|
24
|
+
* - Idempotent startPolling — calling twice does not stack timers.
|
|
25
|
+
*
|
|
26
|
+
* - No-op when platformRefreshRate === 0.
|
|
27
|
+
*/
|
|
28
|
+
import type { API, Logging } from 'homebridge'
|
|
29
|
+
|
|
30
|
+
import type { AugustPlatformConfig } from '../src/settings.js'
|
|
31
|
+
|
|
32
|
+
import { readFileSync } from 'node:fs'
|
|
33
|
+
|
|
34
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
35
|
+
|
|
36
|
+
import { AugustPlatform } from '../src/platform.js'
|
|
37
|
+
|
|
38
|
+
// Same mock conventions as the rest of the suite.
|
|
39
|
+
vi.mock('august-yale', () => {
|
|
40
|
+
// eslint-disable-next-line prefer-arrow-callback
|
|
41
|
+
const MockAugust = vi.fn().mockImplementation(function () {
|
|
42
|
+
return { destroy: vi.fn(), end: vi.fn(), details: vi.fn(), locks: vi.fn() }
|
|
43
|
+
})
|
|
44
|
+
const MockConstructor = MockAugust as any
|
|
45
|
+
MockConstructor.details = vi.fn()
|
|
46
|
+
MockConstructor.authorize = vi.fn()
|
|
47
|
+
MockConstructor.validate = vi.fn()
|
|
48
|
+
return { default: MockConstructor }
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
vi.mock('node:fs', () => ({
|
|
52
|
+
readFileSync: vi.fn(),
|
|
53
|
+
writeFileSync: vi.fn(),
|
|
54
|
+
}))
|
|
55
|
+
|
|
56
|
+
// Helper: install a stub connectivity manager on the platform whose
|
|
57
|
+
// execute() runs the supplied fn against a fake "client" passed through
|
|
58
|
+
// from the test. Returns the underlying mock execute fn for assertions.
|
|
59
|
+
function installConnectivity(
|
|
60
|
+
platform: AugustPlatform,
|
|
61
|
+
client: any,
|
|
62
|
+
state: 'healthy' | 'degraded' | 'offline' | 'recovering' = 'healthy',
|
|
63
|
+
) {
|
|
64
|
+
const execute = vi.fn(async (_label: string, fn: (c: any) => Promise<any>) => {
|
|
65
|
+
try {
|
|
66
|
+
return await fn(client)
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
;(platform as any).connectivity = {
|
|
72
|
+
getState: () => state,
|
|
73
|
+
execute,
|
|
74
|
+
}
|
|
75
|
+
return execute
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Helper: register N stub LockMechanism objects on the platform. Each
|
|
79
|
+
// has its own lockId and a vi.fn() applyRefresh. Returns the array of
|
|
80
|
+
// stubs in registration order.
|
|
81
|
+
function registerLocks(platform: AugustPlatform, count: number) {
|
|
82
|
+
const locks: any[] = []
|
|
83
|
+
for (let i = 0; i < count; i++) {
|
|
84
|
+
const lock = {
|
|
85
|
+
device: { lockId: `lock-${i}` },
|
|
86
|
+
accessory: { displayName: `Lock ${i}` },
|
|
87
|
+
lockUpdateInProgress: false,
|
|
88
|
+
applyRefresh: vi.fn().mockResolvedValue(undefined),
|
|
89
|
+
}
|
|
90
|
+
;(platform as any).lockMechanisms.set(lock.device.lockId, lock)
|
|
91
|
+
locks.push(lock)
|
|
92
|
+
}
|
|
93
|
+
return locks
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('augustPlatform.startPolling', () => {
|
|
97
|
+
let platform: AugustPlatform
|
|
98
|
+
let mockApi: API
|
|
99
|
+
let mockLog: Logging
|
|
100
|
+
let mockConfig: AugustPlatformConfig
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
vi.clearAllMocks()
|
|
104
|
+
vi.useFakeTimers()
|
|
105
|
+
|
|
106
|
+
// Quiet the package.json read in the platform constructor.
|
|
107
|
+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ version: '1.0.0-test' }))
|
|
108
|
+
|
|
109
|
+
mockApi = {
|
|
110
|
+
hap: { uuid: { generate: vi.fn(() => 'mock-uuid') } },
|
|
111
|
+
user: { configPath: vi.fn(() => '/mock/config.json') },
|
|
112
|
+
on: vi.fn(),
|
|
113
|
+
registerPlatformAccessories: vi.fn(),
|
|
114
|
+
updatePlatformAccessories: vi.fn(),
|
|
115
|
+
unregisterPlatformAccessories: vi.fn(),
|
|
116
|
+
publishExternalAccessories: vi.fn(),
|
|
117
|
+
} as unknown as API
|
|
118
|
+
|
|
119
|
+
mockLog = {
|
|
120
|
+
info: vi.fn(),
|
|
121
|
+
warn: vi.fn(),
|
|
122
|
+
error: vi.fn(),
|
|
123
|
+
debug: vi.fn(),
|
|
124
|
+
success: vi.fn(),
|
|
125
|
+
} as any
|
|
126
|
+
|
|
127
|
+
mockConfig = {
|
|
128
|
+
platform: 'August',
|
|
129
|
+
name: 'Test August',
|
|
130
|
+
credentials: {
|
|
131
|
+
augustId: 'test@example.com',
|
|
132
|
+
password: 'testpassword',
|
|
133
|
+
countryCode: 'US',
|
|
134
|
+
installId: 'test-install-id',
|
|
135
|
+
isValidated: true,
|
|
136
|
+
},
|
|
137
|
+
options: { logging: 'debug' },
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
platform = new AugustPlatform(mockLog, mockConfig, mockApi)
|
|
141
|
+
// Force a deterministic poll interval (1s) so tests can advance
|
|
142
|
+
// time precisely.
|
|
143
|
+
;(platform as any).platformRefreshRate = 1
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
afterEach(() => {
|
|
147
|
+
platform.stopPolling()
|
|
148
|
+
vi.useRealTimers()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('cycle iteration', () => {
|
|
152
|
+
it('iterates locks serially in registration order, calling applyRefresh on each', async () => {
|
|
153
|
+
const client = {
|
|
154
|
+
details: vi.fn().mockImplementation(async (lockId: string) => ({ lockId })),
|
|
155
|
+
}
|
|
156
|
+
installConnectivity(platform, client)
|
|
157
|
+
const locks = registerLocks(platform, 3)
|
|
158
|
+
|
|
159
|
+
platform.startPolling()
|
|
160
|
+
// Advance to the first tick.
|
|
161
|
+
await vi.advanceTimersByTimeAsync(1_000)
|
|
162
|
+
|
|
163
|
+
// All three locks polled.
|
|
164
|
+
expect(client.details).toHaveBeenCalledTimes(3)
|
|
165
|
+
expect(client.details).toHaveBeenNthCalledWith(1, 'lock-0')
|
|
166
|
+
expect(client.details).toHaveBeenNthCalledWith(2, 'lock-1')
|
|
167
|
+
expect(client.details).toHaveBeenNthCalledWith(3, 'lock-2')
|
|
168
|
+
// applyRefresh fired for each.
|
|
169
|
+
expect(locks[0].applyRefresh).toHaveBeenCalledTimes(1)
|
|
170
|
+
expect(locks[1].applyRefresh).toHaveBeenCalledTimes(1)
|
|
171
|
+
expect(locks[2].applyRefresh).toHaveBeenCalledTimes(1)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('short-circuits the rest of the cycle when execute returns undefined', async () => {
|
|
175
|
+
// First lock fails (execute returns undefined), the next two should
|
|
176
|
+
// NOT be polled in this cycle.
|
|
177
|
+
const client = {
|
|
178
|
+
details: vi.fn().mockImplementation(async (lockId: string) => {
|
|
179
|
+
if (lockId === 'lock-0') {
|
|
180
|
+
throw new Error('network down')
|
|
181
|
+
}
|
|
182
|
+
return { lockId }
|
|
183
|
+
}),
|
|
184
|
+
}
|
|
185
|
+
installConnectivity(platform, client)
|
|
186
|
+
const locks = registerLocks(platform, 3)
|
|
187
|
+
|
|
188
|
+
platform.startPolling()
|
|
189
|
+
await vi.advanceTimersByTimeAsync(1_000)
|
|
190
|
+
|
|
191
|
+
// Only the first lock's details() was called; the other two were
|
|
192
|
+
// skipped. Note: execute() is what returns undefined; the inner
|
|
193
|
+
// details() function only ran for lock-0.
|
|
194
|
+
expect(client.details).toHaveBeenCalledTimes(1)
|
|
195
|
+
expect(locks[0].applyRefresh).not.toHaveBeenCalled()
|
|
196
|
+
expect(locks[1].applyRefresh).not.toHaveBeenCalled()
|
|
197
|
+
expect(locks[2].applyRefresh).not.toHaveBeenCalled()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('skips entire cycle when connectivity is offline', async () => {
|
|
201
|
+
const client = { details: vi.fn() }
|
|
202
|
+
installConnectivity(platform, client, 'offline')
|
|
203
|
+
const locks = registerLocks(platform, 3)
|
|
204
|
+
|
|
205
|
+
platform.startPolling()
|
|
206
|
+
await vi.advanceTimersByTimeAsync(1_000)
|
|
207
|
+
|
|
208
|
+
expect(client.details).not.toHaveBeenCalled()
|
|
209
|
+
for (const lock of locks) {
|
|
210
|
+
expect(lock.applyRefresh).not.toHaveBeenCalled()
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('skips locks with lockUpdateInProgress=true but continues to the next', async () => {
|
|
215
|
+
const client = {
|
|
216
|
+
details: vi.fn().mockImplementation(async (lockId: string) => ({ lockId })),
|
|
217
|
+
}
|
|
218
|
+
installConnectivity(platform, client)
|
|
219
|
+
const locks = registerLocks(platform, 3)
|
|
220
|
+
// Middle lock is mid-push.
|
|
221
|
+
locks[1].lockUpdateInProgress = true
|
|
222
|
+
|
|
223
|
+
platform.startPolling()
|
|
224
|
+
await vi.advanceTimersByTimeAsync(1_000)
|
|
225
|
+
|
|
226
|
+
expect(client.details).toHaveBeenCalledTimes(2)
|
|
227
|
+
expect(client.details).toHaveBeenNthCalledWith(1, 'lock-0')
|
|
228
|
+
expect(client.details).toHaveBeenNthCalledWith(2, 'lock-2')
|
|
229
|
+
expect(locks[0].applyRefresh).toHaveBeenCalledTimes(1)
|
|
230
|
+
expect(locks[1].applyRefresh).not.toHaveBeenCalled()
|
|
231
|
+
expect(locks[2].applyRefresh).toHaveBeenCalledTimes(1)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('continues polling on subsequent ticks', async () => {
|
|
235
|
+
const client = {
|
|
236
|
+
details: vi.fn().mockImplementation(async (lockId: string) => ({ lockId })),
|
|
237
|
+
}
|
|
238
|
+
installConnectivity(platform, client)
|
|
239
|
+
registerLocks(platform, 1)
|
|
240
|
+
|
|
241
|
+
platform.startPolling()
|
|
242
|
+
await vi.advanceTimersByTimeAsync(1_000)
|
|
243
|
+
await vi.advanceTimersByTimeAsync(1_000)
|
|
244
|
+
await vi.advanceTimersByTimeAsync(1_000)
|
|
245
|
+
|
|
246
|
+
// 3 ticks × 1 lock = 3 calls.
|
|
247
|
+
expect(client.details).toHaveBeenCalledTimes(3)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('keeps the loop alive even if applyRefresh throws synchronously', async () => {
|
|
251
|
+
const client = {
|
|
252
|
+
details: vi.fn().mockImplementation(async (lockId: string) => ({ lockId })),
|
|
253
|
+
}
|
|
254
|
+
installConnectivity(platform, client)
|
|
255
|
+
const locks = registerLocks(platform, 1)
|
|
256
|
+
// applyRefresh throws on the first call but resolves on the second.
|
|
257
|
+
locks[0].applyRefresh
|
|
258
|
+
.mockRejectedValueOnce(new Error('boom'))
|
|
259
|
+
.mockResolvedValueOnce(undefined)
|
|
260
|
+
|
|
261
|
+
platform.startPolling()
|
|
262
|
+
await vi.advanceTimersByTimeAsync(1_000)
|
|
263
|
+
await vi.advanceTimersByTimeAsync(1_000)
|
|
264
|
+
|
|
265
|
+
// Both ticks fired.
|
|
266
|
+
expect(locks[0].applyRefresh).toHaveBeenCalledTimes(2)
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe('lifecycle', () => {
|
|
271
|
+
it('startPolling is idempotent — calling twice does not stack timers', async () => {
|
|
272
|
+
const client = { details: vi.fn().mockResolvedValue({}) }
|
|
273
|
+
installConnectivity(platform, client)
|
|
274
|
+
registerLocks(platform, 1)
|
|
275
|
+
|
|
276
|
+
platform.startPolling()
|
|
277
|
+
platform.startPolling() // second call should be a no-op
|
|
278
|
+
|
|
279
|
+
await vi.advanceTimersByTimeAsync(1_000)
|
|
280
|
+
|
|
281
|
+
// If timers were stacked we'd see 2 calls per tick.
|
|
282
|
+
expect(client.details).toHaveBeenCalledTimes(1)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('is a no-op when platformRefreshRate is 0', async () => {
|
|
286
|
+
const client = { details: vi.fn() }
|
|
287
|
+
installConnectivity(platform, client)
|
|
288
|
+
registerLocks(platform, 1)
|
|
289
|
+
;(platform as any).platformRefreshRate = 0
|
|
290
|
+
|
|
291
|
+
platform.startPolling()
|
|
292
|
+
await vi.advanceTimersByTimeAsync(60_000)
|
|
293
|
+
|
|
294
|
+
expect(client.details).not.toHaveBeenCalled()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('stopPolling clears the timer', async () => {
|
|
298
|
+
const client = { details: vi.fn().mockResolvedValue({}) }
|
|
299
|
+
installConnectivity(platform, client)
|
|
300
|
+
registerLocks(platform, 1)
|
|
301
|
+
|
|
302
|
+
platform.startPolling()
|
|
303
|
+
await vi.advanceTimersByTimeAsync(1_000)
|
|
304
|
+
expect(client.details).toHaveBeenCalledTimes(1)
|
|
305
|
+
|
|
306
|
+
platform.stopPolling()
|
|
307
|
+
await vi.advanceTimersByTimeAsync(5_000)
|
|
308
|
+
|
|
309
|
+
// No additional calls after stopPolling.
|
|
310
|
+
expect(client.details).toHaveBeenCalledTimes(1)
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
})
|