homebridge-tuya-plus 3.14.0-dev.3 → 3.14.0-dev.30

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 (47) hide show
  1. package/.github/workflows/publish-dev.yml +298 -31
  2. package/AGENTS.md +120 -0
  3. package/CLAUDE.md +1 -0
  4. package/Changelog.md +21 -0
  5. package/Readme.MD +8 -24
  6. package/config.schema.json +122 -68
  7. package/index.js +498 -269
  8. package/lib/BaseAccessory.js +52 -15
  9. package/lib/ConvectorAccessory.js +1 -1
  10. package/lib/CustomMultiOutletAccessory.js +2 -1
  11. package/lib/IrrigationSystemAccessory.js +98 -95
  12. package/lib/MultiOutletAccessory.js +2 -1
  13. package/lib/OilDiffuserAccessory.js +10 -8
  14. package/lib/RGBTWLightAccessory.js +13 -11
  15. package/lib/RGBTWOutletAccessory.js +11 -9
  16. package/lib/SimpleBlindsAccessory.js +5 -5
  17. package/lib/SimpleDimmer2Accessory.js +1 -1
  18. package/lib/SimpleDimmerAccessory.js +10 -6
  19. package/lib/SimpleGarageDoorAccessory.js +78 -24
  20. package/lib/SimpleHeaterAccessory.js +3 -3
  21. package/lib/SwitchAccessory.js +2 -1
  22. package/lib/TWLightAccessory.js +2 -2
  23. package/lib/TuyaAccessory.js +0 -1
  24. package/lib/TuyaCloudApi.js +320 -0
  25. package/lib/TuyaCloudDevice.js +327 -0
  26. package/lib/TuyaCloudMessaging.js +219 -0
  27. package/lib/TuyaDevice.js +266 -0
  28. package/lib/ValveAccessory.js +16 -12
  29. package/lib/VerticalBlindsWithTilt.js +5 -3
  30. package/lib/WledDimmerAccessory.js +46 -81
  31. package/package.json +5 -3
  32. package/scripts/cleanup-pr-tags.js +122 -0
  33. package/test/BaseAccessory.test.js +94 -15
  34. package/test/IrrigationSystemAccessory.test.js +134 -31
  35. package/test/MultiOutletAccessory.test.js +18 -3
  36. package/test/RGBTWLightAccessory.test.js +3 -3
  37. package/test/SimpleFanLightAccessory.test.js +3 -3
  38. package/test/SimpleGarageDoorAccessory.test.js +63 -9
  39. package/test/TuyaCloudApi.test.js +221 -0
  40. package/test/TuyaCloudDevice.test.js +304 -0
  41. package/test/TuyaCloudMessaging.test.js +113 -0
  42. package/test/TuyaDevice.test.js +252 -0
  43. package/test/getCategory.test.js +1 -0
  44. package/test/index.test.js +183 -0
  45. package/test/support/mocks.js +13 -0
  46. package/wiki/Supported-Device-Types.md +60 -28
  47. package/wiki/Tuya-Cloud-Setup.md +71 -0
