homebridge-tuya-plus 3.14.0-dev.4 → 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.
Files changed (53) hide show
  1. package/.github/workflows/publish-dev.yml +298 -31
  2. package/AGENTS.md +162 -0
  3. package/CLAUDE.md +1 -0
  4. package/Changelog.md +16 -3
  5. package/Readme.MD +8 -32
  6. package/config.schema.json +63 -78
  7. package/index.js +599 -358
  8. package/lib/AirPurifierAccessory.js +1 -1
  9. package/lib/BaseAccessory.js +54 -17
  10. package/lib/ConvectorAccessory.js +1 -1
  11. package/lib/CustomMultiOutletAccessory.js +2 -1
  12. package/lib/DoorbellAccessory.js +9 -16
  13. package/lib/GarageDoorAccessory.js +1 -1
  14. package/lib/IrrigationSystemAccessory.js +93 -114
  15. package/lib/MultiOutletAccessory.js +3 -2
  16. package/lib/OilDiffuserAccessory.js +10 -8
  17. package/lib/RGBTWLightAccessory.js +13 -11
  18. package/lib/RGBTWOutletAccessory.js +11 -9
  19. package/lib/SimpleBlindsAccessory.js +5 -5
  20. package/lib/SimpleDimmer2Accessory.js +1 -1
  21. package/lib/SimpleDimmerAccessory.js +10 -6
  22. package/lib/SimpleGarageDoorAccessory.js +78 -24
  23. package/lib/SimpleHeaterAccessory.js +4 -4
  24. package/lib/SimpleLightAccessory.js +1 -1
  25. package/lib/SwitchAccessory.js +3 -2
  26. package/lib/TWLightAccessory.js +2 -2
  27. package/lib/TuyaAccessory.js +75 -42
  28. package/lib/TuyaCloudApi.js +115 -7
  29. package/lib/TuyaCloudDevice.js +351 -28
  30. package/lib/TuyaCloudMessaging.js +28 -41
  31. package/lib/TuyaDevice.js +269 -0
  32. package/lib/TuyaDiscovery.js +25 -9
  33. package/lib/ValveAccessory.js +16 -12
  34. package/lib/VerticalBlindsWithTilt.js +27 -25
  35. package/lib/WledDimmerAccessory.js +46 -81
  36. package/package.json +5 -6
  37. package/scripts/cleanup-pr-tags.js +122 -0
  38. package/test/BaseAccessory.test.js +94 -15
  39. package/test/IrrigationSystemAccessory.test.js +122 -68
  40. package/test/MultiOutletAccessory.test.js +18 -3
  41. package/test/RGBTWLightAccessory.test.js +3 -3
  42. package/test/SimpleFanLightAccessory.test.js +3 -3
  43. package/test/SimpleGarageDoorAccessory.test.js +63 -9
  44. package/test/TuyaAccessory.protocol.test.js +76 -3
  45. package/test/TuyaCloudApi.test.js +110 -1
  46. package/test/TuyaCloudDevice.test.js +438 -2
  47. package/test/TuyaCloudMessaging.test.js +24 -5
  48. package/test/TuyaDevice.test.js +266 -0
  49. package/test/getCategory.test.js +1 -0
  50. package/test/index.test.js +271 -0
  51. package/test/support/mocks.js +13 -0
  52. package/wiki/Supported-Device-Types.md +48 -30
  53. package/wiki/Tuya-Cloud-Setup.md +31 -108
