homebridge-tuya-plus 3.14.0-dev.5 → 3.14.0-dev.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish-dev.yml +298 -31
- package/AGENTS.md +162 -0
- package/CLAUDE.md +1 -0
- package/Changelog.md +16 -3
- package/Readme.MD +8 -32
- package/config.schema.json +79 -79
- package/index.js +599 -358
- package/lib/AirPurifierAccessory.js +1 -1
- package/lib/BaseAccessory.js +54 -17
- package/lib/ConvectorAccessory.js +1 -1
- package/lib/CustomMultiOutletAccessory.js +2 -1
- package/lib/DoorbellAccessory.js +9 -16
- package/lib/GarageDoorAccessory.js +1 -1
- package/lib/IrrigationSystemAccessory.js +260 -120
- package/lib/MultiOutletAccessory.js +3 -2
- package/lib/OilDiffuserAccessory.js +10 -8
- package/lib/RGBTWLightAccessory.js +13 -11
- package/lib/RGBTWOutletAccessory.js +11 -9
- package/lib/SimpleBlindsAccessory.js +5 -5
- package/lib/SimpleDimmer2Accessory.js +1 -1
- package/lib/SimpleDimmerAccessory.js +10 -6
- package/lib/SimpleGarageDoorAccessory.js +122 -26
- package/lib/SimpleHeaterAccessory.js +4 -4
- package/lib/SimpleLightAccessory.js +1 -1
- package/lib/SwitchAccessory.js +3 -2
- package/lib/TWLightAccessory.js +2 -2
- package/lib/TuyaAccessory.js +75 -42
- package/lib/TuyaCloudApi.js +121 -8
- package/lib/TuyaCloudDevice.js +434 -31
- package/lib/TuyaCloudMessaging.js +34 -41
- package/lib/TuyaDevice.js +269 -0
- package/lib/TuyaDiscovery.js +25 -9
- package/lib/ValveAccessory.js +16 -12
- package/lib/VerticalBlindsWithTilt.js +27 -25
- package/lib/WledDimmerAccessory.js +46 -81
- package/package.json +5 -6
- package/scripts/cleanup-pr-tags.js +122 -0
- package/test/BaseAccessory.test.js +94 -15
- package/test/IrrigationSystemAccessory.test.js +293 -67
- package/test/MultiOutletAccessory.test.js +18 -3
- package/test/RGBTWLightAccessory.test.js +3 -3
- package/test/SimpleFanLightAccessory.test.js +3 -3
- package/test/SimpleGarageDoorAccessory.test.js +193 -19
- package/test/TuyaAccessory.protocol.test.js +76 -3
- package/test/TuyaCloudApi.test.js +110 -1
- package/test/TuyaCloudDevice.test.js +564 -2
- package/test/TuyaCloudMessaging.test.js +24 -5
- package/test/TuyaDevice.test.js +266 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +271 -0
- package/test/support/mocks.js +13 -0
- package/wiki/Supported-Device-Types.md +64 -34
- package/wiki/Tuya-Cloud-Setup.md +31 -108
package/lib/TuyaCloudDevice.js
CHANGED
|
@@ -39,17 +39,54 @@ 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
|
+
this._catchupTimers = null; // post-write state re-reads (see _scheduleStateCatchup)
|
|
79
|
+
this._writeChain = null; // serializes cloud writes to this device (see update)
|
|
80
|
+
this._reportsOverMqtt = false; // set once any realtime update arrives; disables the catch-up
|
|
81
|
+
|
|
82
|
+
// Bidirectional data-point maps, learned from the device's thing-shadow on
|
|
83
|
+
// connect. They let this cloud transport (and the LAN+cloud TuyaDevice that
|
|
84
|
+
// may wrap it) translate between the LAN's numeric data-point ids and the
|
|
85
|
+
// cloud's string codes. Empty until the shadow is read; empty means
|
|
86
|
+
// "code-only" (no numeric bridge), which is still fully functional for a
|
|
87
|
+
// cloud-configured accessory.
|
|
88
|
+
this.codeByDpId = {}; // '20' -> 'switch_led'
|
|
89
|
+
this.dpIdByCode = {}; // 'switch_led' -> '20'
|
|
53
90
|
|
|
54
91
|
if (props.connect !== false) this._connect();
|
|
55
92
|
}
|
|
@@ -63,12 +100,33 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
63
100
|
return this.log.error(`${this.context.name}: Tuya Cloud is not configured (missing accessId/accessKey).`);
|
|
64
101
|
}
|
|
65
102
|
|
|
103
|
+
// First connect only: stagger the initial read so a large install doesn't
|
|
104
|
+
// fire every device's OpenAPI call in the same instant (rate limits). The
|
|
105
|
+
// platform hands each device an increasing cloudStartDelay.
|
|
106
|
+
if (!this._staggered) {
|
|
107
|
+
this._staggered = true;
|
|
108
|
+
const delay = parseInt(this.context.cloudStartDelay) || 0;
|
|
109
|
+
if (delay > 0) {
|
|
110
|
+
await new Promise(resolve => { const t = setTimeout(resolve, delay); if (t.unref) t.unref(); });
|
|
111
|
+
if (this._stopped) return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
66
115
|
try {
|
|
67
|
-
const
|
|
68
|
-
this.
|
|
69
|
-
this.
|
|
116
|
+
const props = await this._readInitialProperties();
|
|
117
|
+
this._learnDpMap(props);
|
|
118
|
+
this.state = this._propsToState(props);
|
|
119
|
+
this.connected = await this._readOnline();
|
|
70
120
|
|
|
71
|
-
this._logDiscoveredCodes(
|
|
121
|
+
this._logDiscoveredCodes(props);
|
|
122
|
+
|
|
123
|
+
// Connected: announce recovery from any earlier failure once, and
|
|
124
|
+
// reset the retry backoff.
|
|
125
|
+
if (this._lastConnectError) {
|
|
126
|
+
this.log.info(`${this.context.name}: Tuya Cloud connection restored.`);
|
|
127
|
+
this._lastConnectError = null;
|
|
128
|
+
}
|
|
129
|
+
this._retryDelay = 0;
|
|
72
130
|
|
|
73
131
|
this.emit('connect');
|
|
74
132
|
// Accessories register their characteristics off the first 'change'.
|
|
@@ -76,14 +134,76 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
76
134
|
|
|
77
135
|
this._subscribeRealtime();
|
|
78
136
|
} catch (ex) {
|
|
79
|
-
this.
|
|
80
|
-
// Retry later — credentials/permissions/network may recover.
|
|
81
|
-
if (!this._stopped) {
|
|
82
|
-
this._retryTimer = setTimeout(() => this._connect(), 30000);
|
|
83
|
-
}
|
|
137
|
+
this._onConnectFailure(ex);
|
|
84
138
|
}
|
|
85
139
|
}
|
|
86
140
|
|
|
141
|
+
// A failed connect is retried, but the cause is frequently permanent — the
|
|
142
|
+
// cloud project can't see this device (permission denied / "no space"), the
|
|
143
|
+
// datacenter region is wrong, or the device isn't linked to the account — and
|
|
144
|
+
// won't clear without the user changing their Tuya project. Since PR #71 the
|
|
145
|
+
// cloud backs *every* device as a fallback, so a LAN-only device whose cloud
|
|
146
|
+
// project lacks permission would otherwise log an error every 30s forever.
|
|
147
|
+
// Two things keep that out of the log: a given message is surfaced once and then
|
|
148
|
+
// suppressed to debug until it changes (or the device connects), and the retry
|
|
149
|
+
// interval backs off (30s → 30m cap) instead of hammering the OpenAPI.
|
|
150
|
+
_onConnectFailure(ex) {
|
|
151
|
+
const message = ex && ex.message ? ex.message : String(ex);
|
|
152
|
+
const {level, text} = this._describeConnectFailure(message);
|
|
153
|
+
|
|
154
|
+
// The full (level + wording) is what the dedup tracks, not just the raw
|
|
155
|
+
// message: it means a device that flips from "reachable over the LAN"
|
|
156
|
+
// (harmless, debug) to "not reachable over the LAN either" (warn) re-surfaces
|
|
157
|
+
// the change, while an unchanged situation stays quiet.
|
|
158
|
+
if (text !== this._lastConnectError) {
|
|
159
|
+
this.log[level](text);
|
|
160
|
+
this._lastConnectError = text;
|
|
161
|
+
} else {
|
|
162
|
+
this.log.debug(`${this.context.name}: Tuya Cloud connection still failing: ${message}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (this._stopped) return;
|
|
166
|
+
// Exponential backoff: 30s after the first failure, doubling to a 30m cap.
|
|
167
|
+
this._retryDelay = Math.min(this._retryDelay ? this._retryDelay * 2 : 30000, 1800000);
|
|
168
|
+
this._retryTimer = setTimeout(() => this._connect(), this._retryDelay);
|
|
169
|
+
if (this._retryTimer && this._retryTimer.unref) this._retryTimer.unref();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Pick the log level and wording for a connect failure from what we know about
|
|
173
|
+
// the device: whether the cloud is merely a fallback (the device has a local
|
|
174
|
+
// key) and, if so, whether that LAN path is reachable right now.
|
|
175
|
+
_describeConnectFailure(message) {
|
|
176
|
+
const lanFallback = !!this.context.key;
|
|
177
|
+
const lanReachable = lanFallback && !!(this.isLanConnected && this.isLanConnected());
|
|
178
|
+
|
|
179
|
+
// "permission deny" (1106) is specifically a visibility problem: the cloud
|
|
180
|
+
// project can't see this device. A device that has been offline for a long
|
|
181
|
+
// time is a prime candidate for having been removed/unbound/reset, which
|
|
182
|
+
// produces exactly this — so point at that, but only for this error (a
|
|
183
|
+
// timeout or token failure is unrelated).
|
|
184
|
+
const unbound = /permission deny|\b1106\b/i.test(message)
|
|
185
|
+
? ' 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".'
|
|
186
|
+
: '';
|
|
187
|
+
|
|
188
|
+
let level, hint;
|
|
189
|
+
if (lanReachable) {
|
|
190
|
+
// Confirmed reachable over the LAN, so the cloud fallback we couldn't
|
|
191
|
+
// reach isn't needed. Keep it out of the way at debug.
|
|
192
|
+
level = 'debug';
|
|
193
|
+
hint = ' The device is reachable over the LAN, so only the (unused) cloud fallback is affected — harmless.';
|
|
194
|
+
} else if (lanFallback) {
|
|
195
|
+
// LAN-capable but not connected right now, so the cloud is currently the
|
|
196
|
+
// only path and the device may be unresponsive in HomeKit.
|
|
197
|
+
level = 'warn';
|
|
198
|
+
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}`;
|
|
199
|
+
} else {
|
|
200
|
+
level = 'error';
|
|
201
|
+
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}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {level, text: `${this.context.name}: Tuya Cloud connection failed: ${message}.${hint}`};
|
|
205
|
+
}
|
|
206
|
+
|
|
87
207
|
/* ------------------------------------------------------------------ *
|
|
88
208
|
* Realtime (shared MQTT stream)
|
|
89
209
|
* ------------------------------------------------------------------ */
|
|
@@ -96,40 +216,133 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
96
216
|
// first 'online' is caught. On any (re)connect we re-read the full state
|
|
97
217
|
// to recover anything missed while the stream was down.
|
|
98
218
|
this.messaging.on('online', () => this._refreshState());
|
|
99
|
-
this.messaging.subscribeDevice(this.context.id, status => this.
|
|
219
|
+
this.messaging.subscribeDevice(this.context.id, status => this._onRealtime(status));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// A realtime (MQTT) update arrived for this device, so it reports its changes
|
|
223
|
+
// over the stream and the post-write catch-up read is redundant — drop any
|
|
224
|
+
// pending one. A device whose reports never arrive over MQTT (some custom
|
|
225
|
+
// controllers — the stream carries nothing for them) gets none of these, so its
|
|
226
|
+
// catch-up stays armed and remains the only way its state reaches HomeKit. This
|
|
227
|
+
// makes the catch-up self-cancelling: it costs an HTTP read only on devices that
|
|
228
|
+
// actually need it.
|
|
229
|
+
_onRealtime(status) {
|
|
230
|
+
// This device reports over MQTT, so the catch-up is pure overhead for it —
|
|
231
|
+
// latch it off for good and drop any pending one. Only devices that never
|
|
232
|
+
// report here (the stream carries nothing for them) keep the catch-up.
|
|
233
|
+
this._reportsOverMqtt = true;
|
|
234
|
+
if (this._catchupTimers) {
|
|
235
|
+
this._catchupTimers.forEach(t => clearTimeout(t));
|
|
236
|
+
this._catchupTimers = null;
|
|
237
|
+
}
|
|
238
|
+
this._applyStatus(status);
|
|
100
239
|
}
|
|
101
240
|
|
|
102
241
|
async _refreshState() {
|
|
103
242
|
if (this._stopped) return;
|
|
104
243
|
try {
|
|
105
|
-
const
|
|
106
|
-
this.
|
|
244
|
+
const online = await this._readOnline();
|
|
245
|
+
if (this.connected !== online) {
|
|
246
|
+
this.connected = online;
|
|
247
|
+
this.log.info(`${this.context.name}: Tuya now reports the device ${online ? 'online' : 'offline'}.`);
|
|
248
|
+
}
|
|
249
|
+
// Read via the same shadow-preferred path as the initial connect, not
|
|
250
|
+
// the plain /status endpoint: a thing-model-only device (e.g. a custom
|
|
251
|
+
// gate controller) returns an EMPTY /status result, so /status would
|
|
252
|
+
// silently wipe nothing in and never reflect a state change.
|
|
253
|
+
this._applyStatus(await this._readInitialProperties());
|
|
107
254
|
} catch (ex) {
|
|
108
255
|
this.log.debug(`${this.context.name}: cloud state refresh failed: ${ex.message}`);
|
|
109
256
|
}
|
|
110
257
|
}
|
|
111
258
|
|
|
259
|
+
// Resolve the device's reachability from Tuya's `online` flag, so HomeKit
|
|
260
|
+
// shows "No Response" when the device is genuinely offline. If the lookup
|
|
261
|
+
// isn't available (e.g. the project lacks the device-management API) fall
|
|
262
|
+
// back to reachable so control is never blocked.
|
|
263
|
+
async _readOnline() {
|
|
264
|
+
try {
|
|
265
|
+
const info = await this.api.getDeviceInfo(this.context.id);
|
|
266
|
+
if (info && typeof info.online === 'boolean') return info.online;
|
|
267
|
+
} catch (ex) {
|
|
268
|
+
this.log.debug(`${this.context.name}: online-status check failed: ${ex.message}`);
|
|
269
|
+
}
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
112
273
|
/* ------------------------------------------------------------------ *
|
|
113
274
|
* State helpers
|
|
114
275
|
* ------------------------------------------------------------------ */
|
|
115
276
|
|
|
116
|
-
//
|
|
117
|
-
|
|
277
|
+
// Read the device's initial data-points. Prefer the thing-shadow (it carries
|
|
278
|
+
// the numeric dp_id alongside each code, so LAN<->cloud bridging works); fall
|
|
279
|
+
// back to the plain status endpoint (code+value only) when the shadow isn't
|
|
280
|
+
// available. Returns an array of {code, value, dp_id?}.
|
|
281
|
+
async _readInitialProperties() {
|
|
282
|
+
if (typeof this.api.getShadowProperties === 'function') {
|
|
283
|
+
const props = await this.api.getShadowProperties(this.context.id);
|
|
284
|
+
if (Array.isArray(props)) return props;
|
|
285
|
+
}
|
|
286
|
+
return this.api.getStatus(this.context.id);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Learn the code <-> numeric dp_id mapping from a shadow-properties read.
|
|
290
|
+
_learnDpMap(props) {
|
|
291
|
+
(props || []).forEach(p => {
|
|
292
|
+
if (!p || typeof p.code !== 'string' || p.dp_id == null) return;
|
|
293
|
+
const dp = String(p.dp_id);
|
|
294
|
+
this.codeByDpId[dp] = p.code;
|
|
295
|
+
this.dpIdByCode[p.code] = dp;
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Index a data-point under BOTH its string code and (when known) its numeric
|
|
300
|
+
// dp id, so an accessory configured the LAN way (numeric) or the cloud way
|
|
301
|
+
// (code) both resolve. The dp id comes from the shadow item, or — for code-only
|
|
302
|
+
// realtime deltas — from the map learned on connect.
|
|
303
|
+
_indexInto(target, code, value, dpId) {
|
|
304
|
+
target[code] = value;
|
|
305
|
+
const dp = dpId != null ? String(dpId) : this.dpIdByCode[code];
|
|
306
|
+
if (dp != null) target[dp] = value;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Build the dual-keyed state object from a properties/status array.
|
|
310
|
+
_propsToState(props) {
|
|
118
311
|
const state = {};
|
|
119
|
-
(
|
|
120
|
-
if (item
|
|
312
|
+
(props || []).forEach(item => {
|
|
313
|
+
if (!item || typeof item !== 'object') return;
|
|
314
|
+
if (typeof item.code === 'string') {
|
|
315
|
+
this._indexInto(state, item.code, item.value, item.dp_id != null ? item.dp_id : item.dpId);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// A realtime item with no `code`: Tuya's MQTT stream delivers some
|
|
319
|
+
// data-points as a bare { "<dpId>": value } pair — no `code`, no `value`
|
|
320
|
+
// field. Custom / non-standard DPs come through this way (e.g. an
|
|
321
|
+
// irrigation timer's `charging`, dp 101, which lives only in the
|
|
322
|
+
// thing-model shadow, not the standard instruction set, so the message
|
|
323
|
+
// service never attaches a code). Map the numeric id back to its code,
|
|
324
|
+
// learned from the shadow on connect, so the change is applied instead of
|
|
325
|
+
// silently dropped; a still-unmapped id is indexed numerically, which a
|
|
326
|
+
// LAN-style config resolves.
|
|
327
|
+
Object.keys(item).forEach(key => {
|
|
328
|
+
if (!/^\d+$/.test(key)) return;
|
|
329
|
+
const code = this.codeByDpId[key];
|
|
330
|
+
if (code != null) this._indexInto(state, code, item[key], key);
|
|
331
|
+
else state[key] = item[key];
|
|
332
|
+
});
|
|
121
333
|
});
|
|
122
334
|
return state;
|
|
123
335
|
}
|
|
124
336
|
|
|
125
337
|
// Apply a fresh snapshot (initial / catch-up) or a realtime delta, diff it
|
|
126
338
|
// against what we hold, and emit only what changed — exactly mirroring
|
|
127
|
-
// TuyaAccessory._change so accessories behave identically.
|
|
339
|
+
// TuyaAccessory._change so accessories behave identically. State is dual-keyed
|
|
340
|
+
// (code + numeric dp) so a LAN-style or cloud-style config both resolve.
|
|
128
341
|
_applyStatus(status) {
|
|
129
|
-
const incoming = this.
|
|
342
|
+
const incoming = this._propsToState(status);
|
|
130
343
|
const changes = {};
|
|
131
|
-
Object.keys(incoming).forEach(
|
|
132
|
-
if (incoming[
|
|
344
|
+
Object.keys(incoming).forEach(key => {
|
|
345
|
+
if (incoming[key] !== this.state[key]) changes[key] = incoming[key];
|
|
133
346
|
});
|
|
134
347
|
if (Object.keys(changes).length) {
|
|
135
348
|
this.state = {...this.state, ...incoming};
|
|
@@ -138,10 +351,13 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
138
351
|
return changes;
|
|
139
352
|
}
|
|
140
353
|
|
|
141
|
-
_logDiscoveredCodes(
|
|
354
|
+
_logDiscoveredCodes(props) {
|
|
142
355
|
try {
|
|
143
|
-
const list = (
|
|
144
|
-
|
|
356
|
+
const list = (props || []).map(i => {
|
|
357
|
+
const dp = i.dp_id != null ? i.dp_id : (i.dpId != null ? i.dpId : this.dpIdByCode[i.code]);
|
|
358
|
+
return `${i.code}${dp != null ? `(dp ${dp})` : ''}=${JSON.stringify(i.value)}`;
|
|
359
|
+
}).join(', ');
|
|
360
|
+
this.log.debug(`${this.context.name}: Tuya Cloud data-points → ${list || '(none reported)'}`);
|
|
145
361
|
} catch (_) { /* logging must never throw */ }
|
|
146
362
|
}
|
|
147
363
|
|
|
@@ -154,11 +370,22 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
154
370
|
// `this.state`: the accessory already reflects the user's intent in HomeKit
|
|
155
371
|
// immediately, and leaving `state` untouched lets the realtime stream
|
|
156
372
|
// confirm the real device state without a spurious "revert" while a sleepy
|
|
157
|
-
// device catches up.
|
|
373
|
+
// device catches up.
|
|
374
|
+
//
|
|
375
|
+
// Return contract mirrors TuyaAccessory.update() as far as a synchronous
|
|
376
|
+
// caller is concerned (truthy on a no-op, `false` when not connected) but,
|
|
377
|
+
// unlike the LAN class, the actual command travels over HTTP and only its
|
|
378
|
+
// outcome reveals a failure. So when a command IS dispatched we return the
|
|
379
|
+
// promise that resolves to the boolean result (and never rejects), letting
|
|
380
|
+
// `BaseAccessory.setMultiStateAsync` await it and surface a rejected cloud
|
|
381
|
+
// command to HomeKit instead of silently dropping it.
|
|
158
382
|
update(dps) {
|
|
159
383
|
if (!dps || typeof dps !== 'object') return true;
|
|
160
384
|
|
|
161
|
-
|
|
385
|
+
// Keys may be numeric LAN dp ids (when a LAN-configured accessory falls
|
|
386
|
+
// back to the cloud) or string codes (a cloud-configured accessory); the
|
|
387
|
+
// commands API speaks codes, so translate via the learned map.
|
|
388
|
+
const commands = Object.keys(dps).map(key => ({code: this._toCode(key), value: dps[key]}));
|
|
162
389
|
if (!commands.length) return true;
|
|
163
390
|
|
|
164
391
|
if (!this.connected) {
|
|
@@ -166,13 +393,188 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
166
393
|
return false;
|
|
167
394
|
}
|
|
168
395
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
396
|
+
// Split off data-points we couldn't translate to a Tuya Cloud code: their
|
|
397
|
+
// "code" is still a numeric LAN dp id (e.g. '1'), which the commands API
|
|
398
|
+
// can't address — it would always answer "command or value not support
|
|
399
|
+
// (2008)". This is the missing-dp-map case (the device-shadow API that
|
|
400
|
+
// carries the id→code mapping isn't authorised for the project), so don't
|
|
401
|
+
// fire a doomed command; explain it once instead.
|
|
402
|
+
const sendable = [], undeliverable = [];
|
|
403
|
+
commands.forEach(c => (this._isUntranslatedDpId(c.code) ? undeliverable : sendable).push(c));
|
|
404
|
+
if (undeliverable.length) this._reportUndeliverableWrite(undeliverable);
|
|
405
|
+
if (!sendable.length) return false;
|
|
174
406
|
|
|
175
|
-
|
|
407
|
+
// Serialize writes to this device. HomeKit frequently double-sends a
|
|
408
|
+
// characteristic write, and rapid changes (dragging a thermostat) fire a
|
|
409
|
+
// burst; running them concurrently both races the per-code endpoint
|
|
410
|
+
// learning (a duplicate could see a code "tried" but not yet learned and
|
|
411
|
+
// fail spuriously) and bursts the cloud's QPS limit (uneven latency). One
|
|
412
|
+
// at a time keeps learning coherent and the request rate smooth. _dispatch
|
|
413
|
+
// never rejects, so the chain can't break.
|
|
414
|
+
const run = () => this._dispatch(sendable);
|
|
415
|
+
this._writeChain = this._writeChain ? this._writeChain.then(run, run) : run();
|
|
416
|
+
return this._writeChain;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Send a write, routing each data-point to the cloud endpoint it accepts. Codes
|
|
420
|
+
// go over the standard commands API unless we've learned they need the thing
|
|
421
|
+
// model, so the two groups can travel in parallel to their own endpoints. A
|
|
422
|
+
// device with both kinds (a normal switch plus a custom trigger) drives each
|
|
423
|
+
// correctly. Resolves true only if every group succeeded, false otherwise
|
|
424
|
+
// (never rejects), mirroring TuyaAccessory.update for the awaiting accessory.
|
|
425
|
+
async _dispatch(commands) {
|
|
426
|
+
const viaProperties = commands.filter(c => this._codeMethod[c.code] === 'properties');
|
|
427
|
+
const viaCommands = commands.filter(c => this._codeMethod[c.code] !== 'properties');
|
|
428
|
+
|
|
429
|
+
const [byCommands, byProperties] = await Promise.all([
|
|
430
|
+
this._sendGroup('commands', viaCommands),
|
|
431
|
+
this._sendGroup('properties', viaProperties)
|
|
432
|
+
]);
|
|
433
|
+
|
|
434
|
+
if (byCommands.ok && byProperties.ok) {
|
|
435
|
+
this._lastWriteError = null;
|
|
436
|
+
this._scheduleStateCatchup();
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
this._reportWriteFailure(byCommands.error || byProperties.error);
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Send one group of codes over `method`, recording per code which endpoint
|
|
444
|
+
// worked. A commands group rejected as unsupported (2008/2003) is retried over
|
|
445
|
+
// the thing-model property endpoint — where a custom device's data-points live —
|
|
446
|
+
// but only for codes not already confirmed on commands or already tried there,
|
|
447
|
+
// so a confirmed code is never moved and an uncontrollable one can't re-probe on
|
|
448
|
+
// every write. Resolves to {ok, error}; never rejects.
|
|
449
|
+
async _sendGroup(method, codes) {
|
|
450
|
+
if (!codes.length) return {ok: true};
|
|
451
|
+
|
|
452
|
+
const res = await this._send(method, codes);
|
|
453
|
+
if (res.ok) return this._learn(codes, method);
|
|
454
|
+
if (method !== 'commands' || !this._isInstructionMismatch(res.error)) return res;
|
|
455
|
+
|
|
456
|
+
const fresh = codes.filter(c => this._codeMethod[c.code] !== 'commands' && !this._propertiesTried.has(c.code));
|
|
457
|
+
if (!fresh.length) return res;
|
|
458
|
+
fresh.forEach(c => this._propertiesTried.add(c.code));
|
|
459
|
+
|
|
460
|
+
this.log.debug(`${this.context.name}: the cloud command API rejected ${this._codesOf(fresh)} (${res.error}); trying the thing-model property endpoint.`);
|
|
461
|
+
const viaProperties = await this._send('properties', fresh);
|
|
462
|
+
if (!viaProperties.ok) {
|
|
463
|
+
this.log.debug(`${this.context.name}: the thing-model property endpoint also failed for ${this._codesOf(fresh)}: ${viaProperties.error}`);
|
|
464
|
+
return res;
|
|
465
|
+
}
|
|
466
|
+
if (!this._announcedProperties) {
|
|
467
|
+
this._announcedProperties = true;
|
|
468
|
+
this.log.debug(`${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).`);
|
|
469
|
+
}
|
|
470
|
+
return this._learn(fresh, 'properties');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// After a cloud write the resulting state (e.g. a gate's return_state flipping
|
|
474
|
+
// to "opening") shows up in the cloud within ~a second, but the realtime stream
|
|
475
|
+
// doesn't always carry it — and an accessory that mirrors a status DP (the
|
|
476
|
+
// garage door) would otherwise sit on the old value forever. So re-read the
|
|
477
|
+
// device a couple of times after a command to catch the transition. Strictly
|
|
478
|
+
// supplemental: a device that has ever reported over MQTT skips this entirely
|
|
479
|
+
// (realtime is its path), each new command re-arms it, and it only runs on the
|
|
480
|
+
// cloud path — so a LAN-reachable or MQTT-covered device pays nothing.
|
|
481
|
+
_scheduleStateCatchup() {
|
|
482
|
+
if (this._reportsOverMqtt) return;
|
|
483
|
+
if (this._catchupTimers) this._catchupTimers.forEach(t => clearTimeout(t));
|
|
484
|
+
this._catchupTimers = [2000, 8000].map(delay => {
|
|
485
|
+
const t = setTimeout(() => this._catchupOnce(), delay);
|
|
486
|
+
if (t && t.unref) t.unref();
|
|
487
|
+
return t;
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async _catchupOnce() {
|
|
492
|
+
if (this._stopped) return;
|
|
493
|
+
try {
|
|
494
|
+
this._applyStatus(await this._readInitialProperties());
|
|
495
|
+
} catch (ex) {
|
|
496
|
+
this.log.debug(`${this.context.name}: post-write state catch-up failed: ${ex.message}`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Invoke one control endpoint; resolves to {ok, error} and never rejects, so a
|
|
501
|
+
// missing-method mock or a network error can't escape the dispatch.
|
|
502
|
+
_send(method, commands) {
|
|
503
|
+
let call;
|
|
504
|
+
try {
|
|
505
|
+
call = method === 'properties'
|
|
506
|
+
? this.api.sendProperties(this.context.id, commands)
|
|
507
|
+
: this.api.sendCommands(this.context.id, commands);
|
|
508
|
+
} catch (ex) {
|
|
509
|
+
return Promise.resolve({ok: false, error: ex && ex.message ? ex.message : String(ex)});
|
|
510
|
+
}
|
|
511
|
+
return Promise.resolve(call).then(
|
|
512
|
+
res => res !== false ? {ok: true} : {ok: false, error: `Tuya Cloud rejected ${JSON.stringify(commands)}`},
|
|
513
|
+
ex => ({ok: false, error: ex && ex.message ? ex.message : String(ex)})
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
_learn(codes, method) {
|
|
518
|
+
codes.forEach(c => { this._codeMethod[c.code] = method; });
|
|
519
|
+
return {ok: true};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
_codesOf(commands) {
|
|
523
|
+
return commands.map(c => c.code).join(', ');
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// True for the "this code/value isn't a valid command for this device" errors —
|
|
527
|
+
// command or value not support (2008) and function not support (2003) — which
|
|
528
|
+
// are what a device with no standard instruction set answers, and exactly the
|
|
529
|
+
// case the thing-model endpoint can still handle. Other failures (auth,
|
|
530
|
+
// permission, network) won't be helped by the fallback, so we don't probe.
|
|
531
|
+
_isInstructionMismatch(message) {
|
|
532
|
+
return /\bcode (2008|2003)\b|command or value not support|function not support/i.test(message || '');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// A Tuya Cloud data-point is addressed by a string "code" (e.g. 'switch',
|
|
536
|
+
// 'temp_set'); a code that is all digits is really a numeric LAN dp id that
|
|
537
|
+
// _toCode couldn't translate (no map learned), which the cloud can't address.
|
|
538
|
+
_isUntranslatedDpId(code) {
|
|
539
|
+
return /^\d+$/.test(code);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Surfaced once: the cloud can't address these data-points because the numeric
|
|
543
|
+
// id→code map was never learned (the device-shadow/thing-model API isn't
|
|
544
|
+
// authorised for this project — the same gap that makes shadow/properties
|
|
545
|
+
// return "No space permission"). Mirrors the connect-failure dedup: one
|
|
546
|
+
// actionable line, then quiet at debug. Stays harmless (debug) while the device
|
|
547
|
+
// is reachable over the LAN, since the cloud is only a fallback there.
|
|
548
|
+
_reportUndeliverableWrite(commands) {
|
|
549
|
+
const ids = commands.map(c => c.code).join(', ');
|
|
550
|
+
if (this._warnedNoCloudCode) {
|
|
551
|
+
return this.log.debug(`${this.context.name}: cloud write skipped — no Tuya Cloud code for data-point(s) ${ids}.`);
|
|
552
|
+
}
|
|
553
|
+
this._warnedNoCloudCode = true;
|
|
554
|
+
const lanReachable = !!(this.isLanConnected && this.isLanConnected());
|
|
555
|
+
const level = lanReachable ? 'debug' : (this.context.key ? 'warn' : 'error');
|
|
556
|
+
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.`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// A genuine command failure (the cloud was reached but rejected/errored).
|
|
560
|
+
// Deduped like the connect path — surfaced once, identical repeats drop to
|
|
561
|
+
// debug — with the level matched to severity: a LAN-capable device is only
|
|
562
|
+
// momentarily off the LAN (warn), a cloud-only one is stuck until it clears
|
|
563
|
+
// (error). _lastWriteError is cleared on the next successful command.
|
|
564
|
+
_reportWriteFailure(message) {
|
|
565
|
+
if (message === this._lastWriteError) {
|
|
566
|
+
return this.log.debug(`${this.context.name}: cloud command still failing: ${message}`);
|
|
567
|
+
}
|
|
568
|
+
this._lastWriteError = message;
|
|
569
|
+
this.log[this.context.key ? 'warn' : 'error'](`${this.context.name}: cloud command failed: ${message}`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Resolve a write key (a numeric LAN dp id or a string code) to the Tuya Cloud
|
|
573
|
+
// `code` the commands API expects. A numeric id with a known code is
|
|
574
|
+
// translated; anything else (already a code, or an unmapped id) is passed
|
|
575
|
+
// through unchanged.
|
|
576
|
+
_toCode(key) {
|
|
577
|
+
return this.codeByDpId[key] || key;
|
|
176
578
|
}
|
|
177
579
|
|
|
178
580
|
/* ------------------------------------------------------------------ *
|
|
@@ -182,6 +584,7 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
182
584
|
stop() {
|
|
183
585
|
this._stopped = true;
|
|
184
586
|
if (this._retryTimer) { clearTimeout(this._retryTimer); this._retryTimer = null; }
|
|
587
|
+
if (this._catchupTimers) { this._catchupTimers.forEach(t => clearTimeout(t)); this._catchupTimers = null; }
|
|
185
588
|
}
|
|
186
589
|
}
|
|
187
590
|
|