homebridge-tuya-plus 3.14.0-dev.5 → 3.14.0-dev.51
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/workflows/publish-dev.yml +298 -31
- package/AGENTS.md +162 -0
- package/CLAUDE.md +1 -0
- package/Changelog.md +16 -3
- package/Readme.MD +8 -32
- package/config.schema.json +79 -79
- package/index.js +599 -358
- package/lib/AirPurifierAccessory.js +1 -1
- package/lib/BaseAccessory.js +54 -17
- package/lib/ConvectorAccessory.js +1 -1
- package/lib/CustomMultiOutletAccessory.js +2 -1
- package/lib/DoorbellAccessory.js +9 -16
- package/lib/GarageDoorAccessory.js +1 -1
- package/lib/IrrigationSystemAccessory.js +260 -120
- package/lib/MultiOutletAccessory.js +3 -2
- package/lib/OilDiffuserAccessory.js +10 -8
- package/lib/RGBTWLightAccessory.js +13 -11
- package/lib/RGBTWOutletAccessory.js +11 -9
- package/lib/SimpleBlindsAccessory.js +5 -5
- package/lib/SimpleDimmer2Accessory.js +1 -1
- package/lib/SimpleDimmerAccessory.js +10 -6
- package/lib/SimpleGarageDoorAccessory.js +114 -25
- package/lib/SimpleHeaterAccessory.js +4 -4
- package/lib/SimpleLightAccessory.js +1 -1
- package/lib/SwitchAccessory.js +3 -2
- package/lib/TWLightAccessory.js +2 -2
- package/lib/TuyaAccessory.js +75 -42
- package/lib/TuyaCloudApi.js +121 -8
- package/lib/TuyaCloudDevice.js +434 -31
- package/lib/TuyaCloudMessaging.js +34 -41
- package/lib/TuyaDevice.js +269 -0
- package/lib/TuyaDiscovery.js +25 -9
- package/lib/ValveAccessory.js +16 -12
- package/lib/VerticalBlindsWithTilt.js +27 -25
- package/lib/WledDimmerAccessory.js +46 -81
- package/package.json +5 -6
- package/scripts/cleanup-pr-tags.js +122 -0
- package/test/BaseAccessory.test.js +94 -15
- package/test/IrrigationSystemAccessory.test.js +293 -67
- package/test/MultiOutletAccessory.test.js +18 -3
- package/test/RGBTWLightAccessory.test.js +3 -3
- package/test/SimpleFanLightAccessory.test.js +3 -3
- package/test/SimpleGarageDoorAccessory.test.js +146 -19
- package/test/TuyaAccessory.protocol.test.js +76 -3
- package/test/TuyaCloudApi.test.js +110 -1
- package/test/TuyaCloudDevice.test.js +564 -2
- package/test/TuyaCloudMessaging.test.js +24 -5
- package/test/TuyaDevice.test.js +266 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +271 -0
- package/test/support/mocks.js +13 -0
- package/wiki/Supported-Device-Types.md +64 -34
- package/wiki/Tuya-Cloud-Setup.md +31 -108
|
@@ -9,7 +9,9 @@ function makeApi(status = [{code: 'switch_1', value: false}, {code: 'battery_per
|
|
|
9
9
|
return {
|
|
10
10
|
isConfigured: () => true,
|
|
11
11
|
getStatus: jest.fn().mockResolvedValue(status),
|
|
12
|
-
|
|
12
|
+
getDeviceInfo: jest.fn().mockResolvedValue({online: true}),
|
|
13
|
+
sendCommands: jest.fn().mockResolvedValue(true),
|
|
14
|
+
sendProperties: jest.fn().mockResolvedValue(true)
|
|
13
15
|
};
|
|
14
16
|
}
|
|
15
17
|
|
|
@@ -55,10 +57,31 @@ describe('TuyaCloudDevice', () => {
|
|
|
55
57
|
const api = makeApi();
|
|
56
58
|
const dev = makeDevice(api);
|
|
57
59
|
expect(dev.connected).toBe(false);
|
|
58
|
-
expect(
|
|
60
|
+
expect(dev.update({switch_1: true})).toBe(false);
|
|
59
61
|
expect(api.sendCommands).not.toHaveBeenCalled();
|
|
60
62
|
});
|
|
61
63
|
|
|
64
|
+
test('update resolves to the cloud command result so failures are awaitable', async () => {
|
|
65
|
+
const api = makeApi();
|
|
66
|
+
const dev = makeDevice(api);
|
|
67
|
+
await dev._connect();
|
|
68
|
+
|
|
69
|
+
api.sendCommands.mockResolvedValueOnce(true);
|
|
70
|
+
await expect(dev.update({switch_1: true})).resolves.toBe(true);
|
|
71
|
+
|
|
72
|
+
api.sendCommands.mockResolvedValueOnce(false);
|
|
73
|
+
await expect(dev.update({switch_1: true})).resolves.toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('update resolves to false (never rejects) when the cloud request throws', async () => {
|
|
77
|
+
const api = makeApi();
|
|
78
|
+
const dev = makeDevice(api);
|
|
79
|
+
await dev._connect();
|
|
80
|
+
|
|
81
|
+
api.sendCommands.mockRejectedValueOnce(new Error('network down'));
|
|
82
|
+
await expect(dev.update({switch_1: true})).resolves.toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
62
85
|
test('applyStatus emits only the changed data-points', async () => {
|
|
63
86
|
const api = makeApi();
|
|
64
87
|
const dev = makeDevice(api);
|
|
@@ -77,6 +100,229 @@ describe('TuyaCloudDevice', () => {
|
|
|
77
100
|
expect(changes).toHaveLength(0);
|
|
78
101
|
});
|
|
79
102
|
|
|
103
|
+
test('connect reflects the device online status (offline → not connected)', async () => {
|
|
104
|
+
const api = makeApi();
|
|
105
|
+
api.getDeviceInfo.mockResolvedValue({online: false});
|
|
106
|
+
const dev = makeDevice(api);
|
|
107
|
+
await dev._connect();
|
|
108
|
+
expect(api.getDeviceInfo).toHaveBeenCalledWith('dev1');
|
|
109
|
+
expect(dev.connected).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('online lookup failure → assume reachable (never block control)', async () => {
|
|
113
|
+
const api = makeApi();
|
|
114
|
+
api.getDeviceInfo.mockRejectedValue(new Error('no device-management permission'));
|
|
115
|
+
const dev = makeDevice(api);
|
|
116
|
+
await dev._connect();
|
|
117
|
+
expect(dev.connected).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a state refresh re-checks online and flips connected', async () => {
|
|
121
|
+
const api = makeApi();
|
|
122
|
+
const dev = makeDevice(api);
|
|
123
|
+
await dev._connect();
|
|
124
|
+
expect(dev.connected).toBe(true);
|
|
125
|
+
|
|
126
|
+
api.getDeviceInfo.mockResolvedValue({online: false});
|
|
127
|
+
await dev._refreshState();
|
|
128
|
+
expect(dev.connected).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('builds the code<->dp_id map and dual-keys state from the shadow', async () => {
|
|
132
|
+
const props = [{code: 'switch_1', dp_id: 1, value: false}, {code: 'battery_percentage', dp_id: 46, value: 99}];
|
|
133
|
+
const api = makeApi();
|
|
134
|
+
api.getShadowProperties = jest.fn().mockResolvedValue(props);
|
|
135
|
+
const dev = makeDevice(api);
|
|
136
|
+
await dev._connect();
|
|
137
|
+
|
|
138
|
+
expect(api.getShadowProperties).toHaveBeenCalledWith('dev1');
|
|
139
|
+
expect(dev.codeByDpId).toEqual({'1': 'switch_1', '46': 'battery_percentage'});
|
|
140
|
+
expect(dev.dpIdByCode).toEqual({'switch_1': '1', 'battery_percentage': '46'});
|
|
141
|
+
// state is addressable by BOTH the cloud code and the numeric LAN dp id
|
|
142
|
+
expect(dev.state).toEqual({switch_1: false, '1': false, battery_percentage: 99, '46': 99});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('update translates a numeric dp id to its cloud code', async () => {
|
|
146
|
+
const api = makeApi();
|
|
147
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
148
|
+
const dev = makeDevice(api);
|
|
149
|
+
await dev._connect();
|
|
150
|
+
|
|
151
|
+
dev.update({'1': true}); // a LAN-style numeric write
|
|
152
|
+
expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'switch_1', value: true}]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a translated write is dispatched to the (iot-03) commands API', async () => {
|
|
156
|
+
const api = makeApi();
|
|
157
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'wfh_close', dp_id: 102, value: false}]);
|
|
158
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
159
|
+
await dev._connect();
|
|
160
|
+
|
|
161
|
+
await dev.update({'102': true});
|
|
162
|
+
expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'wfh_close', value: true}]);
|
|
163
|
+
expect(api.sendProperties).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// A fully-custom device (e.g. a gate controller) is rejected by every /commands
|
|
167
|
+
// API with 2008; the plugin must fall back to the thing-model property endpoint
|
|
168
|
+
// on its own, with no configuration.
|
|
169
|
+
describe('automatic control-endpoint fallback', () => {
|
|
170
|
+
const gate = async () => {
|
|
171
|
+
const api = makeApi();
|
|
172
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'wfh_close', dp_id: 102, value: false}]);
|
|
173
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
174
|
+
await dev._connect();
|
|
175
|
+
return {api, dev};
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
test('a 2008 from commands falls back to the thing-model property endpoint', async () => {
|
|
179
|
+
const {api, dev} = await gate();
|
|
180
|
+
api.sendCommands.mockRejectedValue(new Error('POST /v1.0/iot-03/devices/dev1/commands failed: command or value not support (code 2008)'));
|
|
181
|
+
|
|
182
|
+
await expect(dev.update({'102': true})).resolves.toBe(true);
|
|
183
|
+
expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'wfh_close', value: true}]);
|
|
184
|
+
expect(api.sendProperties).toHaveBeenCalledWith('dev1', [{code: 'wfh_close', value: true}]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('once the property endpoint works it is used directly (no repeat doomed command)', async () => {
|
|
188
|
+
const {api, dev} = await gate();
|
|
189
|
+
api.sendCommands.mockRejectedValue(new Error('command or value not support (code 2008)'));
|
|
190
|
+
|
|
191
|
+
await dev.update({'102': true}); // probes: commands → properties
|
|
192
|
+
await dev.update({'102': false}); // should go straight to properties
|
|
193
|
+
expect(api.sendCommands).toHaveBeenCalledTimes(1);
|
|
194
|
+
expect(api.sendProperties).toHaveBeenCalledTimes(2);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('a non-instruction failure (network) does NOT probe the thing model', async () => {
|
|
198
|
+
const {api, dev} = await gate();
|
|
199
|
+
api.sendCommands.mockRejectedValue(new Error('request timed out'));
|
|
200
|
+
|
|
201
|
+
await expect(dev.update({'102': true})).resolves.toBe(false);
|
|
202
|
+
expect(api.sendProperties).not.toHaveBeenCalled();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('the property fallback is probed only once when it also fails', async () => {
|
|
206
|
+
const {api, dev} = await gate();
|
|
207
|
+
api.sendCommands.mockRejectedValue(new Error('command or value not support (code 2008)'));
|
|
208
|
+
api.sendProperties.mockRejectedValue(new Error('command or value not support (code 2008)'));
|
|
209
|
+
|
|
210
|
+
await dev.update({'102': true});
|
|
211
|
+
await dev.update({'102': true});
|
|
212
|
+
expect(api.sendProperties).toHaveBeenCalledTimes(1);
|
|
213
|
+
expect(api.sendCommands).toHaveBeenCalledTimes(2);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// The crux: a single device with a normal DP (standard instruction set) and
|
|
217
|
+
// a custom DP (thing model only) must drive EACH over its own endpoint.
|
|
218
|
+
test('a mixed device routes each data-point to the endpoint it accepts', async () => {
|
|
219
|
+
const api = makeApi();
|
|
220
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([
|
|
221
|
+
{code: 'switch_1', dp_id: 1, value: false}, // standard → commands
|
|
222
|
+
{code: 'wfh_open', dp_id: 101, value: false} // custom → properties only
|
|
223
|
+
]);
|
|
224
|
+
// The commands API accepts switch_1 but rejects any batch containing wfh_open.
|
|
225
|
+
api.sendCommands.mockImplementation((id, cmds) =>
|
|
226
|
+
cmds.some(c => c.code === 'wfh_open')
|
|
227
|
+
? Promise.reject(new Error('command or value not support (code 2008)'))
|
|
228
|
+
: Promise.resolve(true));
|
|
229
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
230
|
+
await dev._connect();
|
|
231
|
+
|
|
232
|
+
await dev.update({'1': true}); // learns switch_1 → commands
|
|
233
|
+
await dev.update({'101': true}); // commands 2008 → learns wfh_open → properties
|
|
234
|
+
|
|
235
|
+
api.sendCommands.mockClear();
|
|
236
|
+
api.sendProperties.mockClear();
|
|
237
|
+
|
|
238
|
+
// A write touching both now splits: switch_1 over commands, wfh_open over properties.
|
|
239
|
+
await expect(dev.update({'1': false, '101': true})).resolves.toBe(true);
|
|
240
|
+
expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'switch_1', value: false}]);
|
|
241
|
+
expect(api.sendProperties).toHaveBeenCalledWith('dev1', [{code: 'wfh_open', value: true}]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// HomeKit often double-sends a write; running the two concurrently used to
|
|
245
|
+
// race the learning (the 2nd saw the code "tried" but not yet learned and
|
|
246
|
+
// failed). Serializing writes per device fixes it.
|
|
247
|
+
test('concurrent duplicate writes of an unknown code are serialized, not raced', async () => {
|
|
248
|
+
const api = makeApi();
|
|
249
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'Power', dp_id: 1, value: false}]);
|
|
250
|
+
api.sendCommands.mockImplementation((id, cmds) =>
|
|
251
|
+
cmds.some(c => c.code === 'Power')
|
|
252
|
+
? Promise.reject(new Error('command or value not support (code 2008)'))
|
|
253
|
+
: Promise.resolve(true));
|
|
254
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
255
|
+
await dev._connect();
|
|
256
|
+
const warn = jest.spyOn(log, 'warn');
|
|
257
|
+
|
|
258
|
+
const [a, b] = await Promise.all([dev.update({'1': true}), dev.update({'1': true})]);
|
|
259
|
+
|
|
260
|
+
expect(a).toBe(true);
|
|
261
|
+
expect(b).toBe(true);
|
|
262
|
+
expect(warn).not.toHaveBeenCalled(); // the 2nd write didn't spuriously fail
|
|
263
|
+
expect(api.sendCommands).toHaveBeenCalledTimes(1); // only the 1st probed commands
|
|
264
|
+
jest.restoreAllMocks();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('realtime code-only deltas are mirrored to numeric ids via the learned map', async () => {
|
|
269
|
+
const api = makeApi();
|
|
270
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
271
|
+
const dev = makeDevice(api);
|
|
272
|
+
await dev._connect();
|
|
273
|
+
|
|
274
|
+
const changes = [];
|
|
275
|
+
dev.on('change', c => changes.push(c));
|
|
276
|
+
dev._applyStatus([{code: 'switch_1', value: true}]); // code-only (as MQTT delivers)
|
|
277
|
+
expect(dev.state.switch_1).toBe(true);
|
|
278
|
+
expect(dev.state['1']).toBe(true);
|
|
279
|
+
expect(changes[0]).toEqual({switch_1: true, '1': true});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('realtime bare numeric-keyed items (no code) are mapped to their code', async () => {
|
|
283
|
+
// Custom/non-standard DPs (e.g. an irrigation timer's `charging`, dp 101)
|
|
284
|
+
// arrive over MQTT as a bare { "<dpId>": value } pair with no `code` field.
|
|
285
|
+
// They must be applied via the learned dp→code map, not dropped.
|
|
286
|
+
const api = makeApi();
|
|
287
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([
|
|
288
|
+
{code: 'switch_1', dp_id: 1, value: false},
|
|
289
|
+
{code: 'charging', dp_id: 101, value: false}
|
|
290
|
+
]);
|
|
291
|
+
const dev = makeDevice(api);
|
|
292
|
+
await dev._connect();
|
|
293
|
+
|
|
294
|
+
const changes = [];
|
|
295
|
+
dev.on('change', c => changes.push(c));
|
|
296
|
+
dev._applyStatus([{'101': true}]); // bare numeric, as the message service delivers `charging`
|
|
297
|
+
expect(dev.state.charging).toBe(true);
|
|
298
|
+
expect(dev.state['101']).toBe(true);
|
|
299
|
+
expect(changes[0]).toEqual({charging: true, '101': true});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('a bare numeric item with no learned code is still indexed numerically', async () => {
|
|
303
|
+
const api = makeApi();
|
|
304
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
305
|
+
const dev = makeDevice(api);
|
|
306
|
+
await dev._connect();
|
|
307
|
+
|
|
308
|
+
const changes = [];
|
|
309
|
+
dev.on('change', c => changes.push(c));
|
|
310
|
+
dev._applyStatus([{'137': 42}]); // unknown dp, no code mapping
|
|
311
|
+
expect(dev.state['137']).toBe(42);
|
|
312
|
+
expect(changes[0]).toEqual({'137': 42});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('falls back to /status (code-only) when the shadow is unavailable', async () => {
|
|
316
|
+
const api = makeApi();
|
|
317
|
+
api.getShadowProperties = jest.fn().mockResolvedValue(null);
|
|
318
|
+
const dev = makeDevice(api);
|
|
319
|
+
await dev._connect();
|
|
320
|
+
|
|
321
|
+
expect(dev.codeByDpId).toEqual({});
|
|
322
|
+
expect(dev.state).toEqual({switch_1: false, battery_percentage: 99}); // code-only, no numeric mirror
|
|
323
|
+
expect(api.getStatus).toHaveBeenCalledWith('dev1');
|
|
324
|
+
});
|
|
325
|
+
|
|
80
326
|
test('subscribes to realtime and re-reads state when the stream (re)connects', async () => {
|
|
81
327
|
const api = makeApi();
|
|
82
328
|
const messaging = Object.assign(new EventEmitter(), {
|
|
@@ -102,4 +348,320 @@ describe('TuyaCloudDevice', () => {
|
|
|
102
348
|
await new Promise(r => setImmediate(r));
|
|
103
349
|
expect(api.getStatus).toHaveBeenCalledWith('dev1');
|
|
104
350
|
});
|
|
351
|
+
|
|
352
|
+
describe('cloud state sync', () => {
|
|
353
|
+
test('the post-write catch-up re-reads the shadow and emits the resulting change', async () => {
|
|
354
|
+
const api = makeApi();
|
|
355
|
+
let rs = 13;
|
|
356
|
+
api.getShadowProperties = jest.fn(async () => [{code: 'return_state', dp_id: 105, value: rs}]);
|
|
357
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
358
|
+
await dev._connect(); // state: return_state=13, '105'=13
|
|
359
|
+
|
|
360
|
+
const changes = [];
|
|
361
|
+
dev.on('change', c => changes.push(c));
|
|
362
|
+
rs = 12; // the gate is now "opening" in the cloud
|
|
363
|
+
await dev._catchupOnce();
|
|
364
|
+
expect(changes[0]).toEqual({return_state: 12, '105': 12});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test('a successful cloud write arms a state catch-up', async () => {
|
|
368
|
+
const api = makeApi();
|
|
369
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
370
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
371
|
+
await dev._connect();
|
|
372
|
+
expect(dev._catchupTimers).toBeNull();
|
|
373
|
+
await dev.update({'1': true});
|
|
374
|
+
expect(Array.isArray(dev._catchupTimers)).toBe(true);
|
|
375
|
+
dev.stop();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('a realtime update cancels the pending catch-up (no redundant read on MQTT-covered devices)', async () => {
|
|
379
|
+
const api = makeApi();
|
|
380
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
381
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
382
|
+
await dev._connect();
|
|
383
|
+
await dev.update({'1': true});
|
|
384
|
+
expect(Array.isArray(dev._catchupTimers)).toBe(true);
|
|
385
|
+
|
|
386
|
+
dev._onRealtime([{code: 'switch_1', value: true}]); // MQTT delivers → catch-up not needed
|
|
387
|
+
expect(dev._catchupTimers).toBeNull();
|
|
388
|
+
dev.stop();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('once a device has reported over MQTT, later writes never arm a catch-up', async () => {
|
|
392
|
+
const api = makeApi();
|
|
393
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
394
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
395
|
+
await dev._connect();
|
|
396
|
+
dev._onRealtime([{code: 'switch_1', value: true}]); // device reports over MQTT → latch
|
|
397
|
+
|
|
398
|
+
await dev.update({'1': false});
|
|
399
|
+
expect(dev._catchupTimers).toBeNull(); // strictly supplemental: no read for MQTT-covered devices
|
|
400
|
+
dev.stop();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('a refresh reads via the shadow, so a thing-model device whose /status is empty still updates', async () => {
|
|
404
|
+
const api = makeApi();
|
|
405
|
+
let rs = 13;
|
|
406
|
+
api.getStatus = jest.fn().mockResolvedValue([]); // thing-model-only device: /status comes back empty
|
|
407
|
+
api.getShadowProperties = jest.fn(async () => [{code: 'return_state', dp_id: 105, value: rs}]);
|
|
408
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
409
|
+
await dev._connect();
|
|
410
|
+
|
|
411
|
+
const changes = [];
|
|
412
|
+
dev.on('change', c => changes.push(c));
|
|
413
|
+
rs = 12;
|
|
414
|
+
await dev._refreshState();
|
|
415
|
+
|
|
416
|
+
expect(api.getShadowProperties).toHaveBeenCalled();
|
|
417
|
+
expect(changes.some(c => c['105'] === 12)).toBe(true);
|
|
418
|
+
expect(dev.state['105']).toBe(12); // not wiped by the empty /status
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
describe('connect-failure handling (no log spam)', () => {
|
|
423
|
+
afterEach(() => jest.restoreAllMocks());
|
|
424
|
+
|
|
425
|
+
test('a repeated failure is surfaced once, suppressed after, and backs off', () => {
|
|
426
|
+
jest.useFakeTimers();
|
|
427
|
+
try {
|
|
428
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'}); // LAN-capable → fallback
|
|
429
|
+
const warn = jest.spyOn(log, 'warn');
|
|
430
|
+
const debug = jest.spyOn(log, 'debug');
|
|
431
|
+
const err = new Error('GET /v1.0/devices/dev1/status failed: permission deny (code 1106)');
|
|
432
|
+
|
|
433
|
+
dev._onConnectFailure(err);
|
|
434
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
435
|
+
expect(warn.mock.calls[0][0]).toContain('permission deny (code 1106)');
|
|
436
|
+
expect(dev._retryDelay).toBe(30000);
|
|
437
|
+
|
|
438
|
+
dev._onConnectFailure(err); // identical → not surfaced again, just debug + backoff
|
|
439
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
440
|
+
expect(debug).toHaveBeenCalledWith(expect.stringContaining('still failing'));
|
|
441
|
+
expect(dev._retryDelay).toBe(60000);
|
|
442
|
+
|
|
443
|
+
dev._onConnectFailure(err);
|
|
444
|
+
expect(dev._retryDelay).toBe(120000);
|
|
445
|
+
} finally {
|
|
446
|
+
jest.useRealTimers();
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('a cloud-only device (no key) surfaces the failure at error level', () => {
|
|
451
|
+
jest.useFakeTimers();
|
|
452
|
+
try {
|
|
453
|
+
const dev = makeDevice(makeApi()); // no key → cloud is the only path
|
|
454
|
+
const error = jest.spyOn(log, 'error');
|
|
455
|
+
const warn = jest.spyOn(log, 'warn');
|
|
456
|
+
|
|
457
|
+
dev._onConnectFailure(new Error('permission deny (code 1106)'));
|
|
458
|
+
expect(warn).not.toHaveBeenCalled();
|
|
459
|
+
expect(error).toHaveBeenCalledTimes(1);
|
|
460
|
+
expect(error.mock.calls[0][0]).toContain('cloud-only');
|
|
461
|
+
} finally {
|
|
462
|
+
jest.useRealTimers();
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test('a device reachable over the LAN logs the fallback failure as harmless (debug, not warn)', () => {
|
|
467
|
+
jest.useFakeTimers();
|
|
468
|
+
try {
|
|
469
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc', isLanConnected: () => true});
|
|
470
|
+
const warn = jest.spyOn(log, 'warn');
|
|
471
|
+
const debug = jest.spyOn(log, 'debug');
|
|
472
|
+
|
|
473
|
+
dev._onConnectFailure(new Error('permission deny (code 1106)'));
|
|
474
|
+
|
|
475
|
+
expect(warn).not.toHaveBeenCalled();
|
|
476
|
+
expect(debug).toHaveBeenCalledTimes(1);
|
|
477
|
+
expect(debug.mock.calls[0][0]).toContain('reachable over the LAN');
|
|
478
|
+
} finally {
|
|
479
|
+
jest.useRealTimers();
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test('the same failure re-surfaces (debug → warn) when the LAN path drops', () => {
|
|
484
|
+
jest.useFakeTimers();
|
|
485
|
+
try {
|
|
486
|
+
let lanUp = true;
|
|
487
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc', isLanConnected: () => lanUp});
|
|
488
|
+
const warn = jest.spyOn(log, 'warn');
|
|
489
|
+
const err = new Error('permission deny (code 1106)');
|
|
490
|
+
|
|
491
|
+
dev._onConnectFailure(err); // LAN up → harmless, debug only
|
|
492
|
+
expect(warn).not.toHaveBeenCalled();
|
|
493
|
+
|
|
494
|
+
lanUp = false;
|
|
495
|
+
dev._onConnectFailure(err); // identical error, but LAN now down → surfaced
|
|
496
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
497
|
+
expect(warn.mock.calls[0][0]).toContain("isn't reachable over the LAN");
|
|
498
|
+
} finally {
|
|
499
|
+
jest.useRealTimers();
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test('permission-deny adds the offline-unbinding hint; an unrelated error does not', () => {
|
|
504
|
+
jest.useFakeTimers();
|
|
505
|
+
try {
|
|
506
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'}); // LAN-capable, LAN down
|
|
507
|
+
const warn = jest.spyOn(log, 'warn');
|
|
508
|
+
|
|
509
|
+
dev._onConnectFailure(new Error('permission deny (code 1106)'));
|
|
510
|
+
expect(warn.mock.calls[0][0]).toContain('offline for a long time');
|
|
511
|
+
|
|
512
|
+
dev._onConnectFailure(new Error('request timed out'));
|
|
513
|
+
expect(warn.mock.calls[1][0]).not.toContain('offline for a long time');
|
|
514
|
+
} finally {
|
|
515
|
+
jest.useRealTimers();
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test('a different error message is surfaced again, not suppressed', () => {
|
|
520
|
+
jest.useFakeTimers();
|
|
521
|
+
try {
|
|
522
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'});
|
|
523
|
+
const warn = jest.spyOn(log, 'warn');
|
|
524
|
+
|
|
525
|
+
dev._onConnectFailure(new Error('permission deny (code 1106)'));
|
|
526
|
+
dev._onConnectFailure(new Error('request timed out'));
|
|
527
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
528
|
+
} finally {
|
|
529
|
+
jest.useRealTimers();
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('backoff is capped at 30 minutes', () => {
|
|
534
|
+
jest.useFakeTimers();
|
|
535
|
+
try {
|
|
536
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'});
|
|
537
|
+
jest.spyOn(log, 'warn');
|
|
538
|
+
jest.spyOn(log, 'debug');
|
|
539
|
+
const err = new Error('permission deny (code 1106)');
|
|
540
|
+
|
|
541
|
+
for (let i = 0; i < 20; i++) dev._onConnectFailure(err);
|
|
542
|
+
expect(dev._retryDelay).toBe(1800000);
|
|
543
|
+
} finally {
|
|
544
|
+
jest.useRealTimers();
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test('reconnecting after a failure logs recovery once and resets backoff', async () => {
|
|
549
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'});
|
|
550
|
+
dev._lastConnectError = 'permission deny (code 1106)';
|
|
551
|
+
dev._retryDelay = 240000;
|
|
552
|
+
const info = jest.spyOn(log, 'info');
|
|
553
|
+
|
|
554
|
+
await dev._connect(); // mocked api resolves → success path runs
|
|
555
|
+
|
|
556
|
+
expect(dev._lastConnectError).toBeNull();
|
|
557
|
+
expect(dev._retryDelay).toBe(0);
|
|
558
|
+
expect(info).toHaveBeenCalledWith(expect.stringContaining('connection restored'));
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test('a stopped device does not schedule another retry', () => {
|
|
562
|
+
jest.useFakeTimers();
|
|
563
|
+
try {
|
|
564
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'});
|
|
565
|
+
jest.spyOn(log, 'warn');
|
|
566
|
+
dev.stop();
|
|
567
|
+
dev._onConnectFailure(new Error('permission deny (code 1106)'));
|
|
568
|
+
expect(dev._retryTimer).toBeNull();
|
|
569
|
+
} finally {
|
|
570
|
+
jest.useRealTimers();
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
describe('write-failure handling (no log spam)', () => {
|
|
576
|
+
afterEach(() => jest.restoreAllMocks());
|
|
577
|
+
|
|
578
|
+
// A LAN-style accessory (numeric dps) over a cloud project that never
|
|
579
|
+
// learned the id→code map: the write can't be addressed, so it must not be
|
|
580
|
+
// fired (it would always be a 2008), and the cause is surfaced just once.
|
|
581
|
+
test('a numeric write with no learned code map is skipped, not sent, and surfaced once', async () => {
|
|
582
|
+
const api = makeApi(); // /status only → no dp map
|
|
583
|
+
const dev = makeDevice(api, null, {key: 'abc'}); // LAN-capable, LAN down here
|
|
584
|
+
await dev._connect();
|
|
585
|
+
expect(dev.codeByDpId).toEqual({});
|
|
586
|
+
|
|
587
|
+
const warn = jest.spyOn(log, 'warn');
|
|
588
|
+
const debug = jest.spyOn(log, 'debug');
|
|
589
|
+
|
|
590
|
+
expect(dev.update({'1': true})).toBe(false);
|
|
591
|
+
expect(api.sendCommands).not.toHaveBeenCalled();
|
|
592
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
593
|
+
expect(warn.mock.calls[0][0]).toContain('over the Tuya Cloud');
|
|
594
|
+
|
|
595
|
+
dev.update({'1': true}); // identical → quiet at debug, still not sent
|
|
596
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
597
|
+
expect(debug).toHaveBeenCalledWith(expect.stringContaining('cloud write skipped'));
|
|
598
|
+
expect(api.sendCommands).not.toHaveBeenCalled();
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test('the undeliverable-write note is harmless (debug) while the device is reachable over the LAN', async () => {
|
|
602
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc', isLanConnected: () => true});
|
|
603
|
+
await dev._connect();
|
|
604
|
+
const warn = jest.spyOn(log, 'warn');
|
|
605
|
+
const debug = jest.spyOn(log, 'debug');
|
|
606
|
+
|
|
607
|
+
expect(dev.update({'1': true})).toBe(false);
|
|
608
|
+
expect(warn).not.toHaveBeenCalled();
|
|
609
|
+
expect(debug).toHaveBeenCalledWith(expect.stringContaining('over the Tuya Cloud'));
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('a cloud-only device (no key) surfaces the undeliverable write at error level', async () => {
|
|
613
|
+
const dev = makeDevice(makeApi()); // no key → cloud is the only path
|
|
614
|
+
await dev._connect();
|
|
615
|
+
const error = jest.spyOn(log, 'error');
|
|
616
|
+
const warn = jest.spyOn(log, 'warn');
|
|
617
|
+
|
|
618
|
+
expect(dev.update({'1': true})).toBe(false);
|
|
619
|
+
expect(warn).not.toHaveBeenCalled();
|
|
620
|
+
expect(error).toHaveBeenCalledTimes(1);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// A genuinely dispatched command that the cloud rejects (e.g. a real 2008
|
|
624
|
+
// on a mapped code): surface once, then suppress identical repeats to debug.
|
|
625
|
+
test('a repeated cloud command failure is surfaced once then suppressed to debug', async () => {
|
|
626
|
+
const api = makeApi();
|
|
627
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
628
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
629
|
+
await dev._connect();
|
|
630
|
+
|
|
631
|
+
const warn = jest.spyOn(log, 'warn');
|
|
632
|
+
const debug = jest.spyOn(log, 'debug');
|
|
633
|
+
const ex = new Error('POST /v1.0/iot-03/devices/dev1/commands failed: command or value not support (code 2008)');
|
|
634
|
+
api.sendCommands.mockRejectedValue(ex);
|
|
635
|
+
api.sendProperties.mockRejectedValue(ex); // thing-model fallback can't rescue it either
|
|
636
|
+
|
|
637
|
+
await dev.update({'1': true}); // mapped → dispatched → rejected by both endpoints
|
|
638
|
+
expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'switch_1', value: true}]);
|
|
639
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
640
|
+
expect(warn.mock.calls[0][0]).toContain('code 2008');
|
|
641
|
+
|
|
642
|
+
await dev.update({'1': true}); // identical failure → quiet
|
|
643
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
644
|
+
expect(debug).toHaveBeenCalledWith(expect.stringContaining('still failing'));
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test('a command failure re-surfaces after an intervening success', async () => {
|
|
648
|
+
const api = makeApi();
|
|
649
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
650
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
651
|
+
await dev._connect();
|
|
652
|
+
const warn = jest.spyOn(log, 'warn');
|
|
653
|
+
api.sendProperties.mockRejectedValue(new Error('command or value not support (code 2008)')); // fallback can't rescue
|
|
654
|
+
|
|
655
|
+
api.sendCommands.mockRejectedValueOnce(new Error('command or value not support (code 2008)'));
|
|
656
|
+
await dev.update({'1': true});
|
|
657
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
658
|
+
|
|
659
|
+
api.sendCommands.mockResolvedValueOnce(true); // success clears the dedup
|
|
660
|
+
await dev.update({'1': true});
|
|
661
|
+
|
|
662
|
+
api.sendCommands.mockRejectedValueOnce(new Error('command or value not support (code 2008)'));
|
|
663
|
+
await dev.update({'1': true});
|
|
664
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
665
|
+
});
|
|
666
|
+
});
|
|
105
667
|
});
|
|
@@ -56,6 +56,30 @@ describe('TuyaCloudMessaging — decryption + dispatch', () => {
|
|
|
56
56
|
expect(received[0]).toEqual([{code: 'switch_1', value: true, t}, {code: 'battery_percentage', value: 80, t}]);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
test('decrypts a GCM frame even when the auth tag does not verify (Tuya quirk)', () => {
|
|
60
|
+
// Real Tuya status frames carry a GCM tag that does NOT verify against
|
|
61
|
+
// the documented AAD. We must decrypt with update() only (no final(),
|
|
62
|
+
// no tag check) or every realtime update is silently dropped — which is
|
|
63
|
+
// exactly what stopped external changes from reaching HomeKit. Simulate
|
|
64
|
+
// it by clobbering the trailing 16-byte tag: the plaintext must still
|
|
65
|
+
// come through.
|
|
66
|
+
const mq = makeIdle();
|
|
67
|
+
const received = [];
|
|
68
|
+
mq.subscribeDevice('DEV1', status => received.push(status));
|
|
69
|
+
|
|
70
|
+
const t = Date.now();
|
|
71
|
+
const raw = Buffer.from(
|
|
72
|
+
encryptGCM(JSON.stringify({devId: 'DEV1', status: [{code: 'switch_1', value: true}]}), mq.config.password, t),
|
|
73
|
+
'base64'
|
|
74
|
+
);
|
|
75
|
+
crypto.randomBytes(16).copy(raw, raw.length - 16); // corrupt the auth tag
|
|
76
|
+
|
|
77
|
+
mq._onMessage('topic', Buffer.from(JSON.stringify({protocol: 4, data: raw.toString('base64'), t})));
|
|
78
|
+
|
|
79
|
+
expect(received).toHaveLength(1);
|
|
80
|
+
expect(received[0]).toEqual([{code: 'switch_1', value: true}]);
|
|
81
|
+
});
|
|
82
|
+
|
|
59
83
|
test('decrypts a legacy ECB (v1.0) frame too', () => {
|
|
60
84
|
const mq = makeIdle();
|
|
61
85
|
const received = [];
|
|
@@ -86,9 +110,4 @@ describe('TuyaCloudMessaging — decryption + dispatch', () => {
|
|
|
86
110
|
mq.subscribeDevice('DEV1', () => { throw new Error('should not be called'); });
|
|
87
111
|
expect(() => mq._onMessage('topic', Buffer.from('not json'))).not.toThrow();
|
|
88
112
|
});
|
|
89
|
-
|
|
90
|
-
test('reports whether realtime is available (mqtt installed)', () => {
|
|
91
|
-
// mqtt is an (optional) dependency of this project, so it should load.
|
|
92
|
-
expect(typeof TuyaCloudMessaging.isAvailable()).toBe('boolean');
|
|
93
|
-
});
|
|
94
113
|
});
|