homebridge-tuya-plus 3.14.0-dev.40 → 3.14.0-dev.42

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,23 @@ 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;
78
+ this._catchupTimers = null; // post-write state re-reads (see _scheduleStateCatchup)
62
79
 
63
80
  // Bidirectional data-point maps, learned from the device's thing-shadow on
64
81
  // connect. They let this cloud transport (and the LAN+cloud TuyaDevice that
@@ -208,8 +225,11 @@ class TuyaCloudDevice extends EventEmitter {
208
225
  this.connected = online;
209
226
  this.log.info(`${this.context.name}: Tuya now reports the device ${online ? 'online' : 'offline'}.`);
210
227
  }
211
- const status = await this.api.getStatus(this.context.id);
212
- this._applyStatus(status);
228
+ // Read via the same shadow-preferred path as the initial connect, not
229
+ // the plain /status endpoint: a thing-model-only device (e.g. a custom
230
+ // gate controller) returns an EMPTY /status result, so /status would
231
+ // silently wipe nothing in and never reflect a state change.
232
+ this._applyStatus(await this._readInitialProperties());
213
233
  } catch (ex) {
214
234
  this.log.debug(`${this.context.name}: cloud state refresh failed: ${ex.message}`);
215
235
  }
@@ -335,15 +355,169 @@ class TuyaCloudDevice extends EventEmitter {
335
355
  return false;
336
356
  }
