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.
@@ -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
 
@@ -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
- return this.api.sendCommands(this.context.id, commands)
339
- .then(ok => {
340
- if (!ok) this.log.warn(`${this.context.name}: Tuya Cloud rejected command ${JSON.stringify(commands)}`);
341
- return ok;
342
- })
343
- .catch(ex => {
344
- this.log.error(`${this.context.name}: cloud command failed: ${ex.message}`);
345
- return false;
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.40",
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
  });
@@ -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.