@@ -39,17 +39,51 @@ class TuyaCloudDevice extends EventEmitter {
39
39
  this.api = props.cloudApi;
40
40
  this.messaging = props.messaging || null; // shared realtime stream
41
41
 
42
+ // Lets the cloud backend ask its parent TuyaDevice whether the SAME device
43
+ // is currently reachable over the LAN. A cloud-fallback failure is harmless
44
+ // when the LAN is up and worth surfacing when it isn't, so the failure log
45
+ // adapts to it. Null when used standalone (no LAN sibling).
46
+ this.isLanConnected = typeof props.isLanConnected === 'function' ? props.isLanConnected : null;
47
+
42
48
  // Keep a plain config object on `context`, mirroring TuyaAccessory, but
43
49
  // without the live helper objects we were handed.
44
50
  this.context = {...props};
45
51
  delete this.context.cloudApi;
46
52
  delete this.context.messaging;
47
53
  delete this.context.log;
54
+ delete this.context.isLanConnected;
48
55
 
49
56
  this.state = {};
50
57
  this.connected = false;
51
58
  this._stopped = false;
52
59
  this._retryTimer = null;
60
+ this._retryDelay = 0; // current connect-retry backoff (ms); grows on repeated failure
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
+
79
+ // Bidirectional data-point maps, learned from the device's thing-shadow on
80
+ // connect. They let this cloud transport (and the LAN+cloud TuyaDevice that
81
+ // may wrap it) translate between the LAN's numeric data-point ids and the
82
+ // cloud's string codes. Empty until the shadow is read; empty means
83
+ // "code-only" (no numeric bridge), which is still fully functional for a
84
+ // cloud-configured accessory.
85
+ this.codeByDpId = {}; // '20' -> 'switch_led'
86
+ this.dpIdByCode = {}; // 'switch_led' -> '20'
53
87
 
54
88
  if (props.connect !== false) this._connect();
55
89
  }
@@ -63,12 +97,33 @@ class TuyaCloudDevice extends EventEmitter {
63
97
  return this.log.error(`${this.context.name}: Tuya Cloud is not configured (missing accessId/accessKey).`);
64
98
  }
65
99
 
