homebridge-tuya-plus 3.14.0-dev.30 → 3.14.0-dev.32
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/index.js +44 -6
- package/lib/BaseAccessory.js +1 -1
- package/lib/TuyaAccessory.js +18 -18
- package/lib/TuyaCloudDevice.js +53 -13
- package/lib/TuyaDevice.js +3 -0
- package/package.json +1 -1
- package/test/TuyaCloudDevice.test.js +53 -0
- package/test/TuyaDevice.test.js +14 -0
- package/test/index.test.js +58 -0
package/index.js
CHANGED
|
@@ -228,12 +228,7 @@ class TuyaLan {
|
|
|
228
228
|
(config) => {
|
|
229
229
|
if (!config || !config.id) return;
|
|
230
230
|
const tuyaDevice = this.tuyaDevices.get(config.id);
|
|
231
|
-
if (!tuyaDevice)
|
|
232
|
-
return this.log.warn(
|
|
233
|
-
'Discovered a device that has not been configured yet (%s@%s).',
|
|
234
|
-
config.id,
|
|
235
|
-
config.ip,
|
|
236
|
-
);
|
|
231
|
+
if (!tuyaDevice) return this._warnUnconfiguredDevice(config);
|
|
237
232
|
if (connectedDevices.includes(config.id)) return;
|
|
238
233
|
|
|
239
234
|
connectedDevices.push(config.id);
|
|
@@ -284,6 +279,49 @@ class TuyaLan {
|
|
|
284
279
|
}, this.config.discoverTimeout ?? DEFAULT_DISCOVER_TIMEOUT);
|
|
285
280
|
}
|
|
286
281
|
|
|
282
|
+
// A device showed up on the LAN that isn't in the user's config. When the
|
|
283
|
+
// optional cloud session is available we look the device up there first, so
|
|
284
|
+
// the warning can name it (and hint at its product/category) and the user
|
|
285
|
+
// can tell which physical device still needs configuring — far more useful
|
|
286
|
+
// than a bare id@ip. If there's no cloud session, or the lookup fails (the
|
|
287
|
+
// device belongs to another account, the device-management API isn't
|
|
288
|
+
// authorised, the network is down, …), fall back to the plain warning.
|
|
289
|
+
// Never throws: enrichment is best-effort and must not disrupt discovery.
|
|
290
|
+
async _warnUnconfiguredDevice(config) {
|
|
291
|
+
const bareWarning = () =>
|
|
292
|
+
this.log.warn(
|
|
293
|
+
'Discovered a device that has not been configured yet (%s@%s).',
|
|
294
|
+
config.id,
|
|
295
|
+
config.ip,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
if (!this.cloudApi) return bareWarning();
|
|
299
|
+
|
|
300
|
+
let info;
|
|
301
|
+
try {
|
|
302
|
+
info = await this.cloudApi.getDeviceInfo(config.id);
|
|
303
|
+
} catch (ex) {
|
|
304
|
+
this.log.debug(
|
|
305
|
+
'Tuya Cloud lookup for unconfigured device %s failed: %s',
|
|
306
|
+
config.id,
|
|
307
|
+
ex.message,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
if (!info || !info.name) return bareWarning();
|
|
311
|
+
|
|
312
|
+
const details = [];
|
|
313
|
+
if (info.product_name) details.push(info.product_name);
|
|
314
|
+
if (info.category) details.push(`category ${info.category}`);
|
|
315
|
+
|
|
316
|
+
this.log.warn(
|
|
317
|
+
'Discovered a device that has not been configured yet: %s%s (%s@%s).',
|
|
318
|
+
`"${info.name}"`,
|
|
319
|
+
details.length ? ` — ${details.join(', ')}` : '',
|
|
320
|
+
config.id,
|
|
321
|
+
config.ip,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
287
325
|
/* ------------------------------------------------------------------ *
|
|
288
326
|
* Tuya Cloud — a single, global fallback session.
|
|
289
327
|
*
|
package/lib/BaseAccessory.js
CHANGED
|
@@ -24,7 +24,7 @@ class BaseAccessory {
|
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
this.device.once('change', () => {
|
|
27
|
-
this.log(`Ready to handle ${this.device.context.name} (${this.device.context.type}:${this.device.context.version}) with signature ${JSON.stringify(this.device.state)}`);
|
|
27
|
+
this.log.debug(`Ready to handle ${this.device.context.name} (${this.device.context.type}:${this.device.context.version}) with signature ${JSON.stringify(this.device.state)}`);
|
|
28
28
|
|
|
29
29
|
this._registerCharacteristics(this.device.state);
|
|
30
30
|
});
|
package/lib/TuyaAccessory.js
CHANGED
|
@@ -328,8 +328,8 @@ class TuyaAccessory extends EventEmitter {
|
|
|
328
328
|
data = JSON.parse(decryptedMsg);
|
|
329
329
|
} catch (ex) {
|
|
330
330
|
data = decryptedMsg;
|
|
331
|
-
this.log.
|
|
332
|
-
this.log.
|
|
331
|
+
this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, data);
|
|
332
|
+
this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
|
|
333
333
|
break;
|
|
334
334
|
}
|
|
335
335
|
|
|
@@ -351,8 +351,8 @@ class TuyaAccessory extends EventEmitter {
|
|
|
351
351
|
try {
|
|
352
352
|
data = JSON.parse(data);
|
|
353
353
|
} catch (ex) {
|
|
354
|
-
this.log.
|
|
355
|
-
this.log.
|
|
354
|
+
this.log.debug(`Malformed update from ${this.context.name} with command ${cmd}:`, data);
|
|
355
|
+
this.log.debug(`Raw update from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
|
|
356
356
|
break;
|
|
357
357
|
}
|
|
358
358
|
|
|
@@ -361,8 +361,8 @@ class TuyaAccessory extends EventEmitter {
|
|
|
361
361
|
break;
|
|
362
362
|
|
|
363
363
|
default:
|
|
364
|
-
this.log.
|
|
365
|
-
this.log.
|
|
364
|
+
this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, data);
|
|
365
|
+
this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
|
|
366
366
|
}
|
|
367
367
|
|
|
368
368
|
callback();
|
|
@@ -416,8 +416,8 @@ class TuyaAccessory extends EventEmitter {
|
|
|
416
416
|
try {
|
|
417
417
|
data = JSON.parse(decryptedMsg);
|
|
418
418
|
} catch(ex) {
|
|
419
|
-
this.log.
|
|
420
|
-
this.log.
|
|
419
|
+
this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
|
|
420
|
+
this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
|
|
421
421
|
return callback();
|
|
422
422
|
}
|
|
423
423
|
|
|
@@ -429,15 +429,15 @@ class TuyaAccessory extends EventEmitter {
|
|
|
429
429
|
//this.log.info(`Heard back from ${this.context.name} with command ${cmd}`);
|
|
430
430
|
this._change(data.dps);
|
|
431
431
|
} else {
|
|
432
|
-
this.log.
|
|
433
|
-
this.log.
|
|
432
|
+
this.log.debug(`Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
|
|
433
|
+
this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
|
|
434
434
|
}
|
|
435
435
|
}
|
|
436
436
|
break;
|
|
437
437
|
|
|
438
438
|
default:
|
|
439
|
-
this.log.
|
|
440
|
-
this.log.
|
|
439
|
+
this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
|
|
440
|
+
this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
|
|
441
441
|
}
|
|
442
442
|
|
|
443
443
|
callback();
|
|
@@ -550,15 +550,15 @@ class TuyaAccessory extends EventEmitter {
|
|
|
550
550
|
//this.log.info(`Heard back from ${this.context.name} with command ${cmd}`);
|
|
551
551
|
this._change(parsedPayload.dps);
|
|
552
552
|
} else {
|
|
553
|
-
this.log.
|
|
554
|
-
this.log.
|
|
553
|
+
this.log.debug(`Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
|
|
554
|
+
this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
|
|
555
555
|
}
|
|
556
556
|
}
|
|
557
557
|
break;
|
|
558
558
|
|
|
559
559
|
default:
|
|
560
|
-
this.log.
|
|
561
|
-
this.log.
|
|
560
|
+
this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
|
|
561
|
+
this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
|
|
562
562
|
}
|
|
563
563
|
|
|
564
564
|
callback();
|
|
@@ -674,11 +674,11 @@ class TuyaAccessory extends EventEmitter {
|
|
|
674
674
|
if (parsedPayload && parsedPayload.dps) {
|
|
675
675
|
this._change(parsedPayload.dps);
|
|
676
676
|
} else {
|
|
677
|
-
this.log.
|
|
677
|
+
this.log.debug(`Malformed message from ${this.context.name} with command ${cmd}:`, payload.toString('utf8'));
|
|
678
678
|
}
|
|
679
679
|
break;
|
|
680
680
|
default:
|
|
681
|
-
this.log.
|
|
681
|
+
this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, payload.toString('utf8'));
|
|
682
682
|
}
|
|
683
683
|
|
|
684
684
|
callback();
|
package/lib/TuyaCloudDevice.js
CHANGED
|
@@ -39,12 +39,19 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
39
39
|
this.api = props.cloudApi;
|
|
40
40
|
this.messaging = props.messaging || null; // shared realtime stream
|
|
41
41
|
|
|
42
|
+
// Lets the cloud backend ask its parent TuyaDevice whether the SAME device
|
|
43
|
+
// is currently reachable over the LAN. A cloud-fallback failure is harmless
|
|
44
|
+
// when the LAN is up and worth surfacing when it isn't, so the failure log
|
|
45
|
+
// adapts to it. Null when used standalone (no LAN sibling).
|
|
46
|
+
this.isLanConnected = typeof props.isLanConnected === 'function' ? props.isLanConnected : null;
|
|
47
|
+
|
|
42
48
|
// Keep a plain config object on `context`, mirroring TuyaAccessory, but
|
|
43
49
|
// without the live helper objects we were handed.
|
|
44
50
|
this.context = {...props};
|
|
45
51
|
delete this.context.cloudApi;
|
|
46
52
|
delete this.context.messaging;
|
|
47
53
|
delete this.context.log;
|
|
54
|
+
delete this.context.isLanConnected;
|
|
48
55
|
|
|
49
56
|
this.state = {};
|
|
50
57
|
this.connected = false;
|
|
@@ -118,22 +125,20 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
118
125
|
// won't clear without the user changing their Tuya project. Since PR #71 the
|
|
119
126
|
// cloud backs *every* device as a fallback, so a LAN-only device whose cloud
|
|
120
127
|
// project lacks permission would otherwise log an error every 30s forever.
|
|
121
|
-
// Two things keep that out of the log: a given
|
|
128
|
+
// Two things keep that out of the log: a given message is surfaced once and then
|
|
122
129
|
// suppressed to debug until it changes (or the device connects), and the retry
|
|
123
130
|
// interval backs off (30s → 30m cap) instead of hammering the OpenAPI.
|
|
124
131
|
_onConnectFailure(ex) {
|
|
125
132
|
const message = ex && ex.message ? ex.message : String(ex);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
this.log[lanFallback ? 'warn' : 'error'](`${this.context.name}: Tuya Cloud connection failed: ${message}.${hint}`);
|
|
136
|
-
this._lastConnectError = message;
|
|
133
|
+
const {level, text} = this._describeConnectFailure(message);
|
|
134
|
+
|
|
135
|
+
// The full (level + wording) is what the dedup tracks, not just the raw
|
|
136
|
+
// message: it means a device that flips from "reachable over the LAN"
|
|
137
|
+
// (harmless, debug) to "not reachable over the LAN either" (warn) re-surfaces
|
|
138
|
+
// the change, while an unchanged situation stays quiet.
|
|
139
|
+
if (text !== this._lastConnectError) {
|
|
140
|
+
this.log[level](text);
|
|
141
|
+
this._lastConnectError = text;
|
|
137
142
|
} else {
|
|
138
143
|
this.log.debug(`${this.context.name}: Tuya Cloud connection still failing: ${message}`);
|
|
139
144
|
}
|
|
@@ -145,6 +150,41 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
145
150
|
if (this._retryTimer && this._retryTimer.unref) this._retryTimer.unref();
|
|
146
151
|
}
|
|
147
152
|
|
|
153
|
+
// Pick the log level and wording for a connect failure from what we know about
|
|
154
|
+
// the device: whether the cloud is merely a fallback (the device has a local
|
|
155
|
+
// key) and, if so, whether that LAN path is reachable right now.
|
|
156
|
+
_describeConnectFailure(message) {
|
|
157
|
+
const lanFallback = !!this.context.key;
|
|
158
|
+
const lanReachable = lanFallback && !!(this.isLanConnected && this.isLanConnected());
|
|
159
|
+
|
|
160
|
+
// "permission deny" (1106) is specifically a visibility problem: the cloud
|
|
161
|
+
// project can't see this device. A device that has been offline for a long
|
|
162
|
+
// time is a prime candidate for having been removed/unbound/reset, which
|
|
163
|
+
// produces exactly this — so point at that, but only for this error (a
|
|
164
|
+
// timeout or token failure is unrelated).
|
|
165
|
+
const unbound = /permission deny|\b1106\b/i.test(message)
|
|
166
|
+
? ' Note: a device left offline for a long time can be removed or unbound from the account (e.g. reset or re-paired), which also produces this "permission deny".'
|
|
167
|
+
: '';
|
|
168
|
+
|
|
169
|
+
let level, hint;
|
|
170
|
+
if (lanReachable) {
|
|
171
|
+
// Confirmed reachable over the LAN, so the cloud fallback we couldn't
|
|
172
|
+
// reach isn't needed. Keep it out of the way at debug.
|
|
173
|
+
level = 'debug';
|
|
174
|
+
hint = ' The device is reachable over the LAN, so only the (unused) cloud fallback is affected — harmless.';
|
|
175
|
+
} else if (lanFallback) {
|
|
176
|
+
// LAN-capable but not connected right now, so the cloud is currently the
|
|
177
|
+
// only path and the device may be unresponsive in HomeKit.
|
|
178
|
+
level = 'warn';
|
|
179
|
+
hint = ` The cloud is only a fallback, but the device isn't reachable over the LAN right now either, so it may show as unresponsive in HomeKit. Check that it is linked to this cloud project (matching account/home and datacenter region).${unbound}`;
|
|
180
|
+
} else {
|
|
181
|
+
level = 'error';
|
|
182
|
+
hint = ` This device is cloud-only, so it stays unreachable until this clears — check that it is linked to the cloud project (matching account/home and datacenter region).${unbound}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {level, text: `${this.context.name}: Tuya Cloud connection failed: ${message}.${hint}`};
|
|
186
|
+
}
|
|
187
|
+
|
|
148
188
|
/* ------------------------------------------------------------------ *
|
|
149
189
|
* Realtime (shared MQTT stream)
|
|
150
190
|
* ------------------------------------------------------------------ */
|
|
@@ -259,7 +299,7 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
259
299
|
const dp = i.dp_id != null ? i.dp_id : (i.dpId != null ? i.dpId : this.dpIdByCode[i.code]);
|
|
260
300
|
return `${i.code}${dp != null ? `(dp ${dp})` : ''}=${JSON.stringify(i.value)}`;
|
|
261
301
|
}).join(', ');
|
|
262
|
-
this.log.
|
|
302
|
+
this.log.debug(`${this.context.name}: Tuya Cloud data-points → ${list || '(none reported)'}`);
|
|
263
303
|
} catch (_) { /* logging must never throw */ }
|
|
264
304
|
}
|
|
265
305
|
|
package/lib/TuyaDevice.js
CHANGED
|
@@ -79,6 +79,9 @@ class TuyaDevice extends EventEmitter {
|
|
|
79
79
|
cloudApi: props.cloudApi,
|
|
80
80
|
messaging: props.messaging,
|
|
81
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),
|
|
82
85
|
connect: false
|
|
83
86
|
});
|
|
84
87
|
}
|
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.32",
|
|
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": {
|
|
@@ -246,6 +246,59 @@ describe('TuyaCloudDevice', () => {
|
|
|
246
246
|
}
|
|
247
247
|
});
|
|
248
248
|
|
|
249
|
+
test('a device reachable over the LAN logs the fallback failure as harmless (debug, not warn)', () => {
|
|
250
|
+
jest.useFakeTimers();
|
|
251
|
+
try {
|
|
252
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc', isLanConnected: () => true});
|
|
253
|
+
const warn = jest.spyOn(log, 'warn');
|
|
254
|
+
const debug = jest.spyOn(log, 'debug');
|
|
255
|
+
|
|
256
|
+
dev._onConnectFailure(new Error('permission deny (code 1106)'));
|
|
257
|
+
|
|
258
|
+
expect(warn).not.toHaveBeenCalled();
|
|
259
|
+
expect(debug).toHaveBeenCalledTimes(1);
|
|
260
|
+
expect(debug.mock.calls[0][0]).toContain('reachable over the LAN');
|
|
261
|
+
} finally {
|
|
262
|
+
jest.useRealTimers();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('the same failure re-surfaces (debug → warn) when the LAN path drops', () => {
|
|
267
|
+
jest.useFakeTimers();
|
|
268
|
+
try {
|
|
269
|
+
let lanUp = true;
|
|
270
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc', isLanConnected: () => lanUp});
|
|
271
|
+
const warn = jest.spyOn(log, 'warn');
|
|
272
|
+
const err = new Error('permission deny (code 1106)');
|
|
273
|
+
|
|
274
|
+
dev._onConnectFailure(err); // LAN up → harmless, debug only
|
|
275
|
+
expect(warn).not.toHaveBeenCalled();
|
|
276
|
+
|
|
277
|
+
lanUp = false;
|
|
278
|
+
dev._onConnectFailure(err); // identical error, but LAN now down → surfaced
|
|
279
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
280
|
+
expect(warn.mock.calls[0][0]).toContain("isn't reachable over the LAN");
|
|
281
|
+
} finally {
|
|
282
|
+
jest.useRealTimers();
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('permission-deny adds the offline-unbinding hint; an unrelated error does not', () => {
|
|
287
|
+
jest.useFakeTimers();
|
|
288
|
+
try {
|
|
289
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'}); // LAN-capable, LAN down
|
|
290
|
+
const warn = jest.spyOn(log, 'warn');
|
|
291
|
+
|
|
292
|
+
dev._onConnectFailure(new Error('permission deny (code 1106)'));
|
|
293
|
+
expect(warn.mock.calls[0][0]).toContain('offline for a long time');
|
|
294
|
+
|
|
295
|
+
dev._onConnectFailure(new Error('request timed out'));
|
|
296
|
+
expect(warn.mock.calls[1][0]).not.toContain('offline for a long time');
|
|
297
|
+
} finally {
|
|
298
|
+
jest.useRealTimers();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
249
302
|
test('a different error message is surfaced again, not suppressed', () => {
|
|
250
303
|
jest.useFakeTimers();
|
|
251
304
|
try {
|
package/test/TuyaDevice.test.js
CHANGED
|
@@ -232,6 +232,20 @@ describe('TuyaDevice — composition', () => {
|
|
|
232
232
|
expect(dev.cloud).toBeNull();
|
|
233
233
|
});
|
|
234
234
|
|
|
235
|
+
test('the cloud backend can see whether the LAN path is currently up', () => {
|
|
236
|
+
const api = {
|
|
237
|
+
isConfigured: () => true,
|
|
238
|
+
getStatus: jest.fn().mockResolvedValue([]),
|
|
239
|
+
getDeviceInfo: jest.fn().mockResolvedValue({online: true}),
|
|
240
|
+
sendCommands: jest.fn()
|
|
241
|
+
};
|
|
242
|
+
const dev = makeDevice({cloudApi: api});
|
|
243
|
+
|
|
244
|
+
expect(dev.cloud.isLanConnected()).toBe(false); // no LAN attached yet
|
|
245
|
+
dev.lan = {connected: true};
|
|
246
|
+
expect(dev.cloud.isLanConnected()).toBe(true); // reflects live LAN state
|
|
247
|
+
});
|
|
248
|
+
|
|
235
249
|
test('attachLan builds the LAN backend with the discovered version (forceVersion still wins)', () => {
|
|
236
250
|
const dev = makeDevice({ip: '10.0.0.5'});
|
|
237
251
|
dev.attachLan({ip: '10.0.0.9', version: '3.3'});
|
package/test/index.test.js
CHANGED
|
@@ -16,6 +16,7 @@ jest.mock('../lib/TuyaAccessory', () => jest.fn().mockImplementation(function(pr
|
|
|
16
16
|
jest.mock('../lib/TuyaCloudApi', () => jest.fn().mockImplementation(function(cfg) {
|
|
17
17
|
this.endpoint = 'https://openapi.example.com';
|
|
18
18
|
this.cfg = cfg;
|
|
19
|
+
this.getDeviceInfo = jest.fn();
|
|
19
20
|
}));
|
|
20
21
|
jest.mock('../lib/TuyaCloudMessaging', () => jest.fn().mockImplementation(function() {}));
|
|
21
22
|
jest.mock('../lib/TuyaDiscovery', () => ({
|
|
@@ -135,6 +136,63 @@ describe('TuyaLan — discovery routing', () => {
|
|
|
135
136
|
});
|
|
136
137
|
});
|
|
137
138
|
|
|
139
|
+
describe('TuyaLan — unconfigured device discovery', () => {
|
|
140
|
+
const UNKNOWN = {id: 'bf99999999999999', ip: '10.0.0.9'};
|
|
141
|
+
const BARE = 'Discovered a device that has not been configured yet (%s@%s).';
|
|
142
|
+
const ENRICHED = 'Discovered a device that has not been configured yet: %s%s (%s@%s).';
|
|
143
|
+
|
|
144
|
+
test('without a cloud session, the bare id@ip warning is logged', async () => {
|
|
145
|
+
const platform = run({devices: [SW()]});
|
|
146
|
+
await platform._warnUnconfiguredDevice(UNKNOWN);
|
|
147
|
+
expect(platform.log.warn).toHaveBeenCalledWith(BARE, UNKNOWN.id, UNKNOWN.ip);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('with a cloud session, the warning is enriched with the device name and product', async () => {
|
|
151
|
+
const platform = run({cloud: CLOUD, devices: [SW()]});
|
|
152
|
+
platform.cloudApi.getDeviceInfo.mockResolvedValue({name: 'Living Room Light', product_name: 'Smart Bulb', category: 'dj', online: true});
|
|
153
|
+
|
|
154
|
+
await platform._warnUnconfiguredDevice(UNKNOWN);
|
|
155
|
+
|
|
156
|
+
expect(platform.cloudApi.getDeviceInfo).toHaveBeenCalledWith(UNKNOWN.id);
|
|
157
|
+
const [format, ...args] = platform.log.warn.mock.calls[0];
|
|
158
|
+
expect(format).toBe(ENRICHED);
|
|
159
|
+
const text = args.join(' ');
|
|
160
|
+
expect(text).toContain('"Living Room Light"');
|
|
161
|
+
expect(text).toContain('Smart Bulb');
|
|
162
|
+
expect(text).toContain('category dj');
|
|
163
|
+
expect(text).toContain(UNKNOWN.id);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('a cloud lookup that fails falls back to the bare warning', async () => {
|
|
167
|
+
const platform = run({cloud: CLOUD, devices: [SW()]});
|
|
168
|
+
platform.cloudApi.getDeviceInfo.mockRejectedValue(new Error('device not found'));
|
|
169
|
+
|
|
170
|
+
await platform._warnUnconfiguredDevice(UNKNOWN);
|
|
171
|
+
|
|
172
|
+
expect(platform.log.warn).toHaveBeenCalledWith(BARE, UNKNOWN.id, UNKNOWN.ip);
|
|
173
|
+
expect(platform.log.debug).toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('a cloud record without a name falls back to the bare warning', async () => {
|
|
177
|
+
const platform = run({cloud: CLOUD, devices: [SW()]});
|
|
178
|
+
platform.cloudApi.getDeviceInfo.mockResolvedValue({online: true});
|
|
179
|
+
|
|
180
|
+
await platform._warnUnconfiguredDevice(UNKNOWN);
|
|
181
|
+
|
|
182
|
+
expect(platform.log.warn).toHaveBeenCalledWith(BARE, UNKNOWN.id, UNKNOWN.ip);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('the discover handler routes an unconfigured device through the cloud lookup', () => {
|
|
186
|
+
const platform = run({cloud: CLOUD, devices: [SW()]});
|
|
187
|
+
platform.cloudApi.getDeviceInfo.mockResolvedValue(null);
|
|
188
|
+
|
|
189
|
+
const onDiscover = TuyaDiscovery.on.mock.calls.find(c => c[0] === 'discover')[1];
|
|
190
|
+
onDiscover(UNKNOWN);
|
|
191
|
+
|
|
192
|
+
expect(platform.cloudApi.getDeviceInfo).toHaveBeenCalledWith(UNKNOWN.id);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
138
196
|
describe('TuyaLan — duplicate device ids', () => {
|
|
139
197
|
// Two entries sharing an id resolve to one accessory UUID; configuring it twice
|
|
140
198
|
// used to crash the child bridge (addAccessory read back its own wrapper and
|