homebridge-tuya-plus 3.14.0-dev.19 → 3.14.0-dev.21

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.
@@ -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 LAN id (1, 2, …) or a Tuya Cloud
55
- // code (e.g. "switch_1"). Accept either; only an empty value is
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
- // Cloud devices address valves by code (switch_1, switch_2, );
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
- // True when this device is reached over the Tuya Cloud (data-points keyed by
87
- // string code) rather than the LAN (numeric data-point ids).
88
- _isCloud() {
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
- if (v !== '') return v;
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 accept a numeric LAN id or a Tuya Cloud code; defaults
219
- // differ per transport (cloud uses the standard Tuya codes).
220
- this.dpBattery = this._resolveDP(this.device.context.dpBattery, 'battery_percentage', '46');
221
- this.dpCharging = this._resolveDP(this.device.context.dpCharging, 'charge_state', '101');
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();
@@ -98,6 +98,12 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
98
98
  // and the close that trails a stop in the stop-before-close path.
99
99
  this.partialStopTimer = null;
100
100
  this.pendingCloseTimer = null;
101
+ // A partial open that has fired its open but is still waiting for the
102
+ // controller to report it's moving before arming the auto-stop.
103
+ this.partialPending = false;
104
+ // Bumped whenever a partial open starts or is superseded/cancelled, so a
105
+ // stale auto-stop (and its re-sends) from an earlier flow bails out.
106
+ this.partialGeneration = 0;
101
107
 
102
108
  // Seed the initial state from whatever the device has already reported.
103
109
  // If it hasn't reported yet, fall back to the persisted target, then to
@@ -227,6 +233,11 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
227
233
  if (this.characteristicPartialOpen) {
228
234
  this.characteristicPartialOpen.updateValue(isOpen);
229
235
  }
236
+
237
+ // A partial open fires its open and then waits for the controller to
238
+ // confirm the gate is actually moving before starting the auto-stop
239
+ // countdown — see _handlePartialOpen.
240
+ if (this.partialPending && isOpen) this._armPartialStop();
230
241
  }
231
242
 
232
243
  // onGet helper: a cached/optimistic door value is fine to report while the
@@ -310,33 +321,63 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
310
321
  }
311
322
  }
312
323
 
313
- // Partial open: fire the open action, then after partialOpenMs fire a stop
314
- // so the gate ends up parked part-way. Anchored to the button press (the
315
- // controller starts moving and reports state within ~1s), which is all the
316
- // user asked for.
324
+ // Partial open: fire the open action, wait partialOpenMs, then fire a stop
325
+ // so the gate ends up parked part-way.
326
+ //
327
+ // The auto-stop is anchored to the controller *reporting the gate is moving*
328
+ // (the status DP flipping to OPEN), not to the button press: the open
329
+ // command takes time to reach the gate, and a stop fired before the gate is
330
+ // actually moving lands as a no-op the controller drops — letting the gate
331
+ // run all the way open. If the gate already reads open/opening, no fresh
332
+ // report is coming, so we arm straight away.
317
333
  _handlePartialOpen() {
318
334
  const {Characteristic} = this.hap;
319
335
  const name = this.device.context.name;
320
336
  if (!this.device.connected) throw this._commError();
321
337
  if (!this.partialOpenMs) return;
322
- if (this.partialStopTimer) {
323
- // Re-entrant press while a partial is already armed (e.g. a
338
+ if (this.partialPending || this.partialStopTimer) {
339
+ // Re-entrant press while a partial is already in progress (e.g. a
324
340
  // HomeKit/iOS WRITE retransmit) — ignore so the stop isn't pushed
325
341
  // out, which would let the gate run further than intended.
326
342
  this.log.debug(`[SimpleGarageDoor] ${name}: partial press ignored — a partial open is already running`);
327
343
  return;
328
344
  }
329
345
 
330
- this.log.debug(`[SimpleGarageDoor] ${name}: partial open — opening, will stop in ${this.partialOpenMs}ms`);
346
+ this.log.debug(`[SimpleGarageDoor] ${name}: partial open — opening, will stop ${this.partialOpenMs}ms after the gate starts moving`);
347
+ this.partialGeneration++;
348
+ this.partialPending = true;
331
349
  this._applyTarget(Characteristic.TargetDoorState.OPEN);
350
+
351
+ const reported = this.device.state ? this.device.state[this.dpState] : undefined;
352
+ if (this._mapDpState(reported) === true) this._armPartialStop();
353
+ }
354
+
355
+ _armPartialStop() {
356
+ if (!this.partialPending || this.partialStopTimer) return;
357
+ this.partialPending = false;
358
+ const name = this.device.context.name;
359
+ const generation = this.partialGeneration;
332
360
  this.partialStopTimer = setTimeout(() => {
333
361
  this.partialStopTimer = null;
334
362
  this.log.debug(`[SimpleGarageDoor] ${name}: partial open — firing stop (dp${this.dpStop})`);
335
- this.setMultiStateLegacyInBackground({[this.dpStop]: true});
363
+ this._sendPartialStop(generation, 1);
336
364
  }, this.partialOpenMs);
337
365
  }
338
366
 
367
+ // The controller occasionally drops a lone write (its command queue can
368
+ // coalesce back-to-back writes, and brief Wi-Fi blips lose individual
369
+ // packets), and a dropped stop here is exactly what lets a partial open run
370
+ // all the way. Re-send it a few times; a stop on an already-parked gate is a
371
+ // harmless no-op. Bails out if the flow was superseded mid-way.
372
+ _sendPartialStop(generation, attempt) {
373
+ if (generation !== this.partialGeneration) return;
374
+ this.setMultiStateLegacyInBackground({[this.dpStop]: true});
375
+ if (attempt < 3) setTimeout(() => this._sendPartialStop(generation, attempt + 1), 300);
376
+ }
377
+
339
378
  _cancelPartialStop() {
379
+ this.partialPending = false;
380
+ this.partialGeneration++;
340
381
  if (this.partialStopTimer) {
341
382
  clearTimeout(this.partialStopTimer);
342
383
  this.partialStopTimer = null;
@@ -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
@@ -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 status = await this.api.getStatus(this.context.id);
68
- this.state = this._statusToState(status);
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(status);
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
- // Convert a Tuya `[{code, value}]` status array into a flat { code: value }.
136
- _statusToState(status) {
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
- (status || []).forEach(item => {
139
- if (item && typeof item.code === 'string') state[item.code] = item.value;
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._statusToState(status);
205
+ const incoming = this._propsToState(status);
149
206
  const changes = {};
150
- Object.keys(incoming).forEach(code => {
151
- if (incoming[code] !== this.state[code]) changes[code] = incoming[code];
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(status) {
217
+ _logDiscoveredCodes(props) {
161
218
  try {
162
- const list = (status || []).map(i => `${i.code}=${JSON.stringify(i.value)}`).join(', ');
163
- this.log.info(`${this.context.name}: Tuya Cloud data-point codes ${list || '(none reported)'}`);
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
- const commands = Object.keys(dps).map(code => ({code, value: dps[code]}));
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;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.14.0-dev.19",
4
- "description": "A community-maintained Homebridge plugin for controlling Tuya devices locally over LAN. Includes new features, fixes, and updated device support.",
3
+ "version": "3.14.0-dev.21",
4
+ "description": "A community-maintained Homebridge plugin for controlling Tuya devices in HomeKit. LAN-first, with an optional Tuya Cloud fallback for any device the LAN can't reach.",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "test": "jest",