337
357
 
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
- });
358
+ // Split off data-points we couldn't translate to a Tuya Cloud code: their
359
+ // "code" is still a numeric LAN dp id (e.g. '1'), which the commands API
360
+ // can't address it would always answer "command or value not support
361
+ // (2008)". This is the missing-dp-map case (the device-shadow API that
362
+ // carries the id→code mapping isn't authorised for the project), so don't
363
+ // fire a doomed command; explain it once instead.
364
+ const sendable = [], undeliverable = [];
365
+ commands.forEach(c => (this._isUntranslatedDpId(c.code) ? undeliverable : sendable).push(c));
366
+ if (undeliverable.length) this._reportUndeliverableWrite(undeliverable);
367
+ if (!sendable.length) return false;
368
+
369
+ return this._dispatch(sendable);
370
+ }
371
+
372
+ // Send a write, routing each data-point to the cloud endpoint it accepts. Codes
373
+ // go over the standard commands API unless we've learned they need the thing
374
+ // model, so the two groups can travel in parallel to their own endpoints. A
375
+ // device with both kinds (a normal switch plus a custom trigger) drives each
376
+ // correctly. Resolves true only if every group succeeded, false otherwise
377
+ // (never rejects), mirroring TuyaAccessory.update for the awaiting accessory.
378
+ async _dispatch(commands) {
379
+ const viaProperties = commands.filter(c => this._codeMethod[c.code] === 'properties');
380
+ const viaCommands = commands.filter(c => this._codeMethod[c.code] !== 'properties');
381
+
382
+ const [byCommands, byProperties] = await Promise.all([
383
+ this._sendGroup('commands', viaCommands),
384
+ this._sendGroup('properties', viaProperties)
385
+ ]);
386
+
387
+ if (byCommands.ok && byProperties.ok) {
388
+ this._lastWriteError = null;
389
+ this._scheduleStateCatchup();
390
+ return true;
391
+ }
392
+ this._reportWriteFailure(byCommands.error || byProperties.error);
393
+ return false;
394
+ }
395
+
396
+ // Send one group of codes over `method`, recording per code which endpoint
397
+ // worked. A commands group rejected as unsupported (2008/2003) is retried over
398
+ // the thing-model property endpoint — where a custom device's data-points live —
399
+ // but only for codes not already confirmed on commands or already tried there,
400
+ // so a confirmed code is never moved and an uncontrollable one can't re-probe on
401
+ // every write. Resolves to {ok, error}; never rejects.
402
+ async _sendGroup(method, codes) {
403
+ if (!codes.length) return {ok: true};
404
+
405
+ const res = await this._send(method, codes);
406
+ if (res.ok) return this._learn(codes, method);
407
+ if (method !== 'commands' || !this._isInstructionMismatch(res.error)) return res;
408
+
409
+ const fresh = codes.filter(c => this._codeMethod[c.code] !== 'commands' && !this._propertiesTried.has(c.code));
410
+ if (!fresh.length) return res;
411
+ fresh.forEach(c => this._propertiesTried.add(c.code));
412
+
413
+ this.log.debug(`${this.context.name}: the cloud command API rejected ${this._codesOf(fresh)} (${res.error}); trying the thing-model property endpoint.`);
414
+ const viaProperties = await this._send('properties', fresh);
415
+ if (!viaProperties.ok) {
416
+ this.log.debug(`${this.context.name}: the thing-model property endpoint also failed for ${this._codesOf(fresh)}: ${viaProperties.error}`);
417
+ return res;
418
+ }
419
+ if (!this._announcedProperties) {
420
+ this._announcedProperties = true;
421
+ 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).`);
422
+ }
423
+ return this._learn(fresh, 'properties');
424
+ }
425
+
426
+ // After a cloud write the resulting state (e.g. a gate's return_state flipping
427
+ // to "opening") shows up in the cloud within ~a second, but the realtime stream
428
+ // doesn't always carry it — and an accessory that mirrors a status DP (the
429
+ // garage door) would otherwise sit on the old value forever. So re-read the
430
+ // device a couple of times after a command to catch the transition. Each new
431
+ // command re-arms it, so a burst of writes only triggers one catch-up; it only
432
+ // runs on the cloud path, so a LAN-reachable device pays nothing.
433
+ _scheduleStateCatchup() {
434
+ if (this._catchupTimers) this._catchupTimers.forEach(t => clearTimeout(t));
435
+ this._catchupTimers = [2000, 8000].map(delay => {
436
+ const t = setTimeout(() => this._catchupOnce(), delay);
437
+ if (t && t.unref) t.unref();
438
+ return t;
439
+ });
440
+ }
441
+
442
+ async _catchupOnce() {
443
+ if (this._stopped) return;
444
+ try {
445
+ this._applyStatus(await this._readInitialProperties());
446
+ } catch (ex) {
447
+ this.log.debug(`${this.context.name}: post-write state catch-up failed: ${ex.message}`);
448
+ }
449
+ }
450
+
451
+ // Invoke one control endpoint; resolves to {ok, error} and never rejects, so a
452
+ // missing-method mock or a network error can't escape the dispatch.
453
+ _send(method, commands) {
454
+ let call;
455
+ try {
456
+ call = method === 'properties'
457
+ ? this.api.sendProperties(this.context.id, commands)
458
+ : this.api.sendCommands(this.context.id, commands);
459
+ } catch (ex) {
460
+ return Promise.resolve({ok: false, error: ex && ex.message ? ex.message : String(ex)});
461
+ }
462
+ return Promise.resolve(call).then(
463
+ res => res !== false ? {ok: true} : {ok: false, error: `Tuya Cloud rejected ${JSON.stringify(commands)}`},
464
+ ex => ({ok: false, error: ex && ex.message ? ex.message : String(ex)})
465
+ );
466
+ }
467
+
468
+ _learn(codes, method) {
469
+ codes.forEach(c => { this._codeMethod[c.code] = method; });
470
+ return {ok: true};
471
+ }
472
+
473
+ _codesOf(commands) {
474
+ return commands.map(c => c.code).join(', ');
475
+ }
476
+
477
+ // True for the "this code/value isn't a valid command for this device" errors —
478
+ // command or value not support (2008) and function not support (2003) — which
479
+ // are what a device with no standard instruction set answers, and exactly the
480
+ // case the thing-model endpoint can still handle. Other failures (auth,
481
+ // permission, network) won't be helped by the fallback, so we don't probe.
482
+ _isInstructionMismatch(message) {
483
+ return /\bcode (2008|2003)\b|command or value not support|function not support/i.test(message || '');
484
+ }
485
+
486
+ // A Tuya Cloud data-point is addressed by a string "code" (e.g. 'switch',
487
+ // 'temp_set'); a code that is all digits is really a numeric LAN dp id that
488
+ // _toCode couldn't translate (no map learned), which the cloud can't address.
489
+ _isUntranslatedDpId(code) {
490
+ return /^\d+$/.test(code);
491
+ }
492
+
493
+ // Surfaced once: the cloud can't address these data-points because the numeric
494
+ // id→code map was never learned (the device-shadow/thing-model API isn't
495
+ // authorised for this project — the same gap that makes shadow/properties
496
+ // return "No space permission"). Mirrors the connect-failure dedup: one
497
+ // actionable line, then quiet at debug. Stays harmless (debug) while the device
498
+ // is reachable over the LAN, since the cloud is only a fallback there.
499
+ _reportUndeliverableWrite(commands) {
500
+ const ids = commands.map(c => c.code).join(', ');
501
+ if (this._warnedNoCloudCode) {
502
+ return this.log.debug(`${this.context.name}: cloud write skipped — no Tuya Cloud code for data-point(s) ${ids}.`);
503
+ }
504
+ this._warnedNoCloudCode = true;
505
+ const lanReachable = !!(this.isLanConnected && this.isLanConnected());
506
+ const level = lanReachable ? 'debug' : (this.context.key ? 'warn' : 'error');
507
+ 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.`);
508
+ }
509
+
510
+ // A genuine command failure (the cloud was reached but rejected/errored).
511
+ // Deduped like the connect path — surfaced once, identical repeats drop to
512
+ // debug — with the level matched to severity: a LAN-capable device is only
513
+ // momentarily off the LAN (warn), a cloud-only one is stuck until it clears
514
+ // (error). _lastWriteError is cleared on the next successful command.
515
+ _reportWriteFailure(message) {
516
+ if (message === this._lastWriteError) {
517
+ return this.log.debug(`${this.context.name}: cloud command still failing: ${message}`);
518
+ }
519
+ this._lastWriteError = message;
520
+ this.log[this.context.key ? 'warn' : 'error'](`${this.context.name}: cloud command failed: ${message}`);
347
521
  }
