homebridge-tuya-plus 3.13.0 → 3.13.1-dev.1

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.
@@ -1,27 +1,32 @@
1
1
  const BaseAccessory = require('./BaseAccessory');
2
2
 
3
- // Delay after the device echoes the stop DP back to false (signalling the
4
- // stop has finished) before sending the open/close command. Some controllers
5
- // drop the direction command when it arrives too soon after the stop, even
6
- // after the stop DP itself has been reset.
7
- const POST_RESET_DELAY_MS = 500;
8
-
9
- // Fallback if the device never echoes the stop DP back to false (e.g. when
10
- // the stop was a no-op because the gate was already idle, or the echo is
11
- // dropped). Picked to comfortably exceed the ~1s reset we observe in
12
- // practice.
13
- const STOP_RESET_TIMEOUT_MS = 3000;
14
-
15
- // Fallback for the direction DP echo when the device misses one.
16
- const DIRECTION_RESET_TIMEOUT_MS = 3000;
17
-
18
- // Debounce window for incoming HomeKit toggles. setTargetDoorState waits
19
- // this long after the most recent press before starting (or rescheduling) a
20
- // stop+direction cycle, so a rapid burst of taps coalesces into one cycle
21
- // on the final target instead of driving the gate back and forth.
22
- const SETTLE_MS = 1000;
23
-
24
- const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
3
+ // This controller reports its movement on a single status DP (default 105),
4
+ // but only distinguishes three values:
5
+ //
6
+ // 11 -> stopped (gate parked part-way, or fully open)
7
+ // 12 -> opening OR open
8
+ // 13 -> closing OR closed
9
+ //
10
+ // HomeKit's GarageDoorOpener only needs the two states it can act on, so we
11
+ // collapse the three: 11/12 => OPEN, 13 => CLOSED. Both CurrentDoorState and
12
+ // TargetDoorState are mirrored straight from this DP, which keeps HomeKit in
13
+ // sync no matter how the gate was triggered (Home app, a physical remote, the
14
+ // Tuya app, ...).
15
+ //
16
+ // Opening is symmetric and forgiving: the controller reverses on its own, so
17
+ // an open command works directly even while the gate is closing — we just fire
18
+ // it. Closing is asymmetric, though: the controller IGNORES a close command
19
+ // while the gate is actively moving. The one exception is when the status DP
20
+ // reads exactly 11 (stopped) e.g. after a partial-open or an external stop —
21
+ // where the gate is idle and accepts close immediately. So unless we can see
22
+ // it's already stopped, a close is sent as stop -> wait -> close.
23
+ const STATE_STOPPED = 11;
24
+ const STATE_OPENING_OR_OPEN = 12;
25
+ const STATE_CLOSING_OR_CLOSED = 13;
26
+
27
+ // How long to wait between the stop and the close in the stop-before-close
28
+ // path. Overridable per-device via the `stopBeforeCloseMs` config option.
29
+ const DEFAULT_STOP_BEFORE_CLOSE_MS = 1500;
25
30
 
26
31
  class SimpleGarageDoorAccessory extends BaseAccessory {
27
32
  static getCategory(Categories) {
@@ -69,39 +74,46 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
69
74
  }
70
75
  }
71
76
 
