homebridge-tuya-plus 3.14.0-dev.33 → 3.14.0-dev.35
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/AGENTS.md +14 -0
- package/index.js +56 -0
- package/lib/TuyaAccessory.js +28 -0
- package/package.json +1 -1
- package/test/TuyaAccessory.protocol.test.js +72 -0
- package/test/index.test.js +30 -0
package/AGENTS.md
CHANGED
|
@@ -131,6 +131,20 @@ devices already paired in HomeKit; a careless change can break them silently.
|
|
|
131
131
|
routing.
|
|
132
132
|
- When a change must alter behavior, make it opt-in.
|
|
133
133
|
|
|
134
|
+
## Debug config block
|
|
135
|
+
|
|
136
|
+
The platform reads an **undocumented** top-level `debug` object from the config
|
|
137
|
+
(see `_debugConfig()` in `index.js`). It holds dev/test-only switches and is
|
|
138
|
+
deliberately **kept out of `config.schema.json`** so it never appears in the
|
|
139
|
+
Homebridge UI. Treat it as the one place such switches belong; add new ones as
|
|
140
|
+
optional, off-by-default flags read through `_debugConfig()` and coerced with
|
|
141
|
+
`coerceBoolean`.
|
|
142
|
+
|
|
143
|
+
- `debug.forceCloudFallback` — pretend LAN discovery fails for every device, so
|
|
144
|
+
the whole platform runs over the Tuya Cloud fallback. Lets the cloud path be
|
|
145
|
+
exercised end-to-end without taking devices off the LAN (needs a configured
|
|
146
|
+
`cloud` block to be useful).
|
|
147
|
+
|
|
134
148
|
## Git & PR workflow
|
|
135
149
|
|
|
136
150
|
- Develop on the branch you've been assigned; never push to `main` directly.
|
package/index.js
CHANGED
|
@@ -79,6 +79,20 @@ let Characteristic,
|
|
|
79
79
|
AdaptiveLightingController,
|
|
80
80
|
UUID;
|
|
81
81
|
|
|
82
|
+
// Lenient boolean coercion for platform-level config flags, mirroring
|
|
83
|
+
// BaseAccessory._coerceBoolean so the same true / 'true' / 1 spellings users
|
|
84
|
+
// already use on device options are accepted on top-level options too.
|
|
85
|
+
function coerceBoolean(value, defaultValue) {
|
|
86
|
+
const df = defaultValue || false;
|
|
87
|
+
return typeof value === 'boolean'
|
|
88
|
+
? value
|
|
89
|
+
: typeof value === 'string'
|
|
90
|
+
? value.toLowerCase().trim() === 'true'
|
|
91
|
+
: typeof value === 'number'
|
|
92
|
+
? value !== 0
|
|
93
|
+
: df;
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
module.exports = function (homebridge) {
|
|
83
97
|
({
|
|
84
98
|
platformAccessory: PlatformAccessory,
|
|
@@ -221,6 +235,37 @@ class TuyaLan {
|
|
|
221
235
|
return; // cloud-only (or empty) configuration: nothing to discover over the LAN
|
|
222
236
|
}
|
|
223
237
|
|
|
238
|
+
// Debug switch: pretend LAN discovery fails for every device so the whole
|
|
239
|
+
// platform runs over the Tuya Cloud fallback — a way to exercise the cloud
|
|
240
|
+
// path end-to-end without taking devices off the LAN. We simply never start
|
|
241
|
+
// discovery (and never attach a LAN backend), leaving the cloud session each
|
|
242
|
+
// device was already wired with as its only transport. Useless without a
|
|
243
|
+
// configured cloud session — the affected devices then have no transport at
|
|
244
|
+
// all, so say so per device rather than failing silently.
|
|
245
|
+
if (coerceBoolean(this._debugConfig().forceCloudFallback)) {
|
|
246
|
+
this.log.warn(
|
|
247
|
+
'debug.forceCloudFallback is set: skipping LAN discovery; every device is forced onto the Tuya Cloud fallback.',
|
|
248
|
+
);
|
|
249
|
+
lanDeviceIds.forEach((deviceId) => {
|
|
250
|
+
const tuyaDevice = this.tuyaDevices.get(deviceId);
|
|
251
|
+
if (!tuyaDevice) return;
|
|
252
|
+
if (tuyaDevice.cloud) {
|
|
253
|
+
this.log.info(
|
|
254
|
+
'Forcing %s (%s) onto the Tuya Cloud fallback (debug.forceCloudFallback).',
|
|
255
|
+
tuyaDevice.context.name,
|
|
256
|
+
deviceId,
|
|
257
|
+
);
|
|
258
|
+
} else {
|
|
259
|
+
this.log.error(
|
|
260
|
+
'debug.forceCloudFallback is set but %s (%s) has no Tuya Cloud session, so it can\'t be reached.',
|
|
261
|
+
tuyaDevice.context.name,
|
|
262
|
+
deviceId,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
224
269
|
this.log.info('Starting discovery...');
|
|
225
270
|
|
|
226
271
|
TuyaDiscovery.start({ ids: lanDeviceIds, log: this.log }).on(
|
|
@@ -279,6 +324,17 @@ class TuyaLan {
|
|
|
279
324
|
}, this.config.discoverTimeout ?? DEFAULT_DISCOVER_TIMEOUT);
|
|
280
325
|
}
|
|
281
326
|
|
|
327
|
+
// The undocumented top-level `debug` block holds switches that only matter
|
|
328
|
+
// when developing or testing the plugin (e.g. forcing the Tuya Cloud path).
|
|
329
|
+
// It is intentionally absent from config.schema.json so it never surfaces in
|
|
330
|
+
// the Homebridge UI. Always returns an object so callers can read a flag
|
|
331
|
+
// without first guarding for the block's presence.
|
|
332
|
+
_debugConfig() {
|
|
333
|
+
return this.config.debug && typeof this.config.debug === 'object'
|
|
334
|
+
? this.config.debug
|
|
335
|
+
: {};
|
|
336
|
+
}
|
|
337
|
+
|
|
282
338
|
// A device showed up on the LAN that isn't in the user's config. When the
|
|
283
339
|
// optional cloud session is available we look the device up there first, so
|
|
284
340
|
// the warning can name it (and hint at its product/category) and the user
|
package/lib/TuyaAccessory.js
CHANGED
|
@@ -48,6 +48,21 @@ class TuyaAccessory extends EventEmitter {
|
|
|
48
48
|
this.log.info(`${this.context.name} reports protocol ${this.context.version}, newer than the newest known Tuya LAN protocol (3.5); using the 3.5 (GCM) stack with ${this.context.version} headers.`);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// A Tuya local key is the AES-128 key used for every LAN frame and must be
|
|
52
|
+
// exactly 16 bytes. A wrong-length key (mistyped, truncated, or pasted with
|
|
53
|
+
// stray characters) makes crypto.createCipheriv throw "Invalid key length"
|
|
54
|
+
// the moment we first talk to the device. That throw originates inside a
|
|
55
|
+
// socket callback, so it goes unhandled and crashes Homebridge - which then
|
|
56
|
+
// restarts and crashes again: a boot loop. Refuse to bring the LAN backend
|
|
57
|
+
// up for such a device and say why; with a Tuya Cloud session configured it
|
|
58
|
+
// still works as a cloud-only device, and other devices are unaffected.
|
|
59
|
+
this._invalidKey = !this.context.fake && !TuyaAccessory.isValidLocalKey(this.context.key);
|
|
60
|
+
if (this._invalidKey && this.log) {
|
|
61
|
+
const key = this.context.key;
|
|
62
|
+
const keyLength = typeof key === 'string' ? Buffer.byteLength(key, 'utf8') : (Buffer.isBuffer(key) ? key.length : 0);
|
|
63
|
+
this.log.error(`${this.context.name} (${this.context.id}) has an invalid local key: a Tuya local key must be exactly 16 characters (this one has ${keyLength}). LAN control is disabled for this device until the key is corrected.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
51
66
|
this.state = {};
|
|
52
67
|
this._cachedBuffer = Buffer.allocUnsafe(0);
|
|
53
68
|
|
|
@@ -72,7 +87,19 @@ class TuyaAccessory extends EventEmitter {
|
|
|
72
87
|
this.session_key = null;
|
|
73
88
|
}
|
|
74
89
|
|
|
90
|
+
// A Tuya local key is the AES-128 key for the LAN protocol, so it must be
|
|
91
|
+
// exactly 16 bytes (createCipheriv rejects any other length). Measure a
|
|
92
|
+
// string the way createCipheriv does - by its UTF-8 byte length - so a key
|
|
93
|
+
// that merely looks 16 characters long but isn't (e.g. a stray multibyte
|
|
94
|
+
// character) is still caught.
|
|
95
|
+
static isValidLocalKey(key) {
|
|
96
|
+
if (Buffer.isBuffer(key)) return key.length === 16;
|
|
97
|
+
if (typeof key === 'string') return Buffer.byteLength(key, 'utf8') === 16;
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
75
101
|
_connect() {
|
|
102
|
+
if (this._invalidKey) return;
|
|
76
103
|
if (this.context.fake) {
|
|
77
104
|
this.connected = true;
|
|
78
105
|
return setTimeout(() => {
|
|
@@ -767,6 +794,7 @@ class TuyaAccessory extends EventEmitter {
|
|
|
767
794
|
|
|
768
795
|
_send(o) {
|
|
769
796
|
if (this.context.fake) return;
|
|
797
|
+
if (this._invalidKey) return false;
|
|
770
798
|
if (!this.connected) return false;
|
|
771
799
|
|
|
772
800
|
if (this._protocolVersion < 3.2) return this._send_3_1(o);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-tuya-plus",
|
|
3
|
-
"version": "3.14.0-dev.
|
|
3
|
+
"version": "3.14.0-dev.35",
|
|
4
4
|
"description": "A community-maintained Homebridge plugin for controlling Tuya devices in HomeKit. LAN-first, with an optional Tuya Cloud fallback for any device the LAN can't reach.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -155,6 +155,78 @@ describe('protocol version normalization', () => {
|
|
|
155
155
|
});
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
+
// A wrong-length local key made crypto.createCipheriv throw "Invalid key length"
|
|
159
|
+
// from inside a socket callback (see _send_3_3), which goes unhandled and crashes
|
|
160
|
+
// Homebridge into a restart loop. An out-of-spec key must be refused clearly,
|
|
161
|
+
// up front, without ever reaching the cipher or taking the process down.
|
|
162
|
+
describe('invalid local key handling', () => {
|
|
163
|
+
describe('isValidLocalKey', () => {
|
|
164
|
+
test.each([
|
|
165
|
+
['0123456789abcdef', true], // 16 ASCII chars
|
|
166
|
+
['short', false],
|
|
167
|
+
['0123456789abcdefXX', false], // 18 chars
|
|
168
|
+
['', false],
|
|
169
|
+
['012345678901234é', false], // 16 chars but 17 UTF-8 bytes
|
|
170
|
+
[Buffer.alloc(16), true],
|
|
171
|
+
[Buffer.alloc(8), false],
|
|
172
|
+
[null, false],
|
|
173
|
+
[undefined, false],
|
|
174
|
+
[12345678901234567, false], // not a string/Buffer
|
|
175
|
+
])('isValidLocalKey(%p) === %p', (key, expected) => {
|
|
176
|
+
expect(TuyaAccessory.isValidLocalKey(key)).toBe(expected);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('a wrong-length key is flagged and logged without throwing', () => {
|
|
181
|
+
let device;
|
|
182
|
+
expect(() => { device = makeDevice('3.3', {key: 'too-short'}); }).not.toThrow();
|
|
183
|
+
expect(device._invalidKey).toBe(true);
|
|
184
|
+
expect(device.log.error).toHaveBeenCalledWith(expect.stringContaining('16 characters'));
|
|
185
|
+
const logged = device.log.error.mock.calls.flat().join(' ');
|
|
186
|
+
expect(logged).toContain('Protocol Test Device');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('a valid 16-character key is accepted and logs no error', () => {
|
|
190
|
+
const device = makeDevice('3.3');
|
|
191
|
+
expect(device._invalidKey).toBe(false);
|
|
192
|
+
expect(device.log.error).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('_connect() opens no socket for an invalid-key device', () => {
|
|
196
|
+
const device = makeDevice('3.5', {key: 'bad'});
|
|
197
|
+
device._connect();
|
|
198
|
+
expect(device._socket).toBeUndefined();
|
|
199
|
+
expect(device.connected).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// The exact path that used to crash: a connected device whose update() flows
|
|
203
|
+
// into _send -> _send_3_x -> createCipheriv. It must short-circuit to false.
|
|
204
|
+
test.each(['3.1', '3.3', '3.4', '3.5'])(
|
|
205
|
+
'update() never reaches the cipher for an invalid-key %s device, even when connected',
|
|
206
|
+
version => {
|
|
207
|
+
const device = makeDevice(version, {key: 'nope'});
|
|
208
|
+
const written = attachSocketStub(device); // also forces connected = true
|
|
209
|
+
|
|
210
|
+
let result;
|
|
211
|
+
expect(() => { result = device.update({1: true}); }).not.toThrow();
|
|
212
|
+
expect(result).toBe(false);
|
|
213
|
+
expect(written).toHaveLength(0);
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
test('a fake device without a key is never treated as invalid', () => {
|
|
218
|
+
const device = new TuyaAccessory({
|
|
219
|
+
id: 'bf1234567890abcdef12',
|
|
220
|
+
name: 'Fake Device',
|
|
221
|
+
fake: true,
|
|
222
|
+
connect: false,
|
|
223
|
+
log: makeLog(),
|
|
224
|
+
});
|
|
225
|
+
expect(device._invalidKey).toBe(false);
|
|
226
|
+
expect(device.log.error).not.toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
158
230
|
describe('message handler routing', () => {
|
|
159
231
|
const HANDLERS = ['_msgHandler_3_1', '_msgHandler_3_3', '_msgHandler_3_4', '_msgHandler_3_5'];
|
|
160
232
|
|
package/test/index.test.js
CHANGED
|
@@ -136,6 +136,36 @@ describe('TuyaLan — discovery routing', () => {
|
|
|
136
136
|
});
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
+
describe('TuyaLan — debug.forceCloudFallback', () => {
|
|
140
|
+
test('skips LAN discovery so a keyed device never attaches a LAN backend', () => {
|
|
141
|
+
run({cloud: CLOUD, debug: {forceCloudFallback: true}, devices: [SW()]});
|
|
142
|
+
expect(TuyaDiscovery.start).not.toHaveBeenCalled();
|
|
143
|
+
expect(TuyaDiscovery.on).not.toHaveBeenCalled();
|
|
144
|
+
expect(instanceFor('bf11111111111111').attachLan).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('the forced device keeps its shared cloud session as its only transport', () => {
|
|
148
|
+
run({cloud: CLOUD, debug: {forceCloudFallback: true}, devices: [SW()]});
|
|
149
|
+
expect(propsFor('bf11111111111111').cloudApi).toBeDefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('off (or absent) leaves discovery running as usual', () => {
|
|
153
|
+
run({cloud: CLOUD, debug: {forceCloudFallback: false}, devices: [SW()]});
|
|
154
|
+
expect(TuyaDiscovery.start).toHaveBeenCalledTimes(1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('lenient boolean spellings are accepted', () => {
|
|
158
|
+
run({cloud: CLOUD, debug: {forceCloudFallback: 'true'}, devices: [SW()]});
|
|
159
|
+
expect(TuyaDiscovery.start).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('without a cloud session, the unreachable device is reported as an error', () => {
|
|
163
|
+
const platform = run({debug: {forceCloudFallback: true}, devices: [SW()]});
|
|
164
|
+
expect(TuyaDiscovery.start).not.toHaveBeenCalled();
|
|
165
|
+
expect(platform.log.error).toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
139
169
|
describe('TuyaLan — unconfigured device discovery', () => {
|
|
140
170
|
const UNKNOWN = {id: 'bf99999999999999', ip: '10.0.0.9'};
|
|
141
171
|
const BARE = 'Discovered a device that has not been configured yet (%s@%s).';
|