100
+ // First connect only: stagger the initial read so a large install doesn't
101
+ // fire every device's OpenAPI call in the same instant (rate limits). The
102
+ // platform hands each device an increasing cloudStartDelay.
103
+ if (!this._staggered) {
104
+ this._staggered = true;
105
+ const delay = parseInt(this.context.cloudStartDelay) || 0;
106
+ if (delay > 0) {
107
+ await new Promise(resolve => { const t = setTimeout(resolve, delay); if (t.unref) t.unref(); });
108
+ if (this._stopped) return;
109
+ }
110
+ }
111
+
66
112
  try {
67
- const status = await this.api.getStatus(this.context.id);
68
- this.state = this._statusToState(status);
69
- this.connected = true;
113
+ const props = await this._readInitialProperties();
114
+ this._learnDpMap(props);
115
+ this.state = this._propsToState(props);
116
+ this.connected = await this._readOnline();
70
117
 
71
- this._logDiscoveredCodes(status);
118
+ this._logDiscoveredCodes(props);
119
+
120
+ // Connected: announce recovery from any earlier failure once, and
121
+ // reset the retry backoff.
122
+ if (this._lastConnectError) {
123
+ this.log.info(`${this.context.name}: Tuya Cloud connection restored.`);
124
+ this._lastConnectError = null;
125
+ }
126
+ this._retryDelay = 0;
72
127
 
73
128
  this.emit('connect');
74
129
  // Accessories register their characteristics off the first 'change'.
@@ -76,12 +131,74 @@ class TuyaCloudDevice extends EventEmitter {
76
131
 
77
132
  this._subscribeRealtime();
78
133
  } catch (ex) {
79
- this.log.error(`${this.context.name}: failed to connect to Tuya Cloud: ${ex.message}`);
80
- // Retry later — credentials/permissions/network may recover.
81
- if (!this._stopped) {
82
- this._retryTimer = setTimeout(() => this._connect(), 30000);
83
- }
134
+ this._onConnectFailure(ex);
135
+ }
136
+ }
137
+
138
+ // A failed connect is retried, but the cause is frequently permanent — the
139
+ // cloud project can't see this device (permission denied / "no space"), the
140
+ // datacenter region is wrong, or the device isn't linked to the account — and
141
+ // won't clear without the user changing their Tuya project. Since PR #71 the
142
+ // cloud backs *every* device as a fallback, so a LAN-only device whose cloud
143
+ // project lacks permission would otherwise log an error every 30s forever.
144
+ // Two things keep that out of the log: a given message is surfaced once and then
145
+ // suppressed to debug until it changes (or the device connects), and the retry
146
+ // interval backs off (30s → 30m cap) instead of hammering the OpenAPI.
147
+ _onConnectFailure(ex) {
148
+ const message = ex && ex.message ? ex.message : String(ex);
149
+ const {level, text} = this._describeConnectFailure(message);
150
+
151
+ // The full (level + wording) is what the dedup tracks, not just the raw
152
+ // message: it means a device that flips from "reachable over the LAN"
153
+ // (harmless, debug) to "not reachable over the LAN either" (warn) re-surfaces
154
+ // the change, while an unchanged situation stays quiet.
155
+ if (text !== this._lastConnectError) {
156
+ this.log[level](text);
157
+ this._lastConnectError = text;
158
+ } else {
159
+ this.log.debug(`${this.context.name}: Tuya Cloud connection still failing: ${message}`);
160
+ }
161
+
162
+ if (this._stopped) return;
163
+ // Exponential backoff: 30s after the first failure, doubling to a 30m cap.
164
+ this._retryDelay = Math.min(this._retryDelay ? this._retryDelay * 2 : 30000, 1800000);
165
+ this._retryTimer = setTimeout(() => this._connect(), this._retryDelay);
166
+ if (this._retryTimer && this._retryTimer.unref) this._retryTimer.unref();
167
+ }
168
+
169
+ // Pick the log level and wording for a connect failure from what we know about
170
+ // the device: whether the cloud is merely a fallback (the device has a local
171
+ // key) and, if so, whether that LAN path is reachable right now.
172
+ _describeConnectFailure(message) {
173
+ const lanFallback = !!this.context.key;
174
+ const lanReachable = lanFallback && !!(this.isLanConnected && this.isLanConnected());
175
+
176
+ // "permission deny" (1106) is specifically a visibility problem: the cloud
177
+ // project can't see this device. A device that has been offline for a long
178
+ // time is a prime candidate for having been removed/unbound/reset, which
179
+ // produces exactly this — so point at that, but only for this error (a
180
+ // timeout or token failure is unrelated).
181
+ const unbound = /permission deny|\b1106\b/i.test(message)
182
+ ? ' Note: a device left offline for a long time can be removed or unbound from the account (e.g. reset or re-paired), which also produces this "permission deny".'
183
+ : '';
184
+
185
+ let level, hint;
186
+ if (lanReachable) {
187
+ // Confirmed reachable over the LAN, so the cloud fallback we couldn't
188
+ // reach isn't needed. Keep it out of the way at debug.
189
+ level = 'debug';
190
+ hint = ' The device is reachable over the LAN, so only the (unused) cloud fallback is affected — harmless.';
191
+ } else if (lanFallback) {
192
+ // LAN-capable but not connected right now, so the cloud is currently the
193
+ // only path and the device may be unresponsive in HomeKit.
194
+ level = 'warn';
195
+ hint = ` The cloud is only a fallback, but the device isn't reachable over the LAN right now either, so it may show as unresponsive in HomeKit. Check that it is linked to this cloud project (matching account/home and datacenter region).${unbound}`;
196
+ } else {
197
+ level = 'error';
198
+ hint = ` This device is cloud-only, so it stays unreachable until this clears — check that it is linked to the cloud project (matching account/home and datacenter region).${unbound}`;
84
199
  }
200
+
201
+ return {level, text: `${this.context.name}: Tuya Cloud connection failed: ${message}.${hint}`};
85
202
  }
86
203
 
