homebridge-tuya-plus 3.14.0-dev.5 → 3.14.0-dev.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish-dev.yml +298 -31
- package/AGENTS.md +162 -0
- package/CLAUDE.md +1 -0
- package/Changelog.md +16 -3
- package/Readme.MD +8 -32
- package/config.schema.json +79 -79
- package/index.js +599 -358
- package/lib/AirPurifierAccessory.js +1 -1
- package/lib/BaseAccessory.js +54 -17
- package/lib/ConvectorAccessory.js +1 -1
- package/lib/CustomMultiOutletAccessory.js +2 -1
- package/lib/DoorbellAccessory.js +9 -16
- package/lib/GarageDoorAccessory.js +1 -1
- package/lib/IrrigationSystemAccessory.js +260 -120
- package/lib/MultiOutletAccessory.js +3 -2
- package/lib/OilDiffuserAccessory.js +10 -8
- package/lib/RGBTWLightAccessory.js +13 -11
- package/lib/RGBTWOutletAccessory.js +11 -9
- package/lib/SimpleBlindsAccessory.js +5 -5
- package/lib/SimpleDimmer2Accessory.js +1 -1
- package/lib/SimpleDimmerAccessory.js +10 -6
- package/lib/SimpleGarageDoorAccessory.js +122 -26
- package/lib/SimpleHeaterAccessory.js +4 -4
- package/lib/SimpleLightAccessory.js +1 -1
- package/lib/SwitchAccessory.js +3 -2
- package/lib/TWLightAccessory.js +2 -2
- package/lib/TuyaAccessory.js +75 -42
- package/lib/TuyaCloudApi.js +121 -8
- package/lib/TuyaCloudDevice.js +434 -31
- package/lib/TuyaCloudMessaging.js +34 -41
- package/lib/TuyaDevice.js +269 -0
- package/lib/TuyaDiscovery.js +25 -9
- package/lib/ValveAccessory.js +16 -12
- package/lib/VerticalBlindsWithTilt.js +27 -25
- package/lib/WledDimmerAccessory.js +46 -81
- package/package.json +5 -6
- package/scripts/cleanup-pr-tags.js +122 -0
- package/test/BaseAccessory.test.js +94 -15
- package/test/IrrigationSystemAccessory.test.js +293 -67
- package/test/MultiOutletAccessory.test.js +18 -3
- package/test/RGBTWLightAccessory.test.js +3 -3
- package/test/SimpleFanLightAccessory.test.js +3 -3
- package/test/SimpleGarageDoorAccessory.test.js +193 -19
- package/test/TuyaAccessory.protocol.test.js +76 -3
- package/test/TuyaCloudApi.test.js +110 -1
- package/test/TuyaCloudDevice.test.js +564 -2
- package/test/TuyaCloudMessaging.test.js +24 -5
- package/test/TuyaDevice.test.js +266 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +271 -0
- package/test/support/mocks.js +13 -0
- package/wiki/Supported-Device-Types.md +64 -34
- package/wiki/Tuya-Cloud-Setup.md +31 -108
|
@@ -6,36 +6,27 @@ const crypto = require('crypto');
|
|
|
6
6
|
/*
|
|
7
7
|
* TuyaCloudMessaging
|
|
8
8
|
* ------------------
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
19
|
*
|
|
20
20
|
* The connection + AES-GCM message decryption are faithful to the
|
|
21
21
|
* actively-maintained `0x5e/homebridge-tuya-platform` (`src/core/TuyaOpenMQ.ts`),
|
|
22
22
|
* re-implemented here with Node's built-in `crypto`.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
const mqtt = require('mqtt');
|
|
26
|
+
|
|
25
27
|
const GCM_TAG_LENGTH = 16;
|
|
26
28
|
const PROTOCOL_DEVICE_STATUS = 4;
|
|
27
29
|
|
|
28
|
-
// `mqtt` is an OPTIONAL dependency: realtime is a bonus, polling is the
|
|
29
|
-
// guarantee. Loading it lazily keeps the plugin LAN-first and lets it run in
|
|
30
|
-
// environments where mqtt isn't (or can't be) installed.
|
|
31
|
-
let mqtt = null;
|
|
32
|
-
let mqttLoadError = null;
|
|
33
|
-
try {
|
|
34
|
-
mqtt = require('mqtt');
|
|
35
|
-
} catch (ex) {
|
|
36
|
-
mqttLoadError = ex;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
30
|
class TuyaCloudMessaging extends EventEmitter {
|
|
40
31
|
constructor({api, log} = {}) {
|
|
41
32
|
super();
|
|
@@ -56,10 +47,6 @@ class TuyaCloudMessaging extends EventEmitter {
|
|
|
56
47
|
this.setMaxListeners(0);
|
|
57
48
|
}
|
|
58
49
|
|
|
59
|
-
static isAvailable() {
|
|
60
|
-
return !!mqtt;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
50
|
// Register a device's status handler and lazily start the shared connection.
|
|
64
51
|
subscribeDevice(devId, handler) {
|
|
65
52
|
const id = '' + devId;
|
|
@@ -72,13 +59,8 @@ class TuyaCloudMessaging extends EventEmitter {
|
|
|
72
59
|
if (this._stopped || this._started) return;
|
|
73
60
|
this._started = true;
|
|
74
61
|
|
|
75
|
-
if (!mqtt) {
|
|
76
|
-
this.log.warn(`Tuya Cloud realtime disabled: the optional "mqtt" package is not installed${mqttLoadError ? ` (${mqttLoadError.message})` : ''}. Devices will poll instead. Install it with "npm install mqtt" in the plugin folder to enable instant updates.`);
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
62
|
this._connect().catch(ex => {
|
|
81
|
-
this.log.warn(`Tuya Cloud realtime could not start (${ex.message});
|
|
63
|
+
this.log.warn(`Tuya Cloud realtime could not start (${ex.message}); it will keep retrying.`);
|
|
82
64
|
this._scheduleRenew(60);
|
|
83
65
|
});
|
|
84
66
|
}
|
|
@@ -145,12 +127,12 @@ class TuyaCloudMessaging extends EventEmitter {
|
|
|
145
127
|
envelope = JSON.parse(payload.toString());
|
|
146
128
|
} catch (_) { return; }
|
|
147
129
|
|
|
148
|
-
const {protocol, data
|
|
130
|
+
const {protocol, data} = envelope;
|
|
149
131
|
if (!data) return;
|
|
150
132
|
|
|
151
133
|
let plaintext;
|
|
152
134
|
try {
|
|
153
|
-
plaintext = this._decrypt(data, this.config && this.config.password
|
|
135
|
+
plaintext = this._decrypt(data, this.config && this.config.password);
|
|
154
136
|
} catch (ex) {
|
|
155
137
|
this.log.debug(`Tuya Cloud realtime decrypt failed: ${ex.message}`);
|
|
156
138
|
return;
|
|
@@ -170,6 +152,12 @@ class TuyaCloudMessaging extends EventEmitter {
|
|
|
170
152
|
if (!devId || !Array.isArray(status)) return;
|
|
171
153
|
|
|
172
154
|
const handlers = this._handlers.get('' + devId);
|
|
155
|
+
// Trace what the realtime stream actually delivers (debug only). A device
|
|
156
|
+
// whose status never arrives here — while it clearly changed in the cloud —
|
|
157
|
+
// is the signature of the message service not pushing that device's reports.
|
|
158
|
+
try {
|
|
159
|
+
this.log.debug(`Tuya Cloud realtime update for ${devId}${handlers && handlers.length ? '' : ' (no subscriber)'}: ${JSON.stringify(status)}`);
|
|
160
|
+
} catch (_) { /* logging must never throw */ }
|
|
173
161
|
if (!handlers || !handlers.length) return;
|
|
174
162
|
handlers.forEach(fn => {
|
|
175
163
|
try { fn(status); } catch (ex) { this.log.debug(`Tuya Cloud realtime handler error: ${ex.message}`); }
|
|
@@ -179,26 +167,31 @@ class TuyaCloudMessaging extends EventEmitter {
|
|
|
179
167
|
// Decrypt the MQTT `data` field. msg_encrypted_version 2.0 → AES-128-GCM,
|
|
180
168
|
// 1.0 → AES-128-ECB. The AES key is the middle 16 chars of the broker
|
|
181
169
|
// password (NOT the project Access Secret).
|
|
182
|
-
|
|
170
|
+
//
|
|
171
|
+
// The GCM auth tag is intentionally NOT verified: Tuya's real status frames
|
|
172
|
+
// do not carry a tag that verifies against the documented AAD, so calling
|
|
173
|
+
// `decipher.final()` would throw on every genuine message and silently drop
|
|
174
|
+
// all realtime updates. AES-GCM is a stream cipher, so `update()` alone
|
|
175
|
+
// recovers the plaintext correctly regardless of the tag; a wrong key just
|
|
176
|
+
// yields garbage that the subsequent JSON.parse rejects. Both the official
|
|
177
|
+
// `tuya/tuya-homebridge` and `0x5e/homebridge-tuya-platform` decrypt with
|
|
178
|
+
// `update()` only, for exactly this reason — `final()` here was the bug that
|
|
179
|
+
// stopped external changes (physical buttons, the Tuya app) from showing up.
|
|
180
|
+
_decrypt(data, password) {
|
|
183
181
|
const key = Buffer.from(('' + (password || '')).substring(8, 24), 'utf8');
|
|
184
182
|
const buf = Buffer.from(data, 'base64');
|
|
185
183
|
|
|
186
184
|
// GCM (v2.0) layout: [ivLen(4) BE][iv(ivLen)][ciphertext][tag(16)].
|
|
187
|
-
// Try it first; if the header doesn't look like a sane IV length,
|
|
188
|
-
//
|
|
185
|
+
// Try it first; if the header doesn't look like a sane IV length, fall
|
|
186
|
+
// back to ECB (v1.0).
|
|
189
187
|
if (buf.length > 4 + GCM_TAG_LENGTH) {
|
|
190
188
|
const ivLen = buf.readUIntBE(0, 4);
|
|
191
189
|
if (ivLen >= 12 && ivLen <= 16 && (4 + ivLen + GCM_TAG_LENGTH) <= buf.length) {
|
|
192
190
|
try {
|
|
193
191
|
const iv = buf.slice(4, 4 + ivLen);
|
|
194
192
|
const ciphertext = buf.slice(4 + ivLen, buf.length - GCM_TAG_LENGTH);
|
|
195
|
-
const tag = buf.slice(buf.length - GCM_TAG_LENGTH);
|
|
196
193
|
const decipher = crypto.createDecipheriv('aes-128-gcm', key, iv);
|
|
197
|
-
decipher.
|
|
198
|
-
const aad = Buffer.allocUnsafe(6);
|
|
199
|
-
aad.writeUIntBE(Number(t) || 0, 0, 6);
|
|
200
|
-
decipher.setAAD(aad);
|
|
201
|
-
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
194
|
+
return decipher.update(ciphertext).toString('utf8');
|
|
202
195
|
} catch (gcmEx) {
|
|
203
196
|
// fall through to ECB
|
|
204
197
|
}
|
|
@@ -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;
|
package/lib/TuyaDiscovery.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
189
|
+
this.log.debug(`Discovery - unrecognised UDP payload from ${info.address}:${port} (ignored):`, cleanMsg.toString('hex'));
|
|
178
190
|
}
|
|
179
191
|
} catch (ex) {
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
);
|
package/lib/ValveAccessory.js
CHANGED
|
@@ -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.
|
|
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.
|
|
69
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
100
|
-
|
|
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.
|
|
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.
|
|
117
|
-
|
|
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
|
}
|