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.
@@ -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
- if (!this.device.context.noCool && characteristicCoolingThresholdTemperature && characteristicCoolingThresholdTemperature.value !== changes[this.dpThreshold])
162
- characteristicCoolingThresholdTemperature.updateValue(changes[this.dpThreshold]);
163
- if (!this.device.context.noHeat && characteristicHeatingThresholdTemperature && characteristicHeatingThresholdTemperature.value !== changes[this.dpThreshold])
164
- characteristicHeatingThresholdTemperature.updateValue(changes[this.dpThreshold]);
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) && characteristicCurrentTemperature.value !== changes[this.dpCurrentTemperature])
168
- characteristicCurrentTemperature.updateValue(changes[this.dpCurrentTemperature]);
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
- await this.setMultiStateLegacyAsync({[this.dpActive]: true, [this.dpThreshold]: value});
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.11.3-beta.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
- });