homebridge-tuya-plus 3.14.0-dev.4 → 3.14.0-dev.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.github/workflows/publish-dev.yml +298 -31
  2. package/AGENTS.md +162 -0
  3. package/CLAUDE.md +1 -0
  4. package/Changelog.md +16 -3
  5. package/Readme.MD +8 -32
  6. package/config.schema.json +63 -78
  7. package/index.js +599 -358
  8. package/lib/AirPurifierAccessory.js +1 -1
  9. package/lib/BaseAccessory.js +54 -17
  10. package/lib/ConvectorAccessory.js +1 -1
  11. package/lib/CustomMultiOutletAccessory.js +2 -1
  12. package/lib/DoorbellAccessory.js +9 -16
  13. package/lib/GarageDoorAccessory.js +1 -1
  14. package/lib/IrrigationSystemAccessory.js +93 -114
  15. package/lib/MultiOutletAccessory.js +3 -2
  16. package/lib/OilDiffuserAccessory.js +10 -8
  17. package/lib/RGBTWLightAccessory.js +13 -11
  18. package/lib/RGBTWOutletAccessory.js +11 -9
  19. package/lib/SimpleBlindsAccessory.js +5 -5
  20. package/lib/SimpleDimmer2Accessory.js +1 -1
  21. package/lib/SimpleDimmerAccessory.js +10 -6
  22. package/lib/SimpleGarageDoorAccessory.js +78 -24
  23. package/lib/SimpleHeaterAccessory.js +4 -4
  24. package/lib/SimpleLightAccessory.js +1 -1
  25. package/lib/SwitchAccessory.js +3 -2
  26. package/lib/TWLightAccessory.js +2 -2
  27. package/lib/TuyaAccessory.js +75 -42
  28. package/lib/TuyaCloudApi.js +90 -4
  29. package/lib/TuyaCloudDevice.js +204 -25
  30. package/lib/TuyaCloudMessaging.js +28 -41
  31. package/lib/TuyaDevice.js +269 -0
  32. package/lib/TuyaDiscovery.js +25 -9
  33. package/lib/ValveAccessory.js +16 -12
  34. package/lib/VerticalBlindsWithTilt.js +27 -25
  35. package/lib/WledDimmerAccessory.js +46 -81
  36. package/package.json +5 -6
  37. package/scripts/cleanup-pr-tags.js +122 -0
  38. package/test/BaseAccessory.test.js +94 -15
  39. package/test/IrrigationSystemAccessory.test.js +122 -68
  40. package/test/MultiOutletAccessory.test.js +18 -3
  41. package/test/RGBTWLightAccessory.test.js +3 -3
  42. package/test/SimpleFanLightAccessory.test.js +3 -3
  43. package/test/SimpleGarageDoorAccessory.test.js +63 -9
  44. package/test/TuyaAccessory.protocol.test.js +76 -3
  45. package/test/TuyaCloudApi.test.js +80 -0
  46. package/test/TuyaCloudDevice.test.js +253 -1
  47. package/test/TuyaCloudMessaging.test.js +24 -5
  48. package/test/TuyaDevice.test.js +266 -0
  49. package/test/getCategory.test.js +1 -0
  50. package/test/index.test.js +271 -0
  51. package/test/support/mocks.js +13 -0
  52. package/wiki/Supported-Device-Types.md +48 -30
  53. package/wiki/Tuya-Cloud-Setup.md +24 -113
