@homebridge-plugins/homebridge-tuya 2.6.0 → 2.7.0
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/CHANGELOG.md +39 -0
- package/README.md +10 -10
- package/SUPPORTED_DEVICES.md +2 -1
- package/dist/accessory/AccessoryFactory.js +7 -0
- package/dist/accessory/AccessoryFactory.js.map +1 -1
- package/dist/accessory/BaseAccessory.js +2 -2
- package/dist/accessory/BaseAccessory.js.map +1 -1
- package/dist/accessory/CatToiletAccessory.js +130 -0
- package/dist/accessory/CatToiletAccessory.js.map +1 -0
- package/dist/accessory/FanAccessory.js +14 -2
- package/dist/accessory/FanAccessory.js.map +1 -1
- package/dist/accessory/IRAirConditionerAccessory.js +2 -2
- package/dist/accessory/IRAirConditionerAccessory.js.map +1 -1
- package/dist/accessory/LocationWeatherAccessory.js +2 -2
- package/dist/accessory/LocationWeatherAccessory.js.map +1 -1
- package/dist/accessory/LockAccessory.js +254 -3
- package/dist/accessory/LockAccessory.js.map +1 -1
- package/dist/accessory/SaunaAccessory.js +144 -0
- package/dist/accessory/SaunaAccessory.js.map +1 -0
- package/dist/accessory/SecuritySystemAccessory.js +2 -2
- package/dist/accessory/SecuritySystemAccessory.js.map +1 -1
- package/dist/accessory/characteristic/Light.js +25 -4
- package/dist/accessory/characteristic/Light.js.map +1 -1
- package/dist/core/TuyaOpenAPI.js +20 -6
- package/dist/core/TuyaOpenAPI.js.map +1 -1
- package/dist/core/TuyaOpenMQ.js +0 -1
- package/dist/core/TuyaOpenMQ.js.map +1 -1
- package/dist/device/TuyaDeviceManager.js +37 -0
- package/dist/device/TuyaDeviceManager.js.map +1 -1
- package/dist/platform.js +0 -1
- package/dist/platform.js.map +1 -1
- package/dist/util/Logger.js +15 -13
- package/dist/util/Logger.js.map +1 -1
- package/dist/util/TuyaStreamDelegate.js.map +1 -1
- package/dist/util/util.js +48 -0
- package/dist/util/util.js.map +1 -1
- package/package.json +1 -1
- package/test/FanAccessory.test.ts +457 -0
- package/test/Light.test.ts +186 -0
- package/test/accessory/lockAccessory.test.ts +549 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import util from 'util';
|
|
3
|
+
import { describe, expect, test, beforeEach, jest } from '@jest/globals';
|
|
4
|
+
|
|
5
|
+
import LockAccessory from '../../src/accessory/LockAccessory';
|
|
6
|
+
import TuyaDevice, { TuyaDeviceSchemaMode, TuyaDeviceSchemaType } from '../../src/device/TuyaDevice';
|
|
7
|
+
import { initLogger } from '../../src/util/Logger';
|
|
8
|
+
|
|
9
|
+
// HAP constants, mirroring the real values.
|
|
10
|
+
const LockCurrentState = { UNSECURED: 0, SECURED: 1, JAMMED: 2, UNKNOWN: 3 };
|
|
11
|
+
const LockTargetState = { UNSECURED: 0, SECURED: 1 };
|
|
12
|
+
|
|
13
|
+
class MockHapStatusError extends Error {
|
|
14
|
+
constructor(public hapStatus: number) {
|
|
15
|
+
super(`HapStatusError ${hapStatus}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const HAPStatus = { SERVICE_COMMUNICATION_FAILURE: -70402 };
|
|
19
|
+
|
|
20
|
+
describe('LockAccessory', () => {
|
|
21
|
+
|
|
22
|
+
let characteristics: Map<any, any>;
|
|
23
|
+
let services: any[];
|
|
24
|
+
let mockAccessory: any;
|
|
25
|
+
let mockPlatform: any;
|
|
26
|
+
let mockDeviceManager: any;
|
|
27
|
+
let device: TuyaDevice;
|
|
28
|
+
let logs: { level: string; message: string }[];
|
|
29
|
+
|
|
30
|
+
const makeCharacteristic = (type: any) => {
|
|
31
|
+
const characteristic: any = {
|
|
32
|
+
type,
|
|
33
|
+
value: null,
|
|
34
|
+
getHandler: undefined,
|
|
35
|
+
setHandler: undefined,
|
|
36
|
+
onGet: jest.fn((handler: any) => (characteristic.getHandler = handler, characteristic)),
|
|
37
|
+
onSet: jest.fn((handler: any) => (characteristic.setHandler = handler, characteristic)),
|
|
38
|
+
setProps: jest.fn(() => characteristic),
|
|
39
|
+
updateValue: jest.fn((value: any) => (characteristic.value = value, characteristic)),
|
|
40
|
+
};
|
|
41
|
+
characteristics.set(type, characteristic);
|
|
42
|
+
return characteristic;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const getCharacteristic = (type: any) => characteristics.get(type) || makeCharacteristic(type);
|
|
46
|
+
|
|
47
|
+
const makeService = (type: any) => {
|
|
48
|
+
const owned: any[] = [];
|
|
49
|
+
return {
|
|
50
|
+
UUID: type,
|
|
51
|
+
displayName: `${type}`,
|
|
52
|
+
subtype: undefined,
|
|
53
|
+
// `BaseAccessory.updateAllValues()` walks this array.
|
|
54
|
+
characteristics: owned,
|
|
55
|
+
getCharacteristic: jest.fn((characteristicType: any) => {
|
|
56
|
+
const characteristic = getCharacteristic(characteristicType);
|
|
57
|
+
if (!owned.includes(characteristic)) {
|
|
58
|
+
owned.push(characteristic);
|
|
59
|
+
}
|
|
60
|
+
return characteristic;
|
|
61
|
+
}),
|
|
62
|
+
setCharacteristic: jest.fn(function(this: any) {
|
|
63
|
+
return this;
|
|
64
|
+
}),
|
|
65
|
+
addOptionalCharacteristic: jest.fn(),
|
|
66
|
+
testCharacteristic: jest.fn(() => true),
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Codes present in the schema/status of the device under test. */
|
|
71
|
+
const createDevice = (overrides: Partial<TuyaDevice> = {}) => new TuyaDevice({
|
|
72
|
+
id: 'lock-device-id',
|
|
73
|
+
uuid: 'lock-uuid',
|
|
74
|
+
name: 'Fechadura Sala de Estar',
|
|
75
|
+
online: true,
|
|
76
|
+
owner_id: 'owner-1',
|
|
77
|
+
product_id: 'ble-lock-product',
|
|
78
|
+
product_name: 'BLE Smart Lock',
|
|
79
|
+
category: 'ms',
|
|
80
|
+
sub: true,
|
|
81
|
+
schema: [
|
|
82
|
+
{
|
|
83
|
+
code: 'lock_motor_state',
|
|
84
|
+
mode: TuyaDeviceSchemaMode.READ_ONLY,
|
|
85
|
+
type: TuyaDeviceSchemaType.Boolean,
|
|
86
|
+
property: {},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
code: 'residual_electricity',
|
|
90
|
+
mode: TuyaDeviceSchemaMode.READ_ONLY,
|
|
91
|
+
type: TuyaDeviceSchemaType.Integer,
|
|
92
|
+
property: { min: 0, max: 100, scale: 0, step: 1, unit: '%' },
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
status: [
|
|
96
|
+
{ code: 'lock_motor_state', value: false },
|
|
97
|
+
{ code: 'residual_electricity', value: 80 },
|
|
98
|
+
],
|
|
99
|
+
...overrides,
|
|
100
|
+
} as any);
|
|
101
|
+
|
|
102
|
+
// Mirrors how homebridge renders a log line, so assertions can match the
|
|
103
|
+
// text the user actually sees.
|
|
104
|
+
const recordLog = (level: string) => (message?: any, ...args: any[]) => {
|
|
105
|
+
logs.push({ level, message: util.format(message, ...args) });
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
characteristics = new Map();
|
|
110
|
+
services = [];
|
|
111
|
+
logs = [];
|
|
112
|
+
|
|
113
|
+
mockDeviceManager = {
|
|
114
|
+
getDevice: jest.fn(() => device),
|
|
115
|
+
sendCommands: jest.fn(),
|
|
116
|
+
getLockTemporaryKey: jest.fn(async () => ({
|
|
117
|
+
success: true,
|
|
118
|
+
result: { ticket_id: 'TICKET-ABCDEF123456', ticket_key: 'SUPER-SECRET-KEY', expire_time: 300 },
|
|
119
|
+
})),
|
|
120
|
+
sendLockCommands: jest.fn(async () => ({ success: true, result: true })),
|
|
121
|
+
getLockRemoteUnlockMethods: jest.fn(async () => ({ success: true, result: [] })),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const hap = {
|
|
125
|
+
Service: {
|
|
126
|
+
LockMechanism: 'LockMechanism',
|
|
127
|
+
AccessoryInformation: 'AccessoryInformation',
|
|
128
|
+
Battery: 'Battery',
|
|
129
|
+
},
|
|
130
|
+
Characteristic: {
|
|
131
|
+
LockCurrentState,
|
|
132
|
+
LockTargetState,
|
|
133
|
+
StatusLowBattery: { BATTERY_LEVEL_NORMAL: 0, BATTERY_LEVEL_LOW: 1 },
|
|
134
|
+
ChargingState: { NOT_CHARGING: 0, CHARGING: 1 },
|
|
135
|
+
BatteryLevel: 'BatteryLevel',
|
|
136
|
+
StatusActive: 'StatusActive',
|
|
137
|
+
Manufacturer: 'Manufacturer',
|
|
138
|
+
Model: 'Model',
|
|
139
|
+
Name: 'Name',
|
|
140
|
+
ConfiguredName: 'ConfiguredName',
|
|
141
|
+
SerialNumber: 'SerialNumber',
|
|
142
|
+
ProgrammableSwitchEvent: { UUID: 'ProgrammableSwitchEvent' },
|
|
143
|
+
},
|
|
144
|
+
HapStatusError: MockHapStatusError,
|
|
145
|
+
HAPStatus,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
mockPlatform = {
|
|
149
|
+
api: { hap },
|
|
150
|
+
Service: hap.Service,
|
|
151
|
+
Characteristic: hap.Characteristic,
|
|
152
|
+
options: { debug: true, debugLevel: '' },
|
|
153
|
+
deviceManager: mockDeviceManager,
|
|
154
|
+
getDeviceConfig: jest.fn(() => undefined),
|
|
155
|
+
getDeviceSchemaConfig: jest.fn(() => undefined),
|
|
156
|
+
log: {
|
|
157
|
+
info: recordLog('info'),
|
|
158
|
+
warn: recordLog('warn'),
|
|
159
|
+
error: recordLog('error'),
|
|
160
|
+
debug: recordLog('debug'),
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// BaseAccessory builds its PrefixLogger from the module-level logger.
|
|
165
|
+
initLogger(mockPlatform.log);
|
|
166
|
+
|
|
167
|
+
mockAccessory = {
|
|
168
|
+
UUID: 'lock-uuid',
|
|
169
|
+
displayName: 'Fechadura Sala de Estar',
|
|
170
|
+
context: { deviceID: 'lock-device-id' },
|
|
171
|
+
services,
|
|
172
|
+
getService: jest.fn((type: any) => services.find(s => s.UUID === type)),
|
|
173
|
+
addService: jest.fn((type: any) => {
|
|
174
|
+
const service = makeService(type);
|
|
175
|
+
services.push(service);
|
|
176
|
+
return service;
|
|
177
|
+
}),
|
|
178
|
+
removeService: jest.fn(),
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
device = createDevice();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const setup = () => {
|
|
185
|
+
const accessory = new LockAccessory(mockPlatform as any, mockAccessory as any);
|
|
186
|
+
accessory.configureServices();
|
|
187
|
+
return accessory;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const targetStateCharacteristic = () => characteristics.get(LockTargetState);
|
|
191
|
+
const currentStateCharacteristic = () => characteristics.get(LockCurrentState);
|
|
192
|
+
|
|
193
|
+
const allLogText = () => logs.map(l => l.message).join('\n');
|
|
194
|
+
|
|
195
|
+
// ─── Handler registration ────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
test('registers an onSet handler on LockTargetState for a `ms` BLE lock', () => {
|
|
198
|
+
setup();
|
|
199
|
+
|
|
200
|
+
const target = targetStateCharacteristic();
|
|
201
|
+
expect(target).toBeDefined();
|
|
202
|
+
expect(target.onSet).toHaveBeenCalledTimes(1);
|
|
203
|
+
expect(typeof target.setHandler).toBe('function');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('warns and skips LockTargetState when no writable lock dp is present', () => {
|
|
207
|
+
device = createDevice({
|
|
208
|
+
schema: [{
|
|
209
|
+
code: 'closed_opened',
|
|
210
|
+
mode: TuyaDeviceSchemaMode.READ_ONLY,
|
|
211
|
+
type: TuyaDeviceSchemaType.Boolean,
|
|
212
|
+
property: {},
|
|
213
|
+
}],
|
|
214
|
+
status: [{ code: 'closed_opened', value: false }],
|
|
215
|
+
} as any);
|
|
216
|
+
|
|
217
|
+
setup();
|
|
218
|
+
|
|
219
|
+
expect(characteristics.has(LockTargetState)).toBe(false);
|
|
220
|
+
expect(allLogText()).toContain('Remote lock/unlock from HomeKit is DISABLED');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ─── SECURED -> UNSECURED ────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
test('SECURED -> UNSECURED runs the Smart Lock unlock flow with open=true', async () => {
|
|
226
|
+
setup();
|
|
227
|
+
|
|
228
|
+
await targetStateCharacteristic().setHandler(LockTargetState.UNSECURED);
|
|
229
|
+
|
|
230
|
+
expect(mockDeviceManager.getLockTemporaryKey).toHaveBeenCalledWith('lock-device-id');
|
|
231
|
+
expect(mockDeviceManager.sendLockCommands)
|
|
232
|
+
.toHaveBeenCalledWith('lock-device-id', 'TICKET-ABCDEF123456', true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('SECURED -> UNSECURED does not fall back to a raw unlock_ble dp write', async () => {
|
|
236
|
+
setup();
|
|
237
|
+
|
|
238
|
+
await targetStateCharacteristic().setHandler(LockTargetState.UNSECURED);
|
|
239
|
+
|
|
240
|
+
expect(mockDeviceManager.sendCommands).not.toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('logs each step of the unlock flow', async () => {
|
|
244
|
+
setup();
|
|
245
|
+
|
|
246
|
+
await targetStateCharacteristic().setHandler(LockTargetState.UNSECURED);
|
|
247
|
+
|
|
248
|
+
const text = allLogText();
|
|
249
|
+
expect(text).toContain('HomeKit requested target state: UNSECURED');
|
|
250
|
+
expect(text).toContain('Requesting Tuya password ticket');
|
|
251
|
+
expect(text).toContain('password-ticket response');
|
|
252
|
+
expect(text).toContain('Sending door operation');
|
|
253
|
+
expect(text).toContain('open=true');
|
|
254
|
+
expect(text).toContain('door-operate response');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('never logs the raw ticket_id or the ticket_key', async () => {
|
|
258
|
+
setup();
|
|
259
|
+
|
|
260
|
+
await targetStateCharacteristic().setHandler(LockTargetState.UNSECURED);
|
|
261
|
+
|
|
262
|
+
const text = allLogText();
|
|
263
|
+
expect(text).not.toContain('TICKET-ABCDEF123456');
|
|
264
|
+
expect(text).not.toContain('SUPER-SECRET-KEY');
|
|
265
|
+
expect(text).toContain('***3456');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ─── getLockTemporaryKey failure ─────────────────────────────────
|
|
269
|
+
|
|
270
|
+
test('password-ticket failure aborts, logs code/msg and reports the error to HomeKit', async () => {
|
|
271
|
+
mockDeviceManager.getLockTemporaryKey.mockResolvedValue({
|
|
272
|
+
success: false, code: 1106, msg: 'permission deny',
|
|
273
|
+
} as never);
|
|
274
|
+
|
|
275
|
+
setup();
|
|
276
|
+
|
|
277
|
+
await expect(targetStateCharacteristic().setHandler(LockTargetState.UNSECURED))
|
|
278
|
+
.rejects.toBeInstanceOf(MockHapStatusError);
|
|
279
|
+
|
|
280
|
+
expect(mockDeviceManager.sendLockCommands).not.toHaveBeenCalled();
|
|
281
|
+
const text = allLogText();
|
|
282
|
+
expect(text).toContain('password-ticket failed');
|
|
283
|
+
expect(text).toContain('1106');
|
|
284
|
+
expect(text).toContain('permission deny');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('password-ticket success without a ticket_id is treated as a failure', async () => {
|
|
288
|
+
mockDeviceManager.getLockTemporaryKey.mockResolvedValue({ success: true, result: {} } as never);
|
|
289
|
+
|
|
290
|
+
setup();
|
|
291
|
+
|
|
292
|
+
await expect(targetStateCharacteristic().setHandler(LockTargetState.UNSECURED))
|
|
293
|
+
.rejects.toBeInstanceOf(MockHapStatusError);
|
|
294
|
+
expect(mockDeviceManager.sendLockCommands).not.toHaveBeenCalled();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// ─── door-operate failure ────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
test('door-operate failure logs code/msg and reports the error to HomeKit', async () => {
|
|
300
|
+
mockDeviceManager.sendLockCommands.mockResolvedValue({
|
|
301
|
+
success: false, code: 2009, msg: 'device not support',
|
|
302
|
+
} as never);
|
|
303
|
+
|
|
304
|
+
setup();
|
|
305
|
+
|
|
306
|
+
await expect(targetStateCharacteristic().setHandler(LockTargetState.UNSECURED))
|
|
307
|
+
.rejects.toBeInstanceOf(MockHapStatusError);
|
|
308
|
+
|
|
309
|
+
const text = allLogText();
|
|
310
|
+
expect(text).toContain('door-operate failed');
|
|
311
|
+
expect(text).toContain('2009');
|
|
312
|
+
expect(text).toContain('device not support');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('door-operate answering success=true, result=false is treated as a rejection', async () => {
|
|
316
|
+
mockDeviceManager.sendLockCommands.mockResolvedValue({ success: true, result: false } as never);
|
|
317
|
+
|
|
318
|
+
setup();
|
|
319
|
+
|
|
320
|
+
await expect(targetStateCharacteristic().setHandler(LockTargetState.UNSECURED))
|
|
321
|
+
.rejects.toBeInstanceOf(MockHapStatusError);
|
|
322
|
+
expect(allLogText()).toContain('door-operate rejected by the device');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('a rejected door operation queries the supported remote unlocking methods', async () => {
|
|
326
|
+
mockDeviceManager.sendLockCommands.mockResolvedValue({ success: true, result: false } as never);
|
|
327
|
+
mockDeviceManager.getLockRemoteUnlockMethods.mockResolvedValue({
|
|
328
|
+
success: true, result: [{ open_type: 'ble_unlock' }],
|
|
329
|
+
} as never);
|
|
330
|
+
|
|
331
|
+
setup();
|
|
332
|
+
|
|
333
|
+
await expect(targetStateCharacteristic().setHandler(LockTargetState.UNSECURED)).rejects.toBeDefined();
|
|
334
|
+
|
|
335
|
+
expect(mockDeviceManager.getLockRemoteUnlockMethods).toHaveBeenCalledWith('lock-device-id');
|
|
336
|
+
expect(allLogText()).toContain('Remote unlocking methods reported by this lock');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ─── Offline device ──────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
// A BLE sub-device behind a gateway is routinely reported offline by Tuya
|
|
342
|
+
// while remote unlocking still works, so `online: false` must warn, not block.
|
|
343
|
+
test('a device Tuya reports as offline still attempts the unlock, with a warning', async () => {
|
|
344
|
+
device = createDevice({ online: false } as any);
|
|
345
|
+
|
|
346
|
+
setup();
|
|
347
|
+
|
|
348
|
+
await targetStateCharacteristic().setHandler(LockTargetState.UNSECURED);
|
|
349
|
+
|
|
350
|
+
expect(mockDeviceManager.getLockTemporaryKey).toHaveBeenCalledWith('lock-device-id');
|
|
351
|
+
expect(mockDeviceManager.sendLockCommands)
|
|
352
|
+
.toHaveBeenCalledWith('lock-device-id', 'TICKET-ABCDEF123456', true);
|
|
353
|
+
expect(allLogText()).toContain('Tuya reports this device as offline');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ─── HTTP success must not imply an unlocked door ────────────────
|
|
357
|
+
|
|
358
|
+
test('a successful HTTP unlock does NOT mark LockCurrentState as unsecured', async () => {
|
|
359
|
+
const accessory = setup();
|
|
360
|
+
|
|
361
|
+
await targetStateCharacteristic().setHandler(LockTargetState.UNSECURED);
|
|
362
|
+
|
|
363
|
+
// The device has not reported anything yet.
|
|
364
|
+
expect(await currentStateCharacteristic().getHandler()).toBe(LockCurrentState.SECURED);
|
|
365
|
+
|
|
366
|
+
await accessory.updateAllValues();
|
|
367
|
+
expect(currentStateCharacteristic().value).toBe(LockCurrentState.SECURED);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test('LockTargetState stays on the requested value while the device has not answered', async () => {
|
|
371
|
+
setup();
|
|
372
|
+
|
|
373
|
+
await targetStateCharacteristic().setHandler(LockTargetState.UNSECURED);
|
|
374
|
+
|
|
375
|
+
expect(await targetStateCharacteristic().getHandler()).toBe(LockTargetState.UNSECURED);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('a failed unlock releases the pending target state back to the reported one', async () => {
|
|
379
|
+
mockDeviceManager.sendLockCommands.mockResolvedValue({ success: true, result: false } as never);
|
|
380
|
+
|
|
381
|
+
setup();
|
|
382
|
+
|
|
383
|
+
await expect(targetStateCharacteristic().setHandler(LockTargetState.UNSECURED)).rejects.toBeDefined();
|
|
384
|
+
|
|
385
|
+
expect(await targetStateCharacteristic().getHandler()).toBe(LockTargetState.SECURED);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ─── Real state arriving from the device ─────────────────────────
|
|
389
|
+
|
|
390
|
+
test('a later lock_motor_state update drives LockCurrentState', async () => {
|
|
391
|
+
const accessory = setup();
|
|
392
|
+
|
|
393
|
+
expect(await currentStateCharacteristic().getHandler()).toBe(LockCurrentState.SECURED);
|
|
394
|
+
|
|
395
|
+
// Simulate the MQTT device status update (TuyaDeviceManager mutates
|
|
396
|
+
// device.status before emitting).
|
|
397
|
+
device.status.find(s => s.code === 'lock_motor_state')!.value = true;
|
|
398
|
+
await accessory.onDeviceStatusUpdate([{ code: 'lock_motor_state', value: true }]);
|
|
399
|
+
|
|
400
|
+
expect(await currentStateCharacteristic().getHandler()).toBe(LockCurrentState.UNSECURED);
|
|
401
|
+
expect(currentStateCharacteristic().value).toBe(LockCurrentState.UNSECURED);
|
|
402
|
+
expect(allLogText()).toContain('Device reported lock_motor_state = true');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('reaching the requested state releases the pending target state', async () => {
|
|
406
|
+
const accessory = setup();
|
|
407
|
+
|
|
408
|
+
await targetStateCharacteristic().setHandler(LockTargetState.UNSECURED);
|
|
409
|
+
expect(await targetStateCharacteristic().getHandler()).toBe(LockTargetState.UNSECURED);
|
|
410
|
+
|
|
411
|
+
device.status.find(s => s.code === 'lock_motor_state')!.value = true;
|
|
412
|
+
await accessory.onDeviceStatusUpdate([{ code: 'lock_motor_state', value: true }]);
|
|
413
|
+
|
|
414
|
+
expect(allLogText()).toContain('Releasing the pending target state');
|
|
415
|
+
|
|
416
|
+
// Once the lock relatches, HomeKit follows the device again.
|
|
417
|
+
device.status.find(s => s.code === 'lock_motor_state')!.value = false;
|
|
418
|
+
await accessory.onDeviceStatusUpdate([{ code: 'lock_motor_state', value: false }]);
|
|
419
|
+
|
|
420
|
+
expect(await targetStateCharacteristic().getHandler()).toBe(LockTargetState.SECURED);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// ─── Retry on 2312 (lock between connection windows) ─────────────
|
|
424
|
+
|
|
425
|
+
describe('retry when the lock is not reachable', () => {
|
|
426
|
+
const NOT_ONLINE = { success: false, code: 2312, msg: 'Door lock equipment is not online!' };
|
|
427
|
+
|
|
428
|
+
beforeEach(() => {
|
|
429
|
+
jest.useFakeTimers();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
afterEach(() => {
|
|
433
|
+
jest.clearAllTimers();
|
|
434
|
+
jest.useRealTimers();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test('a 2312 does NOT fail the HomeKit write — the retry runs behind it', async () => {
|
|
438
|
+
mockDeviceManager.sendLockCommands.mockResolvedValue(NOT_ONLINE as never);
|
|
439
|
+
|
|
440
|
+
setup();
|
|
441
|
+
|
|
442
|
+
// Must resolve: a HAP write that blocks for the whole retry window would
|
|
443
|
+
// time out on the controller regardless of what it returns.
|
|
444
|
+
await expect(targetStateCharacteristic().setHandler(LockTargetState.UNSECURED)).resolves.toBeUndefined();
|
|
445
|
+
expect(allLogText()).toContain('Retrying for up to 90s in the background');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test('keeps retrying and succeeds when the lock’s window opens', async () => {
|
|
449
|
+
mockDeviceManager.sendLockCommands
|
|
450
|
+
.mockResolvedValueOnce(NOT_ONLINE as never)
|
|
451
|
+
.mockResolvedValueOnce(NOT_ONLINE as never)
|
|
452
|
+
.mockResolvedValueOnce(NOT_ONLINE as never)
|
|
453
|
+
.mockResolvedValue({ success: true, result: true } as never);
|
|
454
|
+
|
|
455
|
+
setup();
|
|
456
|
+
await targetStateCharacteristic().setHandler(LockTargetState.UNSECURED);
|
|
457
|
+
expect(mockDeviceManager.sendLockCommands).toHaveBeenCalledTimes(1);
|
|
458
|
+
|
|
459
|
+
await jest.advanceTimersByTimeAsync(40 * 1000);
|
|
460
|
+
|
|
461
|
+
expect(mockDeviceManager.sendLockCommands).toHaveBeenCalledTimes(4);
|
|
462
|
+
expect(allLogText()).toContain('Door operation accepted on retry');
|
|
463
|
+
// Every attempt carries its own ticket, since a rejected one may or may
|
|
464
|
+
// not have been consumed.
|
|
465
|
+
expect(mockDeviceManager.getLockTemporaryKey).toHaveBeenCalledTimes(4);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test('stops once the window closes and falls back to the reported state', async () => {
|
|
469
|
+
mockDeviceManager.sendLockCommands.mockResolvedValue(NOT_ONLINE as never);
|
|
470
|
+
|
|
471
|
+
setup();
|
|
472
|
+
await targetStateCharacteristic().setHandler(LockTargetState.UNSECURED);
|
|
473
|
+
expect(await targetStateCharacteristic().getHandler()).toBe(LockTargetState.UNSECURED);
|
|
474
|
+
|
|
475
|
+
await jest.advanceTimersByTimeAsync(95 * 1000);
|
|
476
|
+
|
|
477
|
+
expect(allLogText()).toContain('Gave up after 90s');
|
|
478
|
+
expect(await targetStateCharacteristic().getHandler()).toBe(LockTargetState.SECURED);
|
|
479
|
+
|
|
480
|
+
const before = mockDeviceManager.sendLockCommands.mock.calls.length;
|
|
481
|
+
await jest.advanceTimersByTimeAsync(60 * 1000);
|
|
482
|
+
expect(mockDeviceManager.sendLockCommands.mock.calls.length).toBe(before);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test('a real failure is not retried — it fails the write immediately', async () => {
|
|
486
|
+
mockDeviceManager.sendLockCommands.mockResolvedValue({
|
|
487
|
+
success: false, code: 1106, msg: 'permission deny',
|
|
488
|
+
} as never);
|
|
489
|
+
|
|
490
|
+
setup();
|
|
491
|
+
await expect(targetStateCharacteristic().setHandler(LockTargetState.UNSECURED))
|
|
492
|
+
.rejects.toBeInstanceOf(MockHapStatusError);
|
|
493
|
+
|
|
494
|
+
await jest.advanceTimersByTimeAsync(60 * 1000);
|
|
495
|
+
expect(mockDeviceManager.sendLockCommands).toHaveBeenCalledTimes(1);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('a 2312 skips the diagnostics lookup, so retries do not spam the API', async () => {
|
|
499
|
+
mockDeviceManager.sendLockCommands.mockResolvedValue(NOT_ONLINE as never);
|
|
500
|
+
|
|
501
|
+
setup();
|
|
502
|
+
await targetStateCharacteristic().setHandler(LockTargetState.UNSECURED);
|
|
503
|
+
await jest.advanceTimersByTimeAsync(40 * 1000);
|
|
504
|
+
|
|
505
|
+
expect(mockDeviceManager.getLockRemoteUnlockMethods).not.toHaveBeenCalled();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test('the device reporting the requested state stops the retry loop', async () => {
|
|
509
|
+
mockDeviceManager.sendLockCommands.mockResolvedValue(NOT_ONLINE as never);
|
|
510
|
+
|
|
511
|
+
const accessory = setup();
|
|
512
|
+
await targetStateCharacteristic().setHandler(LockTargetState.UNSECURED);
|
|
513
|
+
|
|
514
|
+
device.status.find(s => s.code === 'lock_motor_state')!.value = true;
|
|
515
|
+
await accessory.onDeviceStatusUpdate([{ code: 'lock_motor_state', value: true }]);
|
|
516
|
+
|
|
517
|
+
const before = mockDeviceManager.sendLockCommands.mock.calls.length;
|
|
518
|
+
await jest.advanceTimersByTimeAsync(40 * 1000);
|
|
519
|
+
expect(mockDeviceManager.sendLockCommands.mock.calls.length).toBe(before);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test('a new request supersedes the one still being retried', async () => {
|
|
523
|
+
mockDeviceManager.sendLockCommands.mockResolvedValue(NOT_ONLINE as never);
|
|
524
|
+
|
|
525
|
+
setup();
|
|
526
|
+
await targetStateCharacteristic().setHandler(LockTargetState.UNSECURED);
|
|
527
|
+
await targetStateCharacteristic().setHandler(LockTargetState.SECURED);
|
|
528
|
+
|
|
529
|
+
await jest.advanceTimersByTimeAsync(25 * 1000);
|
|
530
|
+
|
|
531
|
+
// Only the newest direction is still being asked for.
|
|
532
|
+
const opens = mockDeviceManager.sendLockCommands.mock.calls.map((c: any[]) => c[2]);
|
|
533
|
+
expect(opens.slice(2).every((o: boolean) => o === false)).toBe(true);
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// ─── UNSECURED -> SECURED ────────────────────────────────────────
|
|
538
|
+
|
|
539
|
+
test('UNSECURED -> SECURED sends the documented door-operate with open=false', async () => {
|
|
540
|
+
setup();
|
|
541
|
+
|
|
542
|
+
await targetStateCharacteristic().setHandler(LockTargetState.SECURED);
|
|
543
|
+
|
|
544
|
+
expect(mockDeviceManager.sendLockCommands)
|
|
545
|
+
.toHaveBeenCalledWith('lock-device-id', 'TICKET-ABCDEF123456', false);
|
|
546
|
+
expect(allLogText()).toContain('HomeKit requested target state: SECURED');
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
});
|