homebridge-tuya-plus 3.14.0-dev.40 → 3.14.0-dev.41
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/lib/TuyaCloudApi.js +25 -3
- package/lib/TuyaCloudDevice.js +153 -9
- package/package.json +1 -1
- package/test/TuyaCloudApi.test.js +30 -1
- package/test/TuyaCloudDevice.test.js +185 -1
- package/wiki/Tuya-Cloud-Setup.md +12 -0
package/lib/TuyaCloudApi.js
CHANGED
|
@@ -218,8 +218,12 @@ class TuyaCloudApi {
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
// Read every reported data-point in one call → array of {code, value}.
|
|
221
|
+
// Uses the current iot-03 device API, not the legacy /v1.0/devices/* set: the
|
|
222
|
+
// legacy endpoints answer 2003 ("function not support") for devices they don't
|
|
223
|
+
// model, where iot-03 works. This is the same family tinytuya and the official
|
|
224
|
+
// Tuya Homebridge plugin use throughout.
|
|
221
225
|
async getStatus(deviceId) {
|
|
222
|
-
const res = await this.request('GET', `/v1.0/devices/${encodeURIComponent(deviceId)}/status`);
|
|
226
|
+
const res = await this.request('GET', `/v1.0/iot-03/devices/${encodeURIComponent(deviceId)}/status`);
|
|
223
227
|
return (res && Array.isArray(res.result)) ? res.result : [];
|
|
224
228
|
}
|
|
225
229
|
|
|
@@ -253,10 +257,28 @@ class TuyaCloudApi {
|
|
|
253
257
|
return (res && res.result && typeof res.result === 'object') ? res.result : null;
|
|
254
258
|
}
|
|
255
259
|
|
|
256
|
-
// Issue one or more commands: [{code, value}, …].
|
|
260
|
+
// Issue one or more commands: [{code, value}, …]. Uses the current iot-03
|
|
261
|
+
// device-control endpoint (the legacy /v1.0/devices/{id}/commands answers 2008
|
|
262
|
+
// for devices it doesn't model), matching tinytuya and the official plugin.
|
|
257
263
|
async sendCommands(deviceId, commands) {
|
|
258
264
|
if (!Array.isArray(commands) || !commands.length) return true;
|
|
259
|
-
const res = await this.request('POST', `/v1.0/devices/${encodeURIComponent(deviceId)}/commands`, {commands});
|
|
265
|
+
const res = await this.request('POST', `/v1.0/iot-03/devices/${encodeURIComponent(deviceId)}/commands`, {commands});
|
|
266
|
+
return !!(res && res.result);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Control a device through the thing-model "send property" endpoint. Some
|
|
270
|
+
// fully-custom devices (e.g. gate controllers) have no standard instruction
|
|
271
|
+
// set at all — every /commands API, legacy or iot-03, answers 2008 for them —
|
|
272
|
+
// but the cloud still models them as thing-model properties (their shadow
|
|
273
|
+
// reads fine). Issuing those properties is how Tuya's own app drives such a
|
|
274
|
+
// device. Same [{code, value}, …] in; the body is {properties: "<json>"} where
|
|
275
|
+
// <json> is a JSON string of {code: value, …}.
|
|
276
|
+
// Docs: https://developer.tuya.com/en/docs/cloud/c057ad5cfd?id=Kcp2kxdzftp91
|
|
277
|
+
async sendProperties(deviceId, commands) {
|
|
278
|
+
if (!Array.isArray(commands) || !commands.length) return true;
|
|
279
|
+
const properties = {};
|
|
280
|
+
commands.forEach(c => { properties[c.code] = c.value; });
|
|
281
|
+
const res = await this.request('POST', `/v2.0/cloud/thing/${encodeURIComponent(deviceId)}/shadow/properties/issue`, {properties: JSON.stringify(properties)});
|
|
260
282
|
return !!(res && res.result);
|
|
261
283
|
}
|
|
262
284
|
|
package/lib/TuyaCloudDevice.js
CHANGED
|
@@ -59,6 +59,22 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
59
59
|
this._retryTimer = null;
|
|
60
60
|
this._retryDelay = 0; // current connect-retry backoff (ms); grows on repeated failure
|
|
61
61
|
this._lastConnectError = null; // last surfaced connect error, so identical repeats stay quiet
|
|
62
|
+
this._lastWriteError = null; // last surfaced command-failure message, so identical repeats stay quiet
|
|
63
|
+
this._warnedNoCloudCode = false; // surfaced the "data-point has no cloud code" note once
|
|
64
|
+
|
|
65
|
+
// Per data-point code, the cloud control endpoint learned to work for it
|
|
66
|
+
// ('commands' or 'properties'). It's tracked per code, not per device,
|
|
67
|
+
// because one device can be mixed: some data-points live in its standard
|
|
68
|
+
// instruction set (driven by the iot-03 commands API) and others only in its
|
|
69
|
+
// thing model (driven by the property endpoint). Every code starts on the
|
|
70
|
+
// commands API — the one tinytuya and the official plugin use, so the
|
|
71
|
+
// best-understood for rate limits — and only a code the commands API rejects
|
|
72
|
+
// as unsupported (2008/2003) is moved to the thing-model endpoint.
|
|
73
|
+
// _propertiesTried bounds that probe to once per code so a genuinely
|
|
74
|
+
// uncontrollable data-point can't re-probe on every write.
|
|
75
|
+
this._codeMethod = {};
|
|
76
|
+
this._propertiesTried = new Set();
|
|
77
|
+
this._announcedProperties = false;
|
|
62
78
|
|
|
63
79
|
// Bidirectional data-point maps, learned from the device's thing-shadow on
|
|
64
80
|
// connect. They let this cloud transport (and the LAN+cloud TuyaDevice that
|
|
@@ -335,15 +351,143 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
335
351
|
return false;
|
|
336
352
|
}
|
|
337
353
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
354
|
+
// Split off data-points we couldn't translate to a Tuya Cloud code: their
|
|
355
|
+
// "code" is still a numeric LAN dp id (e.g. '1'), which the commands API
|
|
356
|
+
// can't address — it would always answer "command or value not support
|
|
357
|
+
// (2008)". This is the missing-dp-map case (the device-shadow API that
|
|
358
|
+
// carries the id→code mapping isn't authorised for the project), so don't
|
|
359
|
+
// fire a doomed command; explain it once instead.
|
|
360
|
+
const sendable = [], undeliverable = [];
|
|
361
|
+
commands.forEach(c => (this._isUntranslatedDpId(c.code) ? undeliverable : sendable).push(c));
|
|
362
|
+
if (undeliverable.length) this._reportUndeliverableWrite(undeliverable);
|
|
363
|
+
if (!sendable.length) return false;
|
|
364
|
+
|
|
365
|
+
return this._dispatch(sendable);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Send a write, routing each data-point to the cloud endpoint it accepts. Codes
|
|
369
|
+
// go over the standard commands API unless we've learned they need the thing
|
|
370
|
+
// model, so the two groups can travel in parallel to their own endpoints. A
|
|
371
|
+
// device with both kinds (a normal switch plus a custom trigger) drives each
|
|
372
|
+
// correctly. Resolves true only if every group succeeded, false otherwise
|
|
373
|
+
// (never rejects), mirroring TuyaAccessory.update for the awaiting accessory.
|
|
374
|
+
async _dispatch(commands) {
|
|
375
|
+
const viaProperties = commands.filter(c => this._codeMethod[c.code] === 'properties');
|
|
376
|
+
const viaCommands = commands.filter(c => this._codeMethod[c.code] !== 'properties');
|
|
377
|
+
|
|
378
|
+
const [byCommands, byProperties] = await Promise.all([
|
|
379
|
+
this._sendGroup('commands', viaCommands),
|
|
380
|
+
this._sendGroup('properties', viaProperties)
|
|
381
|
+
]);
|
|
382
|
+
|
|
383
|
+
if (byCommands.ok && byProperties.ok) {
|
|
384
|
+
this._lastWriteError = null;
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
this._reportWriteFailure(byCommands.error || byProperties.error);
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Send one group of codes over `method`, recording per code which endpoint
|
|
392
|
+
// worked. A commands group rejected as unsupported (2008/2003) is retried over
|
|
393
|
+
// the thing-model property endpoint — where a custom device's data-points live —
|
|
394
|
+
// but only for codes not already confirmed on commands or already tried there,
|
|
395
|
+
// so a confirmed code is never moved and an uncontrollable one can't re-probe on
|
|
396
|
+
// every write. Resolves to {ok, error}; never rejects.
|
|
397
|
+
async _sendGroup(method, codes) {
|
|
398
|
+
if (!codes.length) return {ok: true};
|
|
399
|
+
|
|
400
|
+
const res = await this._send(method, codes);
|
|
401
|
+
if (res.ok) return this._learn(codes, method);
|
|
402
|
+
if (method !== 'commands' || !this._isInstructionMismatch(res.error)) return res;
|
|
403
|
+
|
|
404
|
+
const fresh = codes.filter(c => this._codeMethod[c.code] !== 'commands' && !this._propertiesTried.has(c.code));
|
|
405
|
+
if (!fresh.length) return res;
|
|
406
|
+
fresh.forEach(c => this._propertiesTried.add(c.code));
|
|
407
|
+
|
|
408
|
+
this.log.debug(`${this.context.name}: the cloud command API rejected ${this._codesOf(fresh)} (${res.error}); trying the thing-model property endpoint.`);
|
|
409
|
+
const viaProperties = await this._send('properties', fresh);
|
|
410
|
+
if (!viaProperties.ok) {
|
|
411
|
+
this.log.debug(`${this.context.name}: the thing-model property endpoint also failed for ${this._codesOf(fresh)}: ${viaProperties.error}`);
|
|
412
|
+
return res;
|
|
413
|
+
}
|
|
414
|
+
if (!this._announcedProperties) {
|
|
415
|
+
this._announcedProperties = true;
|
|
416
|
+
this.log.info(`${this.context.name}: controlling some of this device's data-points over the Tuya thing-model property endpoint (they aren't in its standard instruction set).`);
|
|
417
|
+
}
|
|
418
|
+
return this._learn(fresh, 'properties');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Invoke one control endpoint; resolves to {ok, error} and never rejects, so a
|
|
422
|
+
// missing-method mock or a network error can't escape the dispatch.
|
|
423
|
+
_send(method, commands) {
|
|
424
|
+
let call;
|
|
425
|
+
try {
|
|
426
|
+
call = method === 'properties'
|
|
427
|
+
? this.api.sendProperties(this.context.id, commands)
|
|
428
|
+
: this.api.sendCommands(this.context.id, commands);
|
|
429
|
+
} catch (ex) {
|
|
430
|
+
return Promise.resolve({ok: false, error: ex && ex.message ? ex.message : String(ex)});
|
|
431
|
+
}
|
|
432
|
+
return Promise.resolve(call).then(
|
|
433
|
+
res => res !== false ? {ok: true} : {ok: false, error: `Tuya Cloud rejected ${JSON.stringify(commands)}`},
|
|
434
|
+
ex => ({ok: false, error: ex && ex.message ? ex.message : String(ex)})
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
_learn(codes, method) {
|
|
439
|
+
codes.forEach(c => { this._codeMethod[c.code] = method; });
|
|
440
|
+
return {ok: true};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
_codesOf(commands) {
|
|
444
|
+
return commands.map(c => c.code).join(', ');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// True for the "this code/value isn't a valid command for this device" errors —
|
|
448
|
+
// command or value not support (2008) and function not support (2003) — which
|
|
449
|
+
// are what a device with no standard instruction set answers, and exactly the
|
|
450
|
+
// case the thing-model endpoint can still handle. Other failures (auth,
|
|
451
|
+
// permission, network) won't be helped by the fallback, so we don't probe.
|
|
452
|
+
_isInstructionMismatch(message) {
|
|
453
|
+
return /\bcode (2008|2003)\b|command or value not support|function not support/i.test(message || '');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// A Tuya Cloud data-point is addressed by a string "code" (e.g. 'switch',
|
|
457
|
+
// 'temp_set'); a code that is all digits is really a numeric LAN dp id that
|
|
458
|
+
// _toCode couldn't translate (no map learned), which the cloud can't address.
|
|
459
|
+
_isUntranslatedDpId(code) {
|
|
460
|
+
return /^\d+$/.test(code);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Surfaced once: the cloud can't address these data-points because the numeric
|
|
464
|
+
// id→code map was never learned (the device-shadow/thing-model API isn't
|
|
465
|
+
// authorised for this project — the same gap that makes shadow/properties
|
|
466
|
+
// return "No space permission"). Mirrors the connect-failure dedup: one
|
|
467
|
+
// actionable line, then quiet at debug. Stays harmless (debug) while the device
|
|
468
|
+
// is reachable over the LAN, since the cloud is only a fallback there.
|
|
469
|
+
_reportUndeliverableWrite(commands) {
|
|
470
|
+
const ids = commands.map(c => c.code).join(', ');
|
|
471
|
+
if (this._warnedNoCloudCode) {
|
|
472
|
+
return this.log.debug(`${this.context.name}: cloud write skipped — no Tuya Cloud code for data-point(s) ${ids}.`);
|
|
473
|
+
}
|
|
474
|
+
this._warnedNoCloudCode = true;
|
|
475
|
+
const lanReachable = !!(this.isLanConnected && this.isLanConnected());
|
|
476
|
+
const level = lanReachable ? 'debug' : (this.context.key ? 'warn' : 'error');
|
|
477
|
+
this.log[level](`${this.context.name}: can't control this device over the Tuya Cloud — its data-points are addressed by numeric id (${ids}) but the cloud only accepts string "codes", so the command would be rejected ("command or value not support"). That id→code mapping is read from the device-shadow API, which isn't authorised for this cloud project ("No space permission"); authorise it (and add the device to the project's space), or set the accessory's dp* options to cloud codes. The device still works over the LAN.`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// A genuine command failure (the cloud was reached but rejected/errored).
|
|
481
|
+
// Deduped like the connect path — surfaced once, identical repeats drop to
|
|
482
|
+
// debug — with the level matched to severity: a LAN-capable device is only
|
|
483
|
+
// momentarily off the LAN (warn), a cloud-only one is stuck until it clears
|
|
484
|
+
// (error). _lastWriteError is cleared on the next successful command.
|
|
485
|
+
_reportWriteFailure(message) {
|
|
486
|
+
if (message === this._lastWriteError) {
|
|
487
|
+
return this.log.debug(`${this.context.name}: cloud command still failing: ${message}`);
|
|
488
|
+
}
|
|
489
|
+
this._lastWriteError = message;
|
|
490
|
+
this.log[this.context.key ? 'warn' : 'error'](`${this.context.name}: cloud command failed: ${message}`);
|
|
347
491
|
}
|
|
348
492
|
|
|
349
493
|
// Resolve a write key (a numeric LAN dp id or a string code) to the Tuya Cloud
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-tuya-plus",
|
|
3
|
-
"version": "3.14.0-dev.
|
|
3
|
+
"version": "3.14.0-dev.41",
|
|
4
4
|
"description": "A community-maintained Homebridge plugin for controlling Tuya devices in HomeKit. LAN-first, with an optional Tuya Cloud fallback for any device the LAN can't reach.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -173,7 +173,7 @@ describe('TuyaCloudApi — endpoints', () => {
|
|
|
173
173
|
const ok = await api.sendCommands('dev', [{code: 'switch_1', value: true}]);
|
|
174
174
|
expect(ok).toBe(true);
|
|
175
175
|
expect(captured.method).toBe('POST');
|
|
176
|
-
expect(captured.path).toBe('/v1.0/devices/dev/commands');
|
|
176
|
+
expect(captured.path).toBe('/v1.0/iot-03/devices/dev/commands');
|
|
177
177
|
expect(captured.body).toEqual({commands: [{code: 'switch_1', value: true}]});
|
|
178
178
|
});
|
|
179
179
|
|
|
@@ -184,6 +184,35 @@ describe('TuyaCloudApi — endpoints', () => {
|
|
|
184
184
|
expect(api._httpsRequest).not.toHaveBeenCalled();
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
+
test('getStatus reads from the iot-03 device endpoint', async () => {
|
|
188
|
+
const api = ready();
|
|
189
|
+
let path;
|
|
190
|
+
api._httpsRequest = async (method, p) => { path = p; return {success: true, result: []}; };
|
|
191
|
+
await api.getStatus('dev');
|
|
192
|
+
expect(path).toBe('/v1.0/iot-03/devices/dev/status');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('sendProperties issues thing-model properties as a JSON-string body', async () => {
|
|
196
|
+
const api = ready();
|
|
197
|
+
let captured;
|
|
198
|
+
api._httpsRequest = async (method, path, headers, bodyStr) => {
|
|
199
|
+
captured = {method, path, body: JSON.parse(bodyStr)};
|
|
200
|
+
return {success: true, result: true};
|
|
201
|
+
};
|
|
202
|
+
const ok = await api.sendProperties('dev', [{code: 'wfh_close', value: true}]);
|
|
203
|
+
expect(ok).toBe(true);
|
|
204
|
+
expect(captured.method).toBe('POST');
|
|
205
|
+
expect(captured.path).toBe('/v2.0/cloud/thing/dev/shadow/properties/issue');
|
|
206
|
+
expect(captured.body).toEqual({properties: JSON.stringify({wfh_close: true})});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('sendProperties short-circuits on an empty command list', async () => {
|
|
210
|
+
const api = ready();
|
|
211
|
+
api._httpsRequest = jest.fn();
|
|
212
|
+
await expect(api.sendProperties('dev', [])).resolves.toBe(true);
|
|
213
|
+
expect(api._httpsRequest).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
|
|
187
216
|
test('getMqttConfig posts the expected body and returns the broker config', async () => {
|
|
188
217
|
const api = ready();
|
|
189
218
|
api.uid = 'UID';
|
|
@@ -10,7 +10,8 @@ function makeApi(status = [{code: 'switch_1', value: false}, {code: 'battery_per
|
|
|
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)
|
|
13
|
+
sendCommands: jest.fn().mockResolvedValue(true),
|
|
14
|
+
sendProperties: jest.fn().mockResolvedValue(true)
|
|
14
15
|
};
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -151,6 +152,96 @@ describe('TuyaCloudDevice', () => {
|
|
|
151
152
|
expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'switch_1', value: true}]);
|
|
152
153
|
});
|
|
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
|
+
|
|
154
245
|
test('realtime code-only deltas are mirrored to numeric ids via the learned map', async () => {
|
|
155
246
|
const api = makeApi();
|
|
156
247
|
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
@@ -354,4 +445,97 @@ describe('TuyaCloudDevice', () => {
|
|
|
354
445
|
}
|
|
355
446
|
});
|
|
356
447
|
});
|
|
448
|
+
|
|
449
|
+
describe('write-failure handling (no log spam)', () => {
|
|
450
|
+
afterEach(() => jest.restoreAllMocks());
|
|
451
|
+
|
|
452
|
+
// A LAN-style accessory (numeric dps) over a cloud project that never
|
|
453
|
+
// learned the id→code map: the write can't be addressed, so it must not be
|
|
454
|
+
// fired (it would always be a 2008), and the cause is surfaced just once.
|
|
455
|
+
test('a numeric write with no learned code map is skipped, not sent, and surfaced once', async () => {
|
|
456
|
+
const api = makeApi(); // /status only → no dp map
|
|
457
|
+
const dev = makeDevice(api, null, {key: 'abc'}); // LAN-capable, LAN down here
|
|
458
|
+
await dev._connect();
|
|
459
|
+
expect(dev.codeByDpId).toEqual({});
|
|
460
|
+
|
|
461
|
+
const warn = jest.spyOn(log, 'warn');
|
|
462
|
+
const debug = jest.spyOn(log, 'debug');
|
|
463
|
+
|
|
464
|
+
expect(dev.update({'1': true})).toBe(false);
|
|
465
|
+
expect(api.sendCommands).not.toHaveBeenCalled();
|
|
466
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
467
|
+
expect(warn.mock.calls[0][0]).toContain('over the Tuya Cloud');
|
|
468
|
+
|
|
469
|
+
dev.update({'1': true}); // identical → quiet at debug, still not sent
|
|
470
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
471
|
+
expect(debug).toHaveBeenCalledWith(expect.stringContaining('cloud write skipped'));
|
|
472
|
+
expect(api.sendCommands).not.toHaveBeenCalled();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test('the undeliverable-write note is harmless (debug) while the device is reachable over the LAN', async () => {
|
|
476
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc', isLanConnected: () => true});
|
|
477
|
+
await dev._connect();
|
|
478
|
+
const warn = jest.spyOn(log, 'warn');
|
|
479
|
+
const debug = jest.spyOn(log, 'debug');
|
|
480
|
+
|
|
481
|
+
expect(dev.update({'1': true})).toBe(false);
|
|
482
|
+
expect(warn).not.toHaveBeenCalled();
|
|
483
|
+
expect(debug).toHaveBeenCalledWith(expect.stringContaining('over the Tuya Cloud'));
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test('a cloud-only device (no key) surfaces the undeliverable write at error level', async () => {
|
|
487
|
+
const dev = makeDevice(makeApi()); // no key → cloud is the only path
|
|
488
|
+
await dev._connect();
|
|
489
|
+
const error = jest.spyOn(log, 'error');
|
|
490
|
+
const warn = jest.spyOn(log, 'warn');
|
|
491
|
+
|
|
492
|
+
expect(dev.update({'1': true})).toBe(false);
|
|
493
|
+
expect(warn).not.toHaveBeenCalled();
|
|
494
|
+
expect(error).toHaveBeenCalledTimes(1);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// A genuinely dispatched command that the cloud rejects (e.g. a real 2008
|
|
498
|
+
// on a mapped code): surface once, then suppress identical repeats to debug.
|
|
499
|
+
test('a repeated cloud command failure is surfaced once then suppressed to debug', async () => {
|
|
500
|
+
const api = makeApi();
|
|
501
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
502
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
503
|
+
await dev._connect();
|
|
504
|
+
|
|
505
|
+
const warn = jest.spyOn(log, 'warn');
|
|
506
|
+
const debug = jest.spyOn(log, 'debug');
|
|
507
|
+
const ex = new Error('POST /v1.0/iot-03/devices/dev1/commands failed: command or value not support (code 2008)');
|
|
508
|
+
api.sendCommands.mockRejectedValue(ex);
|
|
509
|
+
api.sendProperties.mockRejectedValue(ex); // thing-model fallback can't rescue it either
|
|
510
|
+
|
|
511
|
+
await dev.update({'1': true}); // mapped → dispatched → rejected by both endpoints
|
|
512
|
+
expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'switch_1', value: true}]);
|
|
513
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
514
|
+
expect(warn.mock.calls[0][0]).toContain('code 2008');
|
|
515
|
+
|
|
516
|
+
await dev.update({'1': true}); // identical failure → quiet
|
|
517
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
518
|
+
expect(debug).toHaveBeenCalledWith(expect.stringContaining('still failing'));
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('a command failure re-surfaces after an intervening success', async () => {
|
|
522
|
+
const api = makeApi();
|
|
523
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
524
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
525
|
+
await dev._connect();
|
|
526
|
+
const warn = jest.spyOn(log, 'warn');
|
|
527
|
+
api.sendProperties.mockRejectedValue(new Error('command or value not support (code 2008)')); // fallback can't rescue
|
|
528
|
+
|
|
529
|
+
api.sendCommands.mockRejectedValueOnce(new Error('command or value not support (code 2008)'));
|
|
530
|
+
await dev.update({'1': true});
|
|
531
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
532
|
+
|
|
533
|
+
api.sendCommands.mockResolvedValueOnce(true); // success clears the dedup
|
|
534
|
+
await dev.update({'1': true});
|
|
535
|
+
|
|
536
|
+
api.sendCommands.mockRejectedValueOnce(new Error('command or value not support (code 2008)'));
|
|
537
|
+
await dev.update({'1': true});
|
|
538
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
539
|
+
});
|
|
540
|
+
});
|
|
357
541
|
});
|
package/wiki/Tuya-Cloud-Setup.md
CHANGED
|
@@ -69,3 +69,15 @@ Add a **top-level `cloud` block** with your credentials:
|
|
|
69
69
|
### Security note
|
|
70
70
|
|
|
71
71
|
Your **Access Secret** and app password are sensitive. Keep them only in your Homebridge `config.json`. If you ever share a config for support, redact them.
|
|
72
|
+
|
|
73
|
+
## Troubleshooting
|
|
74
|
+
|
|
75
|
+
### A device reads fine over the cloud but won't accept commands (`command or value not support`, code `2008` / `2003`)
|
|
76
|
+
|
|
77
|
+
The plugin controls devices through Tuya's current **`iot-03`** device API (`POST /v1.0/iot-03/devices/{id}/commands`), the same one tinytuya and the official Tuya Homebridge plugin use. That works for almost everything.
|
|
78
|
+
|
|
79
|
+
Some **custom data-points** (e.g. a gate/garage controller's open/close/stop) aren't in the device's standard instruction set — `/commands` answers `2003`/`2008` for them — yet the cloud still models them as **thing-model properties** (their status reads fine, and the device acts on the same write over the LAN). For these, **you don't need to configure anything**: when a `/commands` write is rejected with `2008`/`2003`, the plugin automatically retries it through Tuya's thing-model endpoint (`POST /v2.0/cloud/thing/{id}/shadow/properties/issue`) — which is how Tuya's own app drives such a device — and remembers the working endpoint **per data-point**. So a device that mixes ordinary DPs (a switch) with custom ones (a trigger) drives each over the endpoint it accepts; the standard commands API is always tried first.
|
|
80
|
+
|
|
81
|
+
If a write fails on **both** endpoints, the data-point is genuinely **report-only** for that device (its Tuya product defines it that way), which only the manufacturer can change — there's no cloud workaround. Such devices still work normally over the **LAN**; only the cloud fallback can't drive them.
|
|
82
|
+
|
|
83
|
+
The undocumented `debug.logCloudHttp` switch logs the full (credential-redacted) request/response for every cloud call if you want to see exactly what was sent and how the cloud replied.
|