homebridge-tuya-plus 3.11.0 → 3.11.1-beta.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.
@@ -518,6 +518,14 @@
518
518
  "functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
519
519
  }
520
520
  },
521
+ "commandDelay": {
522
+ "type": "integer",
523
+ "placeholder": "2000",
524
+ "description": "Milliseconds to wait between the stop command and the open/close command. Increase if your controller drops the direction command after a stop.",
525
+ "condition": {
526
+ "functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
527
+ }
528
+ },
521
529
  "flipState": {
522
530
  "type": "boolean",
523
531
  "condition": {
@@ -1,10 +1,17 @@
1
1
  const BaseAccessory = require('./BaseAccessory');
2
2
 
3
- // Window during which CurrentDoorState lags TargetDoorState so HomeKit's
4
- // "Opening..."/"Closing..." caption is visible after a toggle. The device
5
- // has no position feedback, so this is purely cosmetic.
3
+ // Delay between the stop command and the open/close command. Some controllers
4
+ // drop the direction command when it arrives while they are still processing
5
+ // the stop, so we wait this long before sending the direction.
6
+ const DEFAULT_COMMAND_DELAY_MS = 2000;
7
+
8
+ // Delay between the direction command and flipping CurrentDoorState. The
9
+ // device has no position feedback so this is purely cosmetic — it keeps
10
+ // HomeKit's "Opening..."/"Closing..." caption visible for at least this long.
6
11
  const CURRENT_STATE_DELAY_MS = 1000;
7
12
 
13
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
14
+
8
15
  class SimpleGarageDoorAccessory extends BaseAccessory {
9
16
  static getCategory(Categories) {
10
17
  return Categories.GARAGE_DOOR_OPENER;
@@ -31,6 +38,11 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
31
38
  this.dpStop = this._getCustomDP(this.device.context.dpStop) || '2';
32
39
  this.dpClose = this._getCustomDP(this.device.context.dpClose) || '3';
33
40
 
41
+ const configuredDelay = parseInt(this.device.context.commandDelay, 10);
42
+ this.commandDelayMs = Number.isFinite(configuredDelay) && configuredDelay >= 0
43
+ ? configuredDelay
44
+ : DEFAULT_COMMAND_DELAY_MS;
45
+
34
46
  // The device only exposes momentary action DPs, so the target state is
35
47
  // tracked locally and persisted via the homebridge accessory context.
36
48
  if (this.accessory.context.cachedTargetDoorState !== Characteristic.TargetDoorState.OPEN &&
@@ -42,6 +54,10 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
42
54
  ? Characteristic.CurrentDoorState.OPEN
43
55
  : Characteristic.CurrentDoorState.CLOSED;
44
56
 
57
+ // Each setTargetDoorState invocation bumps this token; an older
58
+ // in-flight chain bails out at its next await when the token changes.
59
+ this.opToken = 0;
60
+
45
61
  this.characteristicTargetDoorState = service.getCharacteristic(Characteristic.TargetDoorState)
46
62
  .updateValue(initialTarget)
47
63
  .onGet(() => this.accessory.context.cachedTargetDoorState)
@@ -56,28 +72,43 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
56
72
  .onGet(() => false);
57
73
  }
58
74
 
59
- setTargetDoorState(value) {
75
+ // Wraps the synchronous Tuya write so the caller can await it. The Tuya
76
+ // transport is fire-and-forget at the JS level — the data has been handed
77
+ // to the kernel by the time setMultiStateLegacyAsync returns — but using
78
+ // an awaited call keeps the command sequence in one readable async chain.
79
+ async _sendDps(dps) {
80
+ this.setMultiStateLegacyAsync(dps);
81
+ }
82
+
83
+ async setTargetDoorState(value) {
60
84
  const {Characteristic} = this.hap;
61
85
 
62
86
  this.accessory.context.cachedTargetDoorState = value;
63
87
 
88
+ const opToken = ++this.opToken;
89
+
64
90
  // Send stop first so reversing direction mid-motion works; if the gate
65
91
  // is already idle, the stop is a no-op on the device side.
66
- this.setMultiStateLegacyAsync({[this.dpStop]: true});
92
+ await this._sendDps({[this.dpStop]: true});
93
+ if (opToken !== this.opToken) return;
94
+
95
+ await sleep(this.commandDelayMs);
96
+ if (opToken !== this.opToken) return;
97
+
67
98
  if (value === Characteristic.TargetDoorState.OPEN) {
68
- this.setMultiStateLegacyAsync({[this.dpOpen]: true});
99
+ await this._sendDps({[this.dpOpen]: true});
69
100
  } else {
70
- this.setMultiStateLegacyAsync({[this.dpClose]: true});
101
+ await this._sendDps({[this.dpClose]: true});
71
102
  }
103
+ if (opToken !== this.opToken) return;
104
+
105
+ await sleep(CURRENT_STATE_DELAY_MS);
106
+ if (opToken !== this.opToken) return;
72
107
 
73
- if (this.currentStateTimeout) clearTimeout(this.currentStateTimeout);
74
- this.currentStateTimeout = setTimeout(() => {
75
- this.currentStateTimeout = null;
76
- this.currentDoorState = value === Characteristic.TargetDoorState.OPEN
77
- ? Characteristic.CurrentDoorState.OPEN
78
- : Characteristic.CurrentDoorState.CLOSED;
79
- this.characteristicCurrentDoorState.updateValue(this.currentDoorState);
80
- }, CURRENT_STATE_DELAY_MS);
108
+ this.currentDoorState = value === Characteristic.TargetDoorState.OPEN
109
+ ? Characteristic.CurrentDoorState.OPEN
110
+ : Characteristic.CurrentDoorState.CLOSED;
111
+ this.characteristicCurrentDoorState.updateValue(this.currentDoorState);
81
112
  }
82
113
  }
83
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.11.0",
3
+ "version": "3.11.1-beta.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": {
@@ -5,6 +5,10 @@ const { HAP, makeInstance } = require('./support/mocks');
5
5
 
6
6
  const { CurrentDoorState: CDS, TargetDoorState: TDS } = HAP.Characteristic;
7
7
 
8
+ const DEFAULT_COMMAND_DELAY_MS = 500;
9
+ const CURRENT_STATE_DELAY_MS = 1000;
10
+ const TOTAL_DELAY_MS = DEFAULT_COMMAND_DELAY_MS + CURRENT_STATE_DELAY_MS;
11
+
8
12
  function makeSimpleGarage(contextOverrides = {}) {
9
13
  const { instance, device, accessory, platform } = makeInstance(
10
14
  SimpleGarageDoorAccessory,
@@ -16,6 +20,8 @@ function makeSimpleGarage(contextOverrides = {}) {
16
20
  instance.dpOpen = '1';
17
21
  instance.dpStop = '2';
18
22
  instance.dpClose = '3';
23
+ instance.commandDelayMs = DEFAULT_COMMAND_DELAY_MS;
24
+ instance.opToken = 0;
19
25
  instance.currentDoorState = CDS.OPEN;
20
26
  instance.characteristicCurrentDoorState = {
21
27
  value: CDS.OPEN,
@@ -29,35 +35,108 @@ function makeSimpleGarage(contextOverrides = {}) {
29
35
  // setTargetDoorState — device commands
30
36
  // ---------------------------------------------------------------------------
31
37
  describe('SimpleGarageDoorAccessory.setTargetDoorState — device commands', () => {
32
- test('OPEN sends stop=true then open=true', () => {
38
+ beforeEach(() => jest.useFakeTimers());
39
+ afterEach(() => jest.useRealTimers());
40
+
41
+ test('OPEN sends stop=true, then open=true after commandDelay', async () => {
33
42
  const { instance, device } = makeSimpleGarage();
34
- instance.setTargetDoorState(TDS.OPEN);
43
+ const op = instance.setTargetDoorState(TDS.OPEN);
44
+
45
+ await jest.advanceTimersByTimeAsync(0);
46
+ expect(device.update).toHaveBeenCalledTimes(1);
35
47
  expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
48
+
49
+ await jest.advanceTimersByTimeAsync(DEFAULT_COMMAND_DELAY_MS - 1);
50
+ expect(device.update).toHaveBeenCalledTimes(1);
51
+
52
+ await jest.advanceTimersByTimeAsync(1);
53
+ expect(device.update).toHaveBeenCalledTimes(2);
36
54
  expect(device.update).toHaveBeenNthCalledWith(2, { '1': true });
55
+
56
+ await jest.advanceTimersByTimeAsync(CURRENT_STATE_DELAY_MS);
57
+ await op;
37
58
  });
38
59
 
39
- test('CLOSED sends stop=true then close=true', () => {
60
+ test('CLOSED sends stop=true, then close=true after commandDelay', async () => {
40
61
  const { instance, device } = makeSimpleGarage();
41
- instance.setTargetDoorState(TDS.CLOSED);
62
+ const op = instance.setTargetDoorState(TDS.CLOSED);
63
+
64
+ await jest.advanceTimersByTimeAsync(0);
42
65
  expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
66
+
67
+ await jest.advanceTimersByTimeAsync(DEFAULT_COMMAND_DELAY_MS);
43
68
  expect(device.update).toHaveBeenNthCalledWith(2, { '3': true });
44
- });
45
69
 
46
- test('Skips writes when the device is disconnected', () => {
47
- const { instance, device } = makeSimpleGarage();
48
- device.connected = false;
49
- instance.setTargetDoorState(TDS.OPEN);
50
- expect(device.update).not.toHaveBeenCalled();
70
+ await jest.advanceTimersByTimeAsync(CURRENT_STATE_DELAY_MS);
71
+ await op;
51
72
  });
52
73
 
53
- test('Custom DPs are respected', () => {
74
+ test('Custom DPs are respected', async () => {
54
75
  const { instance, device } = makeSimpleGarage();
55
76
  instance.dpOpen = '101';
56
77
  instance.dpStop = '102';
57
78
  instance.dpClose = '103';
58
- instance.setTargetDoorState(TDS.OPEN);
79
+ const op = instance.setTargetDoorState(TDS.OPEN);
80
+
81
+ await jest.advanceTimersByTimeAsync(0);
59
82
  expect(device.update).toHaveBeenNthCalledWith(1, { '102': true });
83
+
84
+ await jest.advanceTimersByTimeAsync(DEFAULT_COMMAND_DELAY_MS);
60
85
  expect(device.update).toHaveBeenNthCalledWith(2, { '101': true });
86
+
87
+ await jest.advanceTimersByTimeAsync(CURRENT_STATE_DELAY_MS);
88
+ await op;
89
+ });
90
+
91
+ test('Skips writes when the device is disconnected', async () => {
92
+ const { instance, device } = makeSimpleGarage();
93
+ device.connected = false;
94
+ const op = instance.setTargetDoorState(TDS.OPEN);
95
+ await jest.advanceTimersByTimeAsync(TOTAL_DELAY_MS);
96
+ await op;
97
+ expect(device.update).not.toHaveBeenCalled();
98
+ });
99
+
100
+ test('Configured commandDelay is used between stop and direction', async () => {
101
+ const { instance, device } = makeSimpleGarage();
102
+ instance.commandDelayMs = 5000;
103
+ const op = instance.setTargetDoorState(TDS.OPEN);
104
+
105
+ await jest.advanceTimersByTimeAsync(0);
106
+ expect(device.update).toHaveBeenCalledTimes(1);
107
+
108
+ await jest.advanceTimersByTimeAsync(4999);
109
+ expect(device.update).toHaveBeenCalledTimes(1);
110
+
111
+ await jest.advanceTimersByTimeAsync(1);
112
+ expect(device.update).toHaveBeenCalledTimes(2);
113
+ expect(device.update).toHaveBeenNthCalledWith(2, { '1': true });
114
+
115
+ await jest.advanceTimersByTimeAsync(CURRENT_STATE_DELAY_MS);
116
+ await op;
117
+ });
118
+
119
+ test('Reversing during the stop->direction window cancels the pending direction command', async () => {
120
+ const { instance, device } = makeSimpleGarage();
121
+ const op1 = instance.setTargetDoorState(TDS.OPEN);
122
+ await jest.advanceTimersByTimeAsync(0);
123
+ expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
124
+
125
+ await jest.advanceTimersByTimeAsync(500);
126
+ const op2 = instance.setTargetDoorState(TDS.CLOSED);
127
+ await jest.advanceTimersByTimeAsync(0);
128
+
129
+ // A second stop is sent, but the originally-pending open must not fire.
130
+ expect(device.update).toHaveBeenCalledTimes(2);
131
+ expect(device.update).toHaveBeenNthCalledWith(2, { '2': true });
132
+
133
+ await jest.advanceTimersByTimeAsync(DEFAULT_COMMAND_DELAY_MS);
134
+ expect(device.update).toHaveBeenCalledTimes(3);
135
+ expect(device.update).toHaveBeenNthCalledWith(3, { '3': true });
136
+
137
+ await jest.advanceTimersByTimeAsync(CURRENT_STATE_DELAY_MS);
138
+ await op1;
139
+ await op2;
61
140
  });
62
141
  });
63
142
 
@@ -68,48 +147,51 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — CurrentDoorState tran
68
147
  beforeEach(() => jest.useFakeTimers());
69
148
  afterEach(() => jest.useRealTimers());
70
149
 
71
- test('CurrentDoorState lags the target by 1s when opening', () => {
150
+ test('CurrentDoorState updates to OPEN after commandDelay + currentStateDelay', async () => {
72
151
  const { instance } = makeSimpleGarage();
73
152
  instance.currentDoorState = CDS.CLOSED;
74
153
  instance.characteristicCurrentDoorState.value = CDS.CLOSED;
75
154
 
76
- instance.setTargetDoorState(TDS.OPEN);
155
+ const op = instance.setTargetDoorState(TDS.OPEN);
156
+ await jest.advanceTimersByTimeAsync(0);
77
157
  expect(instance.characteristicCurrentDoorState.value).toBe(CDS.CLOSED);
78
158
 
79
- jest.advanceTimersByTime(999);
159
+ await jest.advanceTimersByTimeAsync(TOTAL_DELAY_MS - 1);
80
160
  expect(instance.characteristicCurrentDoorState.value).toBe(CDS.CLOSED);
81
161
 
82
- jest.advanceTimersByTime(1);
162
+ await jest.advanceTimersByTimeAsync(1);
163
+ await op;
83
164
  expect(instance.currentDoorState).toBe(CDS.OPEN);
84
165
  expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
85
166
  });
86
167
 
87
- test('CurrentDoorState lags the target by 1s when closing', () => {
168
+ test('CurrentDoorState updates to CLOSED after commandDelay + currentStateDelay', async () => {
88
169
  const { instance } = makeSimpleGarage();
89
170
  instance.currentDoorState = CDS.OPEN;
90
171
  instance.characteristicCurrentDoorState.value = CDS.OPEN;
91
172
 
92
- instance.setTargetDoorState(TDS.CLOSED);
93
- expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
94
-
95
- jest.advanceTimersByTime(1000);
173
+ const op = instance.setTargetDoorState(TDS.CLOSED);
174
+ await jest.advanceTimersByTimeAsync(TOTAL_DELAY_MS);
175
+ await op;
96
176
  expect(instance.currentDoorState).toBe(CDS.CLOSED);
97
177
  expect(instance.characteristicCurrentDoorState.value).toBe(CDS.CLOSED);
98
178
  });
99
179
 
100
- test('Reversing direction within the 1s window resets the timer', () => {
180
+ test('Reversing direction mid-transition cancels the stale CurrentDoorState update', async () => {
101
181
  const { instance } = makeSimpleGarage();
102
182
  instance.currentDoorState = CDS.OPEN;
103
183
  instance.characteristicCurrentDoorState.value = CDS.OPEN;
104
184
 
105
- instance.setTargetDoorState(TDS.CLOSED);
106
- jest.advanceTimersByTime(500);
107
- instance.setTargetDoorState(TDS.OPEN);
185
+ const op1 = instance.setTargetDoorState(TDS.CLOSED);
186
+ await jest.advanceTimersByTimeAsync(DEFAULT_COMMAND_DELAY_MS + 500);
187
+ const op2 = instance.setTargetDoorState(TDS.OPEN);
108
188
 
109
- jest.advanceTimersByTime(500);
189
+ await jest.advanceTimersByTimeAsync(500);
110
190
  expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
111
191
 
112
- jest.advanceTimersByTime(500);
192
+ await jest.advanceTimersByTimeAsync(TOTAL_DELAY_MS);
193
+ await op1;
194
+ await op2;
113
195
  expect(instance.currentDoorState).toBe(CDS.OPEN);
114
196
  expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
115
197
  });
@@ -119,11 +201,18 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — CurrentDoorState tran
119
201
  // Persistence
120
202
  // ---------------------------------------------------------------------------
121
203
  describe('SimpleGarageDoorAccessory persistence', () => {
122
- test('Stores the latest target on the accessory context', () => {
204
+ beforeEach(() => jest.useFakeTimers());
205
+ afterEach(() => jest.useRealTimers());
206
+
207
+ test('Stores the latest target on the accessory context', async () => {
123
208
  const { instance, accessory } = makeSimpleGarage();
124
- instance.setTargetDoorState(TDS.CLOSED);
209
+ const op1 = instance.setTargetDoorState(TDS.CLOSED);
125
210
  expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
126
- instance.setTargetDoorState(TDS.OPEN);
211
+ const op2 = instance.setTargetDoorState(TDS.OPEN);
127
212
  expect(accessory.context.cachedTargetDoorState).toBe(TDS.OPEN);
213
+
214
+ await jest.advanceTimersByTimeAsync(TOTAL_DELAY_MS);
215
+ await op1;
216
+ await op2;
128
217
  });
129
218
  });
@@ -18,6 +18,7 @@ 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>|
21
22
  |Simple Blinds|`SimpleBlinds`<sup>[11](#simple-blinds)</sup>|Smart blinds and smart switches that control blinds <small>([instructions](#simple-blinds))</small>|
22
23
  |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>|
23
24
  |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>|
@@ -402,6 +403,38 @@ While still in early testing, you can use this to open and close the garage door
402
403
  }
403
404
  ```
404
405
 
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), waits `commandDelay` milliseconds, and then sends the open or close command. There is no obstruction detection.
408
+
409
+ If your controller stops the gate but does not start moving in the requested direction afterwards, increase `commandDelay` — some devices drop the direction command if it arrives too soon after the stop.
410
+
411
+ ```json5
412
+ {
413
+ "name": "My Sliding Gate",
414
+ "type": "SimpleGarageDoor",
415
+ "manufacturer": "Generic",
416
+ "model": "Generic Sliding Gate Controller",
417
+ "id": "032000123456789abcde",
418
+ "key": "0123456789abcdef",
419
+
420
+ /* Additional parameters to override defaults only if needed */
421
+
422
+ /* Override the default datapoint identifier for the open action */
423
+ "dpOpen": 1,
424
+
425
+ /* Override the default datapoint identifier for the stop action */
426
+ "dpStop": 2,
427
+
428
+ /* Override the default datapoint identifier for the close action */
429
+ "dpClose": 3,
430
+
431
+ /* Milliseconds to wait between the stop and the open/close command.
432
+ Default is 2000. Raise it if your controller ignores the direction
433
+ command when it arrives too quickly after the stop. */
434
+ "commandDelay": 2000
435
+ }
436
+ ```
437
+
405
438
  ### Simple Blinds
406
439
  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.
407
440