87
204
  /* ------------------------------------------------------------------ *
@@ -102,6 +219,11 @@ class TuyaCloudDevice extends EventEmitter {
102
219
  async _refreshState() {
103
220
  if (this._stopped) return;
104
221
  try {
222
+ const online = await this._readOnline();
223
+ if (this.connected !== online) {
224
+ this.connected = online;
225
+ this.log.info(`${this.context.name}: Tuya now reports the device ${online ? 'online' : 'offline'}.`);
226
+ }
105
227
  const status = await this.api.getStatus(this.context.id);
106
228
  this._applyStatus(status);
107
229
  } catch (ex) {
@@ -109,27 +231,76 @@ class TuyaCloudDevice extends EventEmitter {
109
231
  }
110
232
  }
111
233
 
234
+ // Resolve the device's reachability from Tuya's `online` flag, so HomeKit
235
+ // shows "No Response" when the device is genuinely offline. If the lookup
236
+ // isn't available (e.g. the project lacks the device-management API) fall
237
+ // back to reachable so control is never blocked.
238
+ async _readOnline() {
239
+ try {
240
+ const info = await this.api.getDeviceInfo(this.context.id);
241
+ if (info && typeof info.online === 'boolean') return info.online;
242
+ } catch (ex) {
243
+ this.log.debug(`${this.context.name}: online-status check failed: ${ex.message}`);
244
+ }
245
+ return true;
246
+ }
247
+
112
248
  /* ------------------------------------------------------------------ *
113
249
  * State helpers
114
250
  * ------------------------------------------------------------------ */
115
251
 
