homebridge-tuya-plus 3.14.0-beta.2 → 3.14.0-dev.4

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.
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+
3
+ const EventEmitter = require('events');
4
+ const crypto = require('crypto');
5
+
6
+ /*
7
+ * TuyaCloudMessaging
8
+ * ------------------
9
+ * Optional realtime updates for cloud-backed devices, over Tuya's MQTT message
10
+ * service. ONE instance is shared by every cloud device on the same Tuya
11
+ * project (one broker connection delivers status for all of them); messages are
12
+ * fanned out to the right device by its `devId`.
13
+ *
14
+ * This is intentionally a best-effort accelerator layered on top of polling:
15
+ * if the optional `mqtt` package is missing, or the broker can't be reached, or
16
+ * a message can't be decrypted, nothing breaks — the devices simply keep
17
+ * polling. When it IS working, changes (including physical button presses and
18
+ * the rain sensor) show up in HomeKit within a second or two.
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 GCM_TAG_LENGTH = 16;
26
+ const PROTOCOL_DEVICE_STATUS = 4;
27
+
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
+ class TuyaCloudMessaging extends EventEmitter {
40
+ constructor({api, log} = {}) {
41
+ super();
42
+ this.api = api;
43
+ this.log = log || console;
44
+
45
+ this.linkId = crypto.randomUUID(); // bare v4 UUID, reused across reconnects
46
+ this.client = null;
47
+ this.config = null;
48
+ this.connected = false;
49
+
50
+ this._handlers = new Map(); // devId -> [fn(status)]
51
+ this._renewTimer = null;
52
+ this._started = false;
53
+ this._stopped = false;
54
+
55
+ // EventEmitter with many devices listening to 'online'/'offline'.
56
+ this.setMaxListeners(0);
57
+ }
58
+
59
+ static isAvailable() {
60
+ return !!mqtt;
61
+ }
62
+
63
+ // Register a device's status handler and lazily start the shared connection.
64
+ subscribeDevice(devId, handler) {
65
+ const id = '' + devId;
66
+ if (!this._handlers.has(id)) this._handlers.set(id, []);
67
+ this._handlers.get(id).push(handler);
68
+ if (!this._started) this.start();
69
+ }
70
+
71
+ start() {
72
+ if (this._stopped || this._started) return;
73
+ this._started = true;
74
+
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
+ this._connect().catch(ex => {
81
+ this.log.warn(`Tuya Cloud realtime could not start (${ex.message}); devices will poll instead.`);
82
+ this._scheduleRenew(60);
83
+ });
84
+ }
85
+
86
+ async _connect() {
87
+ if (this._stopped) return;
88
+ this._teardownClient();
89
+
90
+ const cfg = await this.api.getMqttConfig(this.linkId);
91
+ this.config = cfg;
92
+
93
+ if (!cfg.url || !cfg.source_topic || !cfg.source_topic.device) {
94
+ throw new Error('incomplete MQTT config from Tuya');
95
+ }
96
+
97
+ const client = mqtt.connect(cfg.url, {
98
+ clientId: cfg.client_id,
99
+ username: cfg.username,
100
+ password: cfg.password
101
+ });
102
+
103
+ client.on('connect', () => {
104
+ this.connected = true;
105
+ client.subscribe(cfg.source_topic.device, err => {
106
+ if (err) this.log.debug(`Tuya Cloud realtime subscribe error: ${err.message}`);
107
+ });
108
+ this.log.info('Tuya Cloud realtime connected (MQTT).');
109
+ this.emit('online');
110
+ });
111
+ client.on('message', (topic, payload) => this._onMessage(topic, payload));
112
+ client.on('error', err => this.log.debug(`Tuya Cloud realtime error: ${err && err.message}`));
113
+ client.on('close', () => this._markOffline());
114
+ client.on('end', () => this._markOffline());
115
+
116
+ this.client = client;
117
+
118
+ // Broker credentials expire; renew them a minute early (mqtt.js handles
119
+ // transient drops on its own via auto-reconnect).
120
+ const ttl = parseInt(cfg.expire_time) || 7200;
121
+ this._scheduleRenew(Math.max(60, ttl - 60));
122
+ }
123
+
124
+ _markOffline() {
125
+ if (this.connected) {
126
+ this.connected = false;
127
+ this.emit('offline');
128
+ }
129
+ }
130
+
131
+ _scheduleRenew(seconds) {
132
+ if (this._renewTimer) clearTimeout(this._renewTimer);
133
+ if (this._stopped) return;
134
+ this._renewTimer = setTimeout(() => {
135
+ this._connect().catch(ex => {
136
+ this.log.debug(`Tuya Cloud realtime renew failed: ${ex.message}`);
137
+ this._scheduleRenew(60);
138
+ });
139
+ }, seconds * 1000);
140
+ }
141
+
142
+ _onMessage(topic, payload) {
143
+ let envelope;
144
+ try {
145
+ envelope = JSON.parse(payload.toString());
146
+ } catch (_) { return; }
147
+
148
+ const {protocol, data, t} = envelope;
149
+ if (!data) return;
150
+
151
+ let plaintext;
152
+ try {
153
+ plaintext = this._decrypt(data, this.config && this.config.password, t);
154
+ } catch (ex) {
155
+ this.log.debug(`Tuya Cloud realtime decrypt failed: ${ex.message}`);
156
+ return;
157
+ }
158
+
159
+ let msg;
160
+ try {
161
+ msg = JSON.parse(plaintext);
162
+ } catch (_) { return; }
163
+
164
+ // We only care about device status frames (protocol 4). Some firmwares
165
+ // omit `protocol`; in that case fall back to "has a status array".
166
+ if (protocol != null && protocol !== PROTOCOL_DEVICE_STATUS && !(msg && Array.isArray(msg.status))) return;
167
+
168
+ const devId = msg && (msg.devId || msg.devid || msg.dev_id);
169
+ const status = msg && msg.status;
170
+ if (!devId || !Array.isArray(status)) return;
171
+
172
+ const handlers = this._handlers.get('' + devId);
173
+ if (!handlers || !handlers.length) return;
174
+ handlers.forEach(fn => {
175
+ try { fn(status); } catch (ex) { this.log.debug(`Tuya Cloud realtime handler error: ${ex.message}`); }
176
+ });
177
+ }
178
+
179
+ // Decrypt the MQTT `data` field. msg_encrypted_version 2.0 → AES-128-GCM,
180
+ // 1.0 → AES-128-ECB. The AES key is the middle 16 chars of the broker
181
+ // password (NOT the project Access Secret).
182
+ _decrypt(data, password, t) {
183
+ const key = Buffer.from(('' + (password || '')).substring(8, 24), 'utf8');
184
+ const buf = Buffer.from(data, 'base64');
185
+
186
+ // 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, or
188
+ // auth fails, fall back to ECB (v1.0).
189
+ if (buf.length > 4 + GCM_TAG_LENGTH) {
190
+ const ivLen = buf.readUIntBE(0, 4);
191
+ if (ivLen >= 12 && ivLen <= 16 && (4 + ivLen + GCM_TAG_LENGTH) <= buf.length) {
192
+ try {
193
+ const iv = buf.slice(4, 4 + ivLen);
194
+ const ciphertext = buf.slice(4 + ivLen, buf.length - GCM_TAG_LENGTH);
195
+ const tag = buf.slice(buf.length - GCM_TAG_LENGTH);
196
+ const decipher = crypto.createDecipheriv('aes-128-gcm', key, iv);
197
+ decipher.setAuthTag(tag);
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');
202
+ } catch (gcmEx) {
203
+ // fall through to ECB
204
+ }
205
+ }
206
+ }
207
+
208
+ const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
209
+ return Buffer.concat([decipher.update(buf), decipher.final()]).toString('utf8');
210
+ }
211
+
212
+ _teardownClient() {
213
+ if (this.client) {
214
+ try {
215
+ this.client.removeAllListeners();
216
+ this.client.end(true);
217
+ } catch (_) { /* ignore */ }
218
+ this.client = null;
219
+ }
220
+ this.connected = false;
221
+ }
222
+
223
+ stop() {
224
+ this._stopped = true;
225
+ if (this._renewTimer) { clearTimeout(this._renewTimer); this._renewTimer = null; }
226
+ this._teardownClient();
227
+ }
228
+ }
229
+
230
+ // Expose for unit tests / encryption round-trips.
231
+ TuyaCloudMessaging.GCM_TAG_LENGTH = GCM_TAG_LENGTH;
232
+ module.exports = TuyaCloudMessaging;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.14.0-beta.2",
3
+ "version": "3.14.0-dev.4",
4
4
  "description": "A community-maintained Homebridge plugin for controlling Tuya devices locally over LAN. Includes new features, fixes, and updated device support.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -31,6 +31,9 @@
