homebridge-tuya-plus 3.14.0-dev.37 → 3.14.0-dev.39
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/AGENTS.md +3 -4
- package/lib/TuyaCloudApi.js +9 -3
- package/lib/TuyaCloudDevice.js +54 -3
- package/package.json +1 -1
- package/test/TuyaCloudApi.test.js +9 -1
- package/test/TuyaCloudDevice.test.js +101 -0
- package/wiki/Tuya-Cloud-Setup.md +10 -0
package/AGENTS.md
CHANGED
|
@@ -146,10 +146,9 @@ optional, off-by-default flags read through `_debugConfig()` and coerced with
|
|
|
146
146
|
`cloud` block to be useful).
|
|
147
147
|
- `debug.logCloudHttp` — trace every Tuya Cloud HTTP request/response (method,
|
|
148
148
|
path, headers, body, status, response) at `debug`, with all credentials
|
|
149
|
-
(token, signature, password, access key/id, uid) redacted.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
normal operation, and remember Homebridge debug logging must be on to see it.
|
|
149
|
+
(token, signature, password, access key/id, uid) redacted. Verbose and
|
|
150
|
+
per-request — keep it off in normal operation, and remember Homebridge
|
|
151
|
+
debug logging must be on to see it.
|
|
153
152
|
|
|
154
153
|
## Git & PR workflow
|
|
155
154
|
|
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,12 @@ 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});
|
|
260
266
|
return !!(res && res.result);
|
|
261
267
|
}
|
|
262
268
|
|
package/lib/TuyaCloudDevice.js
CHANGED
|
@@ -59,6 +59,8 @@ 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
|
|
62
64
|
|
|
63
65
|
// Bidirectional data-point maps, learned from the device's thing-shadow on
|
|
64
66
|
// connect. They let this cloud transport (and the LAN+cloud TuyaDevice that
|
|
@@ -335,17 +337,66 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
335
337
|
return false;
|
|
336
338
|
}
|
|
337
339
|
|
|
338
|
-
|
|
340
|
+
// Split off data-points we couldn't translate to a Tuya Cloud code: their
|
|
341
|
+
// "code" is still a numeric LAN dp id (e.g. '1'), which the commands API
|
|
342
|
+
// can't address — it would always answer "command or value not support
|
|
343
|
+
// (2008)". This is the missing-dp-map case (the device-shadow API that
|
|
344
|
+
// carries the id→code mapping isn't authorised for the project), so don't
|
|
345
|
+
// fire a doomed command; explain it once instead.
|
|
346
|
+
const sendable = [], undeliverable = [];
|
|
347
|
+
commands.forEach(c => (this._isUntranslatedDpId(c.code) ? undeliverable : sendable).push(c));
|
|
348
|
+
if (undeliverable.length) this._reportUndeliverableWrite(undeliverable);
|
|
349
|
+
if (!sendable.length) return false;
|
|
350
|
+
|
|
351
|
+
return this.api.sendCommands(this.context.id, sendable)
|
|
339
352
|
.then(ok => {
|
|
340
|
-
if (
|
|
353
|
+
if (ok) this._lastWriteError = null;
|
|
354
|
+
else this.log.warn(`${this.context.name}: Tuya Cloud rejected command ${JSON.stringify(sendable)}`);
|
|
341
355
|
return ok;
|
|
342
356
|
})
|
|
343
357
|
.catch(ex => {
|
|
344
|
-
this.
|
|
358
|
+
this._reportWriteFailure(ex.message);
|
|
345
359
|
return false;
|
|
346
360
|
});
|
|
347
361
|
}
|
|
348
362
|
|
|
363
|
+
// A Tuya Cloud data-point is addressed by a string "code" (e.g. 'switch',
|
|
364
|
+
// 'temp_set'); a code that is all digits is really a numeric LAN dp id that
|
|
365
|
+
// _toCode couldn't translate (no map learned), which the cloud can't address.
|
|
366
|
+
_isUntranslatedDpId(code) {
|
|
367
|
+
return /^\d+$/.test(code);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Surfaced once: the cloud can't address these data-points because the numeric
|
|
371
|
+
// id→code map was never learned (the device-shadow/thing-model API isn't
|
|
372
|
+
// authorised for this project — the same gap that makes shadow/properties
|
|
373
|
+
// return "No space permission"). Mirrors the connect-failure dedup: one
|
|
374
|
+
// actionable line, then quiet at debug. Stays harmless (debug) while the device
|
|
375
|
+
// is reachable over the LAN, since the cloud is only a fallback there.
|
|
376
|
+
_reportUndeliverableWrite(commands) {
|
|
377
|
+
const ids = commands.map(c => c.code).join(', ');
|
|
378
|
+
if (this._warnedNoCloudCode) {
|
|
379
|
+
return this.log.debug(`${this.context.name}: cloud write skipped — no Tuya Cloud code for data-point(s) ${ids}.`);
|
|
380
|
+
}
|
|
381
|
+
this._warnedNoCloudCode = true;
|
|
382
|
+
const lanReachable = !!(this.isLanConnected && this.isLanConnected());
|
|
383
|
+
const level = lanReachable ? 'debug' : (this.context.key ? 'warn' : 'error');
|
|
384
|
+
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.`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// A genuine command failure (the cloud was reached but rejected/errored).
|
|
388
|
+
// Deduped like the connect path — surfaced once, identical repeats drop to
|
|
389
|
+
// debug — with the level matched to severity: a LAN-capable device is only
|
|
390
|
+
// momentarily off the LAN (warn), a cloud-only one is stuck until it clears
|
|
391
|
+
// (error). _lastWriteError is cleared on the next successful command.
|
|
392
|
+
_reportWriteFailure(message) {
|
|
393
|
+
if (message === this._lastWriteError) {
|
|
394
|
+
return this.log.debug(`${this.context.name}: cloud command still failing: ${message}`);
|
|
395
|
+
}
|
|
396
|
+
this._lastWriteError = message;
|
|
397
|
+
this.log[this.context.key ? 'warn' : 'error'](`${this.context.name}: cloud command failed: ${message}`);
|
|
398
|
+
}
|
|
399
|
+
|
|
349
400
|
// Resolve a write key (a numeric LAN dp id or a string code) to the Tuya Cloud
|
|
350
401
|
// `code` the commands API expects. A numeric id with a known code is
|
|
351
402
|
// translated; anything else (already a code, or an unmapped id) is passed
|
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.39",
|
|
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,14 @@ 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
|
+
|
|
187
195
|
test('getMqttConfig posts the expected body and returns the broker config', async () => {
|
|
188
196
|
const api = ready();
|
|
189
197
|
api.uid = 'UID';
|
|
@@ -151,6 +151,16 @@ describe('TuyaCloudDevice', () => {
|
|
|
151
151
|
expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'switch_1', value: true}]);
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
test('a translated write is dispatched to the (iot-03) commands API', async () => {
|
|
155
|
+
const api = makeApi();
|
|
156
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'wfh_close', dp_id: 102, value: false}]);
|
|
157
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
158
|
+
await dev._connect();
|
|
159
|
+
|
|
160
|
+
await dev.update({'102': true});
|
|
161
|
+
expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'wfh_close', value: true}]);
|
|
162
|
+
});
|
|
163
|
+
|
|
154
164
|
test('realtime code-only deltas are mirrored to numeric ids via the learned map', async () => {
|
|
155
165
|
const api = makeApi();
|
|
156
166
|
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
@@ -354,4 +364,95 @@ describe('TuyaCloudDevice', () => {
|
|
|
354
364
|
}
|
|
355
365
|
});
|
|
356
366
|
});
|
|
367
|
+
|
|
368
|
+
describe('write-failure handling (no log spam)', () => {
|
|
369
|
+
afterEach(() => jest.restoreAllMocks());
|
|
370
|
+
|
|
371
|
+
// A LAN-style accessory (numeric dps) over a cloud project that never
|
|
372
|
+
// learned the id→code map: the write can't be addressed, so it must not be
|
|
373
|
+
// fired (it would always be a 2008), and the cause is surfaced just once.
|
|
374
|
+
test('a numeric write with no learned code map is skipped, not sent, and surfaced once', async () => {
|
|
375
|
+
const api = makeApi(); // /status only → no dp map
|
|
376
|
+
const dev = makeDevice(api, null, {key: 'abc'}); // LAN-capable, LAN down here
|
|
377
|
+
await dev._connect();
|
|
378
|
+
expect(dev.codeByDpId).toEqual({});
|
|
379
|
+
|
|
380
|
+
const warn = jest.spyOn(log, 'warn');
|
|
381
|
+
const debug = jest.spyOn(log, 'debug');
|
|
382
|
+
|
|
383
|
+
expect(dev.update({'1': true})).toBe(false);
|
|
384
|
+
expect(api.sendCommands).not.toHaveBeenCalled();
|
|
385
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
386
|
+
expect(warn.mock.calls[0][0]).toContain('over the Tuya Cloud');
|
|
387
|
+
|
|
388
|
+
dev.update({'1': true}); // identical → quiet at debug, still not sent
|
|
389
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
390
|
+
expect(debug).toHaveBeenCalledWith(expect.stringContaining('cloud write skipped'));
|
|
391
|
+
expect(api.sendCommands).not.toHaveBeenCalled();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('the undeliverable-write note is harmless (debug) while the device is reachable over the LAN', async () => {
|
|
395
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc', isLanConnected: () => true});
|
|
396
|
+
await dev._connect();
|
|
397
|
+
const warn = jest.spyOn(log, 'warn');
|
|
398
|
+
const debug = jest.spyOn(log, 'debug');
|
|
399
|
+
|
|
400
|
+
expect(dev.update({'1': true})).toBe(false);
|
|
401
|
+
expect(warn).not.toHaveBeenCalled();
|
|
402
|
+
expect(debug).toHaveBeenCalledWith(expect.stringContaining('over the Tuya Cloud'));
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('a cloud-only device (no key) surfaces the undeliverable write at error level', async () => {
|
|
406
|
+
const dev = makeDevice(makeApi()); // no key → cloud is the only path
|
|
407
|
+
await dev._connect();
|
|
408
|
+
const error = jest.spyOn(log, 'error');
|
|
409
|
+
const warn = jest.spyOn(log, 'warn');
|
|
410
|
+
|
|
411
|
+
expect(dev.update({'1': true})).toBe(false);
|
|
412
|
+
expect(warn).not.toHaveBeenCalled();
|
|
413
|
+
expect(error).toHaveBeenCalledTimes(1);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// A genuinely dispatched command that the cloud rejects (e.g. a real 2008
|
|
417
|
+
// on a mapped code): surface once, then suppress identical repeats to debug.
|
|
418
|
+
test('a repeated cloud command failure is surfaced once then suppressed to debug', async () => {
|
|
419
|
+
const api = makeApi();
|
|
420
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
421
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
422
|
+
await dev._connect();
|
|
423
|
+
|
|
424
|
+
const warn = jest.spyOn(log, 'warn');
|
|
425
|
+
const debug = jest.spyOn(log, 'debug');
|
|
426
|
+
const ex = new Error('POST /v1.0/iot-03/devices/dev1/commands failed: command or value not support (code 2008)');
|
|
427
|
+
api.sendCommands.mockRejectedValue(ex);
|
|
428
|
+
|
|
429
|
+
await dev.update({'1': true}); // mapped → dispatched → rejected
|
|
430
|
+
expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'switch_1', value: true}]);
|
|
431
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
432
|
+
expect(warn.mock.calls[0][0]).toContain('code 2008');
|
|
433
|
+
|
|
434
|
+
await dev.update({'1': true}); // identical failure → quiet
|
|
435
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
436
|
+
expect(debug).toHaveBeenCalledWith(expect.stringContaining('still failing'));
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('a command failure re-surfaces after an intervening success', async () => {
|
|
440
|
+
const api = makeApi();
|
|
441
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
442
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
443
|
+
await dev._connect();
|
|
444
|
+
const warn = jest.spyOn(log, 'warn');
|
|
445
|
+
|
|
446
|
+
api.sendCommands.mockRejectedValueOnce(new Error('command or value not support (code 2008)'));
|
|
447
|
+
await dev.update({'1': true});
|
|
448
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
449
|
+
|
|
450
|
+
api.sendCommands.mockResolvedValueOnce(true); // success clears the dedup
|
|
451
|
+
await dev.update({'1': true});
|
|
452
|
+
|
|
453
|
+
api.sendCommands.mockRejectedValueOnce(new Error('command or value not support (code 2008)'));
|
|
454
|
+
await dev.update({'1': true});
|
|
455
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
357
458
|
});
|
package/wiki/Tuya-Cloud-Setup.md
CHANGED
|
@@ -69,3 +69,13 @@ 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. Tuya's older `/v1.0/devices/*` endpoints answer `2003` (*function not support*) or `2008` for devices they don't fully model — using `iot-03` avoids that for the vast majority of devices.
|
|
78
|
+
|
|
79
|
+
If a write still fails with `2008`/`2003` on `iot-03`, the data-point is genuinely not cloud-controllable for that device: its Tuya product defines the DP as **report-only** (it shows up in status but can't be commanded), even though the device acts on it over the LAN. That can only be changed by the device's manufacturer in the Tuya cloud — there's no cloud workaround. Such devices still work normally over the **LAN**; only the cloud fallback can't drive them.
|
|
80
|
+
|
|
81
|
+
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.
|