homebridge-tuya-plus 3.13.0 → 3.13.1-dev.2
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/CODEOWNERS +5 -0
- package/.github/workflows/publish-dev.yml +91 -0
- package/Readme.MD +33 -0
- package/config.schema.json +231 -12
- package/index.js +21 -5
- package/lib/BaseAccessory.js +7 -0
- package/lib/DehumidifierAccessory.js +1 -1
- package/lib/IrrigationSystemAccessory.js +611 -0
- package/lib/OilDiffuserAccessory.js +1 -1
- package/lib/SimpleFanLightAccessory.js +54 -12
- package/lib/SimpleGarageDoorAccessory.js +178 -262
- package/lib/TuyaAccessory.js +95 -28
- package/lib/TuyaDiscovery.js +52 -19
- package/lib/WledDimmerAccessory.js +703 -0
- package/package.json +1 -1
- package/test/IrrigationSystemAccessory.test.js +446 -0
- package/test/SimpleFanLightAccessory.test.js +299 -0
- package/test/SimpleGarageDoorAccessory.test.js +258 -538
- package/test/TuyaAccessory.protocol.test.js +626 -0
- package/test/getCategory.test.js +65 -0
- package/test/support/mocks.js +30 -2
- package/wiki/Supported-Device-Types.md +167 -22
- package/wiki/User-documented-device-config.md +57 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-tuya-plus",
|
|
3
|
-
"version": "3.13.
|
|
3
|
+
"version": "3.13.1-dev.2",
|
|
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": {
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const IrrigationSystemAccessory = require('../lib/IrrigationSystemAccessory');
|
|
4
|
+
const { HAP } = require('./support/mocks');
|
|
5
|
+
|
|
6
|
+
const { Service, Characteristic } = HAP;
|
|
7
|
+
|
|
8
|
+
/* ------------------------------------------------------------------ *
|
|
9
|
+
* A richer mock harness than support/mocks.js — it models distinct
|
|
10
|
+
* characteristics per type and real add/get/getById service handling
|
|
11
|
+
* so we can exercise the full irrigation flow (timers, batching,
|
|
12
|
+
* cascade, aggregation, device-change reflection).
|
|
13
|
+
* ------------------------------------------------------------------ */
|
|
14
|
+
|
|
15
|
+
function makeChar(uuid) {
|
|
16
|
+
return {
|
|
17
|
+
UUID: uuid,
|
|
18
|
+
value: null,
|
|
19
|
+
props: { perms: [] },
|
|
20
|
+
_handlers: {},
|
|
21
|
+
updateValue(v) { this.value = v; return this; },
|
|
22
|
+
setValue(v) { this.value = v; return this; },
|
|
23
|
+
setProps(p) { Object.assign(this.props, p); return this; },
|
|
24
|
+
onGet(fn) { this._handlers.get = fn; return this; },
|
|
25
|
+
onSet(fn) { this._handlers.set = fn; return this; },
|
|
26
|
+
on(ev, fn) { this._handlers[ev] = fn; return this; },
|
|
27
|
+
// Test helpers
|
|
28
|
+
triggerSet(v) { return this._handlers.set && this._handlers.set(v); },
|
|
29
|
+
triggerGet() { return this._handlers.get && this._handlers.get(); },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeService(uuid, displayName, subtype) {
|
|
34
|
+
return {
|
|
35
|
+
UUID: uuid,
|
|
36
|
+
displayName: displayName || 'Service',
|
|
37
|
+
subtype: subtype || null,
|
|
38
|
+
characteristics: [],
|
|
39
|
+
isPrimary: false,
|
|
40
|
+
linked: [],
|
|
41
|
+
_chars: {},
|
|
42
|
+
getCharacteristic(type) {
|
|
43
|
+
const key = type.UUID;
|
|
44
|
+
if (!this._chars[key]) {
|
|
45
|
+
const c = makeChar(type.UUID);
|
|
46
|
+
this._chars[key] = c;
|
|
47
|
+
this.characteristics.push(c);
|
|
48
|
+
}
|
|
49
|
+
return this._chars[key];
|
|
50
|
+
},
|
|
51
|
+
addCharacteristic(type) { return this.getCharacteristic(type); },
|
|
52
|
+
setCharacteristic(type, value) { this.getCharacteristic(type).updateValue(value); return this; },
|
|
53
|
+
updateCharacteristic(type, value) { this.getCharacteristic(type).updateValue(value); return this; },
|
|
54
|
+
removeCharacteristic() {},
|
|
55
|
+
setPrimaryService(v = true) { this.isPrimary = v; },
|
|
56
|
+
addLinkedService(s) { if (!this.linked.includes(s)) this.linked.push(s); },
|
|
57
|
+
removeLinkedService(s) { this.linked = this.linked.filter(x => x !== s); },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeAccessory(context = {}) {
|
|
62
|
+
const services = [];
|
|
63
|
+
return {
|
|
64
|
+
services,
|
|
65
|
+
context,
|
|
66
|
+
on() {},
|
|
67
|
+
getService(type) {
|
|
68
|
+
const uuid = type.UUID || type;
|
|
69
|
+
return services.find(s => s.UUID === uuid && !s.subtype) || services.find(s => s.UUID === uuid);
|
|
70
|
+
},
|
|
71
|
+
getServiceById(type, subtype) {
|
|
72
|
+
const uuid = type.UUID || type;
|
|
73
|
+
return services.find(s => s.UUID === uuid && s.subtype === subtype);
|
|
74
|
+
},
|
|
75
|
+
addService(type, displayName, subtype) {
|
|
76
|
+
const s = makeService(type.UUID, displayName, subtype);
|
|
77
|
+
services.push(s);
|
|
78
|
+
return s;
|
|
79
|
+
},
|
|
80
|
+
removeService(s) {
|
|
81
|
+
const i = services.indexOf(s);
|
|
82
|
+
if (i >= 0) services.splice(i, 1);
|
|
83
|
+
},
|
|
84
|
+
configureController() {},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function makeHarness(state = {}, context = {}) {
|
|
89
|
+
const log = Object.assign(jest.fn(), { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() });
|
|
90
|
+
const platform = {
|
|
91
|
+
log,
|
|
92
|
+
api: { hap: HAP, versionGreaterOrEqual: () => false },
|
|
93
|
+
registerPlatformAccessories: jest.fn(),
|
|
94
|
+
};
|
|
95
|
+
const changeHandlers = [];
|
|
96
|
+
const device = {
|
|
97
|
+
connected: true,
|
|
98
|
+
state: { ...state },
|
|
99
|
+
context: { name: 'Sprinklers', id: '12345678abcdef', type: 'irrigationsystem', version: '3.3', ...context },
|
|
100
|
+
update: jest.fn(function (dps) { Object.assign(this.state, dps); return true; }),
|
|
101
|
+
on: jest.fn((ev, fn) => { if (ev === 'change') changeHandlers.push(fn); }),
|
|
102
|
+
once: jest.fn(),
|
|
103
|
+
_connect: jest.fn(),
|
|
104
|
+
emitChange(changes) {
|
|
105
|
+
Object.assign(this.state, changes);
|
|
106
|
+
changeHandlers.forEach(h => h(changes, this.state));
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const accessory = makeAccessory({});
|
|
110
|
+
const instance = new IrrigationSystemAccessory(platform, accessory, device, false);
|
|
111
|
+
// Drive the lifecycle (isNew=false skips it in the constructor).
|
|
112
|
+
instance._registerCharacteristics(device.state);
|
|
113
|
+
return { instance, platform, device, accessory };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const valve = (accessory, dp) => accessory.getServiceById(Service.Valve, 'valve-' + dp);
|
|
117
|
+
const irrigation = (accessory) => accessory.getService(Service.IrrigationSystem);
|
|
118
|
+
|
|
119
|
+
/* ============================== Tests ============================== */
|
|
120
|
+
|
|
121
|
+
describe('IrrigationSystemAccessory — category', () => {
|
|
122
|
+
test('resolves to SPRINKLER', () => {
|
|
123
|
+
expect(IrrigationSystemAccessory.getCategory(HAP.Categories)).toBe(HAP.Categories.SPRINKLER);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('IrrigationSystemAccessory — service topology', () => {
|
|
128
|
+
test('builds an IrrigationSystem (primary) + 4 linked valves + battery + contact sensor by default', () => {
|
|
129
|
+
const { accessory } = makeHarness({ '1': false, '2': false, '3': false, '4': false, '46': 80, '49': 'no_rain' });
|
|
130
|
+
|
|
131
|
+
const irr = irrigation(accessory);
|
|
132
|
+
expect(irr).toBeDefined();
|
|
133
|
+
expect(irr.isPrimary).toBe(true);
|
|
134
|
+
|
|
135
|
+
const valves = accessory.services.filter(s => s.UUID === Service.Valve.UUID);
|
|
136
|
+
expect(valves).toHaveLength(4);
|
|
137
|
+
|
|
138
|
+
// All four valves are linked to the irrigation system.
|
|
139
|
+
expect(irr.linked).toHaveLength(4);
|
|
140
|
+
|
|
141
|
+
// Battery + ContactSensor present; LeakSensor absent.
|
|
142
|
+
expect(accessory.getService(Service.Battery)).toBeDefined();
|
|
143
|
+
expect(accessory.getService(Service.ContactSensor)).toBeDefined();
|
|
144
|
+
expect(accessory.getService(Service.LeakSensor)).toBeUndefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('valves carry IRRIGATION type, CONFIGURED, and sequential ServiceLabelIndex', () => {
|
|
148
|
+
const { accessory } = makeHarness({ '1': false, '2': false, '3': false, '4': false });
|
|
149
|
+
[1, 2, 3, 4].forEach(dp => {
|
|
150
|
+
const v = valve(accessory, dp);
|
|
151
|
+
expect(v.getCharacteristic(Characteristic.ValveType).value).toBe(Characteristic.ValveType.IRRIGATION);
|
|
152
|
+
expect(v.getCharacteristic(Characteristic.IsConfigured).value).toBe(Characteristic.IsConfigured.CONFIGURED);
|
|
153
|
+
expect(v.getCharacteristic(Characteristic.ServiceLabelIndex).value).toBe(dp);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('IrrigationSystem advertises the required characteristics', () => {
|
|
158
|
+
const { accessory } = makeHarness({ '1': false });
|
|
159
|
+
const irr = irrigation(accessory);
|
|
160
|
+
expect(irr.getCharacteristic(Characteristic.ProgramMode).value).toBe(Characteristic.ProgramMode.NO_PROGRAM_SCHEDULED);
|
|
161
|
+
expect(irr.getCharacteristic(Characteristic.Active).value).toBe(Characteristic.Active.INACTIVE);
|
|
162
|
+
expect(irr.getCharacteristic(Characteristic.InUse).value).toBe(Characteristic.InUse.NOT_IN_USE);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('noBattery / noRainSensor omit those services', () => {
|
|
166
|
+
const { accessory } = makeHarness({ '1': false }, { noBattery: true, noRainSensor: true });
|
|
167
|
+
expect(accessory.getService(Service.Battery)).toBeUndefined();
|
|
168
|
+
expect(accessory.getService(Service.ContactSensor)).toBeUndefined();
|
|
169
|
+
expect(accessory.getService(Service.LeakSensor)).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('rainSensorType "leak" uses a LeakSensor instead of a ContactSensor', () => {
|
|
173
|
+
const { accessory } = makeHarness({ '49': 'rain' }, { rainSensorType: 'leak' });
|
|
174
|
+
expect(accessory.getService(Service.LeakSensor)).toBeDefined();
|
|
175
|
+
expect(accessory.getService(Service.ContactSensor)).toBeUndefined();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('IrrigationSystemAccessory — valve configuration', () => {
|
|
180
|
+
test('defaults to four zones A–D on DP 1–4', () => {
|
|
181
|
+
const { instance } = makeHarness();
|
|
182
|
+
const cfgs = instance._getValveConfigs();
|
|
183
|
+
expect(cfgs).toHaveLength(4);
|
|
184
|
+
expect(cfgs.map(c => c.dp)).toEqual(['1', '2', '3', '4']);
|
|
185
|
+
expect(cfgs.map(c => c.name)).toEqual(['Valve A', 'Valve B', 'Valve C', 'Valve D']);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('valveCount controls the number of zones', () => {
|
|
189
|
+
const { instance } = makeHarness({}, { valveCount: 2 });
|
|
190
|
+
expect(instance._getValveConfigs().map(c => c.dp)).toEqual(['1', '2']);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('a custom valves array maps names + data-points', () => {
|
|
194
|
+
const { instance } = makeHarness({}, {
|
|
195
|
+
valves: [{ name: 'Lawn', dp: 5 }, { name: 'Beds', dp: 7, defaultDuration: 1200 }],
|
|
196
|
+
});
|
|
197
|
+
const cfgs = instance._getValveConfigs();
|
|
198
|
+
expect(cfgs).toHaveLength(2);
|
|
199
|
+
expect(cfgs[0]).toMatchObject({ dp: '5', name: 'Lawn', index: 1 });
|
|
200
|
+
expect(cfgs[1]).toMatchObject({ dp: '7', name: 'Beds', index: 2, duration: 1200 });
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('IrrigationSystemAccessory — valve activation & timer', () => {
|
|
205
|
+
beforeEach(() => jest.useFakeTimers());
|
|
206
|
+
afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
|
|
207
|
+
|
|
208
|
+
test('turning a zone on writes the DP (debounced) and starts the countdown', async () => {
|
|
209
|
+
const { accessory, device } = makeHarness({ '1': false }, { defaultDuration: 600 });
|
|
210
|
+
const v = valve(accessory, 1);
|
|
211
|
+
|
|
212
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
213
|
+
|
|
214
|
+
// Optimistic UI updates immediately…
|
|
215
|
+
expect(v.getCharacteristic(Characteristic.Active).value).toBe(1);
|
|
216
|
+
expect(v.getCharacteristic(Characteristic.InUse).value).toBe(1);
|
|
217
|
+
expect(v.getCharacteristic(Characteristic.RemainingDuration).value).toBe(600);
|
|
218
|
+
// …but the device write is debounced.
|
|
219
|
+
expect(device.update).not.toHaveBeenCalled();
|
|
220
|
+
|
|
221
|
+
jest.advanceTimersByTime(500);
|
|
222
|
+
expect(device.update).toHaveBeenCalledWith({ '1': true });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('the zone auto-shuts-off when the timer expires', () => {
|
|
226
|
+
const { accessory, device } = makeHarness({ '1': false }, { defaultDuration: 60 });
|
|
227
|
+
const v = valve(accessory, 1);
|
|
228
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
229
|
+
|
|
230
|
+
jest.advanceTimersByTime(500); // flush the on-write
|
|
231
|
+
expect(device.update).toHaveBeenLastCalledWith({ '1': true });
|
|
232
|
+
|
|
233
|
+
jest.advanceTimersByTime(60 * 1000); // duration elapses
|
|
234
|
+
expect(v.getCharacteristic(Characteristic.Active).value).toBe(0);
|
|
235
|
+
expect(v.getCharacteristic(Characteristic.InUse).value).toBe(0);
|
|
236
|
+
|
|
237
|
+
jest.advanceTimersByTime(500); // flush the off-write
|
|
238
|
+
expect(device.update).toHaveBeenLastCalledWith({ '1': false });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('RemainingDuration counts down from the stored end time', () => {
|
|
242
|
+
const { accessory } = makeHarness({ '1': false }, { defaultDuration: 600 });
|
|
243
|
+
const v = valve(accessory, 1);
|
|
244
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
245
|
+
|
|
246
|
+
jest.advanceTimersByTime(120 * 1000);
|
|
247
|
+
const remaining = v.getCharacteristic(Characteristic.RemainingDuration).triggerGet();
|
|
248
|
+
expect(remaining).toBeGreaterThanOrEqual(479);
|
|
249
|
+
expect(remaining).toBeLessThanOrEqual(480);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('a duration of 0 runs indefinitely — no timer, no countdown', () => {
|
|
253
|
+
const { accessory, device } = makeHarness({ '1': false }, { defaultDuration: 0 });
|
|
254
|
+
const v = valve(accessory, 1);
|
|
255
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
256
|
+
jest.advanceTimersByTime(500);
|
|
257
|
+
expect(device.update).toHaveBeenCalledWith({ '1': true });
|
|
258
|
+
|
|
259
|
+
// Far in the future, the zone is still running and never auto-closed.
|
|
260
|
+
jest.advanceTimersByTime(24 * 3600 * 1000);
|
|
261
|
+
expect(v.getCharacteristic(Characteristic.InUse).value).toBe(1);
|
|
262
|
+
expect(v.getCharacteristic(Characteristic.RemainingDuration).value).toBe(0);
|
|
263
|
+
expect(device.update).toHaveBeenCalledTimes(1); // never wrote an "off"
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('turning a running zone off clears the timer and writes false', () => {
|
|
267
|
+
const { accessory, device } = makeHarness({ '1': false }, { defaultDuration: 600 });
|
|
268
|
+
const v = valve(accessory, 1);
|
|
269
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
270
|
+
jest.advanceTimersByTime(500);
|
|
271
|
+
|
|
272
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(0);
|
|
273
|
+
jest.advanceTimersByTime(500);
|
|
274
|
+
expect(device.update).toHaveBeenLastCalledWith({ '1': false });
|
|
275
|
+
expect(v.getCharacteristic(Characteristic.RemainingDuration).value).toBe(0);
|
|
276
|
+
|
|
277
|
+
// The original auto-off timer must not fire later.
|
|
278
|
+
const calls = device.update.mock.calls.length;
|
|
279
|
+
jest.advanceTimersByTime(600 * 1000);
|
|
280
|
+
expect(device.update.mock.calls.length).toBe(calls);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('IrrigationSystemAccessory — write batching (one Tuya command)', () => {
|
|
285
|
+
beforeEach(() => jest.useFakeTimers());
|
|
286
|
+
afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
|
|
287
|
+
|
|
288
|
+
test('two zones toggled within the window collapse into a single device.update', () => {
|
|
289
|
+
const { accessory, device } = makeHarness({ '1': false, '2': false }, { defaultDuration: 0 });
|
|
290
|
+
valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
291
|
+
valve(accessory, 2).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
292
|
+
|
|
293
|
+
jest.advanceTimersByTime(500);
|
|
294
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
295
|
+
expect(device.update).toHaveBeenCalledWith({ '1': true, '2': true });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('writes are dropped when the device is offline', () => {
|
|
299
|
+
const { accessory, device } = makeHarness({ '1': false }, { defaultDuration: 0 });
|
|
300
|
+
device.connected = false;
|
|
301
|
+
valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
302
|
+
jest.advanceTimersByTime(500);
|
|
303
|
+
expect(device.update).not.toHaveBeenCalled();
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('IrrigationSystemAccessory — master (whole-system) toggle', () => {
|
|
308
|
+
beforeEach(() => jest.useFakeTimers());
|
|
309
|
+
afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
|
|
310
|
+
|
|
311
|
+
test('system OFF closes every open zone in one command', () => {
|
|
312
|
+
const { accessory, device } = makeHarness({ '1': true, '2': true, '3': false, '4': false }, { defaultDuration: 0 });
|
|
313
|
+
irrigation(accessory).getCharacteristic(Characteristic.Active).triggerSet(0);
|
|
314
|
+
jest.advanceTimersByTime(500);
|
|
315
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
316
|
+
expect(device.update).toHaveBeenCalledWith({ '1': false, '2': false });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('system ON opens every closed zone in one command', () => {
|
|
320
|
+
const { accessory, device } = makeHarness({ '1': false, '2': false, '3': false, '4': false }, { defaultDuration: 0 });
|
|
321
|
+
irrigation(accessory).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
322
|
+
jest.advanceTimersByTime(500);
|
|
323
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
324
|
+
expect(device.update).toHaveBeenCalledWith({ '1': true, '2': true, '3': true, '4': true });
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('masterTurnsOnAllZones=false makes ON a passive enable (no writes)', () => {
|
|
328
|
+
const { accessory, device } = makeHarness({ '1': false, '2': false }, { masterTurnsOnAllZones: false });
|
|
329
|
+
irrigation(accessory).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
330
|
+
jest.advanceTimersByTime(500);
|
|
331
|
+
expect(device.update).not.toHaveBeenCalled();
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe('IrrigationSystemAccessory — aggregation', () => {
|
|
336
|
+
beforeEach(() => jest.useFakeTimers());
|
|
337
|
+
afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
|
|
338
|
+
|
|
339
|
+
test('the system reports IN_USE/ACTIVE whenever a zone runs', () => {
|
|
340
|
+
const { accessory } = makeHarness({ '1': false }, { defaultDuration: 600 });
|
|
341
|
+
const irr = irrigation(accessory);
|
|
342
|
+
expect(irr.getCharacteristic(Characteristic.InUse).value).toBe(Characteristic.InUse.NOT_IN_USE);
|
|
343
|
+
|
|
344
|
+
valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
345
|
+
expect(irr.getCharacteristic(Characteristic.InUse).value).toBe(Characteristic.InUse.IN_USE);
|
|
346
|
+
expect(irr.getCharacteristic(Characteristic.Active).value).toBe(Characteristic.Active.ACTIVE);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe('IrrigationSystemAccessory — device-side change reflection', () => {
|
|
351
|
+
beforeEach(() => jest.useFakeTimers());
|
|
352
|
+
afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
|
|
353
|
+
|
|
354
|
+
test('a physical switch toggle is mirrored into HomeKit and arms the timer', () => {
|
|
355
|
+
const { accessory, device } = makeHarness({ '1': false }, { defaultDuration: 600 });
|
|
356
|
+
const v = valve(accessory, 1);
|
|
357
|
+
|
|
358
|
+
device.emitChange({ '1': true });
|
|
359
|
+
expect(v.getCharacteristic(Characteristic.Active).value).toBe(1);
|
|
360
|
+
expect(v.getCharacteristic(Characteristic.InUse).value).toBe(1);
|
|
361
|
+
expect(v.getCharacteristic(Characteristic.RemainingDuration).value).toBe(600);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('battery + rain telemetry updates the corresponding characteristics', () => {
|
|
365
|
+
const { accessory, device } = makeHarness({ '46': 80, '49': 'no_rain' });
|
|
366
|
+
device.emitChange({ '46': 10, '49': 'rain' });
|
|
367
|
+
|
|
368
|
+
const battery = accessory.getService(Service.Battery);
|
|
369
|
+
expect(battery.getCharacteristic(Characteristic.BatteryLevel).value).toBe(10);
|
|
370
|
+
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
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe('IrrigationSystemAccessory — battery mapping', () => {
|
|
378
|
+
test('clamps the level into 0–100', () => {
|
|
379
|
+
const { instance } = makeHarness();
|
|
380
|
+
expect(instance._batteryLevel(150)).toBe(100);
|
|
381
|
+
expect(instance._batteryLevel(-5)).toBe(0);
|
|
382
|
+
expect(instance._batteryLevel('42')).toBe(42);
|
|
383
|
+
expect(instance._batteryLevel(undefined)).toBe(0);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('low-battery threshold (default 20%) is inclusive', () => {
|
|
387
|
+
const { instance } = makeHarness();
|
|
388
|
+
expect(instance._lowBattery(21)).toBe(Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL);
|
|
389
|
+
expect(instance._lowBattery(20)).toBe(Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW);
|
|
390
|
+
expect(instance._lowBattery(5)).toBe(Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test('the low-battery threshold is configurable', () => {
|
|
394
|
+
const { instance } = makeHarness({}, { lowBatteryThreshold: 10 });
|
|
395
|
+
expect(instance._lowBattery(15)).toBe(Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL);
|
|
396
|
+
expect(instance._lowBattery(10)).toBe(Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe('IrrigationSystemAccessory — charging state', () => {
|
|
401
|
+
test('maps the charging data-point: true → CHARGING, false → NOT_CHARGING', () => {
|
|
402
|
+
const { instance } = makeHarness();
|
|
403
|
+
expect(instance._chargingState(true)).toBe(Characteristic.ChargingState.CHARGING);
|
|
404
|
+
expect(instance._chargingState(false)).toBe(Characteristic.ChargingState.NOT_CHARGING);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('falls back to NOT_CHARGEABLE when the device reports no charging data-point', () => {
|
|
408
|
+
const { instance } = makeHarness();
|
|
409
|
+
expect(instance._chargingState(undefined)).toBe(Characteristic.ChargingState.NOT_CHARGEABLE);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('reflects the initial charging data-point onto the Battery service', () => {
|
|
413
|
+
const { accessory } = makeHarness({ '46': 80, '101': true });
|
|
414
|
+
const battery = accessory.getService(Service.Battery);
|
|
415
|
+
expect(battery.getCharacteristic(Characteristic.ChargingState).value).toBe(Characteristic.ChargingState.CHARGING);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('updates ChargingState when the device reports a charging change', () => {
|
|
419
|
+
const { accessory, device } = makeHarness({ '46': 80, '101': false });
|
|
420
|
+
const battery = accessory.getService(Service.Battery);
|
|
421
|
+
expect(battery.getCharacteristic(Characteristic.ChargingState).value).toBe(Characteristic.ChargingState.NOT_CHARGING);
|
|
422
|
+
|
|
423
|
+
device.emitChange({ '101': true });
|
|
424
|
+
expect(battery.getCharacteristic(Characteristic.ChargingState).value).toBe(Characteristic.ChargingState.CHARGING);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe('IrrigationSystemAccessory — rain mapping', () => {
|
|
429
|
+
test('contact sensor: rain → NOT_DETECTED, no_rain → DETECTED', () => {
|
|
430
|
+
const { instance } = makeHarness();
|
|
431
|
+
expect(instance._contactState('rain')).toBe(Characteristic.ContactSensorState.CONTACT_NOT_DETECTED);
|
|
432
|
+
expect(instance._contactState('no_rain')).toBe(Characteristic.ContactSensorState.CONTACT_DETECTED);
|
|
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
|
+
});
|