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.
- package/.github/CODEOWNERS +5 -0
- package/.github/workflows/publish-dev.yml +91 -0
- package/Readme.MD +33 -0
- package/config.schema.json +231 -12
- package/index.js +21 -5
- package/lib/BaseAccessory.js +7 -0
- package/lib/DehumidifierAccessory.js +1 -1
- package/lib/IrrigationSystemAccessory.js +611 -0
- package/lib/OilDiffuserAccessory.js +1 -1
- package/lib/SimpleFanLightAccessory.js +54 -12
- package/lib/SimpleGarageDoorAccessory.js +178 -262
- package/lib/TuyaAccessory.js +56 -27
- package/lib/TuyaDiscovery.js +52 -19
- package/lib/WledDimmerAccessory.js +703 -0
- package/package.json +1 -1
- package/test/IrrigationSystemAccessory.test.js +446 -0
- package/test/SimpleFanLightAccessory.test.js +299 -0
- package/test/SimpleGarageDoorAccessory.test.js +258 -538
- package/test/TuyaAccessory.protocol.test.js +528 -0
- package/test/getCategory.test.js +65 -0
- package/test/support/mocks.js +30 -2
- package/wiki/Supported-Device-Types.md +167 -22
- package/wiki/User-documented-device-config.md +57 -0
|
@@ -1,27 +1,32 @@
|
|
|
1
1
|
const BaseAccessory = require('./BaseAccessory');
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
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) || '
|
|
80
|
-
this.
|
|
81
|
-
this.
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
this.currentDoorState = initialTarget === Characteristic.TargetDoorState.OPEN
|
|
110
|
+
this.currentDoorState = isOpen
|
|
95
111
|
? Characteristic.CurrentDoorState.OPEN
|
|
96
112
|
: Characteristic.CurrentDoorState.CLOSED;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
this.
|
|
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(
|
|
137
|
+
.updateValue(isOpen)
|
|
124
138
|
.onGet(() => this.currentDoorState === Characteristic.CurrentDoorState.OPEN)
|
|
125
139
|
.onSet(value => {
|
|
126
|
-
// Stateful: switch
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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.
|
|
239
|
-
this.
|
|
240
|
-
this.
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
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.
|
|
284
|
-
|
|
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.
|
|
289
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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
|
-
|
|
334
|
-
if (this.
|
|
335
|
-
|
|
336
|
-
this.
|
|
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
|
|