homebridge-tuya-plus 3.11.3-beta.3 → 3.12.0
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/config.schema.json +0 -25
- package/index.js +0 -2
- package/lib/AirConditionerAccessory.js +41 -13
- package/package.json +1 -1
- package/wiki/Supported-Device-Types.md +0 -26
- package/lib/SimpleGarageDoorAccessory.js +0 -158
- package/test/SimpleGarageDoorAccessory.test.js +0 -308
package/config.schema.json
CHANGED
|
@@ -82,10 +82,6 @@
|
|
|
82
82
|
"title": "Garage Door",
|
|
83
83
|
"enum": ["GarageDoor"]
|
|
84
84
|
},
|
|
85
|
-
{
|
|
86
|
-
"title": "Simple Garage Door (Open/Stop/Close)",
|
|
87
|
-
"enum": ["SimpleGarageDoor"]
|
|
88
|
-
},
|
|
89
85
|
{
|
|
90
86
|
"title": "Simple Blinds",
|
|
91
87
|
"enum": ["SimpleBlinds"]
|
|
@@ -497,27 +493,6 @@
|
|
|
497
493
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['GarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
498
494
|
}
|
|
499
495
|
},
|
|
500
|
-
"dpOpen": {
|
|
501
|
-
"type": "integer",
|
|
502
|
-
"placeholder": "1",
|
|
503
|
-
"condition": {
|
|
504
|
-
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
505
|
-
}
|
|
506
|
-
},
|
|
507
|
-
"dpStop": {
|
|
508
|
-
"type": "integer",
|
|
509
|
-
"placeholder": "2",
|
|
510
|
-
"condition": {
|
|
511
|
-
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
512
|
-
}
|
|
513
|
-
},
|
|
514
|
-
"dpClose": {
|
|
515
|
-
"type": "integer",
|
|
516
|
-
"placeholder": "3",
|
|
517
|
-
"condition": {
|
|
518
|
-
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
519
|
-
}
|
|
520
|
-
},
|
|
521
496
|
"flipState": {
|
|
522
497
|
"type": "boolean",
|
|
523
498
|
"condition": {
|
package/index.js
CHANGED
|
@@ -13,7 +13,6 @@ const AirPurifierAccessory = require('./lib/AirPurifierAccessory');
|
|
|
13
13
|
const DehumidifierAccessory = require('./lib/DehumidifierAccessory');
|
|
14
14
|
const ConvectorAccessory = require('./lib/ConvectorAccessory');
|
|
15
15
|
const GarageDoorAccessory = require('./lib/GarageDoorAccessory');
|
|
16
|
-
const SimpleGarageDoorAccessory = require('./lib/SimpleGarageDoorAccessory');
|
|
17
16
|
const SimpleDimmerAccessory = require('./lib/SimpleDimmerAccessory');
|
|
18
17
|
const SimpleDimmer2Accessory = require('./lib/SimpleDimmer2Accessory');
|
|
19
18
|
const SimpleBlindsAccessory = require('./lib/SimpleBlindsAccessory');
|
|
@@ -48,7 +47,6 @@ const CLASS_DEF = {
|
|
|
48
47
|
dehumidifier: DehumidifierAccessory,
|
|
49
48
|
convector: ConvectorAccessory,
|
|
50
49
|
garagedoor: GarageDoorAccessory,
|
|
51
|
-
simplegaragedoor: SimpleGarageDoorAccessory,
|
|
52
50
|
simpledimmer: SimpleDimmerAccessory,
|
|
53
51
|
simpledimmer2: SimpleDimmer2Accessory,
|
|
54
52
|
simpleblinds: SimpleBlindsAccessory,
|
|
@@ -60,6 +60,17 @@ class AirConditionerAccessory extends BaseAccessory {
|
|
|
60
60
|
this.dpTempUnits = this._getCustomDP(this.device.context.dpTempUnits) || '19';
|
|
61
61
|
this.dpSwingMode = this._getCustomDP(this.device.context.dpSwingMode) || '104';
|
|
62
62
|
|
|
63
|
+
// temperatureDivisor is a default for both current and threshold reads.
|
|
64
|
+
// Some Tuya AC firmwares report temperatures scaled by 10 (e.g.
|
|
65
|
+
// temp_set = 170 means 17.0 °C). Use temperatureDivisor: 10 for those.
|
|
66
|
+
// currentTemperatureDivisor / thresholdTemperatureDivisor override
|
|
67
|
+
// the default per side, since the two DPs are not always scaled the
|
|
68
|
+
// same way (it's common for current temp to be unscaled while the
|
|
69
|
+
// setpoint is in tenths of a degree).
|
|
70
|
+
const baseDivisor = parseInt(this.device.context.temperatureDivisor) || 1;
|
|
71
|
+
this.currentTemperatureDivisor = parseInt(this.device.context.currentTemperatureDivisor) || baseDivisor;
|
|
72
|
+
this.thresholdTemperatureDivisor = parseInt(this.device.context.thresholdTemperatureDivisor) || baseDivisor;
|
|
73
|
+
|
|
63
74
|
const characteristicActive = service.getCharacteristic(Characteristic.Active)
|
|
64
75
|
.updateValue(this._getActive(dps[this.dpActive]))
|
|
65
76
|
.onGet(() => this.getActive())
|
|
@@ -81,8 +92,8 @@ class AirConditionerAccessory extends BaseAccessory {
|
|
|
81
92
|
.onSet(value => this.setTargetHeaterCoolerState(value));
|
|
82
93
|
|
|
83
94
|
const characteristicCurrentTemperature = service.getCharacteristic(Characteristic.CurrentTemperature)
|
|
84
|
-
.updateValue(dps[this.dpCurrentTemperature])
|
|
85
|
-
.onGet(() => this.getStateAsync(this.dpCurrentTemperature));
|
|
95
|
+
.updateValue(this._scaleCurrent(dps[this.dpCurrentTemperature]))
|
|
96
|
+
.onGet(() => this._scaleCurrent(this.getStateAsync(this.dpCurrentTemperature)));
|
|
86
97
|
|
|
87
98
|
let characteristicSwingMode;
|
|
88
99
|
if (!this.device.context.noSwing) {
|
|
@@ -108,8 +119,8 @@ class AirConditionerAccessory extends BaseAccessory {
|
|
|
108
119
|
maxValue: this.device.context.maxTemperature || 35,
|
|
109
120
|
minStep: this.device.context.minTemperatureSteps || 1
|
|
110
121
|
})
|
|
111
|
-
.updateValue(dps[this.dpThreshold])
|
|
112
|
-
.onGet(() => this.getStateAsync(this.dpThreshold))
|
|
122
|
+
.updateValue(this._scaleThreshold(dps[this.dpThreshold]))
|
|
123
|
+
.onGet(() => this._scaleThreshold(this.getStateAsync(this.dpThreshold)))
|
|
113
124
|
.onSet(value => this.setTargetThresholdTemperature('cool', value));
|
|
114
125
|
} else this._removeCharacteristic(service, Characteristic.CoolingThresholdTemperature);
|
|
115
126
|
|
|
@@ -121,8 +132,8 @@ class AirConditionerAccessory extends BaseAccessory {
|
|
|
121
132
|
maxValue: this.device.context.maxTemperature || 35,
|
|
122
133
|
minStep: this.device.context.minTemperatureSteps || 1
|
|
123
134
|
})
|
|
124
|
-
.updateValue(dps[this.dpThreshold])
|
|
125
|
-
.onGet(() => this.getStateAsync(this.dpThreshold))
|
|
135
|
+
.updateValue(this._scaleThreshold(dps[this.dpThreshold]))
|
|
136
|
+
.onGet(() => this._scaleThreshold(this.getStateAsync(this.dpThreshold)))
|
|
126
137
|
.onSet(value => this.setTargetThresholdTemperature('heat', value));
|
|
127
138
|
} else this._removeCharacteristic(service, Characteristic.HeatingThresholdTemperature);
|
|
128
139
|
|
|
@@ -158,14 +169,18 @@ class AirConditionerAccessory extends BaseAccessory {
|
|
|
158
169
|
}
|
|
159
170
|
|
|
160
171
|
if (changes.hasOwnProperty(this.dpThreshold)) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
172
|
+
const scaled = this._scaleThreshold(changes[this.dpThreshold]);
|
|
173
|
+
if (!this.device.context.noCool && characteristicCoolingThresholdTemperature && characteristicCoolingThresholdTemperature.value !== scaled)
|
|
174
|
+
characteristicCoolingThresholdTemperature.updateValue(scaled);
|
|
175
|
+
if (!this.device.context.noHeat && characteristicHeatingThresholdTemperature && characteristicHeatingThresholdTemperature.value !== scaled)
|
|
176
|
+
characteristicHeatingThresholdTemperature.updateValue(scaled);
|
|
165
177
|
}
|
|
166
178
|
|
|
167
|
-
if (changes.hasOwnProperty(this.dpCurrentTemperature)
|
|
168
|
-
|
|
179
|
+
if (changes.hasOwnProperty(this.dpCurrentTemperature)) {
|
|
180
|
+
const scaled = this._scaleCurrent(changes[this.dpCurrentTemperature]);
|
|
181
|
+
if (characteristicCurrentTemperature.value !== scaled)
|
|
182
|
+
characteristicCurrentTemperature.updateValue(scaled);
|
|
183
|
+
}
|
|
169
184
|
|
|
170
185
|
if (changes.hasOwnProperty(this.dpMode)) {
|
|
171
186
|
const newTargetHeaterCoolerState = this._getTargetHeaterCoolerState(changes[this.dpMode]);
|
|
@@ -304,7 +319,8 @@ class AirConditionerAccessory extends BaseAccessory {
|
|
|
304
319
|
}
|
|
305
320
|
|
|
306
321
|
async setTargetThresholdTemperature(mode, value) {
|
|
307
|
-
|
|
322
|
+
const raw = Math.round(value * this.thresholdTemperatureDivisor);
|
|
323
|
+
await this.setMultiStateLegacyAsync({[this.dpActive]: true, [this.dpThreshold]: raw});
|
|
308
324
|
if (mode === 'cool' && !this.device.context.noHeat && this.characteristicHeatingThresholdTemperature) {
|
|
309
325
|
this.characteristicHeatingThresholdTemperature.updateValue(value);
|
|
310
326
|
} else if (mode === 'heat' && !this.device.context.noCool && this.characteristicCoolingThresholdTemperature) {
|
|
@@ -312,6 +328,18 @@ class AirConditionerAccessory extends BaseAccessory {
|
|
|
312
328
|
}
|
|
313
329
|
}
|
|
314
330
|
|
|
331
|
+
_scaleCurrent(raw) {
|
|
332
|
+
const v = parseFloat(raw);
|
|
333
|
+
if (!isFinite(v)) return 0;
|
|
334
|
+
return v / this.currentTemperatureDivisor;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
_scaleThreshold(raw) {
|
|
338
|
+
const v = parseFloat(raw);
|
|
339
|
+
if (!isFinite(v)) return 0;
|
|
340
|
+
return v / this.thresholdTemperatureDivisor;
|
|
341
|
+
}
|
|
342
|
+
|
|
315
343
|
getTemperatureDisplayUnits() {
|
|
316
344
|
return this._getTemperatureDisplayUnits(this.getStateAsync(this.dpTempUnits));
|
|
317
345
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-tuya-plus",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.0",
|
|
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": {
|
|
@@ -18,7 +18,6 @@ If you are looking for verified configurations for your specific device, please
|
|
|
18
18
|
|Simple Dimmer|`SimpleDimmer`<sup>[8](#simple-dimmers)</sup>|Dimmer switches with power control <small>([instructions](#simple-dimmers))</small>|
|
|
19
19
|
|Simple Heater|`SimpleHeater`<sup>[9](#simple-heaters)</sup>|Heating solutions with only temperature control <small>([instructions](#simple-heaters))</small>|
|
|
20
20
|
|Garage Door|`GarageDoor`<sup>[10](#garage-doors)</sup>|Smart garage doors or garage door openers <small>([instructions](#garage-doors))</small>|
|
|
21
|
-
|Simple Garage Door|`SimpleGarageDoor`<sup>[10](#simple-garage-doors)</sup>|Garage doors and sliding gate openers that expose only three momentary action DPs: open, stop, close <small>([instructions](#simple-garage-doors))</small>|
|
|
22
21
|
|Simple Blinds|`SimpleBlinds`<sup>[11](#simple-blinds)</sup>|Smart blinds and smart switches that control blinds <small>([instructions](#simple-blinds))</small>|
|
|
23
22
|
|Simple Blinds2|`SimpleBlinds2`<sup>[11](#simple-blinds)</sup>|Smart blinds and smart switches that control blinds(Use if simple Blinds (1) doesn't work for you. <small>([instructions](#simple-blinds))</small>|
|
|
24
23
|
|Vertical Blinds with Tilt|`VerticalBlindsWithTilt`<sup>[11](#vertical-blinds-with-tilt)</sup>|Smart vertical blinds with open/close and panel rotation <small>([instructions](#vertical-blinds-with-tilt))</small>|
|
|
@@ -403,31 +402,6 @@ While still in early testing, you can use this to open and close the garage door
|
|
|
403
402
|
}
|
|
404
403
|
```
|
|
405
404
|
|
|
406
|
-
### Simple Garage Doors
|
|
407
|
-
For very basic garage door openers and sliding gate controllers that expose only three momentary action DPs — one to open, one to stop, one to close — with no position or status feedback. The plugin tracks the target state locally and persists it across restarts, so HomeKit always reflects whatever was last requested. Triggering a change sends the stop command first (so reversing direction mid-motion works; it is a no-op when the gate is idle) and then the open or close command. There is no obstruction detection.
|
|
408
|
-
|
|
409
|
-
```json5
|
|
410
|
-
{
|
|
411
|
-
"name": "My Sliding Gate",
|
|
412
|
-
"type": "SimpleGarageDoor",
|
|
413
|
-
"manufacturer": "Generic",
|
|
414
|
-
"model": "Generic Sliding Gate Controller",
|
|
415
|
-
"id": "032000123456789abcde",
|
|
416
|
-
"key": "0123456789abcdef",
|
|
417
|
-
|
|
418
|
-
/* Additional parameters to override defaults only if needed */
|
|
419
|
-
|
|
420
|
-
/* Override the default datapoint identifier for the open action */
|
|
421
|
-
"dpOpen": 1,
|
|
422
|
-
|
|
423
|
-
/* Override the default datapoint identifier for the stop action */
|
|
424
|
-
"dpStop": 2,
|
|
425
|
-
|
|
426
|
-
/* Override the default datapoint identifier for the close action */
|
|
427
|
-
"dpClose": 3
|
|
428
|
-
}
|
|
429
|
-
```
|
|
430
|
-
|
|
431
405
|
### Simple Blinds
|
|
432
406
|
Normally the blinds don't report their position. This plugin attempts to time the movements to guesstimate the positions. You can adjust a few parameters to make it really close for you.
|
|
433
407
|
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
const BaseAccessory = require('./BaseAccessory');
|
|
2
|
-
|
|
3
|
-
// Delay between the stop command and the open/close command.
|
|
4
|
-
//
|
|
5
|
-
// Sending the opposite direction command while the gate is already moving
|
|
6
|
-
// has no effect, so stop needs to be called every time before calling open or close.
|
|
7
|
-
//
|
|
8
|
-
// Without the delay between the stop and open/close command some controllers
|
|
9
|
-
// drop the direction command because it arrives before they have
|
|
10
|
-
// finished processing the stop.
|
|
11
|
-
const STOP_TO_DIRECTION_DELAY_MS = 500;
|
|
12
|
-
|
|
13
|
-
// Fallback if the device never echoes the stop DP back to false (e.g. when
|
|
14
|
-
// the stop was a no-op because the gate was already idle, or the echo is
|
|
15
|
-
// dropped). Picked to comfortably exceed the ~1s reset we observe in
|
|
16
|
-
// practice.
|
|
17
|
-
const STOP_RESET_TIMEOUT_MS = 3000;
|
|
18
|
-
|
|
19
|
-
// Fallback for the direction DP echo. The CurrentDoorState flips when the
|
|
20
|
-
// direction DP resets to false — the device's natural "command consumed"
|
|
21
|
-
// signal — so this only matters if that echo is missed.
|
|
22
|
-
const DIRECTION_RESET_TIMEOUT_MS = 3000;
|
|
23
|
-
|
|
24
|
-
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
25
|
-
|
|
26
|
-
class SimpleGarageDoorAccessory extends BaseAccessory {
|
|
27
|
-
static getCategory(Categories) {
|
|
28
|
-
return Categories.GARAGE_DOOR_OPENER;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
constructor(...props) {
|
|
32
|
-
super(...props);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
_registerPlatformAccessory() {
|
|
36
|
-
const {Service} = this.hap;
|
|
37
|
-
|
|
38
|
-
this.accessory.addService(Service.GarageDoorOpener, this.device.context.name);
|
|
39
|
-
|
|
40
|
-
super._registerPlatformAccessory();
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
_registerCharacteristics() {
|
|
44
|
-
const {Service, Characteristic} = this.hap;
|
|
45
|
-
const service = this.accessory.getService(Service.GarageDoorOpener);
|
|
46
|
-
this._checkServiceName(service, this.device.context.name);
|
|
47
|
-
|
|
48
|
-
this.dpOpen = this._getCustomDP(this.device.context.dpOpen) || '1';
|
|
49
|
-
this.dpStop = this._getCustomDP(this.device.context.dpStop) || '2';
|
|
50
|
-
this.dpClose = this._getCustomDP(this.device.context.dpClose) || '3';
|
|
51
|
-
|
|
52
|
-
// The device only exposes momentary action DPs, so the target state is
|
|
53
|
-
// tracked locally and persisted via the homebridge accessory context.
|
|
54
|
-
if (this.accessory.context.cachedTargetDoorState !== Characteristic.TargetDoorState.OPEN &&
|
|
55
|
-
this.accessory.context.cachedTargetDoorState !== Characteristic.TargetDoorState.CLOSED) {
|
|
56
|
-
this.accessory.context.cachedTargetDoorState = Characteristic.TargetDoorState.OPEN;
|
|
57
|
-
}
|
|
58
|
-
const initialTarget = this.accessory.context.cachedTargetDoorState;
|
|
59
|
-
this.currentDoorState = initialTarget === Characteristic.TargetDoorState.OPEN
|
|
60
|
-
? Characteristic.CurrentDoorState.OPEN
|
|
61
|
-
: Characteristic.CurrentDoorState.CLOSED;
|
|
62
|
-
|
|
63
|
-
// Each setTargetDoorState invocation bumps this token; an older
|
|
64
|
-
// in-flight chain bails out at its next await when the token changes.
|
|
65
|
-
this.opToken = 0;
|
|
66
|
-
|
|
67
|
-
this.characteristicTargetDoorState = service.getCharacteristic(Characteristic.TargetDoorState)
|
|
68
|
-
.updateValue(initialTarget)
|
|
69
|
-
.onGet(() => this.accessory.context.cachedTargetDoorState)
|
|
70
|
-
.onSet(value => this.setTargetDoorState(value));
|
|
71
|
-
|
|
72
|
-
this.characteristicCurrentDoorState = service.getCharacteristic(Characteristic.CurrentDoorState)
|
|
73
|
-
.updateValue(this.currentDoorState)
|
|
74
|
-
.onGet(() => this.currentDoorState);
|
|
75
|
-
|
|
76
|
-
service.getCharacteristic(Characteristic.ObstructionDetected)
|
|
77
|
-
.updateValue(false)
|
|
78
|
-
.onGet(() => false);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Wraps the synchronous Tuya write so the caller can await it. The Tuya
|
|
82
|
-
// transport is fire-and-forget at the JS level — the data has been handed
|
|
83
|
-
// to the kernel by the time setMultiStateLegacyAsync returns — but using
|
|
84
|
-
// an awaited call keeps the command sequence in one readable async chain.
|
|
85
|
-
async _sendDps(dps) {
|
|
86
|
-
this.setMultiStateLegacyAsync(dps);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Resolves when the device echoes the given DP back to false, signalling
|
|
90
|
-
// the momentary action has completed. Resolves anyway after timeoutMs so
|
|
91
|
-
// a missing echo can't wedge the chain.
|
|
92
|
-
_waitForDpReset(dp, timeoutMs) {
|
|
93
|
-
return new Promise(resolve => {
|
|
94
|
-
const cleanup = () => {
|
|
95
|
-
this.device.removeListener('change', onChange);
|
|
96
|
-
clearTimeout(timer);
|
|
97
|
-
};
|
|
98
|
-
const onChange = changes => {
|
|
99
|
-
if (changes && changes[dp] === false) {
|
|
100
|
-
cleanup();
|
|
101
|
-
resolve();
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
|
-
const timer = setTimeout(() => {
|
|
105
|
-
cleanup();
|
|
106
|
-
resolve();
|
|
107
|
-
}, timeoutMs);
|
|
108
|
-
this.device.on('change', onChange);
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async setTargetDoorState(value) {
|
|
113
|
-
const {Characteristic} = this.hap;
|
|
114
|
-
|
|
115
|
-
this.accessory.context.cachedTargetDoorState = value;
|
|
116
|
-
|
|
117
|
-
const opToken = ++this.opToken;
|
|
118
|
-
|
|
119
|
-
// Subscribe before writing so we don't miss the reset echo.
|
|
120
|
-
const stopReset = this._waitForDpReset(this.dpStop, STOP_RESET_TIMEOUT_MS);
|
|
121
|
-
|
|
122
|
-
// Send stop first so reversing direction mid-motion works; if the gate
|
|
123
|
-
// is already idle, the stop is a no-op on the device side.
|
|
124
|
-
await this._sendDps({[this.dpStop]: true});
|
|
125
|
-
if (opToken !== this.opToken) return;
|
|
126
|
-
|
|
127
|
-
// The stop DP holds at true for ~1s on the controller and then resets
|
|
128
|
-
// to false. Sending the direction command before the device finishes
|
|
129
|
-
// processing the stop causes some controllers to drop it, so wait for
|
|
130
|
-
// the reset and then a short buffer.
|
|
131
|
-
await stopReset;
|
|
132
|
-
if (opToken !== this.opToken) return;
|
|
133
|
-
await sleep(STOP_TO_DIRECTION_DELAY_MS);
|
|
134
|
-
if (opToken !== this.opToken) return;
|
|
135
|
-
|
|
136
|
-
const directionDp = value === Characteristic.TargetDoorState.OPEN
|
|
137
|
-
? this.dpOpen
|
|
138
|
-
: this.dpClose;
|
|
139
|
-
|
|
140
|
-
// Subscribe before writing so we don't miss the reset echo.
|
|
141
|
-
const directionReset = this._waitForDpReset(directionDp, DIRECTION_RESET_TIMEOUT_MS);
|
|
142
|
-
await this._sendDps({[directionDp]: true});
|
|
143
|
-
if (opToken !== this.opToken) return;
|
|
144
|
-
|
|
145
|
-
// Flip CurrentDoorState once the direction DP resets — that's the
|
|
146
|
-
// device's natural "command consumed" signal. The gate itself keeps
|
|
147
|
-
// moving for longer, but we have no position feedback to track that.
|
|
148
|
-
await directionReset;
|
|
149
|
-
if (opToken !== this.opToken) return;
|
|
150
|
-
|
|
151
|
-
this.currentDoorState = value === Characteristic.TargetDoorState.OPEN
|
|
152
|
-
? Characteristic.CurrentDoorState.OPEN
|
|
153
|
-
: Characteristic.CurrentDoorState.CLOSED;
|
|
154
|
-
this.characteristicCurrentDoorState.updateValue(this.currentDoorState);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
module.exports = SimpleGarageDoorAccessory;
|
|
@@ -1,308 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const SimpleGarageDoorAccessory = require('../lib/SimpleGarageDoorAccessory');
|
|
4
|
-
const { HAP, makeInstance } = require('./support/mocks');
|
|
5
|
-
|
|
6
|
-
const { CurrentDoorState: CDS, TargetDoorState: TDS } = HAP.Characteristic;
|
|
7
|
-
|
|
8
|
-
const POST_RESET_DELAY_MS = 500;
|
|
9
|
-
const STOP_RESET_TIMEOUT_MS = 3000;
|
|
10
|
-
const DIRECTION_RESET_TIMEOUT_MS = 3000;
|
|
11
|
-
|
|
12
|
-
// Give the device's `on`/`removeListener` mocks real subscription semantics
|
|
13
|
-
// so the accessory can wait for `change` events the way it does in production.
|
|
14
|
-
function installRealEvents(device) {
|
|
15
|
-
const handlers = new Map();
|
|
16
|
-
device.on = jest.fn((event, handler) => {
|
|
17
|
-
if (!handlers.has(event)) handlers.set(event, []);
|
|
18
|
-
handlers.get(event).push(handler);
|
|
19
|
-
});
|
|
20
|
-
device.removeListener = jest.fn((event, handler) => {
|
|
21
|
-
const list = handlers.get(event);
|
|
22
|
-
if (!list) return;
|
|
23
|
-
const idx = list.indexOf(handler);
|
|
24
|
-
if (idx >= 0) list.splice(idx, 1);
|
|
25
|
-
});
|
|
26
|
-
device.emit = (event, ...args) => {
|
|
27
|
-
const list = handlers.get(event);
|
|
28
|
-
if (list) list.slice().forEach(h => h(...args));
|
|
29
|
-
};
|
|
30
|
-
device.listenerCount = (event) => (handlers.get(event) || []).length;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function makeSimpleGarage(contextOverrides = {}) {
|
|
34
|
-
const { instance, device, accessory, platform } = makeInstance(
|
|
35
|
-
SimpleGarageDoorAccessory,
|
|
36
|
-
{},
|
|
37
|
-
{ manufacturer: 'Generic', ...contextOverrides }
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
installRealEvents(device);
|
|
41
|
-
|
|
42
|
-
// Replicate what _registerCharacteristics would set up.
|
|
43
|
-
instance.dpOpen = '1';
|
|
44
|
-
instance.dpStop = '2';
|
|
45
|
-
instance.dpClose = '3';
|
|
46
|
-
instance.opToken = 0;
|
|
47
|
-
instance.currentDoorState = CDS.OPEN;
|
|
48
|
-
instance.characteristicCurrentDoorState = {
|
|
49
|
-
value: CDS.OPEN,
|
|
50
|
-
updateValue: jest.fn().mockImplementation(function(v) { this.value = v; return this; }),
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
return { instance, device, accessory, platform };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// setTargetDoorState — device commands
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
describe('SimpleGarageDoorAccessory.setTargetDoorState — device commands', () => {
|
|
60
|
-
beforeEach(() => jest.useFakeTimers());
|
|
61
|
-
afterEach(() => jest.useRealTimers());
|
|
62
|
-
|
|
63
|
-
test('OPEN sends stop=true, waits for stop reset, then sends open=true 500ms later', async () => {
|
|
64
|
-
const { instance, device } = makeSimpleGarage();
|
|
65
|
-
const op = instance.setTargetDoorState(TDS.OPEN);
|
|
66
|
-
|
|
67
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
68
|
-
expect(device.update).toHaveBeenCalledTimes(1);
|
|
69
|
-
expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
|
|
70
|
-
|
|
71
|
-
// Even after a full second the open should not have been sent yet —
|
|
72
|
-
// we're still waiting on the reset echo.
|
|
73
|
-
await jest.advanceTimersByTimeAsync(1000);
|
|
74
|
-
expect(device.update).toHaveBeenCalledTimes(1);
|
|
75
|
-
|
|
76
|
-
// Device echoes the stop DP back to false (it auto-resets).
|
|
77
|
-
device.emit('change', { '2': false }, { '2': false });
|
|
78
|
-
|
|
79
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS - 1);
|
|
80
|
-
expect(device.update).toHaveBeenCalledTimes(1);
|
|
81
|
-
|
|
82
|
-
await jest.advanceTimersByTimeAsync(1);
|
|
83
|
-
expect(device.update).toHaveBeenCalledTimes(2);
|
|
84
|
-
expect(device.update).toHaveBeenNthCalledWith(2, { '1': true });
|
|
85
|
-
|
|
86
|
-
// Direction reset closes out the chain.
|
|
87
|
-
device.emit('change', { '1': false }, { '1': false });
|
|
88
|
-
await op;
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test('CLOSED waits for stop reset, then sends close=true', async () => {
|
|
92
|
-
const { instance, device } = makeSimpleGarage();
|
|
93
|
-
const op = instance.setTargetDoorState(TDS.CLOSED);
|
|
94
|
-
|
|
95
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
96
|
-
expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
|
|
97
|
-
|
|
98
|
-
device.emit('change', { '2': false }, { '2': false });
|
|
99
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
100
|
-
expect(device.update).toHaveBeenNthCalledWith(2, { '3': true });
|
|
101
|
-
|
|
102
|
-
device.emit('change', { '3': false }, { '3': false });
|
|
103
|
-
await op;
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test('A {dp: true} echo does not satisfy the reset wait', async () => {
|
|
107
|
-
const { instance, device } = makeSimpleGarage();
|
|
108
|
-
const op = instance.setTargetDoorState(TDS.OPEN);
|
|
109
|
-
|
|
110
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
111
|
-
expect(device.update).toHaveBeenCalledTimes(1);
|
|
112
|
-
|
|
113
|
-
// Device first echoes the stop DP back to true (acknowledging our write).
|
|
114
|
-
// This should NOT be treated as the reset.
|
|
115
|
-
device.emit('change', { '2': true }, { '2': true });
|
|
116
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
117
|
-
expect(device.update).toHaveBeenCalledTimes(1);
|
|
118
|
-
|
|
119
|
-
// Then the device resets it to false.
|
|
120
|
-
device.emit('change', { '2': false }, { '2': false });
|
|
121
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
122
|
-
expect(device.update).toHaveBeenNthCalledWith(2, { '1': true });
|
|
123
|
-
|
|
124
|
-
device.emit('change', { '1': false }, { '1': false });
|
|
125
|
-
await op;
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test('Falls back to sending the direction after STOP_RESET_TIMEOUT_MS if no echo arrives', async () => {
|
|
129
|
-
const { instance, device } = makeSimpleGarage();
|
|
130
|
-
const op = instance.setTargetDoorState(TDS.OPEN);
|
|
131
|
-
|
|
132
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
133
|
-
expect(device.update).toHaveBeenCalledTimes(1);
|
|
134
|
-
|
|
135
|
-
await jest.advanceTimersByTimeAsync(STOP_RESET_TIMEOUT_MS - 1);
|
|
136
|
-
expect(device.update).toHaveBeenCalledTimes(1);
|
|
137
|
-
|
|
138
|
-
await jest.advanceTimersByTimeAsync(1 + POST_RESET_DELAY_MS);
|
|
139
|
-
expect(device.update).toHaveBeenNthCalledWith(2, { '1': true });
|
|
140
|
-
|
|
141
|
-
device.emit('change', { '1': false }, { '1': false });
|
|
142
|
-
await op;
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test('Cleans up the change listener once the wait resolves', async () => {
|
|
146
|
-
const { instance, device } = makeSimpleGarage();
|
|
147
|
-
const op = instance.setTargetDoorState(TDS.OPEN);
|
|
148
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
149
|
-
expect(device.listenerCount('change')).toBe(1);
|
|
150
|
-
|
|
151
|
-
device.emit('change', { '2': false }, { '2': false });
|
|
152
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
153
|
-
// Direction listener should now be attached.
|
|
154
|
-
expect(device.listenerCount('change')).toBe(1);
|
|
155
|
-
|
|
156
|
-
device.emit('change', { '1': false }, { '1': false });
|
|
157
|
-
await op;
|
|
158
|
-
expect(device.listenerCount('change')).toBe(0);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test('Custom DPs are respected', async () => {
|
|
162
|
-
const { instance, device } = makeSimpleGarage();
|
|
163
|
-
instance.dpOpen = '101';
|
|
164
|
-
instance.dpStop = '102';
|
|
165
|
-
instance.dpClose = '103';
|
|
166
|
-
const op = instance.setTargetDoorState(TDS.OPEN);
|
|
167
|
-
|
|
168
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
169
|
-
expect(device.update).toHaveBeenNthCalledWith(1, { '102': true });
|
|
170
|
-
|
|
171
|
-
device.emit('change', { '102': false }, { '102': false });
|
|
172
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
173
|
-
expect(device.update).toHaveBeenNthCalledWith(2, { '101': true });
|
|
174
|
-
|
|
175
|
-
device.emit('change', { '101': false }, { '101': false });
|
|
176
|
-
await op;
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
test('Skips writes when the device is disconnected', async () => {
|
|
180
|
-
const { instance, device } = makeSimpleGarage();
|
|
181
|
-
device.connected = false;
|
|
182
|
-
const op = instance.setTargetDoorState(TDS.OPEN);
|
|
183
|
-
await jest.advanceTimersByTimeAsync(
|
|
184
|
-
STOP_RESET_TIMEOUT_MS + POST_RESET_DELAY_MS + DIRECTION_RESET_TIMEOUT_MS
|
|
185
|
-
);
|
|
186
|
-
await op;
|
|
187
|
-
expect(device.update).not.toHaveBeenCalled();
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test('Reversing during the stop->direction wait cancels the pending direction command', async () => {
|
|
191
|
-
const { instance, device } = makeSimpleGarage();
|
|
192
|
-
const op1 = instance.setTargetDoorState(TDS.OPEN);
|
|
193
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
194
|
-
expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
|
|
195
|
-
|
|
196
|
-
const op2 = instance.setTargetDoorState(TDS.CLOSED);
|
|
197
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
198
|
-
|
|
199
|
-
// A second stop is sent; the originally-pending open must not fire.
|
|
200
|
-
expect(device.update).toHaveBeenCalledTimes(2);
|
|
201
|
-
expect(device.update).toHaveBeenNthCalledWith(2, { '2': true });
|
|
202
|
-
|
|
203
|
-
device.emit('change', { '2': false }, { '2': false });
|
|
204
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
205
|
-
expect(device.update).toHaveBeenCalledTimes(3);
|
|
206
|
-
expect(device.update).toHaveBeenNthCalledWith(3, { '3': true });
|
|
207
|
-
|
|
208
|
-
device.emit('change', { '3': false }, { '3': false });
|
|
209
|
-
await op1;
|
|
210
|
-
await op2;
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
// ---------------------------------------------------------------------------
|
|
215
|
-
// CurrentDoorState transition
|
|
216
|
-
// ---------------------------------------------------------------------------
|
|
217
|
-
describe('SimpleGarageDoorAccessory.setTargetDoorState — CurrentDoorState transition', () => {
|
|
218
|
-
beforeEach(() => jest.useFakeTimers());
|
|
219
|
-
afterEach(() => jest.useRealTimers());
|
|
220
|
-
|
|
221
|
-
test('CurrentDoorState flips when the direction DP echoes back to false', async () => {
|
|
222
|
-
const { instance, device } = makeSimpleGarage();
|
|
223
|
-
instance.currentDoorState = CDS.CLOSED;
|
|
224
|
-
instance.characteristicCurrentDoorState.value = CDS.CLOSED;
|
|
225
|
-
|
|
226
|
-
const op = instance.setTargetDoorState(TDS.OPEN);
|
|
227
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
228
|
-
device.emit('change', { '2': false }, { '2': false });
|
|
229
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
230
|
-
|
|
231
|
-
// Direction has been sent but not yet acknowledged — UI still old.
|
|
232
|
-
expect(instance.characteristicCurrentDoorState.value).toBe(CDS.CLOSED);
|
|
233
|
-
|
|
234
|
-
device.emit('change', { '1': false }, { '1': false });
|
|
235
|
-
await op;
|
|
236
|
-
expect(instance.currentDoorState).toBe(CDS.OPEN);
|
|
237
|
-
expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
test('CurrentDoorState falls back to DIRECTION_RESET_TIMEOUT_MS if the device never resets the direction DP', async () => {
|
|
241
|
-
const { instance, device } = makeSimpleGarage();
|
|
242
|
-
instance.currentDoorState = CDS.OPEN;
|
|
243
|
-
instance.characteristicCurrentDoorState.value = CDS.OPEN;
|
|
244
|
-
|
|
245
|
-
const op = instance.setTargetDoorState(TDS.CLOSED);
|
|
246
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
247
|
-
device.emit('change', { '2': false }, { '2': false });
|
|
248
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
249
|
-
expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
|
|
250
|
-
|
|
251
|
-
await jest.advanceTimersByTimeAsync(DIRECTION_RESET_TIMEOUT_MS - 1);
|
|
252
|
-
expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
|
|
253
|
-
|
|
254
|
-
await jest.advanceTimersByTimeAsync(1);
|
|
255
|
-
await op;
|
|
256
|
-
expect(instance.currentDoorState).toBe(CDS.CLOSED);
|
|
257
|
-
expect(instance.characteristicCurrentDoorState.value).toBe(CDS.CLOSED);
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
test('Reversing direction mid-transition cancels the stale CurrentDoorState update', async () => {
|
|
261
|
-
const { instance, device } = makeSimpleGarage();
|
|
262
|
-
instance.currentDoorState = CDS.OPEN;
|
|
263
|
-
instance.characteristicCurrentDoorState.value = CDS.OPEN;
|
|
264
|
-
|
|
265
|
-
const op1 = instance.setTargetDoorState(TDS.CLOSED);
|
|
266
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
267
|
-
device.emit('change', { '2': false }, { '2': false });
|
|
268
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
269
|
-
|
|
270
|
-
// Close has been sent; before its echo arrives, user reverses.
|
|
271
|
-
const op2 = instance.setTargetDoorState(TDS.OPEN);
|
|
272
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
273
|
-
|
|
274
|
-
// The stale close-reset echo arriving now must not flip UI to CLOSED.
|
|
275
|
-
device.emit('change', { '3': false }, { '3': false });
|
|
276
|
-
device.emit('change', { '2': false }, { '2': false });
|
|
277
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
278
|
-
device.emit('change', { '1': false }, { '1': false });
|
|
279
|
-
|
|
280
|
-
await op1;
|
|
281
|
-
await op2;
|
|
282
|
-
expect(instance.currentDoorState).toBe(CDS.OPEN);
|
|
283
|
-
expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
|
|
284
|
-
});
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
// ---------------------------------------------------------------------------
|
|
288
|
-
// Persistence
|
|
289
|
-
// ---------------------------------------------------------------------------
|
|
290
|
-
describe('SimpleGarageDoorAccessory persistence', () => {
|
|
291
|
-
beforeEach(() => jest.useFakeTimers());
|
|
292
|
-
afterEach(() => jest.useRealTimers());
|
|
293
|
-
|
|
294
|
-
test('Stores the latest target on the accessory context', async () => {
|
|
295
|
-
const { instance, device, accessory } = makeSimpleGarage();
|
|
296
|
-
const op1 = instance.setTargetDoorState(TDS.CLOSED);
|
|
297
|
-
expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
|
|
298
|
-
const op2 = instance.setTargetDoorState(TDS.OPEN);
|
|
299
|
-
expect(accessory.context.cachedTargetDoorState).toBe(TDS.OPEN);
|
|
300
|
-
|
|
301
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
302
|
-
device.emit('change', { '2': false }, { '2': false });
|
|
303
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
304
|
-
device.emit('change', { '1': false }, { '1': false });
|
|
305
|
-
await op1;
|
|
306
|
-
await op2;
|
|
307
|
-
});
|
|
308
|
-
});
|