348
522
 
349
523
  // Resolve a write key (a numeric LAN dp id or a string code) to the Tuya Cloud
@@ -361,6 +535,7 @@ class TuyaCloudDevice extends EventEmitter {
361
535
  stop() {
362
536
  this._stopped = true;
363
537
  if (this._retryTimer) { clearTimeout(this._retryTimer); this._retryTimer = null; }
538
+ if (this._catchupTimers) { this._catchupTimers.forEach(t => clearTimeout(t)); this._catchupTimers = null; }
364
539
  }
365
540
  }
366
541
 
@@ -152,6 +152,12 @@ class TuyaCloudMessaging extends EventEmitter {
152
152
  if (!devId || !Array.isArray(status)) return;
153
153
 
154
154
  const handlers = this._handlers.get('' + devId);
155
+ // Trace what the realtime stream actually delivers (debug only). A device
156
+ // whose status never arrives here — while it clearly changed in the cloud —
157
+ // is the signature of the message service not pushing that device's reports.
158
+ try {
159
+ this.log.debug(`Tuya Cloud realtime update for ${devId}${handlers && handlers.length ? '' : ' (no subscriber)'}: ${JSON.stringify(status)}`);
160
+ } catch (_) { /* logging must never throw */ }
155
161
  if (!handlers || !handlers.length) return;
156
162
  handlers.forEach(fn => {
157
163
  try { fn(status); } catch (ex) { this.log.debug(`Tuya Cloud realtime handler error: ${ex.message}`); }
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.42",
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}]);
@@ -202,6 +293,51 @@ describe('TuyaCloudDevice', () => {
202
293
  expect(api.getStatus).toHaveBeenCalledWith('dev1');
203
294
  });
