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.
@@ -5,10 +5,10 @@ const { HAP, makeInstance } = require('./support/mocks');
5
5
 
6
6
  const { CurrentDoorState: CDS, TargetDoorState: TDS } = HAP.Characteristic;
7
7
 
8
- const POST_RESET_DELAY_MS = 500;
9
- const STOP_RESET_TIMEOUT_MS = 3000;
10
- const DIRECTION_RESET_TIMEOUT_MS = 3000;
11
- const SETTLE_MS = 1000;
8
+ // Raw status DP values reported by the controller (DP 105).
9
+ const STATE_STOPPED = 11;
10
+ const STATE_OPENING_OR_OPEN = 12;
11
+ const STATE_CLOSING_OR_CLOSED = 13;
12
12
 
13
13
  // Give the device's `on`/`removeListener` mocks real subscription semantics
14
14
  // so the accessory can wait for `change` events the way it does in production.
@@ -44,32 +44,28 @@ function makeSimpleGarage(initialContext = {}) {
44
44
  // call it directly because the mock service shares a single mock
45
45
  // characteristic across calls — wiring each role manually keeps the
46
46
  // assertions untangled.
47
- instance.dpOpen = '1';
48
- instance.dpStop = '2';
49
- instance.dpClose = '3';
47
+ instance.dpOpen = '101';
48
+ instance.dpClose = '102';
49
+ instance.dpStop = '103';
50
+ instance.dpState = '105';
50
51
  instance.partialOpenMs = 0;
51
- instance.currentDoorState = CDS.OPEN;
52
- instance.desiredTarget = TDS.OPEN;
53
- instance.worker = null;
54
- instance.scheduleTimer = null;
52
+ instance.stopBeforeCloseMs = 1500;
55
53
  instance.partialStopTimer = null;
56
- instance.partialOpenId = 0;
57
- instance._partialPending = false;
58
- instance._currentChangePending = null;
59
- instance._currentChangeResolve = null;
54
+ instance.pendingCloseTimer = null;
55
+ instance.currentDoorState = CDS.CLOSED;
60
56
  instance.characteristicCurrentDoorState = {
61
- value: CDS.OPEN,
57
+ value: CDS.CLOSED,
62
58
  updateValue: jest.fn().mockImplementation(function(v) { this.value = v; return this; }),
63
59
  };
64
60
  instance.characteristicTargetDoorState = {
65
- value: TDS.OPEN,
61
+ value: TDS.CLOSED,
66
62
  updateValue: jest.fn().mockImplementation(function(v) { this.value = v; return this; }),
67
63
  };
68
64
  instance.characteristicPartialOpen = {
69
- value: true,
65
+ value: false,
70
66
  updateValue: jest.fn().mockImplementation(function(v) { this.value = v; return this; }),
71
67
  };
72
- accessory.context.cachedTargetDoorState = TDS.OPEN;
68
+ accessory.context.cachedTargetDoorState = TDS.CLOSED;
73
69
 
74
70
  // Mirror the persistent change listener registered in production.
75
71
  device.on('change', changes => instance._onDeviceChange(changes));
@@ -77,318 +73,299 @@ function makeSimpleGarage(initialContext = {}) {
77
73
  return { instance, device, accessory, platform };
78
74
  }
79
75
 
80
- // Simulate the device echoing a DP back to false (its "command consumed"
81
- // signal). Drives both the worker's per-step listener and the persistent
82
- // CurrentDoorState listener.
83
- function emitReset(device, dp) {
84
- device.emit('change', { [dp]: false }, { [dp]: false });
76
+ // Simulate the controller reporting a new value on its status DP.
77
+ function emitState(device, value) {
78
+ device.emit('change', { '105': value });
85
79
  }
86
80
 
87
81
  // ---------------------------------------------------------------------------
88
- // _onDeviceChange the persistent listener that drives CurrentDoorState
82
+ // State DP -> door state mapping
89
83
  // ---------------------------------------------------------------------------
90
84
  describe('SimpleGarageDoorAccessory._onDeviceChange', () => {
91
- test('Open DP resetting to false sets CurrentDoorState to OPEN', () => {
92
- const { instance } = makeSimpleGarage();
85
+ test('State 12 (opening/open) drives Current and Target to OPEN', () => {
86
+ const { instance, accessory } = makeSimpleGarage();
93
87
  instance.currentDoorState = CDS.CLOSED;
94
88
  instance.characteristicCurrentDoorState.value = CDS.CLOSED;
89
+ accessory.context.cachedTargetDoorState = TDS.CLOSED;
95
90
 
96
- instance._onDeviceChange({ '1': false });
91
+ instance._onDeviceChange({ '105': STATE_OPENING_OR_OPEN });
97
92
 
98
93
  expect(instance.currentDoorState).toBe(CDS.OPEN);
99
94
  expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
95
+ expect(instance.characteristicTargetDoorState.value).toBe(TDS.OPEN);
96
+ expect(accessory.context.cachedTargetDoorState).toBe(TDS.OPEN);
100
97
  });
101
98
 
102
- test('Close DP resetting to false sets CurrentDoorState to CLOSED', () => {
99
+ test('State 11 (stopped) is treated as OPEN', () => {
103
100
  const { instance } = makeSimpleGarage();
101
+ instance.currentDoorState = CDS.CLOSED;
102
+ instance.characteristicCurrentDoorState.value = CDS.CLOSED;
103
+
104
+ instance._onDeviceChange({ '105': STATE_STOPPED });
105
+
106
+ expect(instance.currentDoorState).toBe(CDS.OPEN);
107
+ expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
108
+ });
109
+
110
+ test('State 13 (closing/closed) drives Current and Target to CLOSED', () => {
111
+ const { instance, accessory } = makeSimpleGarage();
104
112
  instance.currentDoorState = CDS.OPEN;
105
113
  instance.characteristicCurrentDoorState.value = CDS.OPEN;
114
+ instance.characteristicTargetDoorState.value = TDS.OPEN;
115
+ accessory.context.cachedTargetDoorState = TDS.OPEN;
106
116
 
107
- instance._onDeviceChange({ '3': false });
117
+ instance._onDeviceChange({ '105': STATE_CLOSING_OR_CLOSED });
108
118
 
109
119
  expect(instance.currentDoorState).toBe(CDS.CLOSED);
110
120
  expect(instance.characteristicCurrentDoorState.value).toBe(CDS.CLOSED);
121
+ expect(instance.characteristicTargetDoorState.value).toBe(TDS.CLOSED);
122
+ expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
123
+ });
124
+
125
+ test('String DP values are coerced', () => {
126
+ const { instance } = makeSimpleGarage();
127
+ instance.currentDoorState = CDS.CLOSED;
128
+
129
+ instance._onDeviceChange({ '105': '12' });
130
+
131
+ expect(instance.currentDoorState).toBe(CDS.OPEN);
111
132
  });
112
133
 
113
- test('Open DP echoing back to true does not change CurrentDoorState', () => {
134
+ test('Unknown DP values are ignored', () => {
114
135
  const { instance } = makeSimpleGarage();
115
136
  instance.currentDoorState = CDS.CLOSED;
116
137
 
117
- instance._onDeviceChange({ '1': true });
138
+ instance._onDeviceChange({ '105': 99 });
118
139
 
119
140
  expect(instance.currentDoorState).toBe(CDS.CLOSED);
141
+ expect(instance.characteristicCurrentDoorState.updateValue).not.toHaveBeenCalled();
120
142
  });
121
143
 
122
- test('Stop DP resets do not change CurrentDoorState', () => {
144
+ test('Changes that do not include the state DP are ignored', () => {
123
145
  const { instance } = makeSimpleGarage();
124
146
  instance.currentDoorState = CDS.CLOSED;
125
147
 
126
- instance._onDeviceChange({ '2': false });
148
+ // The action DPs echoing back are irrelevant — only the state DP matters.
149
+ instance._onDeviceChange({ '101': false });
150
+ instance._onDeviceChange({ '103': true });
127
151
 
128
152
  expect(instance.currentDoorState).toBe(CDS.CLOSED);
153
+ expect(instance.characteristicCurrentDoorState.updateValue).not.toHaveBeenCalled();
129
154
  });
130
155
 
131
- test('No characteristic write when CurrentDoorState already matches', () => {
156
+ test('No characteristic write when the state already matches', () => {
132
157
  const { instance } = makeSimpleGarage();
133
158
  instance.currentDoorState = CDS.OPEN;
159
+ instance.characteristicCurrentDoorState.value = CDS.OPEN;
160
+ instance.accessory.context.cachedTargetDoorState = TDS.OPEN;
134
161
 
135
- instance._onDeviceChange({ '1': false });
162
+ instance._onDeviceChange({ '105': STATE_OPENING_OR_OPEN });
136
163
 
137
164
  expect(instance.characteristicCurrentDoorState.updateValue).not.toHaveBeenCalled();
165
+ expect(instance.characteristicTargetDoorState.updateValue).not.toHaveBeenCalled();
138
166
  });
139
167
  });
140
168
 
141
169
  // ---------------------------------------------------------------------------
142
- // setTargetDoorStatesingle command (target differs from current)
170
+ // Openalways fired directly, even mid-motion
143
171
  // ---------------------------------------------------------------------------
144
- describe('SimpleGarageDoorAccessory.setTargetDoorState — single command', () => {
145
- beforeEach(() => jest.useFakeTimers());
146
- afterEach(() => jest.useRealTimers());
147
-
148
- test('OPEN debounces for SETTLE_MS then sends stop, waits for reset+500ms, then sends open', async () => {
172
+ describe('SimpleGarageDoorAccessory.setTargetDoorState — open', () => {
173
+ test('OPEN fires the open action immediately, even while the gate is closing', () => {
149
174
  const { instance, device } = makeSimpleGarage();
150
- instance.currentDoorState = CDS.CLOSED;
151
- instance.characteristicCurrentDoorState.value = CDS.CLOSED;
175
+ device.state['105'] = STATE_CLOSING_OR_CLOSED;
152
176
 
153
177
  instance.setTargetDoorState(TDS.OPEN);
154
- await jest.advanceTimersByTimeAsync(SETTLE_MS - 1);
155
- expect(device.update).not.toHaveBeenCalled();
156
178
 
157
- await jest.advanceTimersByTimeAsync(1);
158
- expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
159
-
160
- emitReset(device, '2');
161
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
162
- expect(device.update).toHaveBeenNthCalledWith(2, { '1': true });
163
-
164
- emitReset(device, '1');
165
- await jest.advanceTimersByTimeAsync(0);
166
- expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
167
- expect(instance.worker).toBeNull();
168
- expect(instance.scheduleTimer).toBeNull();
179
+ expect(device.update).toHaveBeenCalledTimes(1);
180
+ expect(device.update).toHaveBeenCalledWith({ '101': true });
169
181
  });
170
182
 
171
- test('CLOSE debounces then sends stop, waits, then sends close; CurrentDoorState flips on close reset', async () => {
183
+ test('OPEN never fires a stop first', () => {
172
184
  const { instance, device } = makeSimpleGarage();
173
- instance.currentDoorState = CDS.OPEN;
174
- instance.characteristicCurrentDoorState.value = CDS.OPEN;
175
-
176
- instance.setTargetDoorState(TDS.CLOSED);
177
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
178
- expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
185
+ device.state['105'] = STATE_OPENING_OR_OPEN;
179
186
 
180
- emitReset(device, '2');
181
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
182
- expect(device.update).toHaveBeenNthCalledWith(2, { '3': true });
187
+ instance.setTargetDoorState(TDS.OPEN);
183
188
 
184
- emitReset(device, '3');
185
- await jest.advanceTimersByTimeAsync(0);
186
- expect(instance.characteristicCurrentDoorState.value).toBe(CDS.CLOSED);
187
- expect(instance.worker).toBeNull();
189
+ expect(device.update).not.toHaveBeenCalledWith({ '103': true });
190
+ expect(device.update).toHaveBeenCalledWith({ '101': true });
188
191
  });
189
192
 
190
- test('Target matching current produces no commands', async () => {
191
- const { instance, device } = makeSimpleGarage();
192
- instance.currentDoorState = CDS.OPEN;
193
- instance.desiredTarget = TDS.OPEN;
193
+ test('Target + persistence update optimistically; Current waits for the DP', () => {
194
+ const { instance, device, accessory } = makeSimpleGarage();
195
+ instance.currentDoorState = CDS.CLOSED;
196
+ instance.characteristicCurrentDoorState.value = CDS.CLOSED;
194
197
 
195
198
  instance.setTargetDoorState(TDS.OPEN);
196
- await jest.advanceTimersByTimeAsync(
197
- SETTLE_MS + STOP_RESET_TIMEOUT_MS + POST_RESET_DELAY_MS + DIRECTION_RESET_TIMEOUT_MS
198
- );
199
+ expect(accessory.context.cachedTargetDoorState).toBe(TDS.OPEN);
200
+ expect(instance.characteristicTargetDoorState.value).toBe(TDS.OPEN);
201
+ expect(instance.characteristicCurrentDoorState.updateValue).not.toHaveBeenCalled();
202
+ expect(instance.currentDoorState).toBe(CDS.CLOSED);
199
203
 
200
- expect(device.update).not.toHaveBeenCalled();
201
- expect(instance.worker).toBeNull();
204
+ // ~1s later the controller reports it is opening.
205
+ emitState(device, STATE_OPENING_OR_OPEN);
206
+ expect(instance.currentDoorState).toBe(CDS.OPEN);
207
+ expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
202
208
  });
203
209
 
204
- test('Custom DPs are respected', async () => {
210
+ test('Custom open DP is respected', () => {
205
211
  const { instance, device } = makeSimpleGarage();
206
- instance.dpOpen = '101';
207
- instance.dpStop = '102';
208
- instance.dpClose = '103';
209
- instance.currentDoorState = CDS.CLOSED;
212
+ instance.dpOpen = '1';
210
213
 
211
214
  instance.setTargetDoorState(TDS.OPEN);
212
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
213
- expect(device.update).toHaveBeenNthCalledWith(1, { '102': true });
214
215
 
215
- emitReset(device, '102');
216
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
217
- expect(device.update).toHaveBeenNthCalledWith(2, { '101': true });
218
-
219
- emitReset(device, '101');
220
- await jest.advanceTimersByTimeAsync(0);
221
- expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
216
+ expect(device.update).toHaveBeenCalledWith({ '1': true });
222
217
  });
223
218
  });
224
219
 
225
220
  // ---------------------------------------------------------------------------
226
- // Spam / debounce
221
+ // Close direct only when already stopped (state 11), otherwise stop-first
227
222
  // ---------------------------------------------------------------------------
228
- describe('SimpleGarageDoorAccessory.setTargetDoorState — spam debounce', () => {
223
+ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-close)', () => {
229
224
  beforeEach(() => jest.useFakeTimers());
230
225
  afterEach(() => jest.useRealTimers());
231
226
 
232
- test('Rapid presses within the debounce window coalesce into one cycle on the final target', async () => {
233
- const { instance, device } = makeSimpleGarage();
234
- instance.currentDoorState = CDS.OPEN;
227
+ test('CLOSE fires immediately when the gate is already stopped (state 11)', () => {
228
+ const { instance, device, accessory } = makeSimpleGarage();
229
+ device.state['105'] = STATE_STOPPED;
235
230
 
236
- // 5 presses across ~800ms, all within SETTLE_MS of each other.
237
- instance.setTargetDoorState(TDS.CLOSED);
238
- await jest.advanceTimersByTimeAsync(200);
239
- instance.setTargetDoorState(TDS.OPEN);
240
- await jest.advanceTimersByTimeAsync(200);
241
231
  instance.setTargetDoorState(TDS.CLOSED);
242
- await jest.advanceTimersByTimeAsync(200);
243
- instance.setTargetDoorState(TDS.OPEN);
244
- await jest.advanceTimersByTimeAsync(200);
232
+
233
+ expect(device.update).toHaveBeenCalledTimes(1);
234
+ expect(device.update).toHaveBeenCalledWith({ '102': true });
235
+ expect(instance.pendingCloseTimer).toBeNull();
236
+ expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
237
+ expect(instance.characteristicTargetDoorState.value).toBe(TDS.CLOSED);
238
+ });
239
+
240
+ test('CLOSE while opening (state 12) fires stop, then close after stopBeforeCloseMs', () => {
241
+ const { instance, device } = makeSimpleGarage();
242
+ instance.stopBeforeCloseMs = 1500;
243
+ device.state['105'] = STATE_OPENING_OR_OPEN;
244
+
245
245
  instance.setTargetDoorState(TDS.CLOSED);
246
246
 
247
- // Nothing should have fired yet debounce keeps resetting.
248
- expect(device.update).not.toHaveBeenCalled();
247
+ // Stop goes out immediately, close is deferred.
248
+ expect(device.update).toHaveBeenCalledTimes(1);
249
+ expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
249
250
 
250
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
251
- expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
251
+ jest.advanceTimersByTime(1500 - 1);
252
+ expect(device.update).toHaveBeenCalledTimes(1);
252
253
 
253
- emitReset(device, '2');
254
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
255
- expect(device.update).toHaveBeenNthCalledWith(2, { '3': true });
254
+ jest.advanceTimersByTime(1);
256
255
  expect(device.update).toHaveBeenCalledTimes(2);
257
-
258
- emitReset(device, '3');
259
- await jest.advanceTimersByTimeAsync(0);
260
- expect(instance.characteristicCurrentDoorState.value).toBe(CDS.CLOSED);
261
- expect(instance.worker).toBeNull();
256
+ expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
257
+ expect(instance.pendingCloseTimer).toBeNull();
262
258
  });
263
259
 
264
- test('Presses that settle on the current state produce no commands at all', async () => {
260
+ test('CLOSE while closing (state 13) also uses stop-before-close', () => {
265
261
  const { instance, device } = makeSimpleGarage();
266
- instance.currentDoorState = CDS.OPEN;
262
+ instance.stopBeforeCloseMs = 1500;
263
+ device.state['105'] = STATE_CLOSING_OR_CLOSED;
267
264
 
268
265
  instance.setTargetDoorState(TDS.CLOSED);
269
- await jest.advanceTimersByTimeAsync(200);
270
- instance.setTargetDoorState(TDS.OPEN);
271
- await jest.advanceTimersByTimeAsync(200);
272
- instance.setTargetDoorState(TDS.CLOSED);
273
- await jest.advanceTimersByTimeAsync(200);
274
- instance.setTargetDoorState(TDS.OPEN);
266
+ expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
275
267
 
276
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
277
- // Final target matches current — no work to do.
278
- expect(device.update).not.toHaveBeenCalled();
279
- expect(instance.worker).toBeNull();
268
+ jest.advanceTimersByTime(1500);
269
+ expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
280
270
  });
281
271
 
282
- test('Reverting to current while a worker is running suppresses the direction command', async () => {
272
+ test('CLOSE with no reported state yet uses stop-before-close', () => {
283
273
  const { instance, device } = makeSimpleGarage();
284
- instance.currentDoorState = CDS.OPEN;
274
+ instance.stopBeforeCloseMs = 1500;
275
+ // device.state['105'] intentionally left undefined
285
276
 
286
277
  instance.setTargetDoorState(TDS.CLOSED);
287
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
288
- expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
278
+ expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
289
279
 
290
- // User reverts mid-stop.
291
- instance.setTargetDoorState(TDS.OPEN);
292
-
293
- emitReset(device, '2');
294
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
295
- // Only stop went out — no direction, because target reverted to OPEN.
296
- expect(device.update).toHaveBeenCalledTimes(1);
297
- expect(instance.worker).toBeNull();
298
- expect(instance.scheduleTimer).toBeNull();
280
+ jest.advanceTimersByTime(1500);
281
+ expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
299
282
  });
300
283
 
301
- test('Pressing the opposite during the direction wait triggers a follow-up cycle after the debounce', async () => {
284
+ test('A duplicate CLOSE during the wait does not restart or double-fire', () => {
302
285
  const { instance, device } = makeSimpleGarage();
303
- instance.currentDoorState = CDS.OPEN;
304
- instance.characteristicCurrentDoorState.value = CDS.OPEN;
286
+ instance.stopBeforeCloseMs = 1500;
287
+ device.state['105'] = STATE_OPENING_OR_OPEN;
305
288
 
306
289
  instance.setTargetDoorState(TDS.CLOSED);
307
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
308
- emitReset(device, '2');
309
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
310
- expect(device.update).toHaveBeenNthCalledWith(2, { '3': true });
290
+ expect(device.update).toHaveBeenCalledTimes(1); // stop
311
291
 
312
- // User reverses while close is in flight.
313
- instance.setTargetDoorState(TDS.OPEN);
314
- emitReset(device, '3');
315
- await jest.advanceTimersByTimeAsync(0);
316
- // Close reset flipped UI to CLOSED. Worker exited, finally queued
317
- // another cycle behind the debounce.
318
- expect(instance.characteristicCurrentDoorState.value).toBe(CDS.CLOSED);
292
+ jest.advanceTimersByTime(1000);
293
+ instance.setTargetDoorState(TDS.CLOSED); // retransmit / second tap
294
+ expect(device.update).toHaveBeenCalledTimes(1); // no extra stop, timer not pushed out
295
+
296
+ jest.advanceTimersByTime(500);
319
297
  expect(device.update).toHaveBeenCalledTimes(2);
298
+ expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
299
+ });
320
300
 
321
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
322
- expect(device.update).toHaveBeenNthCalledWith(3, { '2': true });
301
+ test('OPEN during the wait cancels the pending close and opens instead', () => {
302
+ const { instance, device } = makeSimpleGarage();
303
+ instance.stopBeforeCloseMs = 1500;
304
+ device.state['105'] = STATE_OPENING_OR_OPEN;
323
305
 
324
- emitReset(device, '2');
325
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
326
- expect(device.update).toHaveBeenNthCalledWith(4, { '1': true });
306
+ instance.setTargetDoorState(TDS.CLOSED);
307
+ expect(device.update).toHaveBeenNthCalledWith(1, { '103': true }); // stop
327
308
 
328
- emitReset(device, '1');
329
- await jest.advanceTimersByTimeAsync(0);
330
- expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
331
- expect(instance.worker).toBeNull();
309
+ jest.advanceTimersByTime(500);
310
+ instance.setTargetDoorState(TDS.OPEN);
311
+ expect(instance.pendingCloseTimer).toBeNull();
312
+ expect(device.update).toHaveBeenNthCalledWith(2, { '101': true }); // open
313
+
314
+ // The trailing close must never fire.
315
+ jest.advanceTimersByTime(5000);
316
+ expect(device.update).toHaveBeenCalledTimes(2);
332
317
  });
333
318
 
334
- test('Only one worker is active at a time across rapid toggles', async () => {
335
- const { instance, device } = makeSimpleGarage();
319
+ test('Status reports are ignored during the stop-before-close window', () => {
320
+ const { instance, device, accessory } = makeSimpleGarage();
321
+ instance.stopBeforeCloseMs = 1500;
322
+ device.state['105'] = STATE_OPENING_OR_OPEN;
336
323
  instance.currentDoorState = CDS.OPEN;
324
+ instance.characteristicCurrentDoorState.value = CDS.OPEN;
337
325
 
338
326
  instance.setTargetDoorState(TDS.CLOSED);
339
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
340
- const workerA = instance.worker;
341
- expect(workerA).not.toBeNull();
327
+ expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
342
328
 
343
- instance.setTargetDoorState(TDS.OPEN);
344
- instance.setTargetDoorState(TDS.CLOSED);
345
- expect(instance.worker).toBe(workerA);
329
+ // The stop makes the controller momentarily report 11 (=OPEN). That
330
+ // must be ignored, or it would bounce Target back to OPEN mid-close.
331
+ emitState(device, STATE_STOPPED);
332
+ expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
333
+ expect(instance.currentDoorState).toBe(CDS.OPEN);
346
334
 
347
- emitReset(device, '2');
348
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
349
- emitReset(device, '3');
350
- await jest.advanceTimersByTimeAsync(0);
351
- expect(instance.worker).toBeNull();
335
+ // Close fires, then the controller reports 13 and we resume mirroring.
336
+ jest.advanceTimersByTime(1500);
337
+ expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
338
+ emitState(device, STATE_CLOSING_OR_CLOSED);
339
+ expect(instance.currentDoorState).toBe(CDS.CLOSED);
340
+ expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
352
341
  });
353
- });
354
-
355
- // ---------------------------------------------------------------------------
356
- // Timeout fallbacks
357
- // ---------------------------------------------------------------------------
358
- describe('SimpleGarageDoorAccessory — timeout fallbacks', () => {
359
- beforeEach(() => jest.useFakeTimers());
360
- afterEach(() => jest.useRealTimers());
361
342
 
362
- test('Falls back to sending the direction after STOP_RESET_TIMEOUT_MS if no echo arrives', async () => {
343
+ test('stopBeforeCloseMs is configurable', () => {
363
344
  const { instance, device } = makeSimpleGarage();
364
- instance.currentDoorState = CDS.CLOSED;
365
-
366
- instance.setTargetDoorState(TDS.OPEN);
367
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
368
- expect(device.update).toHaveBeenCalledTimes(1);
345
+ instance.stopBeforeCloseMs = 300;
346
+ device.state['105'] = STATE_OPENING_OR_OPEN;
369
347
 
370
- await jest.advanceTimersByTimeAsync(STOP_RESET_TIMEOUT_MS + POST_RESET_DELAY_MS);
371
- expect(device.update).toHaveBeenNthCalledWith(2, { '1': true });
348
+ instance.setTargetDoorState(TDS.CLOSED);
349
+ expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
372
350
 
373
- emitReset(device, '1');
374
- await jest.advanceTimersByTimeAsync(0);
375
- expect(instance.worker).toBeNull();
351
+ jest.advanceTimersByTime(299);
352
+ expect(device.update).toHaveBeenCalledTimes(1);
353
+ jest.advanceTimersByTime(1);
354
+ expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
376
355
  });
377
356
 
378
- test('Worker exits and force-flips CurrentDoorState after DIRECTION_RESET_TIMEOUT_MS when the echo is missed', async () => {
357
+ test('Custom stop/close DPs are respected in the stop-before-close path', () => {
379
358
  const { instance, device } = makeSimpleGarage();
380
- instance.currentDoorState = CDS.CLOSED;
381
- instance.characteristicCurrentDoorState.value = CDS.CLOSED;
359
+ instance.dpClose = '2';
360
+ instance.dpStop = '4';
361
+ instance.stopBeforeCloseMs = 1000;
362
+ device.state['105'] = STATE_OPENING_OR_OPEN;
382
363
 
383
- instance.setTargetDoorState(TDS.OPEN);
384
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
385
- emitReset(device, '2');
386
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
387
- expect(device.update).toHaveBeenNthCalledWith(2, { '1': true });
364
+ instance.setTargetDoorState(TDS.CLOSED);
365
+ expect(device.update).toHaveBeenNthCalledWith(1, { '4': true });
388
366
 
389
- await jest.advanceTimersByTimeAsync(DIRECTION_RESET_TIMEOUT_MS);
390
- expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
391
- expect(instance.worker).toBeNull();
367
+ jest.advanceTimersByTime(1000);
368
+ expect(device.update).toHaveBeenNthCalledWith(2, { '2': true });
392
369
  });
393
370
  });
394
371
 
@@ -396,18 +373,12 @@ describe('SimpleGarageDoorAccessory — timeout fallbacks', () => {
396
373
  // Disconnect handling
397
374
  // ---------------------------------------------------------------------------
398
375
  describe('SimpleGarageDoorAccessory — disconnected', () => {
399
- beforeEach(() => jest.useFakeTimers());
400
- afterEach(() => jest.useRealTimers());
401
-
402
- test('Skips writes when the device is disconnected', async () => {
376
+ test('Skips writes when the device is disconnected', () => {
403
377
  const { instance, device } = makeSimpleGarage();
404
378
  device.connected = false;
405
- instance.currentDoorState = CDS.CLOSED;
406
379
 
407
380
  instance.setTargetDoorState(TDS.OPEN);
408
- await jest.advanceTimersByTimeAsync(
409
- SETTLE_MS + STOP_RESET_TIMEOUT_MS + POST_RESET_DELAY_MS + DIRECTION_RESET_TIMEOUT_MS
410
- );
381
+
411
382
  expect(device.update).not.toHaveBeenCalled();
412
383
  });
413
384
  });
@@ -416,400 +387,149 @@ describe('SimpleGarageDoorAccessory — disconnected', () => {
416
387
  // Persistence
417
388
  // ---------------------------------------------------------------------------
418
389
  describe('SimpleGarageDoorAccessory persistence', () => {
419
- beforeEach(() => jest.useFakeTimers());
420
- afterEach(() => jest.useRealTimers());
421
-
422
- test('Stores the latest target on the accessory context', async () => {
390
+ test('Stores the latest target on the accessory context', () => {
423
391
  const { instance, device, accessory } = makeSimpleGarage();
424
- instance.currentDoorState = CDS.OPEN;
425
-
426
- instance.setTargetDoorState(TDS.CLOSED);
427
- expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
392
+ // Already stopped, so the close fires immediately (no dangling timer).
393
+ device.state['105'] = STATE_STOPPED;
428
394
 
429
395
  instance.setTargetDoorState(TDS.OPEN);
430
396
  expect(accessory.context.cachedTargetDoorState).toBe(TDS.OPEN);
431
397
 
432
- // Drain so any spawned worker resolves cleanly.
433
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
434
- if (device.update.mock.calls.length > 0) {
435
- emitReset(device, '2');
436
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
437
- }
398
+ instance.setTargetDoorState(TDS.CLOSED);
399
+ expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
438
400
  });
439
401
  });
440
402
 
441
403
  // ---------------------------------------------------------------------------
442
- // Partial-open switch
404
+ // Partial open
443
405
  // ---------------------------------------------------------------------------
444
406
  describe('SimpleGarageDoorAccessory._handlePartialOpen', () => {
445
407
  beforeEach(() => jest.useFakeTimers());
446
408
  afterEach(() => jest.useRealTimers());
447
409
 
448
- test('Opens the gate, waits partialOpenMs after current=OPEN, then sends a raw STOP', async () => {
410
+ test('Fires open, then fires stop after partialOpenMs', () => {
449
411
  const { instance, device } = makeSimpleGarage();
450
412
  instance.partialOpenMs = 2000;
451
- instance.currentDoorState = CDS.CLOSED;
452
- instance.characteristicCurrentDoorState.value = CDS.CLOSED;
453
- instance.desiredTarget = TDS.CLOSED;
454
413
 
455
414
  instance._handlePartialOpen();
456
415
 
457
- // Open cycle is queued through the regular debounce + worker.
458
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
459
- expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
460
- emitReset(device, '2');
461
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
462
- expect(device.update).toHaveBeenNthCalledWith(2, { '1': true });
463
-
464
- // Until the open echo lands the stop timer is not scheduled.
465
- // (Stay short of DIRECTION_RESET_TIMEOUT_MS so the belt-and-braces
466
- // current-state flip doesn't run yet.)
467
- await jest.advanceTimersByTimeAsync(1000);
468
- expect(device.update).toHaveBeenCalledTimes(2);
416
+ // Open goes out immediately.
417
+ expect(device.update).toHaveBeenCalledTimes(1);
418
+ expect(device.update).toHaveBeenNthCalledWith(1, { '101': true });
419
+ expect(instance.characteristicTargetDoorState.value).toBe(TDS.OPEN);
469
420
 
470
- // Open echo flips CurrentDoorState, partial-open flow then starts
471
- // its 2 s timer.
472
- emitReset(device, '1');
473
- await jest.advanceTimersByTimeAsync(0);
474
- expect(instance.currentDoorState).toBe(CDS.OPEN);
421
+ // Nothing happens until the timer elapses.
422
+ jest.advanceTimersByTime(2000 - 1);
423
+ expect(device.update).toHaveBeenCalledTimes(1);
475
424
 
476
- await jest.advanceTimersByTimeAsync(2000 - 1);
425
+ // Then a single stop is fired.
426
+ jest.advanceTimersByTime(1);
477
427
  expect(device.update).toHaveBeenCalledTimes(2);
428
+ expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
478
429
 
479
- // Timer fires: raw STOP write goes straight to the device, no queue,
480
- // no debounce, no follow-up direction. The stop is re-sent a few
481
- // times spread over ~1.2 s to defend against dropped writes.
482
- await jest.advanceTimersByTimeAsync(1);
483
- expect(device.update).toHaveBeenCalledTimes(3);
484
- expect(device.update).toHaveBeenNthCalledWith(3, { '2': true });
485
-
486
- await jest.advanceTimersByTimeAsync(1200);
487
- expect(device.update).toHaveBeenCalledTimes(6);
488
- expect(device.update).toHaveBeenNthCalledWith(4, { '2': true });
489
- expect(device.update).toHaveBeenNthCalledWith(5, { '2': true });
490
- expect(device.update).toHaveBeenNthCalledWith(6, { '2': true });
491
-
492
- await jest.advanceTimersByTimeAsync(10_000);
493
- expect(device.update).toHaveBeenCalledTimes(6);
494
- // Gate is now partially open — CurrentDoorState stays OPEN.
495
- expect(instance.currentDoorState).toBe(CDS.OPEN);
430
+ // And nothing more after that.
431
+ jest.advanceTimersByTime(10_000);
432
+ expect(device.update).toHaveBeenCalledTimes(2);
433
+ expect(instance.partialStopTimer).toBeNull();
496
434
  });
497
435
 
498
- test('Sends only the raw STOP if the gate is already OPEN', async () => {
436
+ test('Re-entrant press while armed is ignored (idempotent)', () => {
499
437
  const { instance, device } = makeSimpleGarage();
500
438
  instance.partialOpenMs = 2000;
501
- instance.currentDoorState = CDS.OPEN;
502
439
 
503
440
  instance._handlePartialOpen();
504
-
505
- await jest.advanceTimersByTimeAsync(0);
506
- // No open cycle needed — desiredTarget==current already.
507
- expect(device.update).not.toHaveBeenCalled();
508
-
509
- await jest.advanceTimersByTimeAsync(2000);
510
- // Four stop sends spread over ~1.2 s; first one fires immediately at
511
- // partialOpenMs.
512
441
  expect(device.update).toHaveBeenCalledTimes(1);
513
- expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
514
- await jest.advanceTimersByTimeAsync(1200);
515
- expect(device.update).toHaveBeenCalledTimes(4);
516
- });
517
-
518
- test('Pressing partial again while the stop timer is armed is ignored (idempotent)', async () => {
519
- // HomeKit/iOS retransmit a WRITE if the 204 response is delayed or
520
- // dropped, which fires onSet again. The retry must not cancel the
521
- // armed stop and push it out — otherwise repeated retries can
522
- // delay the auto-stop indefinitely and the gate runs all the way
523
- // open. The retry should be ignored; the original timer fires at
524
- // its original deadline.
525
- const { instance, device } = makeSimpleGarage();
526
- instance.partialOpenMs = 2000;
527
- instance.currentDoorState = CDS.OPEN;
528
-
529
- instance._handlePartialOpen();
530
- await jest.advanceTimersByTimeAsync(0);
531
-
532
- await jest.advanceTimersByTimeAsync(1500);
533
- // 1.5 s in, retry — should be ignored.
534
- instance._handlePartialOpen();
535
- await jest.advanceTimersByTimeAsync(0);
536
- expect(device.update).not.toHaveBeenCalled();
537
-
538
- // Original timer fires at the original 2000 ms deadline.
539
- await jest.advanceTimersByTimeAsync(499);
540
- expect(device.update).not.toHaveBeenCalled();
541
-
542
- await jest.advanceTimersByTimeAsync(1);
543
- expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
544
- });
545
-
546
- test('Pressing partial again while the open wait is pending is ignored (idempotent)', async () => {
547
- const { instance, device } = makeSimpleGarage();
548
- instance.partialOpenMs = 2000;
549
- instance.currentDoorState = CDS.CLOSED;
550
- instance.characteristicCurrentDoorState.value = CDS.CLOSED;
551
- instance.desiredTarget = TDS.CLOSED;
552
-
553
- instance._handlePartialOpen();
554
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
555
- expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
556
-
557
- // Retry lands while we're still waiting for the stop reset / open
558
- // echo. The retry should not bump the generation token or restart
559
- // anything — the original flow should complete normally.
560
- const idBefore = instance.partialOpenId;
561
- instance._handlePartialOpen();
562
- expect(instance.partialOpenId).toBe(idBefore);
563
-
564
- emitReset(device, '2');
565
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
566
- emitReset(device, '1');
567
- await jest.advanceTimersByTimeAsync(0);
568
-
569
- await jest.advanceTimersByTimeAsync(2000);
570
- expect(device.update).toHaveBeenNthCalledWith(3, { '2': true });
571
- });
572
-
573
- test('A same-value setTargetDoorState while the partial stop is armed does not cancel it', async () => {
574
- // HomeKit / iOS Home may write TargetDoorState=OPEN back to the
575
- // accessory after observing CurrentDoorState flip to OPEN (a
576
- // resync). desiredTarget is already OPEN from the partial flow's
577
- // own _setTarget, so this is a no-op write — but the public
578
- // setTargetDoorState path would still cancel the armed partial
579
- // stop and the auto-stop would never fire, leaving the gate to
580
- // run all the way open. The no-op write should be ignored.
581
- const { instance, device } = makeSimpleGarage();
582
- instance.partialOpenMs = 2000;
583
- instance.currentDoorState = CDS.CLOSED;
584
- instance.characteristicCurrentDoorState.value = CDS.CLOSED;
585
- instance.desiredTarget = TDS.CLOSED;
586
-
587
- instance._handlePartialOpen();
588
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
589
- emitReset(device, '2');
590
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
591
- emitReset(device, '1');
592
- await jest.advanceTimersByTimeAsync(0);
593
- expect(instance.partialStopTimer).not.toBeNull();
594
-
595
- // Simulate the HomeKit-side resync write.
596
- instance.setTargetDoorState(TDS.OPEN);
597
- expect(instance.partialStopTimer).not.toBeNull();
598
-
599
- await jest.advanceTimersByTimeAsync(2000);
600
- expect(device.update).toHaveBeenNthCalledWith(3, { '2': true });
601
- });
602
-
603
- test('A same-value setTargetDoorState during the open wait does not supersede the partial flow', async () => {
604
- const { instance, device } = makeSimpleGarage();
605
- instance.partialOpenMs = 2000;
606
- instance.currentDoorState = CDS.CLOSED;
607
- instance.characteristicCurrentDoorState.value = CDS.CLOSED;
608
- instance.desiredTarget = TDS.CLOSED;
609
442
 
443
+ // A retransmit 1.5s in must not re-open or push the stop out.
444
+ jest.advanceTimersByTime(1500);
610
445
  instance._handlePartialOpen();
611
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
612
- expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
613
-
614
- // HomeKit pushes a TargetDoorState=OPEN write mid-cycle. The partial
615
- // flow's _setTarget already set desiredTarget=OPEN, so this is
616
- // redundant — it should not disturb the in-flight partial.
617
- const idBefore = instance.partialOpenId;
618
- instance.setTargetDoorState(TDS.OPEN);
619
- expect(instance.partialOpenId).toBe(idBefore);
620
- expect(instance._partialPending).toBe(true);
621
-
622
- emitReset(device, '2');
623
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
624
- emitReset(device, '1');
625
- await jest.advanceTimersByTimeAsync(0);
446
+ expect(device.update).toHaveBeenCalledTimes(1);
626
447
 
627
- await jest.advanceTimersByTimeAsync(2000);
628
- expect(device.update).toHaveBeenNthCalledWith(3, { '2': true });
448
+ // Original timer still fires at its original 2000ms deadline.
449
+ jest.advanceTimersByTime(499);
450
+ expect(device.update).toHaveBeenCalledTimes(1);
451
+ jest.advanceTimersByTime(1);
452
+ expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
629
453
  });
630
454
 
631
- test('External setTargetDoorState with a different target cancels the armed auto-stop', async () => {
455
+ test('A direct close cancels the partial auto-stop and runs stop-before-close', () => {
632
456
  const { instance, device } = makeSimpleGarage();
633
457
  instance.partialOpenMs = 2000;
634
- instance.currentDoorState = CDS.CLOSED;
635
- instance.characteristicCurrentDoorState.value = CDS.CLOSED;
636
- instance.desiredTarget = TDS.CLOSED;
458
+ instance.stopBeforeCloseMs = 1500;
459
+ device.state['105'] = STATE_OPENING_OR_OPEN; // gate is moving during the partial
637
460
 
638
461
  instance._handlePartialOpen();
639
-
640
- // Run the open cycle.
641
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
642
- emitReset(device, '2');
643
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
644
- emitReset(device, '1');
645
- await jest.advanceTimersByTimeAsync(0);
646
- expect(instance.currentDoorState).toBe(CDS.OPEN);
647
462
  expect(instance.partialStopTimer).not.toBeNull();
463
+ expect(device.update).toHaveBeenNthCalledWith(1, { '101': true }); // open
648
464
 
649
- // Stop timer is now armed (fires in 2 s). User toggles to a real
650
- // different target (CLOSED) — auto-stop should be cancelled.
651
- await jest.advanceTimersByTimeAsync(500);
465
+ // User closes before the partial auto-stop fires.
466
+ jest.advanceTimersByTime(500);
652
467
  instance.setTargetDoorState(TDS.CLOSED);
653
- expect(instance.partialStopTimer).toBeNull();
654
- });
468
+ expect(instance.partialStopTimer).toBeNull(); // partial auto-stop cancelled
469
+ expect(device.update).toHaveBeenNthCalledWith(2, { '103': true }); // stop-before-close stop
655
470
 
656
- test('External setTargetDoorState with a different target during the open wait supersedes the partial flow', async () => {
657
- const { instance, device } = makeSimpleGarage();
658
- instance.partialOpenMs = 2000;
659
- instance.currentDoorState = CDS.CLOSED;
660
- instance.characteristicCurrentDoorState.value = CDS.CLOSED;
661
- instance.desiredTarget = TDS.CLOSED;
471
+ jest.advanceTimersByTime(1500);
472
+ expect(device.update).toHaveBeenNthCalledWith(3, { '102': true }); // close
662
473
 
663
- instance._handlePartialOpen();
664
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
665
- emitReset(device, '2');
666
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
667
- emitReset(device, '1');
668
- // Before the loop notices and arms the stop timer, the user toggles
669
- // to a real different target (CLOSED — the partial intended OPEN).
670
- instance.setTargetDoorState(TDS.CLOSED);
671
- await jest.advanceTimersByTimeAsync(0);
672
-
673
- // partialOpenId got bumped — the partial flow's wait returns silently.
674
- expect(instance.partialStopTimer).toBeNull();
474
+ // The cancelled partial stop never fires a stray write.
475
+ jest.advanceTimersByTime(5000);
476
+ expect(device.update).toHaveBeenCalledTimes(3);
675
477
  });
676
478
 
677
- test('Does nothing when partialOpenMs is not configured', async () => {
479
+ test('Does nothing when partialOpenMs is not configured', () => {
678
480
  const { instance, device } = makeSimpleGarage();
679
481
  instance.partialOpenMs = 0;
680
- instance.currentDoorState = CDS.OPEN;
681
482
 
682
483
  instance._handlePartialOpen();
683
- await jest.advanceTimersByTimeAsync(10_000);
484
+ jest.advanceTimersByTime(10_000);
684
485
 
685
486
  expect(device.update).not.toHaveBeenCalled();
686
487
  expect(instance.partialStopTimer).toBeNull();
687
488
  });
688
489
  });
689
490
 
690
- // ---------------------------------------------------------------------------
691
- // Force open/close switches (just verify they route through setTargetDoorState
692
- // — the queue/debounce behaviour is already covered by the earlier suites)
693
- // ---------------------------------------------------------------------------
694
- describe('SimpleGarageDoorAccessory — force switches', () => {
695
- beforeEach(() => jest.useFakeTimers());
696
- afterEach(() => jest.useRealTimers());
697
-
698
- test('Force Open routes through the public setTargetDoorState path', async () => {
699
- const { instance, device, accessory } = makeSimpleGarage();
700
- instance.currentDoorState = CDS.CLOSED;
701
- instance.characteristicCurrentDoorState.value = CDS.CLOSED;
702
- instance.desiredTarget = TDS.CLOSED;
703
-
704
- instance.setTargetDoorState(TDS.OPEN);
705
- expect(accessory.context.cachedTargetDoorState).toBe(TDS.OPEN);
706
- expect(instance.desiredTarget).toBe(TDS.OPEN);
707
-
708
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
709
- expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
710
- emitReset(device, '2');
711
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
712
- expect(device.update).toHaveBeenNthCalledWith(2, { '1': true });
713
- emitReset(device, '1');
714
- await jest.advanceTimersByTimeAsync(0);
715
- expect(instance.currentDoorState).toBe(CDS.OPEN);
716
- });
717
-
718
- test('Force Close routes through the public setTargetDoorState path', async () => {
719
- const { instance, device, accessory } = makeSimpleGarage();
720
- instance.currentDoorState = CDS.OPEN;
721
-
722
- instance.setTargetDoorState(TDS.CLOSED);
723
- expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
724
-
725
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
726
- emitReset(device, '2');
727
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
728
- expect(device.update).toHaveBeenNthCalledWith(2, { '3': true });
729
- emitReset(device, '3');
730
- await jest.advanceTimersByTimeAsync(0);
731
- expect(instance.currentDoorState).toBe(CDS.CLOSED);
732
- });
733
-
734
- test('A force action with a different target cancels the in-flight partial', async () => {
735
- const { instance, device } = makeSimpleGarage();
736
- instance.partialOpenMs = 2000;
737
- instance.currentDoorState = CDS.OPEN;
738
- instance.characteristicCurrentDoorState.value = CDS.OPEN;
739
-
740
- // Partial press while already open: just schedules the stop timer.
741
- instance._handlePartialOpen();
742
- await jest.advanceTimersByTimeAsync(0);
743
- expect(instance.partialStopTimer).not.toBeNull();
744
-
745
- // Force Close pressed before the auto-stop fires. The different
746
- // target indicates a real user override — cancel the partial.
747
- instance.setTargetDoorState(TDS.CLOSED);
748
- expect(instance.partialStopTimer).toBeNull();
749
-
750
- // The new target (CLOSED) is acted on through the regular queue.
751
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
752
- expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
753
- });
754
- });
755
-
756
491
  // ---------------------------------------------------------------------------
757
492
  // Partial-open switch is stateful: mirrors CurrentDoorState (ON when the gate
758
- // is open) and toggling it OFF triggers a standard close cycle.
493
+ // is open) via the status DP.
759
494
  // ---------------------------------------------------------------------------
760
495
  describe('SimpleGarageDoorAccessory — partial-open switch state', () => {
761
- beforeEach(() => jest.useFakeTimers());
762
- afterEach(() => jest.useRealTimers());
763
-
764
- test('_setCurrentDoorState mirrors OPEN/CLOSED to the switch', () => {
496
+ test('Reported OPEN/CLOSED is mirrored onto the switch', () => {
765
497
  const { instance } = makeSimpleGarage();
766
498
  instance.partialOpenMs = 2000;
767
- instance.currentDoorState = CDS.CLOSED;
768
499
  instance.characteristicPartialOpen.value = false;
769
500
 
770
- instance._setCurrentDoorState(CDS.OPEN);
501
+ instance._onDeviceChange({ '105': STATE_OPENING_OR_OPEN });
771
502
  expect(instance.characteristicPartialOpen.value).toBe(true);
772
503
 
773
- instance._setCurrentDoorState(CDS.CLOSED);
504
+ instance._onDeviceChange({ '105': STATE_CLOSING_OR_CLOSED });
774
505
  expect(instance.characteristicPartialOpen.value).toBe(false);
506
+
507
+ // A stop mid-travel (state 11) leaves the gate parked open.
508
+ instance._onDeviceChange({ '105': STATE_STOPPED });
509
+ expect(instance.characteristicPartialOpen.value).toBe(true);
775
510
  });
511
+ });
776
512
 
777
- test('After the partial open cycle the switch reads ON', async () => {
778
- const { instance, device } = makeSimpleGarage();
779
- instance.partialOpenMs = 2000;
780
- instance.currentDoorState = CDS.CLOSED;
781
- instance.characteristicCurrentDoorState.value = CDS.CLOSED;
782
- instance.characteristicPartialOpen.value = false;
783
- instance.desiredTarget = TDS.CLOSED;
513
+ // ---------------------------------------------------------------------------
514
+ // Force open/close switches route through setTargetDoorState
515
+ // ---------------------------------------------------------------------------
516
+ describe('SimpleGarageDoorAccessory force switches', () => {
517
+ test('Force Open fires the open action', () => {
518
+ const { instance, device, accessory } = makeSimpleGarage();
784
519
 
785
- instance._handlePartialOpen();
786
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
787
- emitReset(device, '2');
788
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
789
- emitReset(device, '1');
790
- await jest.advanceTimersByTimeAsync(0);
520
+ instance.setTargetDoorState(TDS.OPEN);
791
521
 
792
- expect(instance.currentDoorState).toBe(CDS.OPEN);
793
- expect(instance.characteristicPartialOpen.value).toBe(true);
522
+ expect(device.update).toHaveBeenCalledWith({ '101': true });
523
+ expect(accessory.context.cachedTargetDoorState).toBe(TDS.OPEN);
794
524
  });
795
525
 
796
- test('A subsequent close cycle flips the switch back to OFF', async () => {
797
- const { instance, device } = makeSimpleGarage();
798
- instance.partialOpenMs = 2000;
799
- instance.currentDoorState = CDS.OPEN;
800
- instance.characteristicCurrentDoorState.value = CDS.OPEN;
801
- instance.characteristicPartialOpen.value = true;
526
+ test('Force Close fires the close action (immediately when already stopped)', () => {
527
+ const { instance, device, accessory } = makeSimpleGarage();
528
+ device.state['105'] = STATE_STOPPED;
802
529
 
803
- // Simulate the user tapping the switch OFF — close cycle queued.
804
530
  instance.setTargetDoorState(TDS.CLOSED);
805
- await jest.advanceTimersByTimeAsync(SETTLE_MS);
806
- emitReset(device, '2');
807
- await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
808
- expect(device.update).toHaveBeenNthCalledWith(2, { '3': true });
809
- emitReset(device, '3');
810
- await jest.advanceTimersByTimeAsync(0);
811
531
 
812
- expect(instance.currentDoorState).toBe(CDS.CLOSED);
813
- expect(instance.characteristicPartialOpen.value).toBe(false);
532
+ expect(device.update).toHaveBeenCalledWith({ '102': true });
533
+ expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
814
534
  });
815
535
  });