homebridge-tuya-plus 3.14.0-dev.3 → 3.14.0-dev.31
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 +120 -0
- package/CLAUDE.md +1 -0
- package/Changelog.md +21 -0
- package/Readme.MD +8 -24
- package/config.schema.json +122 -68
- package/index.js +536 -269
- package/lib/BaseAccessory.js +53 -16
- package/lib/ConvectorAccessory.js +1 -1
- package/lib/CustomMultiOutletAccessory.js +2 -1
- package/lib/IrrigationSystemAccessory.js +98 -95
- package/lib/MultiOutletAccessory.js +2 -1
- 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 +78 -24
- package/lib/SimpleHeaterAccessory.js +3 -3
- package/lib/SwitchAccessory.js +2 -1
- package/lib/TWLightAccessory.js +2 -2
- package/lib/TuyaAccessory.js +0 -1
- package/lib/TuyaCloudApi.js +320 -0
- package/lib/TuyaCloudDevice.js +367 -0
- package/lib/TuyaCloudMessaging.js +219 -0
- package/lib/TuyaDevice.js +269 -0
- package/lib/ValveAccessory.js +16 -12
- package/lib/VerticalBlindsWithTilt.js +5 -3
- package/lib/WledDimmerAccessory.js +46 -81
- package/package.json +5 -3
- package/scripts/cleanup-pr-tags.js +122 -0
- package/test/BaseAccessory.test.js +94 -15
- package/test/IrrigationSystemAccessory.test.js +134 -31
- 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 +63 -9
- package/test/TuyaCloudApi.test.js +221 -0
- package/test/TuyaCloudDevice.test.js +357 -0
- package/test/TuyaCloudMessaging.test.js +113 -0
- package/test/TuyaDevice.test.js +266 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +241 -0
- package/test/support/mocks.js +13 -0
- package/wiki/Supported-Device-Types.md +60 -28
- package/wiki/Tuya-Cloud-Setup.md +71 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require('events');
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* TuyaCloudDevice
|
|
7
|
+
* ---------------
|
|
8
|
+
* A drop-in replacement for TuyaAccessory (the LAN device class) that talks to
|
|
9
|
+
* a device through the Tuya Cloud instead of the local network. It deliberately
|
|
10
|
+
* exposes the exact same minimal contract every accessory relies on, so the
|
|
11
|
+
* existing accessories (IrrigationSystemAccessory, …) work unchanged on top:
|
|
12
|
+
*
|
|
13
|
+
* - `context` device config (name, id, type, …)
|
|
14
|
+
* - `state` current data-points { <code>: value, … }
|
|
15
|
+
* - `connected` boolean
|
|
16
|
+
* - `_connect()` start talking to the device
|
|
17
|
+
* - `update(dps)` write data-points
|
|
18
|
+
* - 'connect' / 'change' events (change → (changes, state))
|
|
19
|
+
*
|
|
20
|
+
* The one meaningful difference from the LAN class is the data-point KEY. The
|
|
21
|
+
* LAN protocol addresses data-points by numeric id (1, 2, …); the cloud speaks
|
|
22
|
+
* string "codes" (e.g. `switch_1`, `battery_percentage`). So `state` here is
|
|
23
|
+
* keyed by code, and accessories used over the cloud are configured with codes.
|
|
24
|
+
* The device logs its codes on connect to make that configuration obvious.
|
|
25
|
+
*
|
|
26
|
+
* Updates are delivered by Tuya's realtime MQTT message service (see
|
|
27
|
+
* TuyaCloudMessaging) — there is no periodic polling. Two REST calls are still
|
|
28
|
+
* inherent to the cloud, because MQTT only carries *changes* and is
|
|
29
|
+
* *receive-only*:
|
|
30
|
+
* - one `GET /status` to learn the initial state on connect (and again as a
|
|
31
|
+
* catch-up whenever the realtime stream (re)connects), and
|
|
32
|
+
* - `POST /commands` to actually control the device.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
class TuyaCloudDevice extends EventEmitter {
|
|
36
|
+
constructor(props = {}) {
|
|
37
|
+
super();
|
|
38
|
+
this.log = props.log || console;
|
|
39
|
+
this.api = props.cloudApi;
|
|
40
|
+
this.messaging = props.messaging || null; // shared realtime stream
|
|
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
|
+
|
|
48
|
+
// Keep a plain config object on `context`, mirroring TuyaAccessory, but
|
|
49
|
+
// without the live helper objects we were handed.
|
|
50
|
+
this.context = {...props};
|
|
51
|
+
delete this.context.cloudApi;
|
|
52
|
+
delete this.context.messaging;
|
|
53
|
+
delete this.context.log;
|
|
54
|
+
delete this.context.isLanConnected;
|
|
55
|
+
|
|
56
|
+
this.state = {};
|
|
57
|
+
this.connected = false;
|
|
58
|
+
this._stopped = false;
|
|
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
|
+
|
|
63
|
+
// Bidirectional data-point maps, learned from the device's thing-shadow on
|
|
64
|
+
// connect. They let this cloud transport (and the LAN+cloud TuyaDevice that
|
|
65
|
+
// may wrap it) translate between the LAN's numeric data-point ids and the
|
|
66
|
+
// cloud's string codes. Empty until the shadow is read; empty means
|
|
67
|
+
// "code-only" (no numeric bridge), which is still fully functional for a
|
|
68
|
+
// cloud-configured accessory.
|
|
69
|
+
this.codeByDpId = {}; // '20' -> 'switch_led'
|
|
70
|
+
this.dpIdByCode = {}; // 'switch_led' -> '20'
|
|
71
|
+
|
|
72
|
+
if (props.connect !== false) this._connect();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ------------------------------------------------------------------ *
|
|
76
|
+
* Connect: read initial state, announce, then let realtime keep it fresh.
|
|
77
|
+
* ------------------------------------------------------------------ */
|
|
78
|
+
|
|
79
|
+
async _connect() {
|
|
80
|
+
if (!this.api || !this.api.isConfigured()) {
|
|
81
|
+
return this.log.error(`${this.context.name}: Tuya Cloud is not configured (missing accessId/accessKey).`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// First connect only: stagger the initial read so a large install doesn't
|
|
85
|
+
// fire every device's OpenAPI call in the same instant (rate limits). The
|
|
86
|
+
// platform hands each device an increasing cloudStartDelay.
|
|
87
|
+
if (!this._staggered) {
|
|
88
|
+
this._staggered = true;
|
|
89
|
+
const delay = parseInt(this.context.cloudStartDelay) || 0;
|
|
90
|
+
if (delay > 0) {
|
|
91
|
+
await new Promise(resolve => { const t = setTimeout(resolve, delay); if (t.unref) t.unref(); });
|
|
92
|
+
if (this._stopped) return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const props = await this._readInitialProperties();
|
|
98
|
+
this._learnDpMap(props);
|
|
99
|
+
this.state = this._propsToState(props);
|
|
100
|
+
this.connected = await this._readOnline();
|
|
101
|
+
|
|
102
|
+
this._logDiscoveredCodes(props);
|
|
103
|
+
|
|
104
|
+
// Connected: announce recovery from any earlier failure once, and
|
|
105
|
+
// reset the retry backoff.
|
|
106
|
+
if (this._lastConnectError) {
|
|
107
|
+
this.log.info(`${this.context.name}: Tuya Cloud connection restored.`);
|
|
108
|
+
this._lastConnectError = null;
|
|
109
|
+
}
|
|
110
|
+
this._retryDelay = 0;
|
|
111
|
+
|
|
112
|
+
this.emit('connect');
|
|
113
|
+
// Accessories register their characteristics off the first 'change'.
|
|
114
|
+
this.emit('change', {...this.state}, this.state);
|
|
115
|
+
|
|
116
|
+
this._subscribeRealtime();
|
|
117
|
+
} catch (ex) {
|
|
118
|
+
this._onConnectFailure(ex);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A failed connect is retried, but the cause is frequently permanent — the
|
|
123
|
+
// cloud project can't see this device (permission denied / "no space"), the
|
|
124
|
+
// datacenter region is wrong, or the device isn't linked to the account — and
|
|
125
|
+
// won't clear without the user changing their Tuya project. Since PR #71 the
|
|
126
|
+
// cloud backs *every* device as a fallback, so a LAN-only device whose cloud
|
|
127
|
+
// project lacks permission would otherwise log an error every 30s forever.
|
|
128
|
+
// Two things keep that out of the log: a given message is surfaced once and then
|
|
129
|
+
// suppressed to debug until it changes (or the device connects), and the retry
|
|
130
|
+
// interval backs off (30s → 30m cap) instead of hammering the OpenAPI.
|
|
131
|
+
_onConnectFailure(ex) {
|
|
132
|
+
const message = ex && ex.message ? ex.message : String(ex);
|
|
133
|
+
const {level, text} = this._describeConnectFailure(message);
|
|
134
|
+
|
|
135
|
+
// The full (level + wording) is what the dedup tracks, not just the raw
|
|
136
|
+
// message: it means a device that flips from "reachable over the LAN"
|
|
137
|
+
// (harmless, debug) to "not reachable over the LAN either" (warn) re-surfaces
|
|
138
|
+
// the change, while an unchanged situation stays quiet.
|
|
139
|
+
if (text !== this._lastConnectError) {
|
|
140
|
+
this.log[level](text);
|
|
141
|
+
this._lastConnectError = text;
|
|
142
|
+
} else {
|
|
143
|
+
this.log.debug(`${this.context.name}: Tuya Cloud connection still failing: ${message}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (this._stopped) return;
|
|
147
|
+
// Exponential backoff: 30s after the first failure, doubling to a 30m cap.
|
|
148
|
+
this._retryDelay = Math.min(this._retryDelay ? this._retryDelay * 2 : 30000, 1800000);
|
|
149
|
+
this._retryTimer = setTimeout(() => this._connect(), this._retryDelay);
|
|
150
|
+
if (this._retryTimer && this._retryTimer.unref) this._retryTimer.unref();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Pick the log level and wording for a connect failure from what we know about
|
|
154
|
+
// the device: whether the cloud is merely a fallback (the device has a local
|
|
155
|
+
// key) and, if so, whether that LAN path is reachable right now.
|
|
156
|
+
_describeConnectFailure(message) {
|
|
157
|
+
const lanFallback = !!this.context.key;
|
|
158
|
+
const lanReachable = lanFallback && !!(this.isLanConnected && this.isLanConnected());
|
|
159
|
+
|
|
160
|
+
// "permission deny" (1106) is specifically a visibility problem: the cloud
|
|
161
|
+
// project can't see this device. A device that has been offline for a long
|
|
162
|
+
// time is a prime candidate for having been removed/unbound/reset, which
|
|
163
|
+
// produces exactly this — so point at that, but only for this error (a
|
|
164
|
+
// timeout or token failure is unrelated).
|
|
165
|
+
const unbound = /permission deny|\b1106\b/i.test(message)
|
|
166
|
+
? ' 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".'
|
|
167
|
+
: '';
|
|
168
|
+
|
|
169
|
+
let level, hint;
|
|
170
|
+
if (lanReachable) {
|
|
171
|
+
// Confirmed reachable over the LAN, so the cloud fallback we couldn't
|
|
172
|
+
// reach isn't needed. Keep it out of the way at debug.
|
|
173
|
+
level = 'debug';
|
|
174
|
+
hint = ' The device is reachable over the LAN, so only the (unused) cloud fallback is affected — harmless.';
|
|
175
|
+
} else if (lanFallback) {
|
|
176
|
+
// LAN-capable but not connected right now, so the cloud is currently the
|
|
177
|
+
// only path and the device may be unresponsive in HomeKit.
|
|
178
|
+
level = 'warn';
|
|
179
|
+
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}`;
|
|
180
|
+
} else {
|
|
181
|
+
level = 'error';
|
|
182
|
+
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}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {level, text: `${this.context.name}: Tuya Cloud connection failed: ${message}.${hint}`};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* ------------------------------------------------------------------ *
|
|
189
|
+
* Realtime (shared MQTT stream)
|
|
190
|
+
* ------------------------------------------------------------------ */
|
|
191
|
+
|
|
192
|
+
_subscribeRealtime() {
|
|
193
|
+
if (!this.messaging) {
|
|
194
|
+
return this.log.warn(`${this.context.name}: realtime updates are unavailable (no MQTT). Control still works, but external changes (physical buttons, the device's own timers) won't be reflected until restart. Ensure the "mqtt" package is installed and realtime isn't disabled in the cloud config.`);
|
|
195
|
+
}
|
|
196
|
+
// Register the reconnect catch-up handler before starting, so the very
|
|
197
|
+
// first 'online' is caught. On any (re)connect we re-read the full state
|
|
198
|
+
// to recover anything missed while the stream was down.
|
|
199
|
+
this.messaging.on('online', () => this._refreshState());
|
|
200
|
+
this.messaging.subscribeDevice(this.context.id, status => this._applyStatus(status));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async _refreshState() {
|
|
204
|
+
if (this._stopped) return;
|
|
205
|
+
try {
|
|
206
|
+
const online = await this._readOnline();
|
|
207
|
+
if (this.connected !== online) {
|
|
208
|
+
this.connected = online;
|
|
209
|
+
this.log.info(`${this.context.name}: Tuya now reports the device ${online ? 'online' : 'offline'}.`);
|
|
210
|
+
}
|
|
211
|
+
const status = await this.api.getStatus(this.context.id);
|
|
212
|
+
this._applyStatus(status);
|
|
213
|
+
} catch (ex) {
|
|
214
|
+
this.log.debug(`${this.context.name}: cloud state refresh failed: ${ex.message}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Resolve the device's reachability from Tuya's `online` flag, so HomeKit
|
|
219
|
+
// shows "No Response" when the device is genuinely offline. If the lookup
|
|
220
|
+
// isn't available (e.g. the project lacks the device-management API) fall
|
|
221
|
+
// back to reachable so control is never blocked.
|
|
222
|
+
async _readOnline() {
|
|
223
|
+
try {
|
|
224
|
+
const info = await this.api.getDeviceInfo(this.context.id);
|
|
225
|
+
if (info && typeof info.online === 'boolean') return info.online;
|
|
226
|
+
} catch (ex) {
|
|
227
|
+
this.log.debug(`${this.context.name}: online-status check failed: ${ex.message}`);
|
|
228
|
+
}
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* ------------------------------------------------------------------ *
|
|
233
|
+
* State helpers
|
|
234
|
+
* ------------------------------------------------------------------ */
|
|
235
|
+
|
|
236
|
+
// Read the device's initial data-points. Prefer the thing-shadow (it carries
|
|
237
|
+
// the numeric dp_id alongside each code, so LAN<->cloud bridging works); fall
|
|
238
|
+
// back to the plain status endpoint (code+value only) when the shadow isn't
|
|
239
|
+
// available. Returns an array of {code, value, dp_id?}.
|
|
240
|
+
async _readInitialProperties() {
|
|
241
|
+
if (typeof this.api.getShadowProperties === 'function') {
|
|
242
|
+
const props = await this.api.getShadowProperties(this.context.id);
|
|
243
|
+
if (Array.isArray(props)) return props;
|
|
244
|
+
}
|
|
245
|
+
return this.api.getStatus(this.context.id);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Learn the code <-> numeric dp_id mapping from a shadow-properties read.
|
|
249
|
+
_learnDpMap(props) {
|
|
250
|
+
(props || []).forEach(p => {
|
|
251
|
+
if (!p || typeof p.code !== 'string' || p.dp_id == null) return;
|
|
252
|
+
const dp = String(p.dp_id);
|
|
253
|
+
this.codeByDpId[dp] = p.code;
|
|
254
|
+
this.dpIdByCode[p.code] = dp;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Index a data-point under BOTH its string code and (when known) its numeric
|
|
259
|
+
// dp id, so an accessory configured the LAN way (numeric) or the cloud way
|
|
260
|
+
// (code) both resolve. The dp id comes from the shadow item, or — for code-only
|
|
261
|
+
// realtime deltas — from the map learned on connect.
|
|
262
|
+
_indexInto(target, code, value, dpId) {
|
|
263
|
+
target[code] = value;
|
|
264
|
+
const dp = dpId != null ? String(dpId) : this.dpIdByCode[code];
|
|
265
|
+
if (dp != null) target[dp] = value;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Build the dual-keyed state object from a properties/status array.
|
|
269
|
+
_propsToState(props) {
|
|
270
|
+
const state = {};
|
|
271
|
+
(props || []).forEach(item => {
|
|
272
|
+
if (item && typeof item.code === 'string') {
|
|
273
|
+
this._indexInto(state, item.code, item.value, item.dp_id != null ? item.dp_id : item.dpId);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
return state;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Apply a fresh snapshot (initial / catch-up) or a realtime delta, diff it
|
|
280
|
+
// against what we hold, and emit only what changed — exactly mirroring
|
|
281
|
+
// TuyaAccessory._change so accessories behave identically. State is dual-keyed
|
|
282
|
+
// (code + numeric dp) so a LAN-style or cloud-style config both resolve.
|
|
283
|
+
_applyStatus(status) {
|
|
284
|
+
const incoming = this._propsToState(status);
|
|
285
|
+
const changes = {};
|
|
286
|
+
Object.keys(incoming).forEach(key => {
|
|
287
|
+
if (incoming[key] !== this.state[key]) changes[key] = incoming[key];
|
|
288
|
+
});
|
|
289
|
+
if (Object.keys(changes).length) {
|
|
290
|
+
this.state = {...this.state, ...incoming};
|
|
291
|
+
this.emit('change', changes, this.state);
|
|
292
|
+
}
|
|
293
|
+
return changes;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
_logDiscoveredCodes(props) {
|
|
297
|
+
try {
|
|
298
|
+
const list = (props || []).map(i => {
|
|
299
|
+
const dp = i.dp_id != null ? i.dp_id : (i.dpId != null ? i.dpId : this.dpIdByCode[i.code]);
|
|
300
|
+
return `${i.code}${dp != null ? `(dp ${dp})` : ''}=${JSON.stringify(i.value)}`;
|
|
301
|
+
}).join(', ');
|
|
302
|
+
this.log.debug(`${this.context.name}: Tuya Cloud data-points → ${list || '(none reported)'}`);
|
|
303
|
+
} catch (_) { /* logging must never throw */ }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* ------------------------------------------------------------------ *
|
|
307
|
+
* Writes
|
|
308
|
+
* ------------------------------------------------------------------ */
|
|
309
|
+
|
|
310
|
+
// Write data-points. `dps` is keyed by code → value (the accessory was
|
|
311
|
+
// configured with codes). We deliberately do NOT optimistically mutate
|
|
312
|
+
// `this.state`: the accessory already reflects the user's intent in HomeKit
|
|
313
|
+
// immediately, and leaving `state` untouched lets the realtime stream
|
|
314
|
+
// confirm the real device state without a spurious "revert" while a sleepy
|
|
315
|
+
// device catches up.
|
|
316
|
+
//
|
|
317
|
+
// Return contract mirrors TuyaAccessory.update() as far as a synchronous
|
|
318
|
+
// caller is concerned (truthy on a no-op, `false` when not connected) but,
|
|
319
|
+
// unlike the LAN class, the actual command travels over HTTP and only its
|
|
320
|
+
// outcome reveals a failure. So when a command IS dispatched we return the
|
|
321
|
+
// promise that resolves to the boolean result (and never rejects), letting
|
|
322
|
+
// `BaseAccessory.setMultiStateAsync` await it and surface a rejected cloud
|
|
323
|
+
// command to HomeKit instead of silently dropping it.
|
|
324
|
+
update(dps) {
|
|
325
|
+
if (!dps || typeof dps !== 'object') return true;
|
|
326
|
+
|
|
327
|
+
// Keys may be numeric LAN dp ids (when a LAN-configured accessory falls
|
|
328
|
+
// back to the cloud) or string codes (a cloud-configured accessory); the
|
|
329
|
+
// commands API speaks codes, so translate via the learned map.
|
|
330
|
+
const commands = Object.keys(dps).map(key => ({code: this._toCode(key), value: dps[key]}));
|
|
331
|
+
if (!commands.length) return true;
|
|
332
|
+
|
|
333
|
+
if (!this.connected) {
|
|
334
|
+
this.log.debug(`${this.context.name}: skipping cloud write, not connected`);
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
|
|
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
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Resolve a write key (a numeric LAN dp id or a string code) to the Tuya Cloud
|
|
350
|
+
// `code` the commands API expects. A numeric id with a known code is
|
|
351
|
+
// translated; anything else (already a code, or an unmapped id) is passed
|
|
352
|
+
// through unchanged.
|
|
353
|
+
_toCode(key) {
|
|
354
|
+
return this.codeByDpId[key] || key;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* ------------------------------------------------------------------ *
|
|
358
|
+
* Teardown (tidy; Homebridge doesn't strictly require it)
|
|
359
|
+
* ------------------------------------------------------------------ */
|
|
360
|
+
|
|
361
|
+
stop() {
|
|
362
|
+
this._stopped = true;
|
|
363
|
+
if (this._retryTimer) { clearTimeout(this._retryTimer); this._retryTimer = null; }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
module.exports = TuyaCloudDevice;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require('events');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* TuyaCloudMessaging
|
|
8
|
+
* ------------------
|
|
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
|
+
*
|
|
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
|
+
*
|
|
20
|
+
* The connection + AES-GCM message decryption are faithful to the
|
|
21
|
+
* actively-maintained `0x5e/homebridge-tuya-platform` (`src/core/TuyaOpenMQ.ts`),
|
|
22
|
+
* re-implemented here with Node's built-in `crypto`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const mqtt = require('mqtt');
|
|
26
|
+
|
|
27
|
+
const GCM_TAG_LENGTH = 16;
|
|
28
|
+
const PROTOCOL_DEVICE_STATUS = 4;
|
|
29
|
+
|
|
30
|
+
class TuyaCloudMessaging extends EventEmitter {
|
|
31
|
+
constructor({api, log} = {}) {
|
|
32
|
+
super();
|
|
33
|
+
this.api = api;
|
|
34
|
+
this.log = log || console;
|
|
35
|
+
|
|
36
|
+
this.linkId = crypto.randomUUID(); // bare v4 UUID, reused across reconnects
|
|
37
|
+
this.client = null;
|
|
38
|
+
this.config = null;
|
|
39
|
+
this.connected = false;
|
|
40
|
+
|
|
41
|
+
this._handlers = new Map(); // devId -> [fn(status)]
|
|
42
|
+
this._renewTimer = null;
|
|
43
|
+
this._started = false;
|
|
44
|
+
this._stopped = false;
|
|
45
|
+
|
|
46
|
+
// EventEmitter with many devices listening to 'online'/'offline'.
|
|
47
|
+
this.setMaxListeners(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Register a device's status handler and lazily start the shared connection.
|
|
51
|
+
subscribeDevice(devId, handler) {
|
|
52
|
+
const id = '' + devId;
|
|
53
|
+
if (!this._handlers.has(id)) this._handlers.set(id, []);
|
|
54
|
+
this._handlers.get(id).push(handler);
|
|
55
|
+
if (!this._started) this.start();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
start() {
|
|
59
|
+
if (this._stopped || this._started) return;
|
|
60
|
+
this._started = true;
|
|
61
|
+
|
|
62
|
+
this._connect().catch(ex => {
|
|
63
|
+
this.log.warn(`Tuya Cloud realtime could not start (${ex.message}); it will keep retrying.`);
|
|
64
|
+
this._scheduleRenew(60);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async _connect() {
|
|
69
|
+
if (this._stopped) return;
|
|
70
|
+
this._teardownClient();
|
|
71
|
+
|
|
72
|
+
const cfg = await this.api.getMqttConfig(this.linkId);
|
|
73
|
+
this.config = cfg;
|
|
74
|
+
|
|
75
|
+
if (!cfg.url || !cfg.source_topic || !cfg.source_topic.device) {
|
|
76
|
+
throw new Error('incomplete MQTT config from Tuya');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const client = mqtt.connect(cfg.url, {
|
|
80
|
+
clientId: cfg.client_id,
|
|
81
|
+
username: cfg.username,
|
|
82
|
+
password: cfg.password
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
client.on('connect', () => {
|
|
86
|
+
this.connected = true;
|
|
87
|
+
client.subscribe(cfg.source_topic.device, err => {
|
|
88
|
+
if (err) this.log.debug(`Tuya Cloud realtime subscribe error: ${err.message}`);
|
|
89
|
+
});
|
|
90
|
+
this.log.info('Tuya Cloud realtime connected (MQTT).');
|
|
91
|
+
this.emit('online');
|
|
92
|
+
});
|
|
93
|
+
client.on('message', (topic, payload) => this._onMessage(topic, payload));
|
|
94
|
+
client.on('error', err => this.log.debug(`Tuya Cloud realtime error: ${err && err.message}`));
|
|
95
|
+
client.on('close', () => this._markOffline());
|
|
96
|
+
client.on('end', () => this._markOffline());
|
|
97
|
+
|
|
98
|
+
this.client = client;
|
|
99
|
+
|
|
100
|
+
// Broker credentials expire; renew them a minute early (mqtt.js handles
|
|
101
|
+
// transient drops on its own via auto-reconnect).
|
|
102
|
+
const ttl = parseInt(cfg.expire_time) || 7200;
|
|
103
|
+
this._scheduleRenew(Math.max(60, ttl - 60));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_markOffline() {
|
|
107
|
+
if (this.connected) {
|
|
108
|
+
this.connected = false;
|
|
109
|
+
this.emit('offline');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_scheduleRenew(seconds) {
|
|
114
|
+
if (this._renewTimer) clearTimeout(this._renewTimer);
|
|
115
|
+
if (this._stopped) return;
|
|
116
|
+
this._renewTimer = setTimeout(() => {
|
|
117
|
+
this._connect().catch(ex => {
|
|
118
|
+
this.log.debug(`Tuya Cloud realtime renew failed: ${ex.message}`);
|
|
119
|
+
this._scheduleRenew(60);
|
|
120
|
+
});
|
|
121
|
+
}, seconds * 1000);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_onMessage(topic, payload) {
|
|
125
|
+
let envelope;
|
|
126
|
+
try {
|
|
127
|
+
envelope = JSON.parse(payload.toString());
|
|
128
|
+
} catch (_) { return; }
|
|
129
|
+
|
|
130
|
+
const {protocol, data} = envelope;
|
|
131
|
+
if (!data) return;
|
|
132
|
+
|
|
133
|
+
let plaintext;
|
|
134
|
+
try {
|
|
135
|
+
plaintext = this._decrypt(data, this.config && this.config.password);
|
|
136
|
+
} catch (ex) {
|
|
137
|
+
this.log.debug(`Tuya Cloud realtime decrypt failed: ${ex.message}`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let msg;
|
|
142
|
+
try {
|
|
143
|
+
msg = JSON.parse(plaintext);
|
|
144
|
+
} catch (_) { return; }
|
|
145
|
+
|
|
146
|
+
// We only care about device status frames (protocol 4). Some firmwares
|
|
147
|
+
// omit `protocol`; in that case fall back to "has a status array".
|
|
148
|
+
if (protocol != null && protocol !== PROTOCOL_DEVICE_STATUS && !(msg && Array.isArray(msg.status))) return;
|
|
149
|
+
|
|
150
|
+
const devId = msg && (msg.devId || msg.devid || msg.dev_id);
|
|
151
|
+
const status = msg && msg.status;
|
|
152
|
+
if (!devId || !Array.isArray(status)) return;
|
|
153
|
+
|
|
154
|
+
const handlers = this._handlers.get('' + devId);
|
|
155
|
+
if (!handlers || !handlers.length) return;
|
|
156
|
+
handlers.forEach(fn => {
|
|
157
|
+
try { fn(status); } catch (ex) { this.log.debug(`Tuya Cloud realtime handler error: ${ex.message}`); }
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Decrypt the MQTT `data` field. msg_encrypted_version 2.0 → AES-128-GCM,
|
|
162
|
+
// 1.0 → AES-128-ECB. The AES key is the middle 16 chars of the broker
|
|
163
|
+
// password (NOT the project Access Secret).
|
|
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) {
|
|
175
|
+
const key = Buffer.from(('' + (password || '')).substring(8, 24), 'utf8');
|
|
176
|
+
const buf = Buffer.from(data, 'base64');
|
|
177
|
+
|
|
178
|
+
// GCM (v2.0) layout: [ivLen(4) BE][iv(ivLen)][ciphertext][tag(16)].
|
|
179
|
+
// Try it first; if the header doesn't look like a sane IV length, fall
|
|
180
|
+
// back to ECB (v1.0).
|
|
181
|
+
if (buf.length > 4 + GCM_TAG_LENGTH) {
|
|
182
|
+
const ivLen = buf.readUIntBE(0, 4);
|
|
183
|
+
if (ivLen >= 12 && ivLen <= 16 && (4 + ivLen + GCM_TAG_LENGTH) <= buf.length) {
|
|
184
|
+
try {
|
|
185
|
+
const iv = buf.slice(4, 4 + ivLen);
|
|
186
|
+
const ciphertext = buf.slice(4 + ivLen, buf.length - GCM_TAG_LENGTH);
|
|
187
|
+
const decipher = crypto.createDecipheriv('aes-128-gcm', key, iv);
|
|
188
|
+
return decipher.update(ciphertext).toString('utf8');
|
|
189
|
+
} catch (gcmEx) {
|
|
190
|
+
// fall through to ECB
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
|
|
196
|
+
return Buffer.concat([decipher.update(buf), decipher.final()]).toString('utf8');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
_teardownClient() {
|
|
200
|
+
if (this.client) {
|
|
201
|
+
try {
|
|
202
|
+
this.client.removeAllListeners();
|
|
203
|
+
this.client.end(true);
|
|
204
|
+
} catch (_) { /* ignore */ }
|
|
205
|
+
this.client = null;
|
|
206
|
+
}
|
|
207
|
+
this.connected = false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
stop() {
|
|
211
|
+
this._stopped = true;
|
|
212
|
+
if (this._renewTimer) { clearTimeout(this._renewTimer); this._renewTimer = null; }
|
|
213
|
+
this._teardownClient();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Expose for unit tests / encryption round-trips.
|
|
218
|
+
TuyaCloudMessaging.GCM_TAG_LENGTH = GCM_TAG_LENGTH;
|
|
219
|
+
module.exports = TuyaCloudMessaging;
|