204
295
 
296
+ describe('cloud state sync', () => {
297
+ test('the post-write catch-up re-reads the shadow and emits the resulting change', async () => {
298
+ const api = makeApi();
299
+ let rs = 13;
300
+ api.getShadowProperties = jest.fn(async () => [{code: 'return_state', dp_id: 105, value: rs}]);
301
+ const dev = makeDevice(api, null, {key: 'abc'});
302
+ await dev._connect(); // state: return_state=13, '105'=13
303
+
304
+ const changes = [];
305
+ dev.on('change', c => changes.push(c));
306
+ rs = 12; // the gate is now "opening" in the cloud
307
+ await dev._catchupOnce();
308
+ expect(changes[0]).toEqual({return_state: 12, '105': 12});
309
+ });
310
+
311
+ test('a successful cloud write arms a state catch-up', async () => {
312
+ const api = makeApi();
313
+ api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
314
+ const dev = makeDevice(api, null, {key: 'abc'});
315
+ await dev._connect();
316
+ expect(dev._catchupTimers).toBeNull();
317
+ await dev.update({'1': true});
318
+ expect(Array.isArray(dev._catchupTimers)).toBe(true);
319
+ dev.stop();
320
+ });
321
+
322
+ test('a refresh reads via the shadow, so a thing-model device whose /status is empty still updates', async () => {
323
+ const api = makeApi();
324
+ let rs = 13;
325
+ api.getStatus = jest.fn().mockResolvedValue([]); // thing-model-only device: /status comes back empty
326
+ api.getShadowProperties = jest.fn(async () => [{code: 'return_state', dp_id: 105, value: rs}]);
327
+ const dev = makeDevice(api, null, {key: 'abc'});
328
+ await dev._connect();
329
+
330
+ const changes = [];
331
+ dev.on('change', c => changes.push(c));
332
+ rs = 12;
333
+ await dev._refreshState();
334
+
335
+ expect(api.getShadowProperties).toHaveBeenCalled();
336
+ expect(changes.some(c => c['105'] === 12)).toBe(true);
337
+ expect(dev.state['105']).toBe(12); // not wiped by the empty /status
338
+ });
339
+ });
340
+
205
341
  describe('connect-failure handling (no log spam)', () => {
206
342
  afterEach(() => jest.restoreAllMocks());
207
343
 
@@ -354,4 +490,97 @@ describe('TuyaCloudDevice', () => {
354
490
  }
355
491
  });
356
492
  });
