homebridge-tuya-plus 3.14.0-dev.3 → 3.14.0-dev.5
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/Changelog.md +8 -0
- package/Readme.MD +8 -0
- package/config.schema.json +82 -13
- package/index.js +92 -3
- package/lib/IrrigationSystemAccessory.js +31 -7
- package/lib/TuyaCloudApi.js +290 -0
- package/lib/TuyaCloudDevice.js +188 -0
- package/lib/TuyaCloudMessaging.js +232 -0
- package/package.json +4 -1
- package/test/IrrigationSystemAccessory.test.js +49 -0
- package/test/TuyaCloudApi.test.js +196 -0
- package/test/TuyaCloudDevice.test.js +105 -0
- package/test/TuyaCloudMessaging.test.js +94 -0
- package/wiki/Supported-Device-Types.md +14 -0
- package/wiki/Tuya-Cloud-Setup.md +160 -0
|
@@ -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-dev.
|
|
3
|
+
"version": "3.14.0-dev.5",
|
|
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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require('events');
|
|
4
|
+
const TuyaCloudDevice = require('../lib/TuyaCloudDevice');
|
|
5
|
+
|
|
6
|
+
const log = {info: () => {}, warn: () => {}, error: () => {}, debug: () => {}};
|
|
7
|
+
|
|
8
|
+
function makeApi(status = [{code: 'switch_1', value: false}, {code: 'battery_percentage', value: 99}]) {
|
|
9
|
+
return {
|
|
10
|
+
isConfigured: () => true,
|
|
11
|
+
getStatus: jest.fn().mockResolvedValue(status),
|
|
12
|
+
sendCommands: jest.fn().mockResolvedValue(true)
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeDevice(api, messaging = null, extra = {}) {
|
|
17
|
+
return new TuyaCloudDevice({
|
|
18
|
+
cloudApi: api, messaging, log,
|
|
19
|
+
id: 'dev1', name: 'Sprinklers', type: 'irrigationsystem', cloud: true,
|
|
20
|
+
connect: false, ...extra
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('TuyaCloudDevice', () => {
|
|
25
|
+
test('connect reads status and emits connect then change (state keyed by code)', async () => {
|
|
26
|
+
const api = makeApi();
|
|
27
|
+
const dev = makeDevice(api);
|
|
28
|
+
|
|
29
|
+
const events = [];
|
|
30
|
+
dev.on('connect', () => events.push('connect'));
|
|
31
|
+
dev.on('change', (changes, state) => events.push({changes, state}));
|
|
32
|
+
|
|
33
|
+
await dev._connect();
|
|
34
|
+
|
|
35
|
+
expect(dev.connected).toBe(true);
|
|
36
|
+
expect(dev.state).toEqual({switch_1: false, battery_percentage: 99});
|
|
37
|
+
expect(events[0]).toBe('connect');
|
|
38
|
+
expect(events[1].state).toEqual({switch_1: false, battery_percentage: 99});
|
|
39
|
+
expect(api.getStatus).toHaveBeenCalledWith('dev1');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('update sends code/value commands and does NOT mutate local state', async () => {
|
|
43
|
+
const api = makeApi();
|
|
44
|
+
const dev = makeDevice(api);
|
|
45
|
+
await dev._connect();
|
|
46
|
+
|
|
47
|
+
const before = {...dev.state};
|
|
48
|
+
dev.update({switch_1: true});
|
|
49
|
+
|
|
50
|
+
expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'switch_1', value: true}]);
|
|
51
|
+
expect(dev.state).toEqual(before); // optimism lives in the accessory, not here
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('update is a no-op (no throw) before connect', () => {
|
|
55
|
+
const api = makeApi();
|
|
56
|
+
const dev = makeDevice(api);
|
|
57
|
+
expect(dev.connected).toBe(false);
|
|
58
|
+
expect(() => dev.update({switch_1: true})).not.toThrow();
|
|
59
|
+
expect(api.sendCommands).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('applyStatus emits only the changed data-points', async () => {
|
|
63
|
+
const api = makeApi();
|
|
64
|
+
const dev = makeDevice(api);
|
|
65
|
+
await dev._connect();
|
|
66
|
+
|
|
67
|
+
const changes = [];
|
|
68
|
+
dev.on('change', c => changes.push(c));
|
|
69
|
+
|
|
70
|
+
dev._applyStatus([{code: 'switch_1', value: true}, {code: 'battery_percentage', value: 99}]);
|
|
71
|
+
expect(changes).toHaveLength(1);
|
|
72
|
+
expect(changes[0]).toEqual({switch_1: true}); // battery unchanged → not emitted
|
|
73
|
+
expect(dev.state.switch_1).toBe(true);
|
|
74
|
+
|
|
75
|
+
changes.length = 0;
|
|
76
|
+
dev._applyStatus([{code: 'switch_1', value: true}]); // nothing changed
|
|
77
|
+
expect(changes).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('subscribes to realtime and re-reads state when the stream (re)connects', async () => {
|
|
81
|
+
const api = makeApi();
|
|
82
|
+
const messaging = Object.assign(new EventEmitter(), {
|
|
83
|
+
connected: false,
|
|
84
|
+
subscribeDevice: jest.fn()
|
|
85
|
+
});
|
|
86
|
+
const dev = makeDevice(api, messaging);
|
|
87
|
+
await dev._connect();
|
|
88
|
+
|
|
89
|
+
expect(messaging.subscribeDevice).toHaveBeenCalledWith('dev1', expect.any(Function));
|
|
90
|
+
|
|
91
|
+
// A realtime message routed back to the device updates its state.
|
|
92
|
+
const handler = messaging.subscribeDevice.mock.calls[0][1];
|
|
93
|
+
const changes = [];
|
|
94
|
+
dev.on('change', c => changes.push(c));
|
|
95
|
+
handler([{code: 'switch_2', value: true}]);
|
|
96
|
+
expect(dev.state.switch_2).toBe(true);
|
|
97
|
+
expect(changes[0]).toEqual({switch_2: true});
|
|
98
|
+
|
|
99
|
+
// On 'online' the device re-reads the full state (catch-up).
|
|
100
|
+
api.getStatus.mockClear();
|
|
101
|
+
messaging.emit('online');
|
|
102
|
+
await new Promise(r => setImmediate(r));
|
|
103
|
+
expect(api.getStatus).toHaveBeenCalledWith('dev1');
|
|
104
|
+
});
|
|
105
|
+
});
|