@@ -0,0 +1,269 @@
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
+ // Let the cloud backend tell a harmless fallback failure (the device
83
+ // is reachable over the LAN) apart from one that matters (it isn't).
84
+ isLanConnected: () => !!(this.lan && this.lan.connected),
85
+ connect: false
86
+ });
87
+ }
88
+
89
+ if (props.connect !== false) this._connect();
90
+ }
91
+
92
+ // Reachable over either transport.
93
+ get connected() {
94
+ return !!((this.lan && this.lan.connected) || (this.cloud && this.cloud.connected));
95
+ }
96
+
97
+ /* ------------------------------------------------------------------ *
98
+ * Lifecycle
99
+ * ------------------------------------------------------------------ */
100
+
101
+ _connect() {
102
+ if (this.cloud) {
103
+ this._wire(this.cloud, 'cloud');
104
+ this.cloud._connect();
105
+
106
+ // Safety net for the LAN-without-map registration guard above.
107
+ if (this.context.key) {
108
+ setTimeout(() => {
109
+ this._lanGraceElapsed = true;
110
+ if (!this._registered) this._onBackendChange();
111
+ }, LAN_REGISTRATION_GRACE_MS).unref?.();
112
+ }
113
+ }
114
+ // The LAN backend is attached by the platform via attachLan() once the
115
+ // device's IP is known (from discovery, or a configured `ip`), preserving
116
+ // the existing discovery timing.
117
+ }
118
+
119
+ // Bring up (or update) the LAN transport for a device whose IP is now known.
120
+ // `target` carries the {ip, version} learned from discovery; a configured
121
+ // version/forceVersion still take precedence as they always have.
122
+ attachLan(target = {}) {
123
+ if (this._stopped || this.lan) return;
124
+ if (!this.context.key) {
125
+ this.log.debug(`${this.context.name}: no local key; staying on the cloud only.`);
126
+ return;
127
+ }
128
+ if (!target.ip && !this.context.ip) return;
129
+
130
+ let version = this.context.version;
131
+ if (target.version) version = target.version; // the device's broadcast wins…
132
+ if (this.context.forceVersion) version = this.context.forceVersion; // …but forceVersion wins all
133
+
134
+ this.lan = new TuyaAccessory({
135
+ ...this.context,
136
+ ip: target.ip || this.context.ip,
137
+ version,
138
+ log: this.log,
139
+ connect: false
140
+ });
141
+ this._wire(this.lan, 'lan');
142
+ this.lan._connect();
143
+ }
144
+
145
+ _wire(backend, which) {
146
+ backend.on('connect', () => this._onBackendConnect(which));
147
+ backend.on('change', () => this._onBackendChange(which));
148
+ }
149
+
150
+ _onBackendConnect(which) {
151
+ this.log.debug(`${this.context.name}: ${which} backend connected.`);
152
+ if (!this._connectEmitted) {
153
+ this._connectEmitted = true;
154
+ this.emit('connect');
155
+ }
156
+ }
157
+
158
+ /* ------------------------------------------------------------------ *
159
+ * State merge — LAN wins while it's up, cloud fills the rest / takes over
160
+ * ------------------------------------------------------------------ */
161
+
162
+ _merge() {
163
+ const merged = {};
164
+ if (this.cloud && this.cloud.connected) Object.assign(merged, this.cloud.state);
165
+ // LAN overlays the cloud while it's connected: it's the authoritative,
166
+ // freshest view for a device that's actually reachable locally.
167
+ if (this.lan && this.lan.connected) Object.assign(merged, this._lanStateDualKeyed());
168
+ return merged;
169
+ }
170
+
171
+ // LAN state is keyed by numeric dp id; mirror each value under its cloud code
172
+ // too (using the map the cloud backend learned), so a code-style config still
173
+ // resolves while the device is on the LAN.
174
+ _lanStateDualKeyed() {
175
+ const lanState = this.lan.state;
176
+ if (!this.cloud) return lanState;
177
+ const codeByDpId = this.cloud.codeByDpId;
178
+ const out = {...lanState};
179
+ for (const dp in lanState) {
180
+ const code = codeByDpId[dp];
181
+ if (code != null) out[code] = lanState[dp];
182
+ }
183
+ return out;
184
+ }
185
+
186
+ _onBackendChange(which) {
187
+ if (this._stopped) return;
188
+ if (!this._registered && !this._mayRegisterFrom(which)) return;
189
+
190
+ const merged = this._merge();
191
+ const changes = {};
192
+ Object.keys(merged).forEach(key => {
193
+ if (merged[key] !== this.state[key]) changes[key] = merged[key];
194
+ });
195
+
196
+ const first = !this._registered;
197
+ if (first || Object.keys(changes).length) {
198
+ this.state = {...this.state, ...merged};
199
+ this._registered = true;
200
+ // The first emit drives BaseAccessory's one-time characteristic
201
+ // registration; later emits carry only what actually changed.
202
+ this.emit('change', changes, this.state);
203
+ }
204
+ }
205
+
206
+ // Decide whether `which` backend may drive the one-time characteristic
207
+ // registration. LAN is always safe (numeric signature). The cloud is safe when
208
+ // the device is cloud-only (no local key), or once it has the numeric map (state
209
+ // is dual-keyed and so resolves a numeric-DP config), or once the LAN has been
210
+ // given its head start and didn't show up.
211
+ _mayRegisterFrom(which) {
212
+ if (which === 'lan') return true;
213
+ if (!this.context.key) return true;
214
+ if (this.cloud && Object.keys(this.cloud.codeByDpId).length) return true;
215
+ return this._lanGraceElapsed;
216
+ }
217
+
218
+ /* ------------------------------------------------------------------ *
219
+ * Writes — LAN first, cloud as a fallback
220
+ * ------------------------------------------------------------------ */
221
+
222
+ update(dps) {
223
+ // Pure LAN (no cloud configured): byte-for-byte the legacy behaviour — a
224
+ // synchronous boolean, so the sync write helpers keep detecting failures.
225
+ if (!this.cloud) {
226
+ return this.lan ? this.lan.update(this._toLanDps(dps)) : false;
227
+ }
228
+ return this._updateWithFallback(dps);
229
+ }
230
+
231
+ async _updateWithFallback(dps) {
232
+ // Prefer the LAN while it's connected.
233
+ if (this.lan && this.lan.connected) {
234
+ if (this.lan.update(this._toLanDps(dps)) !== false) return true;
235
+ this.log.debug(`${this.context.name}: LAN write didn't go through; trying the cloud.`);
236
+ }
237
+ if (this.cloud && this.cloud.connected) {
238
+ return (await this.cloud.update(dps)) !== false;
239
+ }
240
+ // Cloud is down too — a last LAN attempt (it may have just dropped, and a
241
+ // buffered write is better than a guaranteed failure).
242
+ if (this.lan) return this.lan.update(this._toLanDps(dps)) !== false;
243
+ return false;
244
+ }
245
+
246
+ // Translate a write keyed by codes and/or numeric ids into the numeric ids the
247
+ // LAN protocol needs. Numeric ids pass straight through; a code with a known id
248
+ // is translated; an unmapped code is left as-is (TuyaAccessory.update will then
249
+ // simply ignore it, which is the safe degradation when no map is available).
250
+ _toLanDps(dps) {
251
+ if (!this.cloud || !dps || typeof dps !== 'object') return dps;
252
+ const dpIdByCode = this.cloud.dpIdByCode;
253
+ const out = {};
254
+ for (const key in dps) out[dpIdByCode[key] || key] = dps[key];
255
+ return out;
256
+ }
257
+
258
+ /* ------------------------------------------------------------------ *
259
+ * Teardown (tidy; Homebridge doesn't strictly require it)
260
+ * ------------------------------------------------------------------ */
261
+
262
+ stop() {
263
+ this._stopped = true;
264
+ if (this.cloud && typeof this.cloud.stop === 'function') this.cloud.stop();
265
+ if (this.lan && typeof this.lan.stop === 'function') this.lan.stop();
266
+ }
267
+ }
268
+
269
+ module.exports = TuyaDevice;
@@ -19,6 +19,7 @@ class TuyaDiscovery extends EventEmitter {
19
19
  this.limitedIds = [];
20
20
  this._servers = {};
21
21
  this._running = false;
22
+ this._portInUseWarned = {}; // ports already warned as in-use, so repeats stay at debug
22
23
  }