@@ -0,0 +1,219 @@
1
+ 'use strict';
2
+
3
+ const EventEmitter = require('events');
4
+ const crypto = require('crypto');
5
+
6
+ /*
7
+ * TuyaCloudMessaging
8
+ * ------------------
9
+ * Realtime updates for cloud-backed devices, over Tuya's MQTT message service.
10
+ * ONE instance is shared by every cloud device on the same Tuya project (one
11
+ * broker connection delivers status for all of them); messages are fanned out
12
+ * to the right device by its `devId`.
13
+ *
14
+ * Cloud updates (including physical button presses and the rain sensor) arrive
15
+ * over this stream rather than by polling, so they show up in HomeKit within a
16
+ * second or two. `mqtt` is a required dependency; if the broker can't be
17
+ * reached or a message can't be decrypted nothing crashes — the stream simply
18
+ * reconnects and the cloud REST reads still cover initial state.
19
+ *
20
+ * The connection + AES-GCM message decryption are faithful to the
21
+ * actively-maintained `0x5e/homebridge-tuya-platform` (`src/core/TuyaOpenMQ.ts`),
22
+ * re-implemented here with Node's built-in `crypto`.
23
+ */
24
+
25
+ const mqtt = require('mqtt');
26
+
27
+ const GCM_TAG_LENGTH = 16;
28
+ const PROTOCOL_DEVICE_STATUS = 4;
29
+
30
+ class TuyaCloudMessaging extends EventEmitter {
31
+ constructor({api, log} = {}) {
32
+ super();
33
+ this.api = api;
34
+ this.log = log || console;
35
+
36
+ this.linkId = crypto.randomUUID(); // bare v4 UUID, reused across reconnects
37
+ this.client = null;
38
+ this.config = null;
39
+ this.connected = false;
40
+
41
+ this._handlers = new Map(); // devId -> [fn(status)]
42
+ this._renewTimer = null;
43
+ this._started = false;
44
+ this._stopped = false;
45
+
46
+ // EventEmitter with many devices listening to 'online'/'offline'.
47
+ this.setMaxListeners(0);
48
+ }
49
+
50
+ // Register a device's status handler and lazily start the shared connection.
51
+ subscribeDevice(devId, handler) {
52
+ const id = '' + devId;
53
+ if (!this._handlers.has(id)) this._handlers.set(id, []);
54
+ this._handlers.get(id).push(handler);
55
+ if (!this._started) this.start();
56
+ }
57
+
58
+ start() {
59
+ if (this._stopped || this._started) return;
60
+ this._started = true;
61
+
62
+ this._connect().catch(ex => {
63
+ this.log.warn(`Tuya Cloud realtime could not start (${ex.message}); it will keep retrying.`);
64
+ this._scheduleRenew(60);
65
+ });
66
+ }
67
+
68
+ async _connect() {
69
+ if (this._stopped) return;
70
+ this._teardownClient();
71
+
72
+ const cfg = await this.api.getMqttConfig(this.linkId);
73
+ this.config = cfg;
74
+
75
+ if (!cfg.url || !cfg.source_topic || !cfg.source_topic.device) {
76
+ throw new Error('incomplete MQTT config from Tuya');
77
+ }
78
+
79
+ const client = mqtt.connect(cfg.url, {
80
+ clientId: cfg.client_id,
81
+ username: cfg.username,
82
+ password: cfg.password
83
+ });
84
+
85
+ client.on('connect', () => {
86
+ this.connected = true;
87
+ client.subscribe(cfg.source_topic.device, err => {
88
+ if (err) this.log.debug(`Tuya Cloud realtime subscribe error: ${err.message}`);
89
+ });
90
+ this.log.info('Tuya Cloud realtime connected (MQTT).');
91
+ this.emit('online');
92
+ });
93
+ client.on('message', (topic, payload) => this._onMessage(topic, payload));
94
+ client.on('error', err => this.log.debug(`Tuya Cloud realtime error: ${err && err.message}`));
95
+ client.on('close', () => this._markOffline());
96
+ client.on('end', () => this._markOffline());
97
+
98
+ this.client = client;
99
+
100
+ // Broker credentials expire; renew them a minute early (mqtt.js handles
101
+ // transient drops on its own via auto-reconnect).
102
+ const ttl = parseInt(cfg.expire_time) || 7200;
103
+ this._scheduleRenew(Math.max(60, ttl - 60));
104
+ }
105
+
106
+ _markOffline() {
107
+ if (this.connected) {
108
+ this.connected = false;
109
+ this.emit('offline');
110
+ }
111
+ }
112
+
113
+ _scheduleRenew(seconds) {
114
+ if (this._renewTimer) clearTimeout(this._renewTimer);
115
+ if (this._stopped) return;
116
+ this._renewTimer = setTimeout(() => {
117
+ this._connect().catch(ex => {
118
+ this.log.debug(`Tuya Cloud realtime renew failed: ${ex.message}`);
119
+ this._scheduleRenew(60);
120
+ });
121
+ }, seconds * 1000);
122
+ }
123
+
124
+ _onMessage(topic, payload) {
125
+ let envelope;
126
+ try {
127
+ envelope = JSON.parse(payload.toString());
128
+ } catch (_) { return; }
129
+
130
+ const {protocol, data} = envelope;
131
+ if (!data) return;
132
+
133
+ let plaintext;
134
+ try {
135
+ plaintext = this._decrypt(data, this.config && this.config.password);
136
+ } catch (ex) {
137
+ this.log.debug(`Tuya Cloud realtime decrypt failed: ${ex.message}`);
138
+ return;
139
+ }
140
+
141
+ let msg;
142
+ try {
143
+ msg = JSON.parse(plaintext);
144
+ } catch (_) { return; }
145
+
146
+ // We only care about device status frames (protocol 4). Some firmwares
147
+ // omit `protocol`; in that case fall back to "has a status array".
148
+ if (protocol != null && protocol !== PROTOCOL_DEVICE_STATUS && !(msg && Array.isArray(msg.status))) return;
149
+
150
+ const devId = msg && (msg.devId || msg.devid || msg.dev_id);
151
+ const status = msg && msg.status;
152
+ if (!devId || !Array.isArray(status)) return;
153
+
154
+ const handlers = this._handlers.get('' + devId);
155
+ if (!handlers || !handlers.length) return;
156
+ handlers.forEach(fn => {
157
+ try { fn(status); } catch (ex) { this.log.debug(`Tuya Cloud realtime handler error: ${ex.message}`); }
158
+ });
159
+ }
160
+
161
+ // Decrypt the MQTT `data` field. msg_encrypted_version 2.0 → AES-128-GCM,
162
+ // 1.0 → AES-128-ECB. The AES key is the middle 16 chars of the broker
163
+ // password (NOT the project Access Secret).
164
+ //
165
+ // The GCM auth tag is intentionally NOT verified: Tuya's real status frames
166
+ // do not carry a tag that verifies against the documented AAD, so calling
167
+ // `decipher.final()` would throw on every genuine message and silently drop
168
+ // all realtime updates. AES-GCM is a stream cipher, so `update()` alone
169
+ // recovers the plaintext correctly regardless of the tag; a wrong key just
170
+ // yields garbage that the subsequent JSON.parse rejects. Both the official
171
+ // `tuya/tuya-homebridge` and `0x5e/homebridge-tuya-platform` decrypt with
172
+ // `update()` only, for exactly this reason — `final()` here was the bug that
173
+ // stopped external changes (physical buttons, the Tuya app) from showing up.
174
+ _decrypt(data, password) {
175
+ const key = Buffer.from(('' + (password || '')).substring(8, 24), 'utf8');
176
+ const buf = Buffer.from(data, 'base64');
177
+
178
+ // GCM (v2.0) layout: [ivLen(4) BE][iv(ivLen)][ciphertext][tag(16)].
179
+ // Try it first; if the header doesn't look like a sane IV length, fall
180
+ // back to ECB (v1.0).
181
+ if (buf.length > 4 + GCM_TAG_LENGTH) {
182
+ const ivLen = buf.readUIntBE(0, 4);
183
+ if (ivLen >= 12 && ivLen <= 16 && (4 + ivLen + GCM_TAG_LENGTH) <= buf.length) {
184
+ try {
185
+ const iv = buf.slice(4, 4 + ivLen);
186
+ const ciphertext = buf.slice(4 + ivLen, buf.length - GCM_TAG_LENGTH);
187
+ const decipher = crypto.createDecipheriv('aes-128-gcm', key, iv);
188
+ return decipher.update(ciphertext).toString('utf8');
189
+ } catch (gcmEx) {
190
+ // fall through to ECB
191
+ }
192
+ }
193
+ }
194
+
195
+ const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
196
+ return Buffer.concat([decipher.update(buf), decipher.final()]).toString('utf8');
197
+ }
198
+
199
+ _teardownClient() {
200
+ if (this.client) {
201
+ try {
202
+ this.client.removeAllListeners();
203
+ this.client.end(true);
204
+ } catch (_) { /* ignore */ }
205
+ this.client = null;
206
+ }
207
+ this.connected = false;
208
+ }
209
+
210
+ stop() {
211
+ this._stopped = true;
212
+ if (this._renewTimer) { clearTimeout(this._renewTimer); this._renewTimer = null; }
213
+ this._teardownClient();
214
+ }
215
+ }
216
+
217
+ // Expose for unit tests / encryption round-trips.
218
+ TuyaCloudMessaging.GCM_TAG_LENGTH = GCM_TAG_LENGTH;
219
+ module.exports = TuyaCloudMessaging;
@@ -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;
@@ -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]))
@@ -76,6 +76,7 @@ class VerticalBlindsWithTilt extends BaseAccessory {
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) {
@@ -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