72
- _registerCharacteristics() {
77
+ _registerCharacteristics(dps) {
73
78
  this._reconcileOptionalServices();
74
79
 
75
80
  const {Service, Characteristic} = this.hap;
76
81
  const service = this.accessory.getService(Service.GarageDoorOpener);
77
82
  this._checkServiceName(service, this.device.context.name);
78
83
 
79
- this.dpOpen = this._getCustomDP(this.device.context.dpOpen) || '1';
80
- this.dpStop = this._getCustomDP(this.device.context.dpStop) || '2';
81
- this.dpClose = this._getCustomDP(this.device.context.dpClose) || '3';
84
+ this.dpOpen = this._getCustomDP(this.device.context.dpOpen) || '101';
85
+ this.dpClose = this._getCustomDP(this.device.context.dpClose) || '102';
86
+ this.dpStop = this._getCustomDP(this.device.context.dpStop) || '103';
87
+ this.dpState = this._getCustomDP(this.device.context.dpState) || '105';
82
88
 
83
89
  const partialOpenMs = parseInt(this.device.context.partialOpenMs, 10);
84
90
  this.partialOpenMs = Number.isFinite(partialOpenMs) && partialOpenMs > 0 ? partialOpenMs : 0;
85
91
 
86
- // The device exposes no status DPs, so the only "memory" of where the
87
- // gate is comes from the last HomeKit-triggered change, persisted via
88
- // the homebridge accessory context.
89
- if (this.accessory.context.cachedTargetDoorState !== Characteristic.TargetDoorState.OPEN &&
90
- this.accessory.context.cachedTargetDoorState !== Characteristic.TargetDoorState.CLOSED) {
91
- this.accessory.context.cachedTargetDoorState = Characteristic.TargetDoorState.OPEN;
92
+ const stopBeforeCloseMs = parseInt(this.device.context.stopBeforeCloseMs, 10);
93
+ this.stopBeforeCloseMs = Number.isFinite(stopBeforeCloseMs) && stopBeforeCloseMs >= 0
94
+ ? stopBeforeCloseMs
95
+ : DEFAULT_STOP_BEFORE_CLOSE_MS;
96
+
97
+ // Pending side effects we may need to cancel: the partial-open auto-stop
98
+ // and the close that trails a stop in the stop-before-close path.
99
+ this.partialStopTimer = null;
100
+ this.pendingCloseTimer = null;
101
+
102
+ // Seed the initial state from whatever the device has already reported.
103
+ // If it hasn't reported yet, fall back to the persisted target, then to
104
+ // CLOSED (the safer assumption for a gate). The real state DP almost
105
+ // always arrives within a second of connecting and corrects this.
106
+ let isOpen = this._mapDpState(dps[this.dpState]);
107
+ if (isOpen === null) {
108
+ isOpen = this.accessory.context.cachedTargetDoorState === Characteristic.TargetDoorState.OPEN;
92
109
  }
93
- const initialTarget = this.accessory.context.cachedTargetDoorState;
94
- this.currentDoorState = initialTarget === Characteristic.TargetDoorState.OPEN
110
+ this.currentDoorState = isOpen
95
111
  ? Characteristic.CurrentDoorState.OPEN
96
112
  : Characteristic.CurrentDoorState.CLOSED;
97
- this.desiredTarget = initialTarget;
98
- this.worker = null;
99
- this.scheduleTimer = null;
100
- this.partialStopTimer = null;
101
- this.partialOpenId = 0;
102
- this._partialPending = false;
103
- this._currentChangePending = null;
104
- this._currentChangeResolve = null;
113
+ const initialTarget = isOpen
114
+ ? Characteristic.TargetDoorState.OPEN
115
+ : Characteristic.TargetDoorState.CLOSED;
116
+ this.accessory.context.cachedTargetDoorState = initialTarget;
105
117
 
106
118
  this.characteristicTargetDoorState = service.getCharacteristic(Characteristic.TargetDoorState)
107
119
  .updateValue(initialTarget)
@@ -112,22 +124,23 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
112
124
  .updateValue(this.currentDoorState)
113
125
  .onGet(() => this.currentDoorState);
114
126
 
127
+ // The controller exposes limit switches (l_open/l_close) but they don't
128
+ // work in practice, and there's no obstruction sensor wired up, so this
129
+ // is always reported clear.
115
130
  service.getCharacteristic(Characteristic.ObstructionDetected)
116
131
  .updateValue(false)
117
132
  .onGet(() => false);
118
133
 
119
134
  const partialSwitch = this.accessory.getServiceById(Service.Switch, 'partialOpen');
120
135
  if (partialSwitch) {
121
- const initialOn = this.currentDoorState === Characteristic.CurrentDoorState.OPEN;
122
136
  const onChar = partialSwitch.getCharacteristic(Characteristic.On)
123
- .updateValue(initialOn)
137
+ .updateValue(isOpen)
124
138
  .onGet(() => this.currentDoorState === Characteristic.CurrentDoorState.OPEN)
125
139
  .onSet(value => {
126
- // Stateful: switch ON triggers a partial-open cycle,
127
- // switch OFF triggers a full close (queue stop+close).
128
- // The switch value itself mirrors CurrentDoorState (see
129
- // _setCurrentDoorState) so it sits at ON whenever the
130
- // gate is currently open in HomeKit's view.
140
+ // Stateful: the switch mirrors CurrentDoorState (ON while
141
+ // the gate is open in HomeKit's view — see
142
+ // _applyReportedState). Tapping it ON triggers a
143
+ // partial-open; tapping it OFF triggers a full close.
131
144
  if (value) {
132
145
  this._handlePartialOpen();
133
146
  } else {
@@ -163,255 +176,158 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
163
176
  this.characteristicForceClose = onChar;
164
177
  }
165
178
 
166
- // CurrentDoorState follows the device's own pacing — it flips to OPEN
167
- // when the open DP transitions back to false, and to CLOSED when the
168
- // close DP transitions back to false. That reset echo is the closest
169
- // thing to position feedback we have.
170
179
  this.device.on('change', changes => this._onDeviceChange(changes));
171
180
  }
172
181
 
182
+ // Maps a raw status DP value to open (true) / closed (false) / unknown
183
+ // (null). 11 (stopped) and 12 (opening or open) are both "open" as far as
184
+ // HomeKit is concerned; 13 (closing or closed) is "closed".
185
+ _mapDpState(raw) {
186
+ const value = typeof raw === 'string' ? parseInt(raw, 10) : raw;
187
+ if (value === STATE_STOPPED || value === STATE_OPENING_OR_OPEN) return true;
188
+ if (value === STATE_CLOSING_OR_CLOSED) return false;
189
+ return null;
190
+ }
191
+
173
192
  _onDeviceChange(changes) {
174
- const {Characteristic} = this.hap;
175
- if (!changes) return;
176
- if (changes[this.dpOpen] === false) {
177
- this._setCurrentDoorState(Characteristic.CurrentDoorState.OPEN);
178
- } else if (changes[this.dpClose] === false) {
179
- this._setCurrentDoorState(Characteristic.CurrentDoorState.CLOSED);
193
+ if (!changes || !changes.hasOwnProperty(this.dpState)) return;
194
+ // Mid stop-before-close: the stop we just issued can briefly report 11
195
+ // (=OPEN), which would fight the close we're about to send. Ignore
196
+ // status reports until the close has gone out.
197
+ if (this.pendingCloseTimer) return;
198
+ const isOpen = this._mapDpState(changes[this.dpState]);
199
+ if (isOpen === null) {
200
+ this.log.info(`[SimpleGarageDoor] ${this.device.context.name}: ignoring unknown state DP value ${JSON.stringify(changes[this.dpState])}`);
201
+ return;
180
202
  }
203
+ this._applyReportedState(isOpen);
181
204
  }
182
205
 
183
- _setCurrentDoorState(state) {
184
- if (this.currentDoorState === state) return;
185
- this.currentDoorState = state;
186
- this.characteristicCurrentDoorState.updateValue(state);
206
+ // Mirrors the device's reported state onto both door-state characteristics
207
+ // (and the partial-open switch). Driving TargetDoorState from the DP too —
208
+ // not just CurrentDoorState — is what keeps HomeKit honest when the gate is
209
+ // operated outside HomeKit.
210
+ _applyReportedState(isOpen) {
211
+ const {Characteristic} = this.hap;
212
+ const current = isOpen
213
+ ? Characteristic.CurrentDoorState.OPEN
214
+ : Characteristic.CurrentDoorState.CLOSED;
215
+ const target = isOpen
216
+ ? Characteristic.TargetDoorState.OPEN
217
+ : Characteristic.TargetDoorState.CLOSED;
218
+
219
+ if (this.currentDoorState !== current) {
220
+ this.currentDoorState = current;
221
+ this.characteristicCurrentDoorState.updateValue(current);
222
+ }
223
+ if (this.accessory.context.cachedTargetDoorState !== target) {
224
+ this.accessory.context.cachedTargetDoorState = target;
225
+ if (this.characteristicTargetDoorState) this.characteristicTargetDoorState.updateValue(target);
226
+ }
187
227
  if (this.characteristicPartialOpen) {
188
- this.characteristicPartialOpen.updateValue(
189
- state === this.hap.Characteristic.CurrentDoorState.OPEN
190
- );
228
+ this.characteristicPartialOpen.updateValue(isOpen);
191
229
  }
192
- this._wakeCurrentChangeWaiters();
193
230
  }
194
231
 
195
- _waitForCurrentChange() {
196
- if (!this._currentChangePending) {
197
- this._currentChangePending = new Promise(resolve => {
198
- this._currentChangeResolve = resolve;
199
- });
200
- }
201
- return this._currentChangePending;
232
+ setTargetDoorState(value) {
233
+ // A direct open/close (the GarageDoorOpener target, a Force switch, or
234
+ // the partial switch tapped OFF) is manual control: cancel any pending
235
+ // partial-open auto-stop so it can't halt this movement part-way.
236
+ this._cancelPartialStop();
237
+ this._applyTarget(value);
202
238
  }
203
239
 
204
- _wakeCurrentChangeWaiters() {
205
- if (this._currentChangeResolve) {
206
- const resolve = this._currentChangeResolve;
207
- this._currentChangeResolve = null;
208
- this._currentChangePending = null;
209
- resolve();
210
- }
240
+ // Optimistically reflects the requested target in HomeKit and fires the
241
+ // matching action on the device. CurrentDoorState is intentionally left to
242
+ // catch up from the status DP. The device echoes the action DP back to
243
+ // false on its own; we don't wait for it.
244
+ _applyTarget(value) {
245
+ const {Characteristic} = this.hap;
246
+ const open = value === Characteristic.TargetDoorState.OPEN;
247
+ this.accessory.context.cachedTargetDoorState = value;
248
+ if (this.characteristicTargetDoorState) this.characteristicTargetDoorState.updateValue(value);
249
+ if (open) this._sendOpen();
250
+ else this._sendClose();
211
251
  }
212
252
 
213
- setTargetDoorState(value) {
253
+ // Open is always safe to fire directly — the controller reverses on its
254
+ // own, even mid-close. Abandon any pending stop-before-close.
255
+ _sendOpen() {
256
+ this._cancelPendingClose();
257
+ this.log.info(`[SimpleGarageDoor] ${this.device.context.name}: open (dp${this.dpOpen})`);
258
+ this.setMultiStateLegacyAsync({[this.dpOpen]: true});
259
+ }
260
+
261
+ // Close is ignored while the gate is actively moving, so fire it directly
262
+ // only when the status DP shows the gate is already stopped (11). Otherwise
263
+ // stop first, wait stopBeforeCloseMs, then close.
264
+ _sendClose() {
214
265
  const name = this.device.context.name;
215
- // If a partial-open flow is in progress (waiting for OPEN or armed
216
- // with a pending stop) and the incoming value matches the target
217
- // the partial already set, treat this as a HomeKit/iOS resync (the
218
- // accessory's TargetDoorState characteristic wasn't notified of
219
- // the partial-induced target change, so iOS may write the value
220
- // back to "confirm" the state it observed via CurrentDoorState).
221
- // A no-op resync must not cancel the partial's auto-stop —
222
- // otherwise the gate runs all the way open.
223
- if ((this._partialPending || this.partialStopTimer)
224
- && value === this.desiredTarget) {
225
- this.log.info(`[SimpleGarageDoor] ${name}: setTargetDoorState(${value}) ignored as same-value resync during partial`);
266
+ if (this.pendingCloseTimer) {
267
+ // A stop-before-close is already running don't restart it (and
268
+ // push the close out) on a duplicate or retransmitted request.
269
+ this.log.info(`[SimpleGarageDoor] ${name}: close ignored a stop-before-close is already running`);
226
270
  return;
227
271
  }
228
- this.log.info(`[SimpleGarageDoor] ${name}: setTargetDoorState(${value}); was desiredTarget=${this.desiredTarget}, partialStopTimer=${this.partialStopTimer ? 'armed' : 'null'}, _partialPending=${this._partialPending}`);
229
- // Public path: a direct user toggle cancels any pending partial-stop
230
- // timer and supersedes any in-flight partial-open wait — once the
231
- // user takes manual control, the auto-stop shouldn't fire later, and
232
- // the partial flow's open-wait should bail out rather than hanging
233
- // forever in case currentDoorState never reaches OPEN.
234
- if (this.partialStopTimer) {
235
- clearTimeout(this.partialStopTimer);
236
- this.partialStopTimer = null;
272
+ if (this._isStopped()) {
273
+ this.log.info(`[SimpleGarageDoor] ${name}: close (dp${this.dpClose}) gate already stopped`);
274
+ this.setMultiStateLegacyAsync({[this.dpClose]: true});
275
+ return;
237
276
  }
238
- this._partialPending = false;
239
- this.partialOpenId++;
240
- this._wakeCurrentChangeWaiters();
241
- this._setTarget(value);
277
+ this.log.info(`[SimpleGarageDoor] ${name}: stop-before-close — stop (dp${this.dpStop}) now, close (dp${this.dpClose}) in ${this.stopBeforeCloseMs}ms`);
278
+ this.setMultiStateLegacyAsync({[this.dpStop]: true});
279
+ this.pendingCloseTimer = setTimeout(() => {
280
+ this.pendingCloseTimer = null;
281
+ this.log.info(`[SimpleGarageDoor] ${name}: stop-before-close — firing close (dp${this.dpClose})`);
282
+ this.setMultiStateLegacyAsync({[this.dpClose]: true});
283
+ }, this.stopBeforeCloseMs);
242
284
  }
243
285
 
244
- _setTarget(value) {
245
- this.accessory.context.cachedTargetDoorState = value;
246
- this.desiredTarget = value;
247
- // Mirror the target through the characteristic so HomeKit's view of
248
- // TargetDoorState stays in sync with what we actually want without
249
- // this, HAP's internal value stays at whatever was last written by a
250
- // client (e.g. CLOSED) and a hub may push a resync write trying to
251
- // reconcile it with the new CurrentDoorState we just emitted.
252
- if (this.characteristicTargetDoorState) {
253
- this.characteristicTargetDoorState.updateValue(value);
286
+ // True only when the controller reports the gate is stopped (11) — the one
287
+ // state where it will accept a close without a stop first.
288
+ _isStopped() {
289
+ const raw = this.device.state ? this.device.state[this.dpState] : undefined;
290
+ const value = typeof raw === 'string' ? parseInt(raw, 10) : raw;
291
+ return value === STATE_STOPPED;
292
+ }
293
+
294
+ _cancelPendingClose() {
295
+ if (this.pendingCloseTimer) {
296
+ clearTimeout(this.pendingCloseTimer);
297
+ this.pendingCloseTimer = null;
254
298
  }
255
- // If a worker is already running it will pick up the new target at
256
- // its next decision point — no need to debounce again. Otherwise
257
- // (re)start the debounce so a burst of taps coalesces into one
258
- // cycle on the final target.
259
- if (this.worker) return;
260
- this._scheduleWorker();
261
299
  }
262
300
 
263
- // Drives the partial-open flow: STOP+OPEN through the normal queue, wait
264
- // until CurrentDoorState reaches OPEN (so the timer is anchored to the
265
- // device actually receiving the open rather than to the button press
266
- // otherwise a short partialOpenMs would land mid-cycle and a stop fired
267
- // then could land before the open even reaches the device), then after
268
- // partialOpenMs send a raw STOP to halt the gate mid-opening so it ends
269
- // up partially open.
270
- //
271
- // Idempotent against re-invocation while a flow is already in progress
272
- // (waiting for OPEN or armed with a pending stop): HomeKit/iOS will
273
- // retransmit a WRITE if the 204 response is delayed or dropped, and
274
- // each retry firing through onSet would otherwise cancel the original
275
- // stop timer and push it out by another partialOpenMs — eventually
276
- // letting the gate run all the way open. A direct toggle on the main
277
- // GarageDoorOpener target or a Force switch still supersedes this flow
278
- // through setTargetDoorState.
279
- async _handlePartialOpen() {
301
+ // Partial open: fire the open action, then after partialOpenMs fire a stop
302
+ // so the gate ends up parked part-way. Anchored to the button press (the
303
+ // controller starts moving and reports state within ~1s), which is all the
304
+ // user asked for.
305
+ _handlePartialOpen() {
280
306
  const {Characteristic} = this.hap;
281
307
  const name = this.device.context.name;
282
308
  if (!this.partialOpenMs) return;
283
- if (this._partialPending || this.partialStopTimer) {
284
- this.log.info(`[SimpleGarageDoor] ${name}: partial press ignored flow already in progress (_partialPending=${this._partialPending}, partialStopTimer=${this.partialStopTimer ? 'armed' : 'null'})`);
309
+ if (this.partialStopTimer) {
310
+ // Re-entrant press while a partial is already armed (e.g. a
311
+ // HomeKit/iOS WRITE retransmit) — ignore so the stop isn't pushed
312
+ // out, which would let the gate run further than intended.
313
+ this.log.info(`[SimpleGarageDoor] ${name}: partial press ignored — a partial open is already running`);
285
314
  return;
286
315
  }
287
316
 
288
- this._partialPending = true;
289
- const myId = ++this.partialOpenId;
290
- this.log.info(`[SimpleGarageDoor] ${name}: partial press accepted (myId=${myId}); driving target to OPEN`);
291
-
292
- this._setTarget(Characteristic.TargetDoorState.OPEN);
293
-
294
- try {
295
- while (this.currentDoorState !== Characteristic.CurrentDoorState.OPEN) {
296
- await this._waitForCurrentChange();
297
- // A direct toggle (which clears _partialPending) supersedes
298
- // this flow.
299
- if (myId !== this.partialOpenId) {
300
- this.log.info(`[SimpleGarageDoor] ${name}: partial flow superseded mid-wait (myId=${myId} vs ${this.partialOpenId})`);
301
- return;
302
- }
303
- }
304
- } finally {
305
- this._partialPending = false;
306
- }
307
-
308
- this.log.info(`[SimpleGarageDoor] ${name}: CurrentDoorState reached OPEN; arming partial-stop in ${this.partialOpenMs}ms`);
317
+ this.log.info(`[SimpleGarageDoor] ${name}: partial open — opening, will stop in ${this.partialOpenMs}ms`);
318
+ this._applyTarget(Characteristic.TargetDoorState.OPEN);
309
319
  this.partialStopTimer = setTimeout(() => {
310
320
  this.partialStopTimer = null;
311
- if (myId !== this.partialOpenId) {
312
- this.log.info(`[SimpleGarageDoor] ${name}: partial-stop timer fired but flow superseded (myId=${myId} vs ${this.partialOpenId})`);
313
- return;
314
- }
315
- // Send the raw stop several times spread over ~1s. The device's
316
- // command queue can drop back-to-back writes, and brief WiFi
317
- // dropouts silently lose individual writes — re-sending a few
318
- // times defends against both. A stop on an already-stopped gate
319
- // is a no-op on the device side.
320
- this.log.info(`[SimpleGarageDoor] ${name}: firing partial-stop (dp${this.dpStop}=true) with retries`);
321
- const send = (attempt) => {
322
- if (myId !== this.partialOpenId) return;
323
- this.log.info(`[SimpleGarageDoor] ${name}: partial-stop attempt ${attempt} (device.connected=${this.device.connected})`);
324
- this.setMultiStateLegacyAsync({[this.dpStop]: true});
325
- };
326
- send(1);
327
- setTimeout(() => send(2), 250);
328
- setTimeout(() => send(3), 600);
329
- setTimeout(() => send(4), 1200);
321
+ this.log.info(`[SimpleGarageDoor] ${name}: partial open — firing stop (dp${this.dpStop})`);
322
+ this.setMultiStateLegacyAsync({[this.dpStop]: true});
330
323
  }, this.partialOpenMs);
331
324
  }
332
325
 
333
- _scheduleWorker() {
334
- if (this.scheduleTimer) clearTimeout(this.scheduleTimer);
335
- this.scheduleTimer = setTimeout(() => {
336
- this.scheduleTimer = null;
337
- this._spawnWorker();
338
- }, SETTLE_MS);
339
- }
340
-
341
- _spawnWorker() {
342
- if (this.worker) return;
343
- if (this._currentMatchesTarget(this.desiredTarget)) return;
344
- this.worker = this._runWorker().finally(() => {
345
- this.worker = null;
346
- // If the target shifted while the cycle was running and still
347
- // differs from where we landed, debounce again before the next
348
- // cycle so another spam burst coalesces.
349
- if (!this._currentMatchesTarget(this.desiredTarget)) {
350
- this._scheduleWorker();
351
- }
352
- });
353
- }
354
-
355
- // One stop + direction cycle. The worker re-reads this.desiredTarget at
356
- // each decision point so a toggle that lands during the cycle is honoured;
357
- // if the target has reverted to the current state, the direction is
358
- // skipped and the worker exits.
359
- async _runWorker() {
360
- const {Characteristic} = this.hap;
361
- if (this._currentMatchesTarget(this.desiredTarget)) return;
362
-
363
- await this._sendAndAwaitReset(this.dpStop, STOP_RESET_TIMEOUT_MS);
364
- await sleep(POST_RESET_DELAY_MS);
365
-
366
- if (this._currentMatchesTarget(this.desiredTarget)) return;
367
-
368
- const target = this.desiredTarget;
369
- const directionDp = target === Characteristic.TargetDoorState.OPEN
370
- ? this.dpOpen
371
- : this.dpClose;
372
- await this._sendAndAwaitReset(directionDp, DIRECTION_RESET_TIMEOUT_MS);
373
- // Belt-and-braces: the persistent change listener will already have
374
- // flipped this on the echo, but force-mirror in case the echo was
375
- // missed so the next loop check exits cleanly.
376
- this._setCurrentDoorState(target === Characteristic.TargetDoorState.OPEN
377
- ? Characteristic.CurrentDoorState.OPEN
378
- : Characteristic.CurrentDoorState.CLOSED);
379
- }
380
-
381
- _currentMatchesTarget(target) {
382
- const {Characteristic} = this.hap;
383
- if (target === Characteristic.TargetDoorState.OPEN) {
384
- return this.currentDoorState === Characteristic.CurrentDoorState.OPEN;
326
+ _cancelPartialStop() {
327
+ if (this.partialStopTimer) {
328
+ clearTimeout(this.partialStopTimer);
329
+ this.partialStopTimer = null;
385
330
  }
386
- return this.currentDoorState === Characteristic.CurrentDoorState.CLOSED;
387
- }
388
-
389
- // Writes the DP and resolves when the device echoes it back to false
390
- // (signalling the action completed) or the timeout fires.
391
- async _sendAndAwaitReset(dp, timeoutMs) {
392
- const wait = this._waitForDpReset(dp, timeoutMs);
393
- this.setMultiStateLegacyAsync({[dp]: true});
394
- await wait;
395
- }
396
-
397
- _waitForDpReset(dp, timeoutMs) {
398
- return new Promise(resolve => {
399
- const cleanup = () => {
400
- this.device.removeListener('change', onChange);
401
- clearTimeout(timer);
402
- };
403
- const onChange = changes => {
404
- if (changes && changes[dp] === false) {
405
- cleanup();
406
- resolve();
407
- }
408
- };
409
- const timer = setTimeout(() => {
410
- cleanup();
411
- resolve();
412
- }, timeoutMs);
413
- this.device.on('change', onChange);
414
- });
415
331
  }
416
332
  }
417
333