homebridge-tuya-plus 3.14.0-dev.18 → 3.14.0-dev.20
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/Changelog.md +4 -3
- package/Readme.MD +4 -6
- package/config.schema.json +5 -14
- package/index.js +349 -358
- package/lib/IrrigationSystemAccessory.js +12 -23
- package/lib/SimpleDimmerAccessory.js +9 -5
- package/lib/SimpleGarageDoorAccessory.js +9 -9
- package/lib/TuyaCloudApi.js +21 -0
- package/lib/TuyaCloudDevice.js +86 -15
- package/lib/TuyaDevice.js +266 -0
- package/lib/ValveAccessory.js +7 -7
- package/lib/WledDimmerAccessory.js +43 -80
- package/package.json +2 -2
- package/test/IrrigationSystemAccessory.test.js +44 -49
- package/test/TuyaCloudApi.test.js +17 -0
- package/test/TuyaCloudDevice.test.js +49 -0
- package/test/TuyaDevice.test.js +252 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +141 -0
- package/wiki/Supported-Device-Types.md +8 -6
- package/wiki/Tuya-Cloud-Setup.md +15 -29
|
@@ -46,14 +46,13 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
46
46
|
// Self-contained (no reliance on instance state) so it is safe to call
|
|
47
47
|
// both during early service reconciliation and later when wiring
|
|
48
48
|
// characteristics.
|
|
49
|
-
const cloud = this._isCloud();
|
|
50
49
|
const defaultDuration = isFinite(this.device.context.defaultDuration) ? parseInt(this.device.context.defaultDuration) : 600;
|
|
51
50
|
|
|
52
51
|
if (Array.isArray(this.device.context.valves) && this.device.context.valves.length) {
|
|
53
52
|
return this.device.context.valves.map((valve, i) => {
|
|
54
|
-
// A data-point may be a numeric
|
|
55
|
-
//
|
|
56
|
-
// invalid.
|
|
53
|
+
// A data-point may be a numeric id (1, 2, …) or a Tuya code
|
|
54
|
+
// (e.g. "switch_1"); both resolve over either transport. Only an
|
|
55
|
+
// empty value is invalid.
|
|
57
56
|
const dp = (valve && valve.dp !== undefined && valve.dp !== null) ? ('' + valve.dp).trim() : '';
|
|
58
57
|
if (!dp) {
|
|
59
58
|
throw new Error(`The valve definition #${i + 1} is missing a 'dp': ${JSON.stringify(valve)}`);
|
|
@@ -72,9 +71,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
72
71
|
const configs = [];
|
|
73
72
|
for (let i = 0; i < count; i++) {
|
|
74
73
|
configs.push({
|
|
75
|
-
|
|
76
|
-
// LAN devices by numeric data-point id (1, 2, …).
|
|
77
|
-
dp: cloud ? ('switch_' + (i + 1)) : String(i + 1),
|
|
74
|
+
dp: String(i + 1),
|
|
78
75
|
name: 'Valve ' + (letters[i] || (i + 1)),
|
|
79
76
|
index: i + 1,
|
|
80
77
|
duration: defaultDuration
|
|
@@ -83,19 +80,11 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
83
80
|
return configs;
|
|
84
81
|
}
|
|
85
82
|
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
return this._coerceBoolean(this.device.context.cloud, false);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Resolve a configurable data-point that may be given as a numeric LAN id or
|
|
93
|
-
// a Tuya Cloud code, falling back to a sensible default for the active
|
|
94
|
-
// transport.
|
|
95
|
-
_resolveDP(value, cloudDefault, lanDefault) {
|
|
83
|
+
// Resolve a configurable data-point (a numeric id or a Tuya code), trimming and
|
|
84
|
+
// falling back to a default when it isn't set.
|
|
85
|
+
_resolveDP(value, fallback) {
|
|
96
86
|
const v = (value === undefined || value === null) ? '' : ('' + value).trim();
|
|
97
|
-
|
|
98
|
-
return this._isCloud() ? cloudDefault : lanDefault;
|
|
87
|
+
return v !== '' ? v : fallback;
|
|
99
88
|
}
|
|
100
89
|
|
|
101
90
|
_hasBattery() {
|
|
@@ -215,10 +204,10 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
215
204
|
this._cascadeOn = this._coerceBoolean(this.device.context.masterTurnsOnAllZones, true);
|
|
216
205
|
this._cascadeOff = this._coerceBoolean(this.device.context.masterTurnsOffAllZones, true);
|
|
217
206
|
|
|
218
|
-
// Data-points
|
|
219
|
-
//
|
|
220
|
-
this.dpBattery = this._resolveDP(this.device.context.dpBattery, '
|
|
221
|
-
this.dpCharging = this._resolveDP(this.device.context.dpCharging, '
|
|
207
|
+
// Data-points default to this device's numeric ids; a Tuya code may be
|
|
208
|
+
// configured instead and works over either transport.
|
|
209
|
+
this.dpBattery = this._resolveDP(this.device.context.dpBattery, '46');
|
|
210
|
+
this.dpCharging = this._resolveDP(this.device.context.dpCharging, '101');
|
|
222
211
|
|
|
223
212
|
// Per-zone runtime state
|
|
224
213
|
this._valves = this._getValveConfigs();
|
|
@@ -25,20 +25,24 @@ class SimpleDimmerAccessory extends BaseAccessory {
|
|
|
25
25
|
this.dpPower = this._getCustomDP(this.device.context.dpPower) || '1';
|
|
26
26
|
this.dpBrightness = this._getCustomDP(this.device.context.dpBrightness) || this._getCustomDP(this.device.context.dp) || '2';
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
this._characteristicOn = service.getCharacteristic(Characteristic.On)
|
|
29
29
|
.updateValue(dps[this.dpPower])
|
|
30
30
|
.onGet(() => this.getStateAsync(this.dpPower))
|
|
31
31
|
.onSet(value => this.setStateAsync(this.dpPower, value));
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
this._characteristicBrightness = service.getCharacteristic(Characteristic.Brightness)
|
|
34
34
|
.updateValue(this.convertBrightnessFromTuyaToHomeKit(dps[this.dpBrightness]))
|
|
35
35
|
.onGet(() => this.getBrightness())
|
|
36
36
|
.onSet(value => this.setBrightness(value));
|
|
37
37
|
|
|
38
|
+
this._registerChangeListener();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_registerChangeListener() {
|
|
38
42
|
this.device.on('change', changes => {
|
|
39
|
-
if (changes.hasOwnProperty(this.dpPower) &&
|
|
40
|
-
if (changes.hasOwnProperty(this.dpBrightness) && this.convertBrightnessFromHomeKitToTuya(
|
|
41
|
-
|
|
43
|
+
if (changes.hasOwnProperty(this.dpPower) && this._characteristicOn.value !== changes[this.dpPower]) this._characteristicOn.updateValue(changes[this.dpPower]);
|
|
44
|
+
if (changes.hasOwnProperty(this.dpBrightness) && this.convertBrightnessFromHomeKitToTuya(this._characteristicBrightness.value) !== changes[this.dpBrightness])
|
|
45
|
+
this._characteristicBrightness.updateValue(this.convertBrightnessFromTuyaToHomeKit(changes[this.dpBrightness]));
|
|
42
46
|
});
|
|
43
47
|
}
|
|
44
48
|
|
|
@@ -197,7 +197,7 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
|
|
|
197
197
|
if (this.pendingCloseTimer) return;
|
|
198
198
|
const isOpen = this._mapDpState(changes[this.dpState]);
|
|
199
199
|
if (isOpen === null) {
|
|
200
|
-
this.log.
|
|
200
|
+
this.log.debug(`[SimpleGarageDoor] ${this.device.context.name}: ignoring unknown state DP value ${JSON.stringify(changes[this.dpState])}`);
|
|
201
201
|
return;
|
|
202
202
|
}
|
|
203
203
|
this._applyReportedState(isOpen);
|
|
@@ -266,7 +266,7 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
|
|
|
266
266
|
// own, even mid-close. Abandon any pending stop-before-close.
|
|
267
267
|
_sendOpen() {
|
|
268
268
|
this._cancelPendingClose();
|
|
269
|
-
this.log.
|
|
269
|
+
this.log.debug(`[SimpleGarageDoor] ${this.device.context.name}: open (dp${this.dpOpen})`);
|
|
270
270
|
this.setMultiStateLegacyInBackground({[this.dpOpen]: true});
|
|
271
271
|
}
|
|
272
272
|
|
|
@@ -278,19 +278,19 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
|
|
|
278
278
|
if (this.pendingCloseTimer) {
|
|
279
279
|
// A stop-before-close is already running — don't restart it (and
|
|
280
280
|
// push the close out) on a duplicate or retransmitted request.
|
|
281
|
-
this.log.
|
|
281
|
+
this.log.debug(`[SimpleGarageDoor] ${name}: close ignored — a stop-before-close is already running`);
|
|
282
282
|
return;
|
|
283
283
|
}
|
|
284
284
|
if (this._isStopped()) {
|
|
285
|
-
this.log.
|
|
285
|
+
this.log.debug(`[SimpleGarageDoor] ${name}: close (dp${this.dpClose}) — gate already stopped`);
|
|
286
286
|
this.setMultiStateLegacyInBackground({[this.dpClose]: true});
|
|
287
287
|
return;
|
|
288
288
|
}
|
|
289
|
-
this.log.
|
|
289
|
+
this.log.debug(`[SimpleGarageDoor] ${name}: stop-before-close — stop (dp${this.dpStop}) now, close (dp${this.dpClose}) in ${this.stopBeforeCloseMs}ms`);
|
|
290
290
|
this.setMultiStateLegacyInBackground({[this.dpStop]: true});
|
|
291
291
|
this.pendingCloseTimer = setTimeout(() => {
|
|
292
292
|
this.pendingCloseTimer = null;
|
|
293
|
-
this.log.
|
|
293
|
+
this.log.debug(`[SimpleGarageDoor] ${name}: stop-before-close — firing close (dp${this.dpClose})`);
|
|
294
294
|
this.setMultiStateLegacyInBackground({[this.dpClose]: true});
|
|
295
295
|
}, this.stopBeforeCloseMs);
|
|
296
296
|
}
|
|
@@ -323,15 +323,15 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
|
|
|
323
323
|
// Re-entrant press while a partial is already armed (e.g. a
|
|
324
324
|
// HomeKit/iOS WRITE retransmit) — ignore so the stop isn't pushed
|
|
325
325
|
// out, which would let the gate run further than intended.
|
|
326
|
-
this.log.
|
|
326
|
+
this.log.debug(`[SimpleGarageDoor] ${name}: partial press ignored — a partial open is already running`);
|
|
327
327
|
return;
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
-
this.log.
|
|
330
|
+
this.log.debug(`[SimpleGarageDoor] ${name}: partial open — opening, will stop in ${this.partialOpenMs}ms`);
|
|
331
331
|
this._applyTarget(Characteristic.TargetDoorState.OPEN);
|
|
332
332
|
this.partialStopTimer = setTimeout(() => {
|
|
333
333
|
this.partialStopTimer = null;
|
|
334
|
-
this.log.
|
|
334
|
+
this.log.debug(`[SimpleGarageDoor] ${name}: partial open — firing stop (dp${this.dpStop})`);
|
|
335
335
|
this.setMultiStateLegacyInBackground({[this.dpStop]: true});
|
|
336
336
|
}, this.partialOpenMs);
|
|
337
337
|
}
|
package/lib/TuyaCloudApi.js
CHANGED
|
@@ -214,6 +214,27 @@ class TuyaCloudApi {
|
|
|
214
214
|
return (res && Array.isArray(res.result)) ? res.result : [];
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
// Read the device's "thing shadow" properties. Like getStatus, but each item
|
|
218
|
+
// ALSO carries its numeric `dp_id` — which is exactly the id the LAN protocol
|
|
219
|
+
// uses to address that data-point. That mapping is what lets the plugin bridge
|
|
220
|
+
// a LAN-style (numeric DP) configuration to the cloud (string codes), and back,
|
|
221
|
+
// transparently — so the same accessory works over either transport.
|
|
222
|
+
//
|
|
223
|
+
// Returns an array of {code, dp_id, value} on success, or null when the
|
|
224
|
+
// endpoint isn't available (older projects, or the device-shadow API isn't
|
|
225
|
+
// authorised). Callers fall back to getStatus() in that case (code-only, no
|
|
226
|
+
// numeric mapping). Never throws.
|
|
227
|
+
// Docs: https://developer.tuya.com/en/docs/cloud/116cc8bf6f?id=Kcp2kwfrpe719
|
|
228
|
+
async getShadowProperties(deviceId) {
|
|
229
|
+
try {
|
|
230
|
+
const res = await this.request('GET', `/v2.0/cloud/thing/${encodeURIComponent(deviceId)}/shadow/properties`);
|
|
231
|
+
return (res && res.result && Array.isArray(res.result.properties)) ? res.result.properties : null;
|
|
232
|
+
} catch (ex) {
|
|
233
|
+
this.log.debug(`Tuya Cloud shadow/properties unavailable for ${deviceId} (${ex.message}); falling back to /status.`);
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
217
238
|
// Read the device record (name, product, and crucially `online`). Used to
|
|
218
239
|
// learn whether the device is currently reachable. Returns the raw `result`
|
|
219
240
|
// object or null. Requires the project to have the device-management API
|
package/lib/TuyaCloudDevice.js
CHANGED
|
@@ -51,6 +51,15 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
51
51
|
this._stopped = false;
|
|
52
52
|
this._retryTimer = null;
|
|
53
53
|
|
|
54
|
+
// Bidirectional data-point maps, learned from the device's thing-shadow on
|
|
55
|
+
// connect. They let this cloud transport (and the LAN+cloud TuyaDevice that
|
|
56
|
+
// may wrap it) translate between the LAN's numeric data-point ids and the
|
|
57
|
+
// cloud's string codes. Empty until the shadow is read; empty means
|
|
58
|
+
// "code-only" (no numeric bridge), which is still fully functional for a
|
|
59
|
+
// cloud-configured accessory.
|
|
60
|
+
this.codeByDpId = {}; // '20' -> 'switch_led'
|
|
61
|
+
this.dpIdByCode = {}; // 'switch_led' -> '20'
|
|
62
|
+
|
|
54
63
|
if (props.connect !== false) this._connect();
|
|
55
64
|
}
|
|
56
65
|
|
|
@@ -63,12 +72,25 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
63
72
|
return this.log.error(`${this.context.name}: Tuya Cloud is not configured (missing accessId/accessKey).`);
|
|
64
73
|
}
|
|
65
74
|
|
|
75
|
+
// First connect only: stagger the initial read so a large install doesn't
|
|
76
|
+
// fire every device's OpenAPI call in the same instant (rate limits). The
|
|
77
|
+
// platform hands each device an increasing cloudStartDelay.
|
|
78
|
+
if (!this._staggered) {
|
|
79
|
+
this._staggered = true;
|
|
80
|
+
const delay = parseInt(this.context.cloudStartDelay) || 0;
|
|
81
|
+
if (delay > 0) {
|
|
82
|
+
await new Promise(resolve => { const t = setTimeout(resolve, delay); if (t.unref) t.unref(); });
|
|
83
|
+
if (this._stopped) return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
try {
|
|
67
|
-
const
|
|
68
|
-
this.
|
|
88
|
+
const props = await this._readInitialProperties();
|
|
89
|
+
this._learnDpMap(props);
|
|
90
|
+
this.state = this._propsToState(props);
|
|
69
91
|
this.connected = await this._readOnline();
|
|
70
92
|
|
|
71
|
-
this._logDiscoveredCodes(
|
|
93
|
+
this._logDiscoveredCodes(props);
|
|
72
94
|
|
|
73
95
|
this.emit('connect');
|
|
74
96
|
// Accessories register their characteristics off the first 'change'.
|
|
@@ -132,23 +154,58 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
132
154
|
* State helpers
|
|
133
155
|
* ------------------------------------------------------------------ */
|
|
134
156
|
|
|
135
|
-
//
|
|
136
|
-
|
|
157
|
+
// Read the device's initial data-points. Prefer the thing-shadow (it carries
|
|
158
|
+
// the numeric dp_id alongside each code, so LAN<->cloud bridging works); fall
|
|
159
|
+
// back to the plain status endpoint (code+value only) when the shadow isn't
|
|
160
|
+
// available. Returns an array of {code, value, dp_id?}.
|
|
161
|
+
async _readInitialProperties() {
|
|
162
|
+
if (typeof this.api.getShadowProperties === 'function') {
|
|
163
|
+
const props = await this.api.getShadowProperties(this.context.id);
|
|
164
|
+
if (Array.isArray(props)) return props;
|
|
165
|
+
}
|
|
166
|
+
return this.api.getStatus(this.context.id);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Learn the code <-> numeric dp_id mapping from a shadow-properties read.
|
|
170
|
+
_learnDpMap(props) {
|
|
171
|
+
(props || []).forEach(p => {
|
|
172
|
+
if (!p || typeof p.code !== 'string' || p.dp_id == null) return;
|
|
173
|
+
const dp = String(p.dp_id);
|
|
174
|
+
this.codeByDpId[dp] = p.code;
|
|
175
|
+
this.dpIdByCode[p.code] = dp;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Index a data-point under BOTH its string code and (when known) its numeric
|
|
180
|
+
// dp id, so an accessory configured the LAN way (numeric) or the cloud way
|
|
181
|
+
// (code) both resolve. The dp id comes from the shadow item, or — for code-only
|
|
182
|
+
// realtime deltas — from the map learned on connect.
|
|
183
|
+
_indexInto(target, code, value, dpId) {
|
|
184
|
+
target[code] = value;
|
|
185
|
+
const dp = dpId != null ? String(dpId) : this.dpIdByCode[code];
|
|
186
|
+
if (dp != null) target[dp] = value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Build the dual-keyed state object from a properties/status array.
|
|
190
|
+
_propsToState(props) {
|
|
137
191
|
const state = {};
|
|
138
|
-
(
|
|
139
|
-
if (item && typeof item.code === 'string')
|
|
192
|
+
(props || []).forEach(item => {
|
|
193
|
+
if (item && typeof item.code === 'string') {
|
|
194
|
+
this._indexInto(state, item.code, item.value, item.dp_id != null ? item.dp_id : item.dpId);
|
|
195
|
+
}
|
|
140
196
|
});
|
|
141
197
|
return state;
|
|
142
198
|
}
|
|
143
199
|
|
|
144
200
|
// Apply a fresh snapshot (initial / catch-up) or a realtime delta, diff it
|
|
145
201
|
// against what we hold, and emit only what changed — exactly mirroring
|
|
146
|
-
// TuyaAccessory._change so accessories behave identically.
|
|
202
|
+
// TuyaAccessory._change so accessories behave identically. State is dual-keyed
|
|
203
|
+
// (code + numeric dp) so a LAN-style or cloud-style config both resolve.
|
|
147
204
|
_applyStatus(status) {
|
|
148
|
-
const incoming = this.
|
|
205
|
+
const incoming = this._propsToState(status);
|
|
149
206
|
const changes = {};
|
|
150
|
-
Object.keys(incoming).forEach(
|
|
151
|
-
if (incoming[
|
|
207
|
+
Object.keys(incoming).forEach(key => {
|
|
208
|
+
if (incoming[key] !== this.state[key]) changes[key] = incoming[key];
|
|
152
209
|
});
|
|
153
210
|
if (Object.keys(changes).length) {
|
|
154
211
|
this.state = {...this.state, ...incoming};
|
|
@@ -157,10 +214,13 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
157
214
|
return changes;
|
|
158
215
|
}
|
|
159
216
|
|
|
160
|
-
_logDiscoveredCodes(
|
|
217
|
+
_logDiscoveredCodes(props) {
|
|
161
218
|
try {
|
|
162
|
-
const list = (
|
|
163
|
-
|
|
219
|
+
const list = (props || []).map(i => {
|
|
220
|
+
const dp = i.dp_id != null ? i.dp_id : (i.dpId != null ? i.dpId : this.dpIdByCode[i.code]);
|
|
221
|
+
return `${i.code}${dp != null ? `(dp ${dp})` : ''}=${JSON.stringify(i.value)}`;
|
|
222
|
+
}).join(', ');
|
|
223
|
+
this.log.info(`${this.context.name}: Tuya Cloud data-points → ${list || '(none reported)'}`);
|
|
164
224
|
} catch (_) { /* logging must never throw */ }
|
|
165
225
|
}
|
|
166
226
|
|
|
@@ -185,7 +245,10 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
185
245
|
update(dps) {
|
|
186
246
|
if (!dps || typeof dps !== 'object') return true;
|
|
187
247
|
|
|
188
|
-
|
|
248
|
+
// Keys may be numeric LAN dp ids (when a LAN-configured accessory falls
|
|
249
|
+
// back to the cloud) or string codes (a cloud-configured accessory); the
|
|
250
|
+
// commands API speaks codes, so translate via the learned map.
|
|
251
|
+
const commands = Object.keys(dps).map(key => ({code: this._toCode(key), value: dps[key]}));
|
|
189
252
|
if (!commands.length) return true;
|
|
190
253
|
|
|
191
254
|
if (!this.connected) {
|
|
@@ -204,6 +267,14 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
204
267
|
});
|
|
205
268
|
}
|
|
206
269
|
|
|
270
|
+
// Resolve a write key (a numeric LAN dp id or a string code) to the Tuya Cloud
|
|
271
|
+
// `code` the commands API expects. A numeric id with a known code is
|
|
272
|
+
// translated; anything else (already a code, or an unmapped id) is passed
|
|
273
|
+
// through unchanged.
|
|
274
|
+
_toCode(key) {
|
|
275
|
+
return this.codeByDpId[key] || key;
|
|
276
|
+
}
|
|
277
|
+
|
|
207
278
|
/* ------------------------------------------------------------------ *
|
|
208
279
|
* Teardown (tidy; Homebridge doesn't strictly require it)
|
|
209
280
|
* ------------------------------------------------------------------ */
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require('events');
|
|
4
|
+
const TuyaAccessory = require('./TuyaAccessory');
|
|
5
|
+
const TuyaCloudDevice = require('./TuyaCloudDevice');
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* TuyaDevice
|
|
9
|
+
* ----------
|
|
10
|
+
* The single `device` object every accessory now talks to. It presents the exact
|
|
11
|
+
* same minimal contract the accessories already rely on —
|
|
12
|
+
*
|
|
13
|
+
* - `context` device config (name, id, type, …)
|
|
14
|
+
* - `state` current data-points
|
|
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
|
+
* — but underneath it transparently spans BOTH transports:
|
|
21
|
+
*
|
|
22
|
+
* - a LAN backend (TuyaAccessory) — the fast, private, local path
|
|
23
|
+
* - a cloud backend (TuyaCloudDevice)— Tuya's internet path, used as a fallback
|
|
24
|
+
*
|
|
25
|
+
* Behaviour mirrors the official Tuya app: control goes over the LAN whenever the
|
|
26
|
+
* device is reachable there; if the LAN is down (or the device never appears on it
|
|
27
|
+
* — e.g. battery-powered "sleepy" units) it falls back to the cloud. Only when
|
|
28
|
+
* BOTH are unreachable does the device report disconnected, so the accessory shows
|
|
29
|
+
* "No Response". Accessories don't know or care which path is live.
|
|
30
|
+
*
|
|
31
|
+
* The one thing that differs between the transports is how a data-point is
|
|
32
|
+
* addressed: the LAN speaks numeric ids (1, 2, …), the cloud speaks string codes
|
|
33
|
+
* (switch_led, …). The cloud backend learns the id<->code map from the device's
|
|
34
|
+
* thing-shadow and keeps `state` keyed BOTH ways, so a configuration written for
|
|
35
|
+
* either transport resolves over the other. Writes are translated to whatever the
|
|
36
|
+
* live backend needs. The net effect: existing LAN configs gain a cloud fallback,
|
|
37
|
+
* and existing cloud configs gain LAN, with no config change.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
// When a LAN-primary device's cloud backend connects first but can't offer the
|
|
41
|
+
// numeric data-point map (the device-shadow API isn't available), give the LAN a
|
|
42
|
+
// short head start before letting the cloud drive the one-time characteristic
|
|
43
|
+
// registration — so a numeric-DP config still registers against numeric keys.
|
|
44
|
+
const LAN_REGISTRATION_GRACE_MS = 15000;
|
|
45
|
+
|
|
46
|
+
class TuyaDevice extends EventEmitter {
|
|
47
|
+
constructor(props = {}) {
|
|
48
|
+
super();
|
|
49
|
+
// Several accessories attach their own 'change' listener on top of the one
|
|
50
|
+
// BaseAccessory uses; keep the default-listener guard from ever warning.
|
|
51
|
+
this.setMaxListeners(0);
|
|
52
|
+
|
|
53
|
+
this.log = props.log || console;
|
|
54
|
+
|
|
55
|
+
// A plain config object on `context`, mirroring the transport classes but
|
|
56
|
+
// without the live helper objects we were handed.
|
|
57
|
+
this.context = {...props};
|
|
58
|
+
delete this.context.log;
|
|
59
|
+
delete this.context.cloudApi;
|
|
60
|
+
delete this.context.messaging;
|
|
61
|
+
|
|
62
|
+
this.state = {};
|
|
63
|
+
this._stopped = false;
|
|
64
|
+
this._registered = false; // has the first 'change' (registration) gone out?
|
|
65
|
+
this._connectEmitted = false;
|
|
66
|
+
this._lanGraceElapsed = false;
|
|
67
|
+
|
|
68
|
+
// LAN backend — created later, once an IP is known (see attachLan). A
|
|
69
|
+
// device with no local key can't speak the LAN protocol at all, so it is
|
|
70
|
+
// cloud-only and this stays null.
|
|
71
|
+
this.lan = null;
|
|
72
|
+
|
|
73
|
+
// Cloud backend — present whenever the platform handed us a shared cloud
|
|
74
|
+
// session and this device isn't opted out of it.
|
|
75
|
+
this.cloud = null;
|
|
76
|
+
if (props.cloudApi) {
|
|
77
|
+
this.cloud = new TuyaCloudDevice({
|
|
78
|
+
...props,
|
|
79
|
+
cloudApi: props.cloudApi,
|
|
80
|
+
messaging: props.messaging,
|
|
81
|
+
log: this.log,
|
|
82
|
+
connect: false
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (props.connect !== false) this._connect();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Reachable over either transport.
|
|
90
|
+
get connected() {
|
|
91
|
+
return !!((this.lan && this.lan.connected) || (this.cloud && this.cloud.connected));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ------------------------------------------------------------------ *
|
|
95
|
+
* Lifecycle
|
|
96
|
+
* ------------------------------------------------------------------ */
|
|
97
|
+
|
|
98
|
+
_connect() {
|
|
99
|
+
if (this.cloud) {
|
|
100
|
+
this._wire(this.cloud, 'cloud');
|
|
101
|
+
this.cloud._connect();
|
|
102
|
+
|
|
103
|
+
// Safety net for the LAN-without-map registration guard above.
|
|
104
|
+
if (this.context.key) {
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
this._lanGraceElapsed = true;
|
|
107
|
+
if (!this._registered) this._onBackendChange();
|
|
108
|
+
}, LAN_REGISTRATION_GRACE_MS).unref?.();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// The LAN backend is attached by the platform via attachLan() once the
|
|
112
|
+
// device's IP is known (from discovery, or a configured `ip`), preserving
|
|
113
|
+
// the existing discovery timing.
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Bring up (or update) the LAN transport for a device whose IP is now known.
|
|
117
|
+
// `target` carries the {ip, version} learned from discovery; a configured
|
|
118
|
+
// version/forceVersion still take precedence as they always have.
|
|
119
|
+
attachLan(target = {}) {
|
|
120
|
+
if (this._stopped || this.lan) return;
|
|
121
|
+
if (!this.context.key) {
|
|
122
|
+
this.log.debug(`${this.context.name}: no local key; staying on the cloud only.`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (!target.ip && !this.context.ip) return;
|
|
126
|
+
|
|
127
|
+
let version = this.context.version;
|
|
128
|
+
if (target.version) version = target.version; // the device's broadcast wins…
|
|
129
|
+
if (this.context.forceVersion) version = this.context.forceVersion; // …but forceVersion wins all
|
|
130
|
+
|
|
131
|
+
this.lan = new TuyaAccessory({
|
|
132
|
+
...this.context,
|
|
133
|
+
ip: target.ip || this.context.ip,
|
|
134
|
+
version,
|
|
135
|
+
log: this.log,
|
|
136
|
+
connect: false
|
|
137
|
+
});
|
|
138
|
+
this._wire(this.lan, 'lan');
|
|
139
|
+
this.lan._connect();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_wire(backend, which) {
|
|
143
|
+
backend.on('connect', () => this._onBackendConnect(which));
|
|
144
|
+
backend.on('change', () => this._onBackendChange(which));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_onBackendConnect(which) {
|
|
148
|
+
this.log.debug(`${this.context.name}: ${which} backend connected.`);
|
|
149
|
+
if (!this._connectEmitted) {
|
|
150
|
+
this._connectEmitted = true;
|
|
151
|
+
this.emit('connect');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* ------------------------------------------------------------------ *
|
|
156
|
+
* State merge — LAN wins while it's up, cloud fills the rest / takes over
|
|
157
|
+
* ------------------------------------------------------------------ */
|
|
158
|
+
|
|
159
|
+
_merge() {
|
|
160
|
+
const merged = {};
|
|
161
|
+
if (this.cloud && this.cloud.connected) Object.assign(merged, this.cloud.state);
|
|
162
|
+
// LAN overlays the cloud while it's connected: it's the authoritative,
|
|
163
|
+
// freshest view for a device that's actually reachable locally.
|
|
164
|
+
if (this.lan && this.lan.connected) Object.assign(merged, this._lanStateDualKeyed());
|
|
165
|
+
return merged;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// LAN state is keyed by numeric dp id; mirror each value under its cloud code
|
|
169
|
+
// too (using the map the cloud backend learned), so a code-style config still
|
|
170
|
+
// resolves while the device is on the LAN.
|
|
171
|
+
_lanStateDualKeyed() {
|
|
172
|
+
const lanState = this.lan.state;
|
|
173
|
+
if (!this.cloud) return lanState;
|
|
174
|
+
const codeByDpId = this.cloud.codeByDpId;
|
|
175
|
+
const out = {...lanState};
|
|
176
|
+
for (const dp in lanState) {
|
|
177
|
+
const code = codeByDpId[dp];
|
|
178
|
+
if (code != null) out[code] = lanState[dp];
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_onBackendChange(which) {
|
|
184
|
+
if (this._stopped) return;
|
|
185
|
+
if (!this._registered && !this._mayRegisterFrom(which)) return;
|
|
186
|
+
|
|
187
|
+
const merged = this._merge();
|
|
188
|
+
const changes = {};
|
|
189
|
+
Object.keys(merged).forEach(key => {
|
|
190
|
+
if (merged[key] !== this.state[key]) changes[key] = merged[key];
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const first = !this._registered;
|
|
194
|
+
if (first || Object.keys(changes).length) {
|
|
195
|
+
this.state = {...this.state, ...merged};
|
|
196
|
+
this._registered = true;
|
|
197
|
+
// The first emit drives BaseAccessory's one-time characteristic
|
|
198
|
+
// registration; later emits carry only what actually changed.
|
|
199
|
+
this.emit('change', changes, this.state);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Decide whether `which` backend may drive the one-time characteristic
|
|
204
|
+
// registration. LAN is always safe (numeric signature). The cloud is safe when
|
|
205
|
+
// the device is cloud-only (no local key), or once it has the numeric map (state
|
|
206
|
+
// is dual-keyed and so resolves a numeric-DP config), or once the LAN has been
|
|
207
|
+
// given its head start and didn't show up.
|
|
208
|
+
_mayRegisterFrom(which) {
|
|
209
|
+
if (which === 'lan') return true;
|
|
210
|
+
if (!this.context.key) return true;
|
|
211
|
+
if (this.cloud && Object.keys(this.cloud.codeByDpId).length) return true;
|
|
212
|
+
return this._lanGraceElapsed;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* ------------------------------------------------------------------ *
|
|
216
|
+
* Writes — LAN first, cloud as a fallback
|
|
217
|
+
* ------------------------------------------------------------------ */
|
|
218
|
+
|
|
219
|
+
update(dps) {
|
|
220
|
+
// Pure LAN (no cloud configured): byte-for-byte the legacy behaviour — a
|
|
221
|
+
// synchronous boolean, so the sync write helpers keep detecting failures.
|
|
222
|
+
if (!this.cloud) {
|
|
223
|
+
return this.lan ? this.lan.update(this._toLanDps(dps)) : false;
|
|
224
|
+
}
|
|
225
|
+
return this._updateWithFallback(dps);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async _updateWithFallback(dps) {
|
|
229
|
+
// Prefer the LAN while it's connected.
|
|
230
|
+
if (this.lan && this.lan.connected) {
|
|
231
|
+
if (this.lan.update(this._toLanDps(dps)) !== false) return true;
|
|
232
|
+
this.log.debug(`${this.context.name}: LAN write didn't go through; trying the cloud.`);
|
|
233
|
+
}
|
|
234
|
+
if (this.cloud && this.cloud.connected) {
|
|
235
|
+
return (await this.cloud.update(dps)) !== false;
|
|
236
|
+
}
|
|
237
|
+
// Cloud is down too — a last LAN attempt (it may have just dropped, and a
|
|
238
|
+
// buffered write is better than a guaranteed failure).
|
|
239
|
+
if (this.lan) return this.lan.update(this._toLanDps(dps)) !== false;
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Translate a write keyed by codes and/or numeric ids into the numeric ids the
|
|
244
|
+
// LAN protocol needs. Numeric ids pass straight through; a code with a known id
|
|
245
|
+
// is translated; an unmapped code is left as-is (TuyaAccessory.update will then
|
|
246
|
+
// simply ignore it, which is the safe degradation when no map is available).
|
|
247
|
+
_toLanDps(dps) {
|
|
248
|
+
if (!this.cloud || !dps || typeof dps !== 'object') return dps;
|
|
249
|
+
const dpIdByCode = this.cloud.dpIdByCode;
|
|
250
|
+
const out = {};
|
|
251
|
+
for (const key in dps) out[dpIdByCode[key] || key] = dps[key];
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/* ------------------------------------------------------------------ *
|
|
256
|
+
* Teardown (tidy; Homebridge doesn't strictly require it)
|
|
257
|
+
* ------------------------------------------------------------------ */
|
|
258
|
+
|
|
259
|
+
stop() {
|
|
260
|
+
this._stopped = true;
|
|
261
|
+
if (this.cloud && typeof this.cloud.stop === 'function') this.cloud.stop();
|
|
262
|
+
if (this.lan && typeof this.lan.stop === 'function') this.lan.stop();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = TuyaDevice;
|