116
- // Convert a Tuya `[{code, value}]` status array into a flat { code: value }.
117
- _statusToState(status) {
252
+ // Read the device's initial data-points. Prefer the thing-shadow (it carries
253
+ // the numeric dp_id alongside each code, so LAN<->cloud bridging works); fall
254
+ // back to the plain status endpoint (code+value only) when the shadow isn't
255
+ // available. Returns an array of {code, value, dp_id?}.
256
+ async _readInitialProperties() {
257
+ if (typeof this.api.getShadowProperties === 'function') {
258
+ const props = await this.api.getShadowProperties(this.context.id);
259
+ if (Array.isArray(props)) return props;
260
+ }
261
+ return this.api.getStatus(this.context.id);
262
+ }
263
+
264
+ // Learn the code <-> numeric dp_id mapping from a shadow-properties read.
265
+ _learnDpMap(props) {
266
+ (props || []).forEach(p => {
267
+ if (!p || typeof p.code !== 'string' || p.dp_id == null) return;
268
+ const dp = String(p.dp_id);
269
+ this.codeByDpId[dp] = p.code;
270
+ this.dpIdByCode[p.code] = dp;
271
+ });
272
+ }
273
+
274
+ // Index a data-point under BOTH its string code and (when known) its numeric
275
+ // dp id, so an accessory configured the LAN way (numeric) or the cloud way
276
+ // (code) both resolve. The dp id comes from the shadow item, or — for code-only
277
+ // realtime deltas — from the map learned on connect.
278
+ _indexInto(target, code, value, dpId) {
279
+ target[code] = value;
280
+ const dp = dpId != null ? String(dpId) : this.dpIdByCode[code];
281
+ if (dp != null) target[dp] = value;
282
+ }
283
+
284
+ // Build the dual-keyed state object from a properties/status array.
285
+ _propsToState(props) {
118
286
  const state = {};
119
- (status || []).forEach(item => {
120
- if (item && typeof item.code === 'string') state[item.code] = item.value;
287
+ (props || []).forEach(item => {
288
+ if (item && typeof item.code === 'string') {
289
+ this._indexInto(state, item.code, item.value, item.dp_id != null ? item.dp_id : item.dpId);
290
+ }
121
291
  });
122
292
  return state;
123
293
  }
124
294
 
125
295
  // Apply a fresh snapshot (initial / catch-up) or a realtime delta, diff it
126
296
  // against what we hold, and emit only what changed — exactly mirroring
127
- // TuyaAccessory._change so accessories behave identically.
297
+ // TuyaAccessory._change so accessories behave identically. State is dual-keyed
298
+ // (code + numeric dp) so a LAN-style or cloud-style config both resolve.
128
299
  _applyStatus(status) {
129
- const incoming = this._statusToState(status);
300
+ const incoming = this._propsToState(status);
130
301
  const changes = {};
131
- Object.keys(incoming).forEach(code => {
132
- if (incoming[code] !== this.state[code]) changes[code] = incoming[code];
302
+ Object.keys(incoming).forEach(key => {
303
+ if (incoming[key] !== this.state[key]) changes[key] = incoming[key];
133
304
  });
134
305
  if (Object.keys(changes).length) {
135
306
  this.state = {...this.state, ...incoming};
@@ -138,10 +309,13 @@ class TuyaCloudDevice extends EventEmitter {
138
309
  return changes;
139
310
  }
140
311
 
141
- _logDiscoveredCodes(status) {
312
+ _logDiscoveredCodes(props) {
142
313
  try {
143
- const list = (status || []).map(i => `${i.code}=${JSON.stringify(i.value)}`).join(', ');
144
- this.log.info(`${this.context.name}: Tuya Cloud data-point codes ${list || '(none reported)'}`);
314
+ const list = (props || []).map(i => {
315
+ const dp = i.dp_id != null ? i.dp_id : (i.dpId != null ? i.dpId : this.dpIdByCode[i.code]);
316
+ return `${i.code}${dp != null ? `(dp ${dp})` : ''}=${JSON.stringify(i.value)}`;
317
+ }).join(', ');
318
+ this.log.debug(`${this.context.name}: Tuya Cloud data-points → ${list || '(none reported)'}`);
145
319
  } catch (_) { /* logging must never throw */ }
146
320
  }
147
321
 
@@ -154,11 +328,22 @@ class TuyaCloudDevice extends EventEmitter {
154
328
  // `this.state`: the accessory already reflects the user's intent in HomeKit
155
329
  // immediately, and leaving `state` untouched lets the realtime stream
156
330
  // confirm the real device state without a spurious "revert" while a sleepy
157
- // device catches up. Returns truthy like TuyaAccessory.update().
331
+ // device catches up.
332
+ //
333
+ // Return contract mirrors TuyaAccessory.update() as far as a synchronous
334
+ // caller is concerned (truthy on a no-op, `false` when not connected) but,
335
+ // unlike the LAN class, the actual command travels over HTTP and only its
336
+ // outcome reveals a failure. So when a command IS dispatched we return the
337
+ // promise that resolves to the boolean result (and never rejects), letting
338
+ // `BaseAccessory.setMultiStateAsync` await it and surface a rejected cloud
339
+ // command to HomeKit instead of silently dropping it.
158
340
  update(dps) {
159
341
  if (!dps || typeof dps !== 'object') return true;
160
342
 
161
- const commands = Object.keys(dps).map(code => ({code, value: dps[code]}));
343
+ // Keys may be numeric LAN dp ids (when a LAN-configured accessory falls
344
+ // back to the cloud) or string codes (a cloud-configured accessory); the
345
+ // commands API speaks codes, so translate via the learned map.
346
+ const commands = Object.keys(dps).map(key => ({code: this._toCode(key), value: dps[key]}));
162
347
  if (!commands.length) return true;
163
348
 
164
349
  if (!this.connected) {
@@ -166,13 +351,151 @@ class TuyaCloudDevice extends EventEmitter {
166
351
  return false;
167
352
  }
168
353
 
169
- this.api.sendCommands(this.context.id, commands)
170
- .then(ok => {
171
- if (!ok) this.log.warn(`${this.context.name}: Tuya Cloud rejected command ${JSON.stringify(commands)}`);
172
- })
173
- .catch(ex => this.log.error(`${this.context.name}: cloud command failed: ${ex.message}`));
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;
174
364
 
175
- return true;
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}`);
491
+ }
492
+
493
+ // Resolve a write key (a numeric LAN dp id or a string code) to the Tuya Cloud
494
+ // `code` the commands API expects. A numeric id with a known code is
495
+ // translated; anything else (already a code, or an unmapped id) is passed
496
+ // through unchanged.
497
+ _toCode(key) {
498
+ return this.codeByDpId[key] || key;
176
499
  }
177
500
 
178
501
  /* ------------------------------------------------------------------ *
@@ -6,36 +6,27 @@ const crypto = require('crypto');
6
6
  /*
7
7
  * TuyaCloudMessaging
8
8
  * ------------------
9
- * Optional realtime updates for cloud-backed devices, over Tuya's MQTT message
10
- * service. ONE instance is shared by every cloud device on the same Tuya
11
- * project (one broker connection delivers status for all of them); messages are
12
- * fanned out to the right device by its `devId`.
9
+ * Realtime updates for cloud-backed devices, over Tuya's MQTT message service.
10
+ * ONE instance is shared by every cloud device on the same Tuya project (one
11
+ * broker connection delivers status for all of them); messages are fanned out
12
+ * to the right device by its `devId`.
13
13
  *
14
- * This is intentionally a best-effort accelerator layered on top of polling:
15
- * if the optional `mqtt` package is missing, or the broker can't be reached, or
16
- * a message can't be decrypted, nothing breaks the devices simply keep
17
- * polling. When it IS working, changes (including physical button presses and
18
- * the rain sensor) show up in HomeKit within a second or two.
14
+ * Cloud updates (including physical button presses and the rain sensor) arrive
15
+ * over this stream rather than by polling, so they show up in HomeKit within a
16
+ * second or two. `mqtt` is a required dependency; if the broker can't be
17
+ * reached or a message can't be decrypted nothing crashes the stream simply
18
+ * reconnects and the cloud REST reads still cover initial state.
19
19
  *
20
20
  * The connection + AES-GCM message decryption are faithful to the
21
21
  * actively-maintained `0x5e/homebridge-tuya-platform` (`src/core/TuyaOpenMQ.ts`),
22
22
  * re-implemented here with Node's built-in `crypto`.
23
23
  */
24
24
 
25
+ const mqtt = require('mqtt');
26
+
25
27
  const GCM_TAG_LENGTH = 16;
26
28
  const PROTOCOL_DEVICE_STATUS = 4;
27
29
 
28
- // `mqtt` is an OPTIONAL dependency: realtime is a bonus, polling is the
29
- // guarantee. Loading it lazily keeps the plugin LAN-first and lets it run in
30
- // environments where mqtt isn't (or can't be) installed.
31
- let mqtt = null;
32
- let mqttLoadError = null;
33
- try {
34
- mqtt = require('mqtt');
35
- } catch (ex) {
36
- mqttLoadError = ex;
37
- }
38
-
39
30
  class TuyaCloudMessaging extends EventEmitter {
40
31
  constructor({api, log} = {}) {
41
32
  super();
@@ -56,10 +47,6 @@ class TuyaCloudMessaging extends EventEmitter {
56
47
  this.setMaxListeners(0);
57
48
  }
58
49
 
59
- static isAvailable() {
60
- return !!mqtt;
61
- }
62
-
63
50
  // Register a device's status handler and lazily start the shared connection.
64
51
  subscribeDevice(devId, handler) {
65
52
  const id = '' + devId;
@@ -72,13 +59,8 @@ class TuyaCloudMessaging extends EventEmitter {
72
59
  if (this._stopped || this._started) return;
73
60
  this._started = true;
74
61
 
75
- if (!mqtt) {
76
- this.log.warn(`Tuya Cloud realtime disabled: the optional "mqtt" package is not installed${mqttLoadError ? ` (${mqttLoadError.message})` : ''}. Devices will poll instead. Install it with "npm install mqtt" in the plugin folder to enable instant updates.`);
77
- return;
78
- }
79
-
80
62
  this._connect().catch(ex => {
81
- this.log.warn(`Tuya Cloud realtime could not start (${ex.message}); devices will poll instead.`);
63
+ this.log.warn(`Tuya Cloud realtime could not start (${ex.message}); it will keep retrying.`);
82
64
  this._scheduleRenew(60);
83
65
  });
84
66
  }
@@ -145,12 +127,12 @@ class TuyaCloudMessaging extends EventEmitter {
145
127
  envelope = JSON.parse(payload.toString());
146
128
  } catch (_) { return; }
147
129
 
148
- const {protocol, data, t} = envelope;
130
+ const {protocol, data} = envelope;
149
131
  if (!data) return;
150
132
 
151
133
  let plaintext;
152
134
  try {
153
- plaintext = this._decrypt(data, this.config && this.config.password, t);
135
+ plaintext = this._decrypt(data, this.config && this.config.password);
154
136
  } catch (ex) {
155
137
  this.log.debug(`Tuya Cloud realtime decrypt failed: ${ex.message}`);
156
138
  return;
@@ -179,26 +161,31 @@ class TuyaCloudMessaging extends EventEmitter {
179
161
  // Decrypt the MQTT `data` field. msg_encrypted_version 2.0 → AES-128-GCM,
180
162
  // 1.0 → AES-128-ECB. The AES key is the middle 16 chars of the broker
181
163
  // password (NOT the project Access Secret).
182
- _decrypt(data, password, t) {
164
+ //
165
+ // The GCM auth tag is intentionally NOT verified: Tuya's real status frames
166
+ // do not carry a tag that verifies against the documented AAD, so calling
167
+ // `decipher.final()` would throw on every genuine message and silently drop
168
+ // all realtime updates. AES-GCM is a stream cipher, so `update()` alone
169
+ // recovers the plaintext correctly regardless of the tag; a wrong key just
170
+ // yields garbage that the subsequent JSON.parse rejects. Both the official
171
+ // `tuya/tuya-homebridge` and `0x5e/homebridge-tuya-platform` decrypt with
172
+ // `update()` only, for exactly this reason — `final()` here was the bug that
173
+ // stopped external changes (physical buttons, the Tuya app) from showing up.
174
+ _decrypt(data, password) {
183
175
  const key = Buffer.from(('' + (password || '')).substring(8, 24), 'utf8');
184
176
  const buf = Buffer.from(data, 'base64');
185
177
 
186
178
  // GCM (v2.0) layout: [ivLen(4) BE][iv(ivLen)][ciphertext][tag(16)].
187
- // Try it first; if the header doesn't look like a sane IV length, or
188
- // auth fails, fall back to ECB (v1.0).
179
+ // Try it first; if the header doesn't look like a sane IV length, fall
180
+ // back to ECB (v1.0).
189
181
  if (buf.length > 4 + GCM_TAG_LENGTH) {
190
182
  const ivLen = buf.readUIntBE(0, 4);
191
183
  if (ivLen >= 12 && ivLen <= 16 && (4 + ivLen + GCM_TAG_LENGTH) <= buf.length) {
192
184
  try {
193
185
  const iv = buf.slice(4, 4 + ivLen);
194
186
  const ciphertext = buf.slice(4 + ivLen, buf.length - GCM_TAG_LENGTH);
195
- const tag = buf.slice(buf.length - GCM_TAG_LENGTH);
196
187
  const decipher = crypto.createDecipheriv('aes-128-gcm', key, iv);
197
- decipher.setAuthTag(tag);
198
- const aad = Buffer.allocUnsafe(6);
199
- aad.writeUIntBE(Number(t) || 0, 0, 6);
200
- decipher.setAAD(aad);
201
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
188
+ return decipher.update(ciphertext).toString('utf8');
202
189
  } catch (gcmEx) {
203
190
  // fall through to ECB
204
191
  }