homebridge-tuya-plus 3.14.0-dev.38 → 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.
@@ -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
 
@@ -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
- return this.api.sendCommands(this.context.id, commands)
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 (!ok) this.log.warn(`${this.context.name}: Tuya Cloud rejected command ${JSON.stringify(commands)}`);
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.log.error(`${this.context.name}: cloud command failed: ${ex.message}`);
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.38",
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
  });
@@ -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.