493
+
494
+ describe('write-failure handling (no log spam)', () => {
495
+ afterEach(() => jest.restoreAllMocks());
496
+
497
+ // A LAN-style accessory (numeric dps) over a cloud project that never
498
+ // learned the id→code map: the write can't be addressed, so it must not be
499
+ // fired (it would always be a 2008), and the cause is surfaced just once.
500
+ test('a numeric write with no learned code map is skipped, not sent, and surfaced once', async () => {
501
+ const api = makeApi(); // /status only → no dp map
502
+ const dev = makeDevice(api, null, {key: 'abc'}); // LAN-capable, LAN down here
503
+ await dev._connect();
504
+ expect(dev.codeByDpId).toEqual({});
505
+
506
+ const warn = jest.spyOn(log, 'warn');
507
+ const debug = jest.spyOn(log, 'debug');
508
+
509
+ expect(dev.update({'1': true})).toBe(false);
510
+ expect(api.sendCommands).not.toHaveBeenCalled();
511
+ expect(warn).toHaveBeenCalledTimes(1);
512
+ expect(warn.mock.calls[0][0]).toContain('over the Tuya Cloud');
513
+
514
+ dev.update({'1': true}); // identical → quiet at debug, still not sent
515
+ expect(warn).toHaveBeenCalledTimes(1);
516
+ expect(debug).toHaveBeenCalledWith(expect.stringContaining('cloud write skipped'));
517
+ expect(api.sendCommands).not.toHaveBeenCalled();
518
+ });
519
+
520
+ test('the undeliverable-write note is harmless (debug) while the device is reachable over the LAN', async () => {
521
+ const dev = makeDevice(makeApi(), null, {key: 'abc', isLanConnected: () => true});
522
+ await dev._connect();
523
+ const warn = jest.spyOn(log, 'warn');
524
+ const debug = jest.spyOn(log, 'debug');
525
+
526
+ expect(dev.update({'1': true})).toBe(false);
527
+ expect(warn).not.toHaveBeenCalled();
528
+ expect(debug).toHaveBeenCalledWith(expect.stringContaining('over the Tuya Cloud'));
529
+ });
530
+
531
+ test('a cloud-only device (no key) surfaces the undeliverable write at error level', async () => {
532
+ const dev = makeDevice(makeApi()); // no key → cloud is the only path
533
+ await dev._connect();
534
+ const error = jest.spyOn(log, 'error');
535
+ const warn = jest.spyOn(log, 'warn');
536
+
537
+ expect(dev.update({'1': true})).toBe(false);
538
+ expect(warn).not.toHaveBeenCalled();
539
+ expect(error).toHaveBeenCalledTimes(1);
540
+ });
541
+
542
+ // A genuinely dispatched command that the cloud rejects (e.g. a real 2008
543
+ // on a mapped code): surface once, then suppress identical repeats to debug.
544
+ test('a repeated cloud command failure is surfaced once then suppressed to debug', async () => {
545
+ const api = makeApi();
546
+ api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
547
+ const dev = makeDevice(api, null, {key: 'abc'});
548
+ await dev._connect();
549
+
550
+ const warn = jest.spyOn(log, 'warn');
551
+ const debug = jest.spyOn(log, 'debug');
552
+ const ex = new Error('POST /v1.0/iot-03/devices/dev1/commands failed: command or value not support (code 2008)');
553
+ api.sendCommands.mockRejectedValue(ex);
554
+ api.sendProperties.mockRejectedValue(ex); // thing-model fallback can't rescue it either
555
+
556
+ await dev.update({'1': true}); // mapped → dispatched → rejected by both endpoints
557
+ expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'switch_1', value: true}]);
558
+ expect(warn).toHaveBeenCalledTimes(1);
559
+ expect(warn.mock.calls[0][0]).toContain('code 2008');
560
+
561
+ await dev.update({'1': true}); // identical failure → quiet
562
+ expect(warn).toHaveBeenCalledTimes(1);
563
+ expect(debug).toHaveBeenCalledWith(expect.stringContaining('still failing'));
564
+ });
565
+
566
+ test('a command failure re-surfaces after an intervening success', async () => {
567
+ const api = makeApi();
568
+ api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
569
+ const dev = makeDevice(api, null, {key: 'abc'});
570
+ await dev._connect();
571
+ const warn = jest.spyOn(log, 'warn');
572
+ api.sendProperties.mockRejectedValue(new Error('command or value not support (code 2008)')); // fallback can't rescue
573
+
574
+ api.sendCommands.mockRejectedValueOnce(new Error('command or value not support (code 2008)'));
575
+ await dev.update({'1': true});
576
+ expect(warn).toHaveBeenCalledTimes(1);
577
+
578
+ api.sendCommands.mockResolvedValueOnce(true); // success clears the dedup
579
+ await dev.update({'1': true});
580
+
581
+ api.sendCommands.mockRejectedValueOnce(new Error('command or value not support (code 2008)'));
582
+ await dev.update({'1': true});
583
+ expect(warn).toHaveBeenCalledTimes(2);
584
+ });
585
+ });
357
586
  });
@@ -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.