31
31
  "qrcode": "^1.4.1",
32
32
  "yaml": "^1.6.0"
33
33
  },
34
+ "optionalDependencies": {
35
+ "mqtt": "^5.15.1"
36
+ },
34
37
  "keywords": [
35
38
  "homebridge-plugin",
36
39
  "homebridge",
@@ -444,3 +444,52 @@ describe('IrrigationSystemAccessory — rain mapping', () => {
444
444
  expect(instance._rainDetected('no_rain')).toBe(false);
445
445
  });
446
446
  });
447
+
448
+ describe('IrrigationSystemAccessory — cloud mode (data-points keyed by code)', () => {
449
+ // Mirrors a real Tuya "sfkzq" watering controller reached over the cloud:
450
+ // valves are switch_1..4, battery is battery_percentage, and there is no
451
+ // rain sensor.
452
+ const cloudState = () => ({ switch_1: false, switch_2: false, switch_3: false, switch_4: false, battery_percentage: 99 });
453
+ const cloudCtx = { cloud: true, noRainSensor: true };
454
+
455
+ beforeEach(() => jest.useFakeTimers());
456
+ afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
457
+
458
+ test('default valves use Tuya codes switch_1..switch_4 (not numeric ids)', () => {
459
+ const { accessory } = makeHarness(cloudState(), cloudCtx);
460
+ ['switch_1', 'switch_2', 'switch_3', 'switch_4'].forEach(code => {
461
+ expect(valve(accessory, code)).toBeTruthy();
462
+ });
463
+ expect(valve(accessory, '1')).toBeFalsy();
464
+ });
465
+
466
+ test('turning a zone on writes the code/value to the device', () => {
467
+ const { accessory, device } = makeHarness(cloudState(), cloudCtx);
468
+ valve(accessory, 'switch_1').getCharacteristic(Characteristic.Active).triggerSet(1);
469
+ jest.advanceTimersByTime(500);
470
+ expect(device.update).toHaveBeenCalledWith({ switch_1: true });
471
+ });
472
+
473
+ test('battery is read from the battery_percentage code', () => {
474
+ const { accessory } = makeHarness(cloudState(), cloudCtx);
475
+ const battery = accessory.getService(Service.Battery);
476
+ expect(battery.getCharacteristic(Characteristic.BatteryLevel).value).toBe(99);
477
+ });
478
+
479
+ test('a realtime change keyed by code is reflected in HomeKit', () => {
480
+ const { accessory, device } = makeHarness(cloudState(), cloudCtx);
481
+ device.emitChange({ switch_2: true });
482
+ expect(valve(accessory, 'switch_2').getCharacteristic(Characteristic.Active).value).toBe(Characteristic.Active.ACTIVE);
483
+ });
484
+
485
+ test('an explicit valve list may use custom codes', () => {
486
+ const { accessory, device } = makeHarness(
487
+ { zone_a: false, zone_b: false, battery_percentage: 50 },
488
+ { cloud: true, noRainSensor: true, valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }] }
489
+ );
490
+ expect(valve(accessory, 'zone_a')).toBeTruthy();
491
+ valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).triggerSet(1);
492
+ jest.advanceTimersByTime(500);
493
+ expect(device.update).toHaveBeenCalledWith({ zone_b: true });
494
+ });
495
+ });
@@ -10,6 +10,7 @@
10
10
  // mix of legacy paths.