23
24
 
24
25
  start(props) {
@@ -85,7 +86,8 @@ class TuyaDiscovery extends EventEmitter {
85
86
  server.on('message', this._onDgramMessage.bind(this, port));
86
87
 
87
88
  server.bind(port, () => {
88
- this.log.info(`Discovery - Discovery started on port ${port}.`);
89
+ this._portInUseWarned[port] = false; // a successful bind re-arms the in-use warning
90
+ this.log.debug(`Discovery started on port ${port}.`);
89
91
  });
90
92
  }
91
93
 
@@ -101,20 +103,30 @@ class TuyaDiscovery extends EventEmitter {
101
103
  this._stop(port);
102
104
 
103
105
  if (err && err.code === 'EADDRINUSE') {
104
- this.log.warn(`Discovery - Port ${port} is in use. Will retry in 15 seconds.`);
106
+ // The retry runs every 15s for as long as the port stays taken (usually
107
+ // another Tuya plugin or a second instance). Warn once so it's
108
+ // actionable, then drop to debug so a persistent conflict doesn't flood
109
+ // the log; a later successful bind re-arms the warning.
110
+ const message = `Port ${port} is in use (is another Tuya plugin or instance running?). Will retry in 15 seconds.`;
111
+ if (this._portInUseWarned[port]) {
112
+ this.log.debug(`Discovery - ${message}`);
113
+ } else {
114
+ this.log.warn(`Discovery - ${message}`);
115
+ this._portInUseWarned[port] = true;
116
+ }
105
117
 
106
118
  setTimeout(() => {
107
119
  this._start(port);
108
120
  }, 15000);
109
121
  } else {
110
- this.log.error(`Discovery - Port ${port} failed:\n${err.stack}`);
122
+ this.log.error(`Discovery - Port ${port} failed:\n${err && err.stack || err}`);
111
123
  }
112
124
  }
113
125
 
114
126
  _onDgramClose(port) {
115
127
  this._stop(port);
116
128
 
117
- this.log.info(`Discovery - Port ${port} closed.${this._running ? ' Restarting...' : ''}`);
129
+ this.log.debug(`Discovery - Port ${port} closed.${this._running ? ' Restarting...' : ''}`);
118
130
  if (this._running)
119
131
  setTimeout(() => {
120
132
  this._start(port);
@@ -152,7 +164,7 @@ class TuyaDiscovery extends EventEmitter {
152
164
  const size = pkt.readUInt32BE(12);
153
165
 
154
166
  if (len - size < 8) {
155
- this.log.error(`Discovery - UDP from ${info.address}:${port} size ${len - size}`);
167
+ this.log.debug(`Discovery - undersized UDP packet from ${info.address}:${port} (ignored).`);
156
168
  return;
157
169
  }
158
170
 
@@ -174,11 +186,14 @@ class TuyaDiscovery extends EventEmitter {
174
186
  if (result && result.gwId && result.ip) {
175
187
  this._onDiscover(result);
176
188
  } else {
177
- this.log.error(`Discovery - UDP from ${info.address}:${port} decrypted`, cleanMsg.toString('hex'));
189
+ this.log.debug(`Discovery - unrecognised UDP payload from ${info.address}:${port} (ignored):`, cleanMsg.toString('hex'));
178
190
  }
179
191
  } catch (ex) {
180
- this.log.error(`Discovery - Failed to parse discovery response on port ${port}: ${decryptedMsg}`);
181
- this.log.error(`Discovery - Failed to parse discovery raw message on port ${port}: ${pkt.toString('hex')}`);
192
+ // Not every Tuya-framed packet on these ports is a discovery reply we
193
+ // can parse (other broadcast types, foreign accounts, partial packets);
194
+ // none of it is an error worth surfacing.
195
+ this.log.debug(`Discovery - could not parse discovery response on port ${port}: ${decryptedMsg}`);
196
+ this.log.debug(`Discovery - raw unparsed message on port ${port}: ${pkt.toString('hex')}`);
182
197
  }
183
198
  }
184
199
 
@@ -230,7 +245,8 @@ class TuyaDiscovery extends EventEmitter {
230
245
  this._onDiscover(payload);
231
246
  }
232
247
  } catch (ex) {
233
- this.log.error(
248
+ // Foreign or partial packets on the v3.5 reply port are expected noise.
249
+ this.log.debug(
234
250
  `Discovery v3.5 – failed to decrypt packet from ${info.address}:${srcPort}:`,
235
251
  ex.message
236
252
  );
@@ -49,13 +49,13 @@ class ValveAccessory extends BaseAccessory {
49
49
  .onSet(value => this.setStateAsync(this.dpPower, value));
50
50
 
51
51
  const characteristicInUse = service.getCharacteristic(Characteristic.InUse)
52
- .onGet(() => characteristicActive.value);
52
+ .onGet(() => { if (!this.device.connected) throw this._commError(); return characteristicActive.value; });
53
53
 
54
54
  if (!this.noTimer) {
55
55
  service.getCharacteristic(Characteristic.SetDuration)
56
- .onGet(() => this.setDuration)
56
+ .onGet(() => { if (!this.device.connected) throw this._commError(); return this.setDuration; })
57
57
  .on('change', (data) => {
58
- this.log.info('Water Valve Time Duration Set to: ' + data.newValue / 60 + ' Minutes');
58
+ this.log.debug('Water Valve Time Duration Set to: ' + data.newValue / 60 + ' Minutes');
59
59
  this.setDuration = data.newValue;
60
60
 
61
61
  if (service.getCharacteristic(Characteristic.InUse).value) {
@@ -65,8 +65,9 @@ class ValveAccessory extends BaseAccessory {
65
65
 
66
66
  clearTimeout(this.timer);
67
67
  this.timer = setTimeout(() => {
68
- this.log.info('Water Valve Timer Expired. Shutting OFF Valve');
69
- service.getCharacteristic(Characteristic.Active).setValue(0);
68
+ this.log.debug('Water Valve Timer Expired. Shutting OFF Valve');
69
+ this.setStateInBackground(this.dpPower, 0);
70
+ service.getCharacteristic(Characteristic.Active).updateValue(0);
70
71
  service.getCharacteristic(Characteristic.InUse).updateValue(0);
71
72
  this.lastActivationTime = null;
72
73
  }, (data.newValue * 1000));
@@ -75,6 +76,7 @@ class ValveAccessory extends BaseAccessory {
75
76
 
76
77
  service.getCharacteristic(Characteristic.RemainingDuration)
77
78
  .onGet(() => {
79
+ if (!this.device.connected) throw this._commError();
78
80
  let remainingTime = this.setDuration - Math.floor(((new Date()).getTime() - this.lastActivationTime) / 1000);
79
81
  return (remainingTime && remainingTime > 0) ? remainingTime : 0;
80
82
  });
@@ -87,17 +89,18 @@ class ValveAccessory extends BaseAccessory {
87
89
  service.getCharacteristic(Characteristic.RemainingDuration).updateValue(0);
88
90
  service.getCharacteristic(Characteristic.Active).updateValue(0);
89
91
  clearTimeout(this.timer);
90
- this.log.info('Water Valve is OFF!');
92
+ this.log.debug('Water Valve is OFF!');
91
93
  break;
92
94
  case 1:
93
95
  this.lastActivationTime = (new Date()).getTime();
94
96
  service.getCharacteristic(Characteristic.RemainingDuration).updateValue(this.setDuration);
95
97
  service.getCharacteristic(Characteristic.Active).updateValue(1);
96
- this.log.info('Water Valve Turning ON with Timer Set to: ' + this.setDuration / 60 + ' Minutes');
98
+ this.log.debug('Water Valve Turning ON with Timer Set to: ' + this.setDuration / 60 + ' Minutes');
97
99
  clearTimeout(this.timer);
98
100
  this.timer = setTimeout(() => {
99
- this.log.info('Water Valve Timer Expired. Shutting OFF Valve');
100
- service.getCharacteristic(Characteristic.Active).setValue(0);
101
+ this.log.debug('Water Valve Timer Expired. Shutting OFF Valve');
102
+ this.setStateInBackground(this.dpPower, 0);
103
+ service.getCharacteristic(Characteristic.Active).updateValue(0);
101
104
  service.getCharacteristic(Characteristic.InUse).updateValue(0);
102
105
  this.lastActivationTime = null;
103
106
  }, (this.setDuration * 1000));
@@ -110,11 +113,12 @@ class ValveAccessory extends BaseAccessory {
110
113
  service.getCharacteristic(Characteristic.RemainingDuration).updateValue(this.setDuration);
111
114
  service.getCharacteristic(Characteristic.Active).updateValue(1);
112
115
  service.getCharacteristic(Characteristic.InUse).updateValue(1);
113
- this.log.info('Water Valve is ON After Restart. Setting Timer to: ' + this.setDuration / 60 + ' Minutes');
116
+ this.log.debug('Water Valve is ON After Restart. Setting Timer to: ' + this.setDuration / 60 + ' Minutes');
114
117
  clearTimeout(this.timer);
115
118
  this.timer = setTimeout(() => {
116
- this.log.info('Water Valve Timer Expired. Shutting OFF Valve');
117
- service.getCharacteristic(Characteristic.Active).setValue(0);
119
+ this.log.debug('Water Valve Timer Expired. Shutting OFF Valve');
120
+ this.setStateInBackground(this.dpPower, 0);
121
+ service.getCharacteristic(Characteristic.Active).updateValue(0);
118
122
  this.lastActivationTime = null;
119
123
  }, (this.setDuration * 1000));
120
124
  }
@@ -38,16 +38,16 @@ class VerticalBlindsWithTilt extends BaseAccessory {
38
38
 
39
39
  const characteristicCurrentPosition = service.getCharacteristic(Characteristic.CurrentPosition)
40
40
  .updateValue(this.currentPosition)
41
- .onGet(() => this.currentPosition);
41
+ .onGet(() => { if (!this.device.connected) throw this._commError(); return this.currentPosition; });
42
42
 
43
43
  const characteristicTargetPosition = service.getCharacteristic(Characteristic.TargetPosition)
44
44
  .updateValue(this.currentPosition)
45
- .onGet(() => this.currentPosition)
45
+ .onGet(() => { if (!this.device.connected) throw this._commError(); return this.currentPosition; })
46
46
  .onSet(value => this.setPosition(value));
47
47
 
48
48
  const characteristicPositionState = service.getCharacteristic(Characteristic.PositionState)
49
49
  .updateValue(Characteristic.PositionState.STOPPED)
50
- .onGet(() => Characteristic.PositionState.STOPPED);
50
+ .onGet(() => { if (!this.device.connected) throw this._commError(); return Characteristic.PositionState.STOPPED; });
51
51
 
52
52
  const characteristicCurrentHorizontalTilt = service.getCharacteristic(Characteristic.CurrentHorizontalTiltAngle)
53
53
  .updateValue(this._getTiltAngle(dps[this.dpTiltState] || dps[this.dpTilt]))
@@ -67,33 +67,34 @@ class VerticalBlindsWithTilt extends BaseAccessory {
67
67
 
68
68
  _getInitialPosition(dps) {
69
69
  if (this.accessory.context.cachedPosition !== undefined) {
70
- this.log('[TuyaAccessory] Restored position from cache:', this.accessory.context.cachedPosition + '%');
70
+ this.log.debug('[TuyaAccessory] Restored position from cache:', this.accessory.context.cachedPosition + '%');
71
71
  return this.accessory.context.cachedPosition;
72
72
  }
73
- this.log('[TuyaAccessory] Initial position unknown (first time setup), defaulting to open (100%)');
73
+ this.log.debug('[TuyaAccessory] Initial position unknown (first time setup), defaulting to open (100%)');
74
74
  return 100;
75
75
  }
76
76
 
77
77
  setPosition(value) {
78
78
  const {Characteristic} = this.hap;
79
+ if (!this.device.connected) throw this._commError();
79
80
 
80
81
  if (value === 0) {
81
82
  if (this.currentPosition === 0) {
82
- this.log('[TuyaAccessory] Blinds already closed, skipping close command');
83
+ this.log.debug('[TuyaAccessory] Blinds already closed, skipping close command');
83
84
  return;
84
85
  }
85
86
 
86
- this.log('[TuyaAccessory] Closing blinds');
87
+ this.log.debug('[TuyaAccessory] Closing blinds');
87
88
  this.lastCloseTime = Date.now();
88
89
  this.isMoving = true;
89
90
 
90
91
  if (this.lastTiltCommand && (Date.now() - this.lastTiltCommand.time < this.timeToClose * 1000)) {
91
- this.log('[TuyaAccessory] Close command received while tilt was pending - rescheduling tilt delay');
92
+ this.log.debug('[TuyaAccessory] Close command received while tilt was pending - rescheduling tilt delay');
92
93
  if (this.tiltBufferTimeout) { clearTimeout(this.tiltBufferTimeout); this.tiltBufferTimeout = null; }
93
94
  if (this.tiltDelayTimeout) clearTimeout(this.tiltDelayTimeout);
94
95
  const delayMs = this.timeToClose * 1000;
95
96
  this.tiltDelayTimeout = setTimeout(() => {
96
- this.log('[TuyaAccessory] Executing delayed tilt angle:', this.lastTiltCommand.angle, '-> Tuya percent_control:', this.lastTiltCommand.value);
97
+ this.log.debug('[TuyaAccessory] Executing delayed tilt angle:', this.lastTiltCommand.angle, '-> Tuya percent_control:', this.lastTiltCommand.value);
97
98
  this._executeTilt(this.lastTiltCommand.value);
98
99
  this.tiltDelayTimeout = null;
99
100
  this.lastTiltCommand = null;
@@ -105,7 +106,7 @@ class VerticalBlindsWithTilt extends BaseAccessory {
105
106
 
106
107
  if (this.positionStateTimeout) clearTimeout(this.positionStateTimeout);
107
108
  this.positionStateTimeout = setTimeout(() => {
108
- this.log('[TuyaAccessory] Blinds finished closing');
109
+ this.log.debug('[TuyaAccessory] Blinds finished closing');
109
110
  this.currentPosition = 0;
110
111
  this.accessory.context.cachedPosition = 0;
111
112
  this.characteristicCurrentPosition.updateValue(0);
@@ -119,19 +120,19 @@ class VerticalBlindsWithTilt extends BaseAccessory {
119
120
 
120
121
  } else if (value === 100) {
121
122
  if (this.currentPosition === 100) {
122
- this.log('[TuyaAccessory] Blinds already open, skipping open command');
123
+ this.log.debug('[TuyaAccessory] Blinds already open, skipping open command');
123
124
  return;
124
125
  }
125
126
 
126
- this.log('[TuyaAccessory] Opening blinds');
127
+ this.log.debug('[TuyaAccessory] Opening blinds');
127
128
 
128
129
  if (this.lastTiltCommand && (Date.now() - this.lastTiltCommand.time < this.timeToClose * 1000)) {
129
- this.log('[TuyaAccessory] Open command received while tilt was pending - rescheduling tilt delay');
130
+ this.log.debug('[TuyaAccessory] Open command received while tilt was pending - rescheduling tilt delay');
130
131
  if (this.tiltBufferTimeout) { clearTimeout(this.tiltBufferTimeout); this.tiltBufferTimeout = null; }
131
132
  if (this.tiltDelayTimeout) clearTimeout(this.tiltDelayTimeout);
132
133
  const delayMs = this.timeToClose * 1000;
133
134
  this.tiltDelayTimeout = setTimeout(() => {
134
- this.log('[TuyaAccessory] Executing delayed tilt angle:', this.lastTiltCommand.angle, '-> Tuya percent_control:', this.lastTiltCommand.value);
135
+ this.log.debug('[TuyaAccessory] Executing delayed tilt angle:', this.lastTiltCommand.angle, '-> Tuya percent_control:', this.lastTiltCommand.value);
135
136
  this._executeTilt(this.lastTiltCommand.value);
136
137
  this.tiltDelayTimeout = null;
137
138
  this.lastTiltCommand = null;
@@ -148,7 +149,7 @@ class VerticalBlindsWithTilt extends BaseAccessory {
148
149
 
149
150
  if (this.positionStateTimeout) clearTimeout(this.positionStateTimeout);
150
151
  this.positionStateTimeout = setTimeout(() => {
151
- this.log('[TuyaAccessory] Blinds finished opening');
152
+ this.log.debug('[TuyaAccessory] Blinds finished opening');
152
153
  this.currentPosition = 100;
153
154
  this.accessory.context.cachedPosition = 100;
154
155
  this.characteristicCurrentPosition.updateValue(100);
@@ -160,15 +161,15 @@ class VerticalBlindsWithTilt extends BaseAccessory {
160
161
  return this.setStateAsync(this.dpAction, 'open');
161
162
 
162
163
  } else {
163
- this.log('[TuyaAccessory] Partial position requested, opening fully');
164
+ this.log.debug('[TuyaAccessory] Partial position requested, opening fully');
164
165
 
165
166
  if (this.lastTiltCommand && (Date.now() - this.lastTiltCommand.time < this.timeToClose * 1000)) {
166
- this.log('[TuyaAccessory] Partial open command received while tilt was pending - rescheduling tilt delay');
167
+ this.log.debug('[TuyaAccessory] Partial open command received while tilt was pending - rescheduling tilt delay');
167
168
  if (this.tiltBufferTimeout) { clearTimeout(this.tiltBufferTimeout); this.tiltBufferTimeout = null; }
168
169
  if (this.tiltDelayTimeout) clearTimeout(this.tiltDelayTimeout);
169
170
  const delayMs = this.timeToClose * 1000;
170
171
  this.tiltDelayTimeout = setTimeout(() => {
171
- this.log('[TuyaAccessory] Executing delayed tilt angle:', this.lastTiltCommand.angle, '-> Tuya percent_control:', this.lastTiltCommand.value);
172
+ this.log.debug('[TuyaAccessory] Executing delayed tilt angle:', this.lastTiltCommand.angle, '-> Tuya percent_control:', this.lastTiltCommand.value);
172
173
  this._executeTilt(this.lastTiltCommand.value);
173
174
  this.tiltDelayTimeout = null;
174
175
  this.lastTiltCommand = null;
@@ -185,7 +186,7 @@ class VerticalBlindsWithTilt extends BaseAccessory {
185
186
 
186
187
  if (this.positionStateTimeout) clearTimeout(this.positionStateTimeout);
187
188
  this.positionStateTimeout = setTimeout(() => {
188
- this.log('[TuyaAccessory] Blinds finished opening');
189
+ this.log.debug('[TuyaAccessory] Blinds finished opening');
189
190
  this.currentPosition = 100;
190
191
  this.accessory.context.cachedPosition = 100;
191
192
  this.characteristicCurrentPosition.updateValue(100);
@@ -205,6 +206,7 @@ class VerticalBlindsWithTilt extends BaseAccessory {
205
206
  }
206
207
 
207
208
  setTiltAngle(value) {
209
+ if (!this.device.connected) throw this._commError();
208
210
  const tuyaValue = Math.round((value / 1.8) + 50);
209
211
  const clampedValue = Math.max(0, Math.min(100, tuyaValue));
210
212
 
@@ -221,12 +223,12 @@ class VerticalBlindsWithTilt extends BaseAccessory {
221
223
  ? (this.timeToClose * 1000) - timeSinceClose
222
224
  : (this.timeToClose * 1000) - timeSinceOpen;
223
225
  const action = shouldDelayForClose ? 'close' : 'open';
224
- this.log('[TuyaAccessory] Tilt command received during', action, '- delaying by', Math.round(delayMs / 1000), 'seconds');
226
+ this.log.debug('[TuyaAccessory] Tilt command received during', action, '- delaying by', Math.round(delayMs / 1000), 'seconds');
225
227
 
226
228
  this._cancelPendingTilts();
227
229
 
228
230
  this.tiltDelayTimeout = setTimeout(() => {
229
- this.log('[TuyaAccessory] Executing delayed tilt angle:', value, '-> Tuya percent_control:', clampedValue);
231
+ this.log.debug('[TuyaAccessory] Executing delayed tilt angle:', value, '-> Tuya percent_control:', clampedValue);
230
232
  this._executeTilt(clampedValue);
231
233
  this.tiltDelayTimeout = null;
232
234
  this.lastCloseTime = 0;
@@ -235,7 +237,7 @@ class VerticalBlindsWithTilt extends BaseAccessory {
235
237
  }, delayMs);
236
238
 
237
239
  } else {
238
- this.log('[TuyaAccessory] Setting tilt angle:', value, '-> Tuya percent_control:', clampedValue);
240
+ this.log.debug('[TuyaAccessory] Setting tilt angle:', value, '-> Tuya percent_control:', clampedValue);
239
241
 
240
242
  if (this.tiltBufferTimeout) clearTimeout(this.tiltBufferTimeout);
241
243
 
@@ -256,7 +258,7 @@ class VerticalBlindsWithTilt extends BaseAccessory {
256
258
 
257
259
  _executeTilt(value) {
258
260
  if (!this.device.connected) {
259
- this.log('[TuyaAccessory] Cannot execute tilt - device not connected');
261
+ this.log.debug('[TuyaAccessory] Cannot execute tilt - device not connected');
260
262
  return;
261
263
  }
262
264
  this.device.update({[this.dpTilt]: value});
@@ -266,12 +268,12 @@ class VerticalBlindsWithTilt extends BaseAccessory {
266
268
  if (this.tiltBufferTimeout) {
267
269
  clearTimeout(this.tiltBufferTimeout);
268
270
  this.tiltBufferTimeout = null;
269
- this.log('[TuyaAccessory] Cleared pending tilt buffer');
271
+ this.log.debug('[TuyaAccessory] Cleared pending tilt buffer');
270
272
  }
271
273
  if (this.tiltDelayTimeout) {
272
274
  clearTimeout(this.tiltDelayTimeout);
273
275
  this.tiltDelayTimeout = null;
274
- this.log('[TuyaAccessory] Cleared pending tilt delay');
276
+ this.log.debug('[TuyaAccessory] Cleared pending tilt delay');
275
277
  }
276
278
  }
277
279