homebridge-tuya-plus 3.14.0-dev.4 → 3.14.0-dev.41
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 +162 -0
- package/CLAUDE.md +1 -0
- package/Changelog.md +16 -3
- package/Readme.MD +8 -32
- package/config.schema.json +63 -78
- package/index.js +599 -358
- package/lib/AirPurifierAccessory.js +1 -1
- package/lib/BaseAccessory.js +54 -17
- package/lib/ConvectorAccessory.js +1 -1
- package/lib/CustomMultiOutletAccessory.js +2 -1
- package/lib/DoorbellAccessory.js +9 -16
- package/lib/GarageDoorAccessory.js +1 -1
- package/lib/IrrigationSystemAccessory.js +93 -114
- package/lib/MultiOutletAccessory.js +3 -2
- 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 +4 -4
- package/lib/SimpleLightAccessory.js +1 -1
- package/lib/SwitchAccessory.js +3 -2
- package/lib/TWLightAccessory.js +2 -2
- package/lib/TuyaAccessory.js +75 -42
- package/lib/TuyaCloudApi.js +115 -7
- package/lib/TuyaCloudDevice.js +351 -28
- package/lib/TuyaCloudMessaging.js +28 -41
- package/lib/TuyaDevice.js +269 -0
- package/lib/TuyaDiscovery.js +25 -9
- package/lib/ValveAccessory.js +16 -12
- package/lib/VerticalBlindsWithTilt.js +27 -25
- package/lib/WledDimmerAccessory.js +46 -81
- package/package.json +5 -6
- package/scripts/cleanup-pr-tags.js +122 -0
- package/test/BaseAccessory.test.js +94 -15
- package/test/IrrigationSystemAccessory.test.js +122 -68
- 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/TuyaAccessory.protocol.test.js +76 -3
- package/test/TuyaCloudApi.test.js +110 -1
- package/test/TuyaCloudDevice.test.js +438 -2
- package/test/TuyaCloudMessaging.test.js +24 -5
- package/test/TuyaDevice.test.js +266 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +271 -0
- package/test/support/mocks.js +13 -0
- package/wiki/Supported-Device-Types.md +48 -30
- package/wiki/Tuya-Cloud-Setup.md +31 -108
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const BaseAccessory = require('../lib/BaseAccessory');
|
|
4
|
-
const { makeInstance } = require('./support/mocks');
|
|
4
|
+
const { makeInstance, HAP } = require('./support/mocks');
|
|
5
5
|
|
|
6
6
|
// Minimal concrete subclass — BaseAccessory itself doesn't define _registerCharacteristics
|
|
7
7
|
class TestAccessory extends BaseAccessory {
|
|
@@ -158,10 +158,13 @@ describe('getStateAsync', () => {
|
|
|
158
158
|
expect(instance.getStateAsync(['1', '2'])).toEqual({ '1': true, '2': 50 });
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
-
test('throws when device is not connected', () => {
|
|
161
|
+
test('throws a comm-failure HapStatusError when device is not connected', () => {
|
|
162
162
|
const { instance, device } = make();
|
|
163
163
|
device.connected = false;
|
|
164
|
-
|
|
164
|
+
let err;
|
|
165
|
+
try { instance.getStateAsync('1'); } catch (e) { err = e; }
|
|
166
|
+
expect(err).toBeInstanceOf(HAP.HapStatusError);
|
|
167
|
+
expect(err.hapStatus).toBe(HAP.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
165
168
|
});
|
|
166
169
|
});
|
|
167
170
|
|
|
@@ -178,10 +181,10 @@ describe('setStateAsync', () => {
|
|
|
178
181
|
expect(device.update).not.toHaveBeenCalled();
|
|
179
182
|
});
|
|
180
183
|
|
|
181
|
-
test('
|
|
184
|
+
test('rejects with a comm-failure HapStatusError when device is not connected', async () => {
|
|
182
185
|
const { instance, device } = make({ '1': false });
|
|
183
186
|
device.connected = false;
|
|
184
|
-
expect(
|
|
187
|
+
await expect(instance.setStateAsync('1', true)).rejects.toBeInstanceOf(HAP.HapStatusError);
|
|
185
188
|
expect(device.update).not.toHaveBeenCalled();
|
|
186
189
|
});
|
|
187
190
|
});
|
|
@@ -201,12 +204,29 @@ describe('setMultiStateAsync', () => {
|
|
|
201
204
|
expect(device.update).not.toHaveBeenCalled();
|
|
202
205
|
});
|
|
203
206
|
|
|
204
|
-
test('
|
|
207
|
+
test('resolves when the device accepts the writes', async () => {
|
|
208
|
+
const { instance } = make({ '1': false });
|
|
209
|
+
await expect(instance.setMultiStateAsync({ '1': true })).resolves.toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('rejects with a comm-failure HapStatusError when device is not connected', async () => {
|
|
205
213
|
const { instance, device } = make({ '1': false });
|
|
206
214
|
device.connected = false;
|
|
207
|
-
expect(
|
|
215
|
+
await expect(instance.setMultiStateAsync({ '1': true, '2': 50 })).rejects.toBeInstanceOf(HAP.HapStatusError);
|
|
208
216
|
expect(device.update).not.toHaveBeenCalled();
|
|
209
217
|
});
|
|
218
|
+
|
|
219
|
+
test('rejects when the device write is not accepted (returns false)', async () => {
|
|
220
|
+
const { instance, device } = make({ '1': false });
|
|
221
|
+
device.update.mockReturnValue(false);
|
|
222
|
+
await expect(instance.setMultiStateAsync({ '1': true })).rejects.toBeInstanceOf(HAP.HapStatusError);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('awaits an async device write result (cloud) and rejects on failure', async () => {
|
|
226
|
+
const { instance, device } = make({ '1': false });
|
|
227
|
+
device.update.mockResolvedValue(false);
|
|
228
|
+
await expect(instance.setMultiStateAsync({ '1': true })).rejects.toBeInstanceOf(HAP.HapStatusError);
|
|
229
|
+
});
|
|
210
230
|
});
|
|
211
231
|
|
|
212
232
|
describe('setMultiStateLegacyAsync', () => {
|
|
@@ -217,12 +237,34 @@ describe('setMultiStateLegacyAsync', () => {
|
|
|
217
237
|
expect(device.update).toHaveBeenCalledWith({ '1': true, '3': '2' });
|
|
218
238
|
});
|
|
219
239
|
|
|
220
|
-
test('
|
|
240
|
+
test('rejects with a comm-failure HapStatusError when device is not connected', async () => {
|
|
221
241
|
const { instance, device } = make();
|
|
222
242
|
device.connected = false;
|
|
223
|
-
expect(
|
|
243
|
+
await expect(instance.setMultiStateLegacyAsync({ '1': true })).rejects.toBeInstanceOf(HAP.HapStatusError);
|
|
224
244
|
expect(device.update).not.toHaveBeenCalled();
|
|
225
245
|
});
|
|
246
|
+
|
|
247
|
+
test('rejects when the device write is not accepted (returns false)', async () => {
|
|
248
|
+
const { instance, device } = make();
|
|
249
|
+
device.update.mockReturnValue(false);
|
|
250
|
+
await expect(instance.setMultiStateLegacyAsync({ '1': true })).rejects.toBeInstanceOf(HAP.HapStatusError);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('background write helpers (never throw/reject)', () => {
|
|
255
|
+
test('setStateInBackground swallows a disconnected-device failure', async () => {
|
|
256
|
+
const { instance, device } = make({ '1': false });
|
|
257
|
+
device.connected = false;
|
|
258
|
+
expect(() => instance.setStateInBackground('1', true)).not.toThrow();
|
|
259
|
+
// Give the rejected inner promise a tick to settle; it must be caught.
|
|
260
|
+
await Promise.resolve();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('setMultiStateLegacyInBackground still dispatches the write when connected', () => {
|
|
264
|
+
const { instance, device } = make();
|
|
265
|
+
instance.setMultiStateLegacyInBackground({ '1': true });
|
|
266
|
+
expect(device.update).toHaveBeenCalledWith({ '1': true });
|
|
267
|
+
});
|
|
226
268
|
});
|
|
227
269
|
|
|
228
270
|
describe('getDividedStateAsync', () => {
|
|
@@ -231,20 +273,57 @@ describe('getDividedStateAsync', () => {
|
|
|
231
273
|
expect(instance.getDividedStateAsync('5', 10)).toBeCloseTo(220);
|
|
232
274
|
});
|
|
233
275
|
|
|
234
|
-
test('throws when state value is not finite', () => {
|
|
276
|
+
test('throws a comm-failure HapStatusError when state value is not finite', () => {
|
|
235
277
|
const { instance } = make({ '5': 'bad' });
|
|
236
|
-
|
|
278
|
+
let err;
|
|
279
|
+
try { instance.getDividedStateAsync('5', 10); } catch (e) { err = e; }
|
|
280
|
+
expect(err).toBeInstanceOf(HAP.HapStatusError);
|
|
281
|
+
expect(err.hapStatus).toBe(HAP.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('getState (callback)', () => {
|
|
286
|
+
test('returns the DP value via the callback when connected', done => {
|
|
287
|
+
const { instance } = make({ '1': true });
|
|
288
|
+
instance.getState('1', (err, value) => {
|
|
289
|
+
expect(err).toBeNull();
|
|
290
|
+
expect(value).toBe(true);
|
|
291
|
+
done();
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('invokes the callback with a comm-failure error when not connected', () => {
|
|
296
|
+
const { instance, device } = make({ '1': true });
|
|
297
|
+
device.connected = false;
|
|
298
|
+
const cb = jest.fn();
|
|
299
|
+
instance.getState('1', cb);
|
|
300
|
+
expect(cb).toHaveBeenCalledWith(expect.any(HAP.HapStatusError));
|
|
237
301
|
});
|
|
238
302
|
});
|
|
239
303
|
|
|
240
304
|
describe('setMultiState (legacy callback)', () => {
|
|
241
|
-
test('
|
|
305
|
+
test('invokes the callback without error on a successful write', () => {
|
|
306
|
+
const { instance } = make({ '1': false });
|
|
307
|
+
const cb = jest.fn();
|
|
308
|
+
instance.setMultiState({ '1': true }, cb);
|
|
309
|
+
expect(cb).toHaveBeenCalledWith(null);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('invokes the callback with a comm-failure error when not connected', () => {
|
|
242
313
|
const { instance, device } = make({ '1': false });
|
|
243
314
|
device.connected = false;
|
|
244
315
|
const cb = jest.fn();
|
|
245
316
|
instance.setMultiState({ '1': true }, cb);
|
|
246
317
|
expect(device.update).not.toHaveBeenCalled();
|
|
247
|
-
expect(cb).toHaveBeenCalledWith();
|
|
318
|
+
expect(cb).toHaveBeenCalledWith(expect.any(HAP.HapStatusError));
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('invokes the callback with a comm-failure error when the write is not accepted', () => {
|
|
322
|
+
const { instance, device } = make({ '1': false });
|
|
323
|
+
device.update.mockReturnValue(false);
|
|
324
|
+
const cb = jest.fn();
|
|
325
|
+
instance.setMultiState({ '1': true }, cb);
|
|
326
|
+
expect(cb).toHaveBeenCalledWith(expect.any(HAP.HapStatusError));
|
|
248
327
|
});
|
|
249
328
|
|
|
250
329
|
test('tolerates a missing callback when not connected', () => {
|
|
@@ -255,13 +334,13 @@ describe('setMultiState (legacy callback)', () => {
|
|
|
255
334
|
});
|
|
256
335
|
|
|
257
336
|
describe('setMultiStateLegacy (legacy callback)', () => {
|
|
258
|
-
test('
|
|
337
|
+
test('invokes the callback with a comm-failure error when not connected', () => {
|
|
259
338
|
const { instance, device } = make();
|
|
260
339
|
device.connected = false;
|
|
261
340
|
const cb = jest.fn();
|
|
262
341
|
instance.setMultiStateLegacy({ '1': true }, cb);
|
|
263
342
|
expect(device.update).not.toHaveBeenCalled();
|
|
264
|
-
expect(cb).toHaveBeenCalledWith();
|
|
343
|
+
expect(cb).toHaveBeenCalledWith(expect.any(HAP.HapStatusError));
|
|
265
344
|
});
|
|
266
345
|
});
|
|
267
346
|
|
|
@@ -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,71 +473,77 @@ describe('IrrigationSystemAccessory — charging state', () => {
|
|
|
425
473
|
});
|
|
426
474
|
});
|
|
427
475
|
|
|
428
|
-
describe('IrrigationSystemAccessory —
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
test('rainInverted flips the polarity', () => {
|
|
436
|
-
const { instance } = makeHarness({}, { rainInverted: true });
|
|
437
|
-
expect(instance._contactState('rain')).toBe(Characteristic.ContactSensorState.CONTACT_DETECTED);
|
|
438
|
-
expect(instance._contactState('no_rain')).toBe(Characteristic.ContactSensorState.CONTACT_NOT_DETECTED);
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
test('leak sensor maps rain → detected', () => {
|
|
442
|
-
const { instance } = makeHarness({}, { rainSensorType: 'leak' });
|
|
443
|
-
expect(instance._rainDetected('rain')).toBe(true);
|
|
444
|
-
expect(instance._rainDetected('no_rain')).toBe(false);
|
|
445
|
-
});
|
|
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
|
-
|
|
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.
|
|
455
482
|
beforeEach(() => jest.useFakeTimers());
|
|
456
483
|
afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
|
|
457
484
|
|
|
458
|
-
test('
|
|
459
|
-
const { accessory } = makeHarness(
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
expect(valve(accessory, '
|
|
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 });
|
|
464
494
|
});
|
|
465
495
|
|
|
466
|
-
test('
|
|
467
|
-
const { accessory
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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);
|
|
471
502
|
});
|
|
472
503
|
|
|
473
|
-
test('
|
|
474
|
-
const { accessory } = makeHarness(
|
|
475
|
-
|
|
476
|
-
|
|
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);
|
|
477
511
|
});
|
|
478
512
|
|
|
479
|
-
test('a
|
|
480
|
-
|
|
481
|
-
device.
|
|
482
|
-
|
|
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 });
|
|
528
|
+
|
|
529
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(0);
|
|
530
|
+
jest.advanceTimersByTime(500);
|
|
531
|
+
expect(device.update).toHaveBeenLastCalledWith({ zone_a: false });
|
|
483
532
|
});
|
|
484
533
|
|
|
485
|
-
test('
|
|
534
|
+
test('master OFF closes zones the device has not echoed as open', () => {
|
|
486
535
|
const { accessory, device } = makeHarness(
|
|
487
|
-
{ zone_a: false, zone_b: false
|
|
488
|
-
{
|
|
536
|
+
{ zone_a: false, zone_b: false },
|
|
537
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], noBattery: true, defaultDuration: 0 }
|
|
489
538
|
);
|
|
490
|
-
|
|
539
|
+
device.update.mockImplementation(() => true); // writes never touch state
|
|
540
|
+
|
|
541
|
+
valve(accessory, 'zone_a').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
491
542
|
valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
492
543
|
jest.advanceTimersByTime(500);
|
|
493
|
-
|
|
544
|
+
|
|
545
|
+
irrigation(accessory).getCharacteristic(Characteristic.Active).triggerSet(0);
|
|
546
|
+
jest.advanceTimersByTime(500);
|
|
547
|
+
expect(device.update).toHaveBeenLastCalledWith({ zone_a: false, zone_b: false });
|
|
494
548
|
});
|
|
495
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
|
});
|