11
11
 
12
12
  const crypto = require('crypto');
13
+ const EventEmitter = require('events');
13
14
  const TuyaAccessory = require('../lib/TuyaAccessory');
14
15
  const TuyaDiscovery = require('../lib/TuyaDiscovery');
15
16
 
@@ -526,3 +527,100 @@ describe('discovery of 3.5+/3.6 devices', () => {
526
527
  expect(found[0].version).toBe('3.6');
527
528
  });
528
529
  });
530
+
531
+ // Many Tuya devices (e.g. LED ceiling lights) recycle their long-lived LAN
532
+ // sockets every few minutes, closing the connection with a TCP FIN ('end').
533
+ // The handler must tear that connection down and reconnect promptly instead of
534
+ // leaving a half-dead socket whose stale heartbeat timer later fires a
535
+ // misleading ERR_PING_TIMED_OUT - which used to be the only thing that
536
+ // triggered a reconnect.
537
+ describe('graceful disconnect handling', () => {
538
+ const net = require('net');
539
+ let realNetSocket;
540
+ let createdSockets;
541
+
542
+ const makeFakeSocket = () => {
543
+ const s = new EventEmitter();
544
+ s.destroyed = false;
545
+ s.setKeepAlive = () => {};
546
+ s.setNoDelay = () => {};
547
+ s.connect = jest.fn();
548
+ s.write = jest.fn(() => true);
549
+ s.destroy = jest.fn(function destroy() { this.destroyed = true; });
550
+ return s;
551
+ };
552
+
553
+ beforeEach(() => {
554
+ jest.useFakeTimers();
555
+ createdSockets = [];
556
+ realNetSocket = net.Socket;
557
+ // TuyaAccessory calls net.Socket() dynamically, so swapping the property
558
+ // is enough to hand it a controllable fake (no real network I/O).
559
+ net.Socket = function FakeSocket() {
560
+ const s = makeFakeSocket();
561
+ createdSockets.push(s);
562
+ return s;
563
+ };
564
+ });
565
+
566
+ afterEach(() => {
567
+ net.Socket = realNetSocket;
568
+ jest.clearAllTimers();
569
+ jest.useRealTimers();
570
+ });
571
+
572
+ // Bring a device up to a steady, connected state on a fake socket.
573
+ const establish = device => {
574
+ device._connect();
575
+ const socket = device._socket;
576
+ // An established connection has already cleared its connect watchdog.
577
+ clearTimeout(socket._connTimeout);
578
+ socket._connTimeout = null;
579
+ device.connected = true;
580
+ return socket;
581
+ };
582
+
583
+ test("a device-initiated 'end' clears the heartbeat, tears down, and schedules a reconnect without a spurious ping timeout", () => {
584
+ const device = makeDevice('3.5');
585
+ const socket = establish(device);
586
+
587
+ // A heartbeat retry timer is in flight. With the old handler it survived
588
+ // the disconnect and later emitted a misleading ERR_PING_TIMED_OUT.
589
+ const pingErrors = [];
590
+ socket.on('error', err => {
591
+ if (err && err.message === 'ERR_PING_TIMED_OUT') pingErrors.push(err);
592
+ });
593
+ socket._pinger = setTimeout(
594
+ () => device._socket.emit('error', new Error('ERR_PING_TIMED_OUT')),
595
+ 50
596
+ );
597
+
598
+ // The device closes the LAN connection on its own (TCP FIN -> 'end').
599
+ socket.emit('end');
600
+
601
+ expect(device.connected).toBe(false);
602
+ expect(socket._pinger).toBeNull(); // stale timer cleared
603
+ expect(socket.destroy).toHaveBeenCalledTimes(1); // socket torn down
604
+ expect(socket._errorReconnect).toBeTruthy(); // a reconnect is scheduled
605
+
606
+ // Advance well past where the leaked timer would have fired: it must not.
607
+ jest.advanceTimersByTime(1000);
608
+ expect(pingErrors).toHaveLength(0);
609
+
610
+ // The log reflects a clean disconnect, never a ping failure.
611
+ expect(device.log.info).toHaveBeenCalledWith('Disconnected from', device.context.name);
612
+ const logged = device.log.info.mock.calls.flat().join(' ');
613
+ expect(logged).not.toMatch(/ERR_PING_TIMED_OUT/);
614
+ });
615
+
616
+ test("the 'close' teardown clears any lingering heartbeat timer", () => {
617
+ const device = makeDevice('3.5');
618
+ const socket = establish(device);
619
+
620
+ socket._pinger = setTimeout(() => {}, 10000);
621
+ socket.emit('close');
622
+
623
+ expect(socket._pinger).toBeNull();
624
+ expect(device.connected).toBe(false);
625
+ });
626
+ });
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const TuyaCloudApi = require('../lib/TuyaCloudApi');
5
+
6
+ const EMPTY_SHA = crypto.createHash('sha256').update('').digest('hex');
7
+ const md5 = s => crypto.createHash('md5').update(s).digest('hex');
8
+ const hmacUpper = (str, key) => crypto.createHmac('sha256', key).update(str, 'utf8').digest('hex').toUpperCase();
9
+
10
+ const ACCESS_ID = 'aid123';
11
+ const ACCESS_KEY = 'secretKey456';
12
+
13
+ const makeApi = (extra = {}) => new TuyaCloudApi({accessId: ACCESS_ID, accessKey: ACCESS_KEY, ...extra});
14
+
15
+ describe('TuyaCloudApi — configuration', () => {
16
+ test('resolves region to a known endpoint', () => {
17
+ expect(makeApi({region: 'eu'}).endpoint).toBe('https://openapi.tuyaeu.com');
18
+ expect(makeApi({region: 'us'}).endpoint).toBe('https://openapi.tuyaus.com');
19
+ expect(makeApi({region: 'in'}).endpoint).toBe('https://openapi.tuyain.com');
20
+ });
21
+
22
+ test('unknown region falls back to US', () => {
23
+ expect(makeApi({region: 'nowhere'}).endpoint).toBe('https://openapi.tuyaus.com');
24
+ });
25
+
26
+ test('explicit endpoint wins and trailing slashes are trimmed', () => {
27
+ expect(makeApi({endpoint: 'https://example.com/'}).endpoint).toBe('https://example.com');
28
+ });
29
+
30
+ test('isConfigured reflects presence of credentials', () => {
31
+ expect(makeApi().isConfigured()).toBe(true);
32
+ expect(new TuyaCloudApi({}).isConfigured()).toBe(false);
33
+ });
34
+
35
+ test('keyFor is stable and distinguishes accounts', () => {
36
+ const a = TuyaCloudApi.keyFor({accessId: 'x', region: 'eu'});
37
+ const b = TuyaCloudApi.keyFor({accessId: 'x', region: 'eu'});
38
+ const c = TuyaCloudApi.keyFor({accessId: 'x', region: 'eu', username: 'u'});
39
+ expect(a).toBe(b);
40
+ expect(a).not.toBe(c);
41
+ });
42
+ });
43
+
44
+ describe('TuyaCloudApi — request signing', () => {
45
+ test('business signature folds in the access token and sets the header', () => {
46
+ const api = makeApi({region: 'eu'});
47
+ api._token = 'TOK';
48
+ const t = '1700000000000';
49
+ const nonce = 'nonce-1';
50
+ const h = api._signedHeaders({method: 'GET', urlPath: '/v1.0/devices/d/status', bodyStr: '', withToken: true, t, nonce});
51
+
52
+ const stringToSign = ['GET', EMPTY_SHA, '', '/v1.0/devices/d/status'].join('\n');
53
+ const expected = hmacUpper(ACCESS_ID + 'TOK' + t + nonce + stringToSign, ACCESS_KEY);
54
+
55
+ expect(h.sign).toBe(expected);
56
+ expect(h.access_token).toBe('TOK');
57
+ expect(h.client_id).toBe(ACCESS_ID);
58
+ expect(h.sign_method).toBe('HMAC-SHA256');
59
+ expect(h.t).toBe(t);
60
+ expect(h.nonce).toBe(nonce);
61
+ });
62
+
63
+ test('token signature omits the access token entirely', () => {
64
+ const api = makeApi();
65
+ api._token = 'SHOULD_NOT_APPEAR';
66
+ const t = '1700000000000';
67
+ const nonce = 'nonce-2';
68
+ const h = api._signedHeaders({method: 'GET', urlPath: '/v1.0/token?grant_type=1', bodyStr: '', withToken: false, t, nonce});
69
+
70
+ const stringToSign = ['GET', EMPTY_SHA, '', '/v1.0/token?grant_type=1'].join('\n');
71
+ const expected = hmacUpper(ACCESS_ID + t + nonce + stringToSign, ACCESS_KEY);
72
+
73
+ expect(h.sign).toBe(expected);
74
+ expect(h.access_token).toBeUndefined();
75
+ });
76
+
77
+ test('a request body changes the Content-SHA256 portion of the signature', () => {
78
+ const api = makeApi();
79
+ api._token = 'TOK';
80
+ const t = '1700000000000';
81
+ const nonce = 'n';
82
+ const body = JSON.stringify({commands: [{code: 'switch_1', value: true}]});
83
+ const h = api._signedHeaders({method: 'POST', urlPath: '/p', bodyStr: body, withToken: true, t, nonce});
84
+
85
+ const contentSha = crypto.createHash('sha256').update(body, 'utf8').digest('hex');
86
+ const stringToSign = ['POST', contentSha, '', '/p'].join('\n');
87
+ const expected = hmacUpper(ACCESS_ID + 'TOK' + t + nonce + stringToSign, ACCESS_KEY);
88
+ expect(h.sign).toBe(expected);
89
+ expect(contentSha).not.toBe(EMPTY_SHA);
90
+ });
91
+ });
92
+
93
+ describe('TuyaCloudApi — auth flow selection', () => {
94
+ test('custom project uses grant_type=1', async () => {
95
+ const api = makeApi();
96
+ const calls = [];
97
+ api._httpsRequest = async (method, path) => {
98
+ calls.push({method, path});
99
+ return {success: true, result: {access_token: 'T', uid: 'U', expire_time: 7200}};
100
+ };
101
+ const token = await api._ensureToken();
102
+ expect(token).toBe('T');
103
+ expect(api.uid).toBe('U');
104
+ expect(calls[0].path).toBe('/v1.0/token?grant_type=1');
105
+ expect(calls[0].method).toBe('GET');
106
+ });
107
+
108
+ test('smart-home project posts an MD5 password to authorized-login', async () => {
109
+ const api = makeApi({username: 'joe@example.com', password: 'pw', countryCode: '48', schema: 'tuyaSmart'});
110
+ let captured;
111
+ api._httpsRequest = async (method, path, headers, bodyStr) => {
112
+ captured = {method, path, bodyStr};
113
+ return {success: true, result: {access_token: 'T2', uid: 'U2', expire_time: 7200}};
114
+ };
115
+ const token = await api._ensureToken();
116
+ expect(token).toBe('T2');
117
+ expect(api.uid).toBe('U2');
118
+ expect(captured.path).toBe('/v1.0/iot-01/associated-users/actions/authorized-login');
119
+ const body = JSON.parse(captured.bodyStr);
120
+ expect(body.username).toBe('joe@example.com');
121
+ expect(body.password).toBe(md5('pw'));
122
+ expect(body.country_code).toBe('48');
123
+ expect(body.schema).toBe('tuyaSmart');
124
+ });
125
+ });
126
+
127
+ describe('TuyaCloudApi — endpoints', () => {
128
+ const ready = () => {
129
+ const api = makeApi();
130
+ api._token = 'TOK';
131
+ api._tokenExpiry = Date.now() + 1e6; // skip token fetch
132
+ return api;
133
+ };
134
+
135
+ test('getStatus returns the result array', async () => {
136
+ const api = ready();
137
+ api._httpsRequest = async () => ({success: true, result: [{code: 'switch_1', value: true}]});
138
+ await expect(api.getStatus('dev')).resolves.toEqual([{code: 'switch_1', value: true}]);
139
+ });
140
+
141
+ test('sendCommands posts the commands and reports success', async () => {
142
+ const api = ready();
143
+ let captured;
144
+ api._httpsRequest = async (method, path, headers, bodyStr) => {
145
+ captured = {method, path, body: JSON.parse(bodyStr)};
146
+ return {success: true, result: true};
147
+ };
148
+ const ok = await api.sendCommands('dev', [{code: 'switch_1', value: true}]);
149
+ expect(ok).toBe(true);
150
+ expect(captured.method).toBe('POST');
151
+ expect(captured.path).toBe('/v1.0/devices/dev/commands');
152
+ expect(captured.body).toEqual({commands: [{code: 'switch_1', value: true}]});
153
+ });
154
+
155
+ test('sendCommands short-circuits on an empty command list', async () => {
156
+ const api = ready();
157
+ api._httpsRequest = jest.fn();
158
+ await expect(api.sendCommands('dev', [])).resolves.toBe(true);
159
+ expect(api._httpsRequest).not.toHaveBeenCalled();
160
+ });
161
+
162
+ test('getMqttConfig posts the expected body and returns the broker config', async () => {
163
+ const api = ready();
164
+ api.uid = 'UID';
165
+ let captured;
166
+ api._httpsRequest = async (method, path, headers, bodyStr) => {
167
+ captured = {path, body: JSON.parse(bodyStr)};
168
+ return {success: true, result: {url: 'ssl://m1:8883', source_topic: {device: 't'}}};
169
+ };
170
+ const cfg = await api.getMqttConfig('link-1');
171
+ expect(captured.path).toBe('/v1.0/iot-03/open-hub/access-config');
172
+ expect(captured.body).toEqual({uid: 'UID', link_id: 'link-1', link_type: 'mqtt', topics: 'device', msg_encrypted_version: '2.0'});
173
+ expect(cfg.url).toBe('ssl://m1:8883');
174
+ });
175
+
176
+ test('an invalid-token error refreshes the token and retries once', async () => {
177
+ const api = ready();
178
+ let business = 0;
179
+ api._httpsRequest = async (method, path) => {
180
+ // The retry re-fetches a token; serve that separately.
181
+ if (path.startsWith('/v1.0/token')) return {success: true, result: {access_token: 'TOK2', expire_time: 7200}};
182
+ business++;
183
+ if (business === 1) return {success: false, code: 1010, msg: 'token invalid'};
184
+ return {success: true, result: 'ok'};
185
+ };
186
+ const res = await api.request('GET', '/x');
187
+ expect(business).toBe(2);
188
+ expect(res.result).toBe('ok');
189
+ });
190
+
191
+ test('a non-token error rejects', async () => {
192
+ const api = ready();
193
+ api._httpsRequest = async () => ({success: false, code: 28841002, msg: 'no permissions'});
194
+ await expect(api.request('GET', '/x')).rejects.toThrow(/no permissions/);
195
+ });
196
+ });