homebridge-tuya-plus 3.14.0-dev.3 → 3.14.0-dev.30
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 +120 -0
- package/CLAUDE.md +1 -0
- package/Changelog.md +21 -0
- package/Readme.MD +8 -24
- package/config.schema.json +122 -68
- package/index.js +498 -269
- package/lib/BaseAccessory.js +52 -15
- package/lib/ConvectorAccessory.js +1 -1
- package/lib/CustomMultiOutletAccessory.js +2 -1
- package/lib/IrrigationSystemAccessory.js +98 -95
- package/lib/MultiOutletAccessory.js +2 -1
- 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 +78 -24
- package/lib/SimpleHeaterAccessory.js +3 -3
- package/lib/SwitchAccessory.js +2 -1
- package/lib/TWLightAccessory.js +2 -2
- package/lib/TuyaAccessory.js +0 -1
- package/lib/TuyaCloudApi.js +320 -0
- package/lib/TuyaCloudDevice.js +327 -0
- package/lib/TuyaCloudMessaging.js +219 -0
- package/lib/TuyaDevice.js +266 -0
- package/lib/ValveAccessory.js +16 -12
- package/lib/VerticalBlindsWithTilt.js +5 -3
- package/lib/WledDimmerAccessory.js +46 -81
- package/package.json +5 -3
- package/scripts/cleanup-pr-tags.js +122 -0
- package/test/BaseAccessory.test.js +94 -15
- package/test/IrrigationSystemAccessory.test.js +134 -31
- 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 +63 -9
- package/test/TuyaCloudApi.test.js +221 -0
- package/test/TuyaCloudDevice.test.js +304 -0
- package/test/TuyaCloudMessaging.test.js +113 -0
- package/test/TuyaDevice.test.js +252 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +183 -0
- package/test/support/mocks.js +13 -0
- package/wiki/Supported-Device-Types.md +60 -28
- package/wiki/Tuya-Cloud-Setup.md +71 -0
|
@@ -125,8 +125,8 @@ describe('IrrigationSystemAccessory — category', () => {
|
|
|
125
125
|
});
|
|
126
126
|
|
|
127
127
|
describe('IrrigationSystemAccessory — service topology', () => {
|
|
128
|
-
test('builds an IrrigationSystem (primary) + 4 linked valves + battery
|
|
129
|
-
const { accessory } = makeHarness({ '1': false, '2': false, '3': false, '4': false, '46': 80
|
|
128
|
+
test('builds an IrrigationSystem (primary) + 4 linked valves + battery by default', () => {
|
|
129
|
+
const { accessory } = makeHarness({ '1': false, '2': false, '3': false, '4': false, '46': 80 });
|
|
130
130
|
|
|
131
131
|
const irr = irrigation(accessory);
|
|
132
132
|
expect(irr).toBeDefined();
|
|
@@ -138,9 +138,9 @@ describe('IrrigationSystemAccessory — service topology', () => {
|
|
|
138
138
|
// All four valves are linked to the irrigation system.
|
|
139
139
|
expect(irr.linked).toHaveLength(4);
|
|
140
140
|
|
|
141
|
-
// Battery
|
|
141
|
+
// Battery present; no rain/leak sensor is ever created.
|
|
142
142
|
expect(accessory.getService(Service.Battery)).toBeDefined();
|
|
143
|
-
expect(accessory.getService(Service.ContactSensor)).
|
|
143
|
+
expect(accessory.getService(Service.ContactSensor)).toBeUndefined();
|
|
144
144
|
expect(accessory.getService(Service.LeakSensor)).toBeUndefined();
|
|
145
145
|
});
|
|
146
146
|
|
|
@@ -154,6 +154,19 @@ describe('IrrigationSystemAccessory — service topology', () => {
|
|
|
154
154
|
});
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
test('adds a ServiceLabel (ARABIC_NUMERALS) so the multi-valve zones group under the system', () => {
|
|
158
|
+
const { accessory } = makeHarness({ '1': false, '2': false, '3': false, '4': false });
|
|
159
|
+
const label = accessory.getService(Service.ServiceLabel);
|
|
160
|
+
expect(label).toBeDefined();
|
|
161
|
+
expect(label.getCharacteristic(Characteristic.ServiceLabelNamespace).value)
|
|
162
|
+
.toBe(Characteristic.ServiceLabelNamespace.ARABIC_NUMERALS);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('a single-valve system omits the ServiceLabel (no collection to label)', () => {
|
|
166
|
+
const { accessory } = makeHarness({ '1': false }, { valveCount: 1 });
|
|
167
|
+
expect(accessory.getService(Service.ServiceLabel)).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
157
170
|
test('IrrigationSystem advertises the required characteristics', () => {
|
|
158
171
|
const { accessory } = makeHarness({ '1': false });
|
|
159
172
|
const irr = irrigation(accessory);
|
|
@@ -162,17 +175,23 @@ describe('IrrigationSystemAccessory — service topology', () => {
|
|
|
162
175
|
expect(irr.getCharacteristic(Characteristic.InUse).value).toBe(Characteristic.InUse.NOT_IN_USE);
|
|
163
176
|
});
|
|
164
177
|
|
|
165
|
-
test('noBattery
|
|
166
|
-
const { accessory } = makeHarness({ '1': false }, { noBattery: true
|
|
178
|
+
test('noBattery omits the battery service', () => {
|
|
179
|
+
const { accessory } = makeHarness({ '1': false }, { noBattery: true });
|
|
167
180
|
expect(accessory.getService(Service.Battery)).toBeUndefined();
|
|
168
|
-
expect(accessory.getService(Service.ContactSensor)).toBeUndefined();
|
|
169
|
-
expect(accessory.getService(Service.LeakSensor)).toBeUndefined();
|
|
170
181
|
});
|
|
171
182
|
|
|
172
|
-
test('
|
|
173
|
-
const { accessory } = makeHarness({ '
|
|
174
|
-
|
|
183
|
+
test('removes a stale rain sensor service left over from an older version', () => {
|
|
184
|
+
const { instance, accessory } = makeHarness({ '1': false });
|
|
185
|
+
// An accessory cached by an older plugin version may still carry the
|
|
186
|
+
// ContactSensor/LeakSensor; reconciliation must drop it so the sprinkler
|
|
187
|
+
// stays a clean, single-category tile.
|
|
188
|
+
accessory.addService(Service.ContactSensor, 'Old Rain');
|
|
189
|
+
accessory.addService(Service.LeakSensor, 'Old Leak');
|
|
190
|
+
|
|
191
|
+
instance._verifyCachedPlatformAccessory();
|
|
192
|
+
|
|
175
193
|
expect(accessory.getService(Service.ContactSensor)).toBeUndefined();
|
|
194
|
+
expect(accessory.getService(Service.LeakSensor)).toBeUndefined();
|
|
176
195
|
});
|
|
177
196
|
});
|
|
178
197
|
|
|
@@ -279,6 +298,38 @@ describe('IrrigationSystemAccessory — valve activation & timer', () => {
|
|
|
279
298
|
jest.advanceTimersByTime(600 * 1000);
|
|
280
299
|
expect(device.update.mock.calls.length).toBe(calls);
|
|
281
300
|
});
|
|
301
|
+
|
|
302
|
+
test('valve Active onGet reports the optimistic value, not the lagging device state (no flicker)', () => {
|
|
303
|
+
// Cloud devices don't advance device.state until the realtime stream
|
|
304
|
+
// echoes the write back. Emulate that (update is a no-op on state) and
|
|
305
|
+
// confirm a freshly-pressed valve keeps reading ON instead of briefly
|
|
306
|
+
// reverting to the pre-press value.
|
|
307
|
+
const { accessory, device } = makeHarness({ '1': false }, { defaultDuration: 0 });
|
|
308
|
+
device.update.mockImplementation(() => true);
|
|
309
|
+
const v = valve(accessory, 1);
|
|
310
|
+
|
|
311
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
312
|
+
jest.advanceTimersByTime(500);
|
|
313
|
+
|
|
314
|
+
expect(device.state['1']).toBe(false); // not echoed yet
|
|
315
|
+
expect(v.getCharacteristic(Characteristic.Active).triggerGet()).toBe(Characteristic.Active.ACTIVE);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('system Active onGet reports the cached aggregate, not the lagging device state', () => {
|
|
319
|
+
const { accessory, device } = makeHarness({ '1': false, '2': false }, { defaultDuration: 0 });
|
|
320
|
+
device.update.mockImplementation(() => true);
|
|
321
|
+
valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
322
|
+
jest.advanceTimersByTime(500);
|
|
323
|
+
|
|
324
|
+
expect(irrigation(accessory).getCharacteristic(Characteristic.Active).triggerGet())
|
|
325
|
+
.toBe(Characteristic.Active.ACTIVE);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('Active onGet still throws while disconnected (HomeKit shows "No Response")', () => {
|
|
329
|
+
const { accessory, device } = makeHarness({ '1': false });
|
|
330
|
+
device.connected = false;
|
|
331
|
+
expect(() => valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerGet()).toThrow();
|
|
332
|
+
});
|
|
282
333
|
});
|
|
283
334
|
|
|
284
335
|
describe('IrrigationSystemAccessory — write batching (one Tuya command)', () => {
|
|
@@ -361,16 +412,13 @@ describe('IrrigationSystemAccessory — device-side change reflection', () => {
|
|
|
361
412
|
expect(v.getCharacteristic(Characteristic.RemainingDuration).value).toBe(600);
|
|
362
413
|
});
|
|
363
414
|
|
|
364
|
-
test('battery
|
|
365
|
-
const { accessory, device } = makeHarness({ '46': 80
|
|
366
|
-
device.emitChange({ '46': 10
|
|
415
|
+
test('battery telemetry updates the corresponding characteristics', () => {
|
|
416
|
+
const { accessory, device } = makeHarness({ '46': 80 });
|
|
417
|
+
device.emitChange({ '46': 10 });
|
|
367
418
|
|
|
368
419
|
const battery = accessory.getService(Service.Battery);
|
|
369
420
|
expect(battery.getCharacteristic(Characteristic.BatteryLevel).value).toBe(10);
|
|
370
421
|
expect(battery.getCharacteristic(Characteristic.StatusLowBattery).value).toBe(Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW);
|
|
371
|
-
|
|
372
|
-
const contact = accessory.getService(Service.ContactSensor);
|
|
373
|
-
expect(contact.getCharacteristic(Characteristic.ContactSensorState).value).toBe(Characteristic.ContactSensorState.CONTACT_NOT_DETECTED);
|
|
374
422
|
});
|
|
375
423
|
});
|
|
376
424
|
|
|
@@ -425,22 +473,77 @@ describe('IrrigationSystemAccessory — charging state', () => {
|
|
|
425
473
|
});
|
|
426
474
|
});
|
|
427
475
|
|
|
428
|
-
describe('IrrigationSystemAccessory —
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
476
|
+
describe('IrrigationSystemAccessory — data-points addressed by code', () => {
|
|
477
|
+
// The accessory is transport-agnostic: a data-point may be a numeric id or a
|
|
478
|
+
// Tuya "code". The LAN+cloud TuyaDevice keeps state dual-keyed and translates
|
|
479
|
+
// writes, so irrigation has no cloud-specific logic — it just uses the dp
|
|
480
|
+
// strings it's given. A device reached over the cloud commonly addresses its
|
|
481
|
+
// zones as switch_1.. and battery as battery_percentage.
|
|
482
|
+
beforeEach(() => jest.useFakeTimers());
|
|
483
|
+
afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
|
|
484
|
+
|
|
485
|
+
test('an explicit valve list may use string codes', () => {
|
|
486
|
+
const { accessory, device } = makeHarness(
|
|
487
|
+
{ zone_a: false, zone_b: false, battery_percentage: 50 },
|
|
488
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], dpBattery: 'battery_percentage' }
|
|
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
|
+
|
|
496
|
+
test('battery can be read from a code data-point', () => {
|
|
497
|
+
const { accessory } = makeHarness(
|
|
498
|
+
{ zone_a: false, battery_percentage: 99 },
|
|
499
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }], dpBattery: 'battery_percentage' }
|
|
500
|
+
);
|
|
501
|
+
expect(accessory.getService(Service.Battery).getCharacteristic(Characteristic.BatteryLevel).value).toBe(99);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('a device-side change keyed by code is reflected in HomeKit', () => {
|
|
505
|
+
const { accessory, device } = makeHarness(
|
|
506
|
+
{ zone_a: false, zone_b: false },
|
|
507
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], noBattery: true }
|
|
508
|
+
);
|
|
509
|
+
device.emitChange({ zone_b: true });
|
|
510
|
+
expect(valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).value).toBe(Characteristic.Active.ACTIVE);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('turning a zone off still writes false when the device never echoed the "on"', () => {
|
|
514
|
+
// A device (notably over the cloud) may not optimistically advance `state`;
|
|
515
|
+
// it only moves when the device confirms. A follow-up "off" must STILL be
|
|
516
|
+
// sent — otherwise the valve stays open while HomeKit shows it closed (the
|
|
517
|
+
// exact "can turn on but not off" report).
|
|
518
|
+
const { accessory, device } = makeHarness(
|
|
519
|
+
{ zone_a: false },
|
|
520
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }], noBattery: true, defaultDuration: 0 }
|
|
521
|
+
);
|
|
522
|
+
device.update.mockImplementation(() => true); // writes never touch state
|
|
523
|
+
const v = valve(accessory, 'zone_a');
|
|
524
|
+
|
|
525
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
526
|
+
jest.advanceTimersByTime(500);
|
|
527
|
+
expect(device.update).toHaveBeenLastCalledWith({ zone_a: true });
|
|
434
528
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
expect(
|
|
438
|
-
expect(instance._contactState('no_rain')).toBe(Characteristic.ContactSensorState.CONTACT_NOT_DETECTED);
|
|
529
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(0);
|
|
530
|
+
jest.advanceTimersByTime(500);
|
|
531
|
+
expect(device.update).toHaveBeenLastCalledWith({ zone_a: false });
|
|
439
532
|
});
|
|
440
533
|
|
|
441
|
-
test('
|
|
442
|
-
const {
|
|
443
|
-
|
|
444
|
-
|
|
534
|
+
test('master OFF closes zones the device has not echoed as open', () => {
|
|
535
|
+
const { accessory, device } = makeHarness(
|
|
536
|
+
{ zone_a: false, zone_b: false },
|
|
537
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], noBattery: true, defaultDuration: 0 }
|
|
538
|
+
);
|
|
539
|
+
device.update.mockImplementation(() => true); // writes never touch state
|
|
540
|
+
|
|
541
|
+
valve(accessory, 'zone_a').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
542
|
+
valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
543
|
+
jest.advanceTimersByTime(500);
|
|
544
|
+
|
|
545
|
+
irrigation(accessory).getCharacteristic(Characteristic.Active).triggerSet(0);
|
|
546
|
+
jest.advanceTimersByTime(500);
|
|
547
|
+
expect(device.update).toHaveBeenLastCalledWith({ zone_a: false, zone_b: false });
|
|
445
548
|
});
|
|
446
549
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const MultiOutletAccessory = require('../lib/MultiOutletAccessory');
|
|
4
|
-
const { makeInstance } = require('./support/mocks');
|
|
4
|
+
const { makeInstance, HAP } = require('./support/mocks');
|
|
5
5
|
|
|
6
6
|
function makeOutlet(state = {}) {
|
|
7
7
|
return makeInstance(MultiOutletAccessory, state);
|
|
@@ -14,10 +14,13 @@ describe('MultiOutletAccessory.getPower', () => {
|
|
|
14
14
|
expect(instance.getPower('2')).toBe(false);
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
test('throws when device is disconnected', () => {
|
|
17
|
+
test('throws a comm-failure HapStatusError when device is disconnected', () => {
|
|
18
18
|
const { instance, device } = makeOutlet();
|
|
19
19
|
device.connected = false;
|
|
20
|
-
|
|
20
|
+
let err;
|
|
21
|
+
try { instance.getPower('1'); } catch (e) { err = e; }
|
|
22
|
+
expect(err).toBeInstanceOf(HAP.HapStatusError);
|
|
23
|
+
expect(err.hapStatus).toBe(HAP.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
21
24
|
});
|
|
22
25
|
});
|
|
23
26
|
|
|
@@ -83,4 +86,16 @@ describe('MultiOutletAccessory.setPower — debounce batching', () => {
|
|
|
83
86
|
await p;
|
|
84
87
|
expect(device.update).not.toHaveBeenCalled();
|
|
85
88
|
});
|
|
89
|
+
|
|
90
|
+
test('surfaces a comm-failure error (No Response) when disconnected', () => {
|
|
91
|
+
// A tap on an unreachable outlet must fail in HomeKit instead of being
|
|
92
|
+
// silently dropped after the debounce.
|
|
93
|
+
const { instance, device } = makeOutlet({ '1': false });
|
|
94
|
+
device.connected = false;
|
|
95
|
+
let err;
|
|
96
|
+
try { instance.setPower('1', true); } catch (e) { err = e; }
|
|
97
|
+
expect(err).toBeInstanceOf(HAP.HapStatusError);
|
|
98
|
+
expect(err.hapStatus).toBe(HAP.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
99
|
+
expect(device.update).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
86
101
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const RGBTWLightAccessory = require('../lib/RGBTWLightAccessory');
|
|
4
|
-
const { makeInstance, makeMockCharacteristic } = require('./support/mocks');
|
|
4
|
+
const { makeInstance, makeMockCharacteristic, HAP } = require('./support/mocks');
|
|
5
5
|
|
|
6
6
|
function makeLight(state = {}, context = {}) {
|
|
7
7
|
const result = makeInstance(RGBTWLightAccessory, state, { colorFunction: 'HEXHSB', ...context });
|
|
@@ -88,12 +88,12 @@ describe('RGBTWLightAccessory.getColorTemperature', () => {
|
|
|
88
88
|
// setColorTemperature
|
|
89
89
|
// ---------------------------------------------------------------------------
|
|
90
90
|
describe('RGBTWLightAccessory.setColorTemperature', () => {
|
|
91
|
-
test('
|
|
91
|
+
test('rejects (No Response) and writes nothing when device is not connected', async () => {
|
|
92
92
|
const { instance, device } = makeLight({ '2': 'white', '4': 255 });
|
|
93
93
|
instance.characteristicHue = makeMockCharacteristic(0);
|
|
94
94
|
instance.characteristicSaturation = makeMockCharacteristic(0);
|
|
95
95
|
device.connected = false;
|
|
96
|
-
expect(
|
|
96
|
+
await expect(instance.setColorTemperature(200)).rejects.toBeInstanceOf(HAP.HapStatusError);
|
|
97
97
|
expect(device.update).not.toHaveBeenCalled();
|
|
98
98
|
});
|
|
99
99
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const SimpleFanLightAccessory = require('../lib/SimpleFanLightAccessory');
|
|
4
|
-
const { makeInstance } = require('./support/mocks');
|
|
4
|
+
const { makeInstance, HAP } = require('./support/mocks');
|
|
5
5
|
|
|
6
6
|
// Mirror the state that _registerCharacteristics would establish (the mock
|
|
7
7
|
// service shares a single characteristic, so wiring the fields manually keeps
|
|
@@ -290,10 +290,10 @@ describe('SimpleFanLightAccessory.getColorTemp / setColorTemp', () => {
|
|
|
290
290
|
expect(device.update).toHaveBeenCalledWith({ '23': 1000 });
|
|
291
291
|
});
|
|
292
292
|
|
|
293
|
-
test('setColorTemp
|
|
293
|
+
test('setColorTemp rejects (No Response) and writes nothing when disconnected', async () => {
|
|
294
294
|
const { instance, device } = makeFanLight({ '23': 500 });
|
|
295
295
|
device.connected = false;
|
|
296
|
-
expect(
|
|
296
|
+
await expect(instance.setColorTemp(370)).rejects.toBeInstanceOf(HAP.HapStatusError);
|
|
297
297
|
expect(device.update).not.toHaveBeenCalled();
|
|
298
298
|
});
|
|
299
299
|
});
|
|
@@ -52,6 +52,8 @@ function makeSimpleGarage(initialContext = {}) {
|
|
|
52
52
|
instance.stopBeforeCloseMs = 1500;
|
|
53
53
|
instance.partialStopTimer = null;
|
|
54
54
|
instance.pendingCloseTimer = null;
|
|
55
|
+
instance.partialPending = false;
|
|
56
|
+
instance.partialGeneration = 0;
|
|
55
57
|
instance.currentDoorState = CDS.CLOSED;
|
|
56
58
|
instance.characteristicCurrentDoorState = {
|
|
57
59
|
value: CDS.CLOSED,
|
|
@@ -373,11 +375,13 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
|
|
|
373
375
|
// Disconnect handling
|
|
374
376
|
// ---------------------------------------------------------------------------
|
|
375
377
|
describe('SimpleGarageDoorAccessory — disconnected', () => {
|
|
376
|
-
test('
|
|
378
|
+
test('Surfaces a No Response error and writes nothing when disconnected', () => {
|
|
377
379
|
const { instance, device } = makeSimpleGarage();
|
|
378
380
|
device.connected = false;
|
|
379
381
|
|
|
380
|
-
|
|
382
|
+
// A tap on an unreachable gate must fail in HomeKit instead of being
|
|
383
|
+
// silently dropped while HomeKit believes the open/close succeeded.
|
|
384
|
+
expect(() => instance.setTargetDoorState(TDS.OPEN)).toThrow(HAP.HapStatusError);
|
|
381
385
|
|
|
382
386
|
expect(device.update).not.toHaveBeenCalled();
|
|
383
387
|
});
|
|
@@ -407,35 +411,71 @@ describe('SimpleGarageDoorAccessory._handlePartialOpen', () => {
|
|
|
407
411
|
beforeEach(() => jest.useFakeTimers());
|
|
408
412
|
afterEach(() => jest.useRealTimers());
|
|
409
413
|
|
|
410
|
-
test('
|
|
414
|
+
test('Stop is anchored to the gate reporting it is moving, not the button press', () => {
|
|
411
415
|
const { instance, device } = makeSimpleGarage();
|
|
412
416
|
instance.partialOpenMs = 2000;
|
|
417
|
+
// Gate starts closed, so the open command needs time to reach it and
|
|
418
|
+
// get the gate moving before a stop can do anything.
|
|
419
|
+
device.state['105'] = STATE_CLOSING_OR_CLOSED;
|
|
413
420
|
|
|
414
421
|
instance._handlePartialOpen();
|
|
415
422
|
|
|
416
|
-
// Open goes out immediately.
|
|
423
|
+
// Open goes out immediately, but the auto-stop is NOT armed yet.
|
|
417
424
|
expect(device.update).toHaveBeenCalledTimes(1);
|
|
418
425
|
expect(device.update).toHaveBeenNthCalledWith(1, { '101': true });
|
|
419
426
|
expect(instance.characteristicTargetDoorState.value).toBe(TDS.OPEN);
|
|
427
|
+
expect(instance.partialStopTimer).toBeNull();
|
|
428
|
+
expect(instance.partialPending).toBe(true);
|
|
429
|
+
|
|
430
|
+
// No amount of waiting fires a stop until the gate reports it's moving —
|
|
431
|
+
// this is what stops a too-early stop from being a dropped no-op.
|
|
432
|
+
jest.advanceTimersByTime(10_000);
|
|
433
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
434
|
+
|
|
435
|
+
// Controller reports it has started opening: now the countdown arms.
|
|
436
|
+
emitState(device, STATE_OPENING_OR_OPEN);
|
|
437
|
+
expect(instance.partialStopTimer).not.toBeNull();
|
|
438
|
+
expect(instance.partialPending).toBe(false);
|
|
420
439
|
|
|
421
|
-
// Nothing happens until the timer elapses.
|
|
422
440
|
jest.advanceTimersByTime(2000 - 1);
|
|
423
441
|
expect(device.update).toHaveBeenCalledTimes(1);
|
|
424
442
|
|
|
425
|
-
//
|
|
443
|
+
// The stop fires, and is re-sent a couple of times to survive a dropped
|
|
444
|
+
// write (the controller occasionally drops a lone command).
|
|
426
445
|
jest.advanceTimersByTime(1);
|
|
427
446
|
expect(device.update).toHaveBeenCalledTimes(2);
|
|
428
447
|
expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
|
|
448
|
+
jest.advanceTimersByTime(300);
|
|
449
|
+
expect(device.update).toHaveBeenNthCalledWith(3, { '103': true });
|
|
450
|
+
jest.advanceTimersByTime(300);
|
|
451
|
+
expect(device.update).toHaveBeenNthCalledWith(4, { '103': true });
|
|
429
452
|
|
|
430
|
-
// And nothing more after
|
|
453
|
+
// And nothing more after the re-sends.
|
|
431
454
|
jest.advanceTimersByTime(10_000);
|
|
432
|
-
expect(device.update).toHaveBeenCalledTimes(
|
|
455
|
+
expect(device.update).toHaveBeenCalledTimes(4);
|
|
433
456
|
expect(instance.partialStopTimer).toBeNull();
|
|
434
457
|
});
|
|
435
458
|
|
|
436
|
-
test('
|
|
459
|
+
test('Arms straight away when the gate already reports open/opening', () => {
|
|
460
|
+
const { instance, device } = makeSimpleGarage();
|
|
461
|
+
instance.partialOpenMs = 2000;
|
|
462
|
+
device.state['105'] = STATE_OPENING_OR_OPEN;
|
|
463
|
+
|
|
464
|
+
instance._handlePartialOpen();
|
|
465
|
+
|
|
466
|
+
// Open fired and the countdown armed without waiting for a fresh report.
|
|
467
|
+
expect(device.update).toHaveBeenNthCalledWith(1, { '101': true });
|
|
468
|
+
expect(instance.partialStopTimer).not.toBeNull();
|
|
469
|
+
expect(instance.partialPending).toBe(false);
|
|
470
|
+
|
|
471
|
+
jest.advanceTimersByTime(2000);
|
|
472
|
+
expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test('Re-entrant press while in progress is ignored (idempotent)', () => {
|
|
437
476
|
const { instance, device } = makeSimpleGarage();
|
|
438
477
|
instance.partialOpenMs = 2000;
|
|
478
|
+
device.state['105'] = STATE_OPENING_OR_OPEN; // arms immediately
|
|
439
479
|
|
|
440
480
|
instance._handlePartialOpen();
|
|
441
481
|
expect(device.update).toHaveBeenCalledTimes(1);
|
|
@@ -452,6 +492,20 @@ describe('SimpleGarageDoorAccessory._handlePartialOpen', () => {
|
|
|
452
492
|
expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
|
|
453
493
|
});
|
|
454
494
|
|
|
495
|
+
test('Re-entrant press while waiting for movement is ignored', () => {
|
|
496
|
+
const { instance, device } = makeSimpleGarage();
|
|
497
|
+
instance.partialOpenMs = 2000;
|
|
498
|
+
device.state['105'] = STATE_CLOSING_OR_CLOSED;
|
|
499
|
+
|
|
500
|
+
instance._handlePartialOpen();
|
|
501
|
+
expect(device.update).toHaveBeenCalledTimes(1); // open
|
|
502
|
+
expect(instance.partialPending).toBe(true);
|
|
503
|
+
|
|
504
|
+
// A second press before the gate reports moving must not fire another open.
|
|
505
|
+
instance._handlePartialOpen();
|
|
506
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
507
|
+
});
|
|
508
|
+
|
|
455
509
|
test('A direct close cancels the partial auto-stop and runs stop-before-close', () => {
|
|
456
510
|
const { instance, device } = makeSimpleGarage();
|
|
457
511
|
instance.partialOpenMs = 2000;
|
|
@@ -0,0 +1,221 @@
|
|
|
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('getShadowProperties returns the properties array (code + dp_id)', async () => {
|
|
142
|
+
const api = ready();
|
|
143
|
+
let path;
|
|
144
|
+
api._httpsRequest = async (method, p) => {
|
|
145
|
+
path = p;
|
|
146
|
+
return {success: true, result: {properties: [{code: 'switch_led', dp_id: 20, value: true}]}};
|
|
147
|
+
};
|
|
148
|
+
await expect(api.getShadowProperties('dev')).resolves.toEqual([{code: 'switch_led', dp_id: 20, value: true}]);
|
|
149
|
+
expect(path).toBe('/v2.0/cloud/thing/dev/shadow/properties');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('getShadowProperties returns null (never throws) when the shadow API is unavailable', async () => {
|
|
153
|
+
const api = ready();
|
|
154
|
+
api._httpsRequest = async () => ({success: false, code: 1106, msg: 'permission deny'});
|
|
155
|
+
await expect(api.getShadowProperties('dev')).resolves.toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('getDeviceInfo returns the device record (with online status)', async () => {
|
|
159
|
+
const api = ready();
|
|
160
|
+
let path;
|
|
161
|
+
api._httpsRequest = async (method, p) => { path = p; return {success: true, result: {id: 'dev', online: false, name: 'X'}}; };
|
|
162
|
+
await expect(api.getDeviceInfo('dev')).resolves.toEqual({id: 'dev', online: false, name: 'X'});
|
|
163
|
+
expect(path).toBe('/v1.0/devices/dev');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('sendCommands posts the commands and reports success', async () => {
|
|
167
|
+
const api = ready();
|
|
168
|
+
let captured;
|
|
169
|
+
api._httpsRequest = async (method, path, headers, bodyStr) => {
|
|
170
|
+
captured = {method, path, body: JSON.parse(bodyStr)};
|
|
171
|
+
return {success: true, result: true};
|
|
172
|
+
};
|
|
173
|
+
const ok = await api.sendCommands('dev', [{code: 'switch_1', value: true}]);
|
|
174
|
+
expect(ok).toBe(true);
|
|
175
|
+
expect(captured.method).toBe('POST');
|
|
176
|
+
expect(captured.path).toBe('/v1.0/devices/dev/commands');
|
|
177
|
+
expect(captured.body).toEqual({commands: [{code: 'switch_1', value: true}]});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('sendCommands short-circuits on an empty command list', async () => {
|
|
181
|
+
const api = ready();
|
|
182
|
+
api._httpsRequest = jest.fn();
|
|
183
|
+
await expect(api.sendCommands('dev', [])).resolves.toBe(true);
|
|
184
|
+
expect(api._httpsRequest).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('getMqttConfig posts the expected body and returns the broker config', async () => {
|
|
188
|
+
const api = ready();
|
|
189
|
+
api.uid = 'UID';
|
|
190
|
+
let captured;
|
|
191
|
+
api._httpsRequest = async (method, path, headers, bodyStr) => {
|
|
192
|
+
captured = {path, body: JSON.parse(bodyStr)};
|
|
193
|
+
return {success: true, result: {url: 'ssl://m1:8883', source_topic: {device: 't'}}};
|
|
194
|
+
};
|
|
195
|
+
const cfg = await api.getMqttConfig('link-1');
|
|
196
|
+
expect(captured.path).toBe('/v1.0/iot-03/open-hub/access-config');
|
|
197
|
+
expect(captured.body).toEqual({uid: 'UID', link_id: 'link-1', link_type: 'mqtt', topics: 'device', msg_encrypted_version: '2.0'});
|
|
198
|
+
expect(cfg.url).toBe('ssl://m1:8883');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('an invalid-token error refreshes the token and retries once', async () => {
|
|
202
|
+
const api = ready();
|
|
203
|
+
let business = 0;
|
|
204
|
+
api._httpsRequest = async (method, path) => {
|
|
205
|
+
// The retry re-fetches a token; serve that separately.
|
|
206
|
+
if (path.startsWith('/v1.0/token')) return {success: true, result: {access_token: 'TOK2', expire_time: 7200}};
|
|
207
|
+
business++;
|
|
208
|
+
if (business === 1) return {success: false, code: 1010, msg: 'token invalid'};
|
|
209
|
+
return {success: true, result: 'ok'};
|
|
210
|
+
};
|
|
211
|
+
const res = await api.request('GET', '/x');
|
|
212
|
+
expect(business).toBe(2);
|
|
213
|
+
expect(res.result).toBe('ok');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('a non-token error rejects', async () => {
|
|
217
|
+
const api = ready();
|
|
218
|
+
api._httpsRequest = async () => ({success: false, code: 28841002, msg: 'no permissions'});
|
|
219
|
+
await expect(api.request('GET', '/x')).rejects.toThrow(/no permissions/);
|
|
220
|
+
});
|
|
221
|
+
});
|