@marcel2215/homebridge-supla-plugin 2.1.27 → 2.1.29
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/README.md +43 -0
- package/config.schema.json +67 -71
- package/dist/Accesories/ActionTriggerAccessory.js.map +1 -1
- package/dist/Accesories/AirQualityAccessory.js +1 -2
- package/dist/Accesories/AirQualityAccessory.js.map +1 -1
- package/dist/Accesories/DimmerAccessory.js.map +1 -1
- package/dist/Accesories/DimmerRgbLightAccessory.js.map +1 -1
- package/dist/Accesories/DoorAccessory.js.map +1 -1
- package/dist/Accesories/ElectricityMeterAccessory.js.map +1 -1
- package/dist/Accesories/FacadeBlindAccessory.js +1 -1
- package/dist/Accesories/FacadeBlindAccessory.js.map +1 -1
- package/dist/Accesories/FrontGateFsm.d.ts +67 -67
- package/dist/Accesories/FrontGateFsm.d.ts.map +1 -1
- package/dist/Accesories/FrontGateFsm.js +499 -399
- package/dist/Accesories/FrontGateFsm.js.map +1 -1
- package/dist/Accesories/GarageDoorOpenerAccesory.js.map +1 -1
- package/dist/Accesories/GateAccessory.d.ts +6 -2
- package/dist/Accesories/GateAccessory.d.ts.map +1 -1
- package/dist/Accesories/GateAccessory.js +127 -82
- package/dist/Accesories/GateAccessory.js.map +1 -1
- package/dist/Accesories/GateLockAccessory.js.map +1 -1
- package/dist/Accesories/LightBulbAccesory.js.map +1 -1
- package/dist/Accesories/PressureAccessory.js.map +1 -1
- package/dist/Accesories/RollerShutterAccessory.d.ts.map +1 -1
- package/dist/Accesories/RollerShutterAccessory.js +6 -5
- package/dist/Accesories/RollerShutterAccessory.js.map +1 -1
- package/dist/Accesories/SwitchAccessory.js.map +1 -1
- package/dist/Accesories/TemperatureAccessory.js.map +1 -1
- package/dist/Accesories/TemperatureHumidityAccessory.js.map +1 -1
- package/dist/Accesories/ThermostatAccessory.js +2 -2
- package/dist/Accesories/ThermostatAccessory.js.map +1 -1
- package/dist/Heplers/ColorConverters.js +4 -5
- package/dist/Heplers/ColorConverters.js.map +1 -1
- package/dist/Heplers/SuplaChannelContext.d.ts.map +1 -1
- package/dist/Heplers/SuplaMqttClient.d.ts +2 -3
- package/dist/Heplers/SuplaMqttClient.d.ts.map +1 -1
- package/dist/Heplers/SuplaMqttClient.js +34 -28
- package/dist/Heplers/SuplaMqttClient.js.map +1 -1
- package/dist/Heplers/SuplaMqttClientContext.d.ts +5 -4
- package/dist/Heplers/SuplaMqttClientContext.d.ts.map +1 -1
- package/dist/Heplers/SuplaMqttClientContext.js +2 -1
- package/dist/Heplers/SuplaMqttClientContext.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/platform.d.ts +34 -34
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +228 -183
- package/dist/platform.js.map +1 -1
- package/package.json +9 -8
- package/.codex/environments/environment.toml +0 -31
|
@@ -1,53 +1,80 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.FrontGateFsm = exports.
|
|
4
|
-
exports.
|
|
3
|
+
exports.FrontGateFsm = exports.FrontGateError = exports.DEFAULT_FRONT_GATE_CONFIG = void 0;
|
|
4
|
+
exports.DEFAULT_FRONT_GATE_CONFIG = {
|
|
5
5
|
fullTravelMs: 25000,
|
|
6
6
|
reversePauseMs: 3000,
|
|
7
|
-
wrongDirectionRunMs: 0,
|
|
8
7
|
minimumPulseGapMs: 3000,
|
|
9
|
-
|
|
8
|
+
unknownOpenPolicy: 'reject',
|
|
9
|
+
unknownClosePolicy: 'reject',
|
|
10
|
+
seekClosedMaxPulses: 3,
|
|
11
|
+
assumeOpenAfterTravel: false,
|
|
10
12
|
};
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
class FrontGateError extends Error {
|
|
14
|
+
constructor(code, message, options) {
|
|
15
|
+
super(message, options);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.name = 'FrontGateError';
|
|
18
|
+
}
|
|
13
19
|
}
|
|
14
|
-
|
|
20
|
+
exports.FrontGateError = FrontGateError;
|
|
21
|
+
const SYSTEM_SCHEDULER = {
|
|
22
|
+
now: () => Date.now(),
|
|
23
|
+
schedule: (callback, delayMs) => {
|
|
24
|
+
const timer = setTimeout(callback, delayMs);
|
|
25
|
+
return () => clearTimeout(timer);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
function clampInt(value, fallback, min, max) {
|
|
15
29
|
if (!Number.isFinite(value)) {
|
|
16
|
-
return
|
|
30
|
+
return fallback;
|
|
17
31
|
}
|
|
18
32
|
return Math.min(max, Math.max(min, Math.round(value)));
|
|
19
33
|
}
|
|
20
|
-
function
|
|
21
|
-
return direction === 'opening' ? '
|
|
34
|
+
function targetForDirection(direction) {
|
|
35
|
+
return direction === 'opening' ? 'open' : 'closed';
|
|
22
36
|
}
|
|
23
37
|
class FrontGateFsm {
|
|
24
|
-
constructor(io,
|
|
38
|
+
constructor(io, config = exports.DEFAULT_FRONT_GATE_CONFIG, scheduler = SYSTEM_SCHEDULER) {
|
|
25
39
|
this.io = io;
|
|
26
40
|
this.facts = {
|
|
41
|
+
transportConnected: false,
|
|
27
42
|
controlConnected: null,
|
|
28
43
|
sensorConnected: null,
|
|
29
44
|
closedSensor: null,
|
|
30
45
|
};
|
|
31
|
-
this.
|
|
32
|
-
this.
|
|
33
|
-
this.
|
|
34
|
-
this.
|
|
35
|
-
this.
|
|
36
|
-
this.
|
|
37
|
-
this.
|
|
38
|
-
this.
|
|
46
|
+
this.observationEpoch = 1;
|
|
47
|
+
this.sensorSampleEpoch = null;
|
|
48
|
+
this.previousSensorInEpoch = null;
|
|
49
|
+
this.desiredTarget = null;
|
|
50
|
+
this.position = { kind: 'notClosedUnknown' };
|
|
51
|
+
this.positionGeneration = 0;
|
|
52
|
+
this.operation = { kind: 'none' };
|
|
53
|
+
this.nextOperationId = 0;
|
|
54
|
+
this.lastPublishedPulseAt = 0;
|
|
39
55
|
this.sequence = Promise.resolve(undefined);
|
|
40
56
|
this.disposed = false;
|
|
41
|
-
this.
|
|
42
|
-
|
|
57
|
+
this.config = {
|
|
58
|
+
fullTravelMs: clampInt(config.fullTravelMs ?? exports.DEFAULT_FRONT_GATE_CONFIG.fullTravelMs, exports.DEFAULT_FRONT_GATE_CONFIG.fullTravelMs, 1, 120000),
|
|
59
|
+
reversePauseMs: clampInt(config.reversePauseMs ?? exports.DEFAULT_FRONT_GATE_CONFIG.reversePauseMs, exports.DEFAULT_FRONT_GATE_CONFIG.reversePauseMs, 0, 15000),
|
|
60
|
+
minimumPulseGapMs: clampInt(config.minimumPulseGapMs ?? exports.DEFAULT_FRONT_GATE_CONFIG.minimumPulseGapMs, exports.DEFAULT_FRONT_GATE_CONFIG.minimumPulseGapMs, 0, 15000),
|
|
61
|
+
unknownOpenPolicy: config.unknownOpenPolicy ?? exports.DEFAULT_FRONT_GATE_CONFIG.unknownOpenPolicy,
|
|
62
|
+
unknownClosePolicy: config.unknownClosePolicy ?? exports.DEFAULT_FRONT_GATE_CONFIG.unknownClosePolicy,
|
|
63
|
+
seekClosedMaxPulses: clampInt(config.seekClosedMaxPulses ?? exports.DEFAULT_FRONT_GATE_CONFIG.seekClosedMaxPulses, exports.DEFAULT_FRONT_GATE_CONFIG.seekClosedMaxPulses, 1, 3),
|
|
64
|
+
assumeOpenAfterTravel: config.assumeOpenAfterTravel ?? exports.DEFAULT_FRONT_GATE_CONFIG.assumeOpenAfterTravel,
|
|
65
|
+
};
|
|
66
|
+
this.scheduler = scheduler;
|
|
43
67
|
this.emitSnapshot('fsm-initialized');
|
|
44
68
|
}
|
|
45
69
|
dispose() {
|
|
46
70
|
this.disposed = true;
|
|
47
|
-
this.
|
|
71
|
+
this.clearMovementTimer();
|
|
72
|
+
this.clearOperation();
|
|
48
73
|
}
|
|
49
|
-
|
|
50
|
-
this.
|
|
74
|
+
handleTransportConnectedChange(connected) {
|
|
75
|
+
void this.enqueue(`transport-connected=${connected}`, () => {
|
|
76
|
+
this.applyTransportConnectedChange(connected);
|
|
77
|
+
});
|
|
51
78
|
}
|
|
52
79
|
handleControlConnectedChange(connected) {
|
|
53
80
|
void this.enqueue(`control-connected=${connected}`, () => {
|
|
@@ -69,7 +96,7 @@ class FrontGateFsm {
|
|
|
69
96
|
this.applyObservedExternalPulse(reason);
|
|
70
97
|
});
|
|
71
98
|
}
|
|
72
|
-
|
|
99
|
+
requestHomeKitTarget(target) {
|
|
73
100
|
return this.enqueue(`homekit-target=${target}`, async () => {
|
|
74
101
|
await this.applyHomeKitTarget(target);
|
|
75
102
|
});
|
|
@@ -77,15 +104,17 @@ class FrontGateFsm {
|
|
|
77
104
|
getSnapshot() {
|
|
78
105
|
return this.buildSnapshot('snapshot-requested');
|
|
79
106
|
}
|
|
107
|
+
whenIdle() {
|
|
108
|
+
return this.sequence;
|
|
109
|
+
}
|
|
80
110
|
enqueue(label, task) {
|
|
81
111
|
if (this.disposed) {
|
|
82
112
|
return Promise.resolve();
|
|
83
113
|
}
|
|
84
114
|
const run = this.sequence.then(async () => {
|
|
85
|
-
if (this.disposed) {
|
|
86
|
-
|
|
115
|
+
if (!this.disposed) {
|
|
116
|
+
await task();
|
|
87
117
|
}
|
|
88
|
-
await task();
|
|
89
118
|
});
|
|
90
119
|
this.sequence = run.catch(error => {
|
|
91
120
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -93,21 +122,31 @@ class FrontGateFsm {
|
|
|
93
122
|
});
|
|
94
123
|
return run;
|
|
95
124
|
}
|
|
125
|
+
applyTransportConnectedChange(connected) {
|
|
126
|
+
if (this.facts.transportConnected === connected) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
this.facts.transportConnected = connected;
|
|
130
|
+
if (!connected) {
|
|
131
|
+
this.facts.controlConnected = null;
|
|
132
|
+
this.facts.sensorConnected = null;
|
|
133
|
+
this.advanceObservationEpoch('mqtt-transport-offline');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.io.log.info('front gate MQTT transport connected');
|
|
137
|
+
this.emitSnapshot('mqtt-transport-online-awaiting-channel-state');
|
|
138
|
+
}
|
|
96
139
|
applyControlConnectedChange(connected) {
|
|
97
140
|
if (this.facts.controlConnected === connected) {
|
|
98
141
|
return;
|
|
99
142
|
}
|
|
100
143
|
this.facts.controlConnected = connected;
|
|
101
144
|
if (!connected) {
|
|
102
|
-
this.
|
|
103
|
-
this.enterUnavailable('control-offline');
|
|
145
|
+
this.advanceObservationEpoch('control-channel-offline');
|
|
104
146
|
return;
|
|
105
147
|
}
|
|
106
148
|
this.io.log.info('front gate control channel connected');
|
|
107
|
-
this.
|
|
108
|
-
this.plan = { kind: 'idle' };
|
|
109
|
-
this.sensorFreshSinceOnline = false;
|
|
110
|
-
this.emitSnapshot('control-online-awaiting-fresh-sensor');
|
|
149
|
+
this.emitSnapshot('control-channel-online');
|
|
111
150
|
}
|
|
112
151
|
applySensorConnectedChange(connected) {
|
|
113
152
|
if (this.facts.sensorConnected === connected) {
|
|
@@ -115,474 +154,535 @@ class FrontGateFsm {
|
|
|
115
154
|
}
|
|
116
155
|
this.facts.sensorConnected = connected;
|
|
117
156
|
if (!connected) {
|
|
118
|
-
this.
|
|
119
|
-
this.enterUnavailable('sensor-offline');
|
|
157
|
+
this.advanceObservationEpoch('sensor-channel-offline');
|
|
120
158
|
return;
|
|
121
159
|
}
|
|
122
160
|
this.io.log.info('front gate sensor channel connected');
|
|
123
|
-
this.
|
|
124
|
-
this.plan = { kind: 'idle' };
|
|
125
|
-
this.sensorFreshSinceOnline = false;
|
|
126
|
-
this.emitSnapshot('sensor-online-awaiting-fresh-state');
|
|
161
|
+
this.emitSnapshot('sensor-channel-online');
|
|
127
162
|
}
|
|
128
163
|
applyClosedSensorChange(closed) {
|
|
129
|
-
const
|
|
130
|
-
const
|
|
164
|
+
const hadSampleInEpoch = this.sensorSampleEpoch === this.observationEpoch;
|
|
165
|
+
const previous = hadSampleInEpoch ? this.previousSensorInEpoch : null;
|
|
131
166
|
this.facts.closedSensor = closed;
|
|
132
|
-
|
|
133
|
-
|
|
167
|
+
this.sensorSampleEpoch = this.observationEpoch;
|
|
168
|
+
this.previousSensorInEpoch = closed;
|
|
169
|
+
if (!hadSampleInEpoch) {
|
|
170
|
+
this.clearOperation();
|
|
171
|
+
if (closed) {
|
|
172
|
+
this.setPosition({ kind: 'closed' });
|
|
173
|
+
if (this.desiredTarget === 'closed') {
|
|
174
|
+
this.desiredTarget = null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
this.setPosition({ kind: 'notClosedUnknown' });
|
|
179
|
+
}
|
|
180
|
+
this.emitSnapshot('sensor-baseline-established');
|
|
181
|
+
return;
|
|
134
182
|
}
|
|
135
|
-
|
|
136
|
-
if (previous === closed && !becameFresh) {
|
|
183
|
+
if (previous === closed) {
|
|
137
184
|
return;
|
|
138
185
|
}
|
|
186
|
+
this.clearOperation();
|
|
139
187
|
if (closed) {
|
|
140
188
|
this.io.log.info('closed sensor is TRUE -> gate is fully closed');
|
|
141
|
-
this.
|
|
142
|
-
this.
|
|
143
|
-
|
|
144
|
-
this.idleNotClosedMode = 'openishUnknown';
|
|
145
|
-
if (this.requestedTarget === 'open' && this.isAvailableForHomeKit()) {
|
|
146
|
-
this.emitSnapshot('closed-sensor-true-but-open-still-requested');
|
|
147
|
-
void this.enqueue('auto-open-after-closed', async () => {
|
|
148
|
-
await this.startOpeningFromClosed('auto-open-after-closed');
|
|
149
|
-
});
|
|
150
|
-
return;
|
|
189
|
+
this.setPosition({ kind: 'closed' });
|
|
190
|
+
if (this.desiredTarget === 'closed') {
|
|
191
|
+
this.desiredTarget = null;
|
|
151
192
|
}
|
|
152
|
-
this.requestedTarget = null;
|
|
153
193
|
this.emitSnapshot('closed-sensor-true');
|
|
154
194
|
return;
|
|
155
195
|
}
|
|
156
|
-
this.io.log.info('closed sensor
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
// the gate is opening.
|
|
160
|
-
this.lastPulseLikeActivityAt = Date.now();
|
|
161
|
-
if (this.requestedTarget !== 'open') {
|
|
162
|
-
this.requestedTarget = null;
|
|
163
|
-
}
|
|
164
|
-
this.startOpeningMotion(this.requestedTarget === 'open' ? 'homekit' : 'external', 'closed-sensor-fell-from-true-to-false');
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
if (this.plan.kind === 'idle' && this.requestedTarget === 'open') {
|
|
168
|
-
this.requestedTarget = null;
|
|
169
|
-
}
|
|
170
|
-
this.emitSnapshot('closed-sensor-false');
|
|
196
|
+
this.io.log.info('closed sensor changed TRUE -> FALSE in the current observation epoch');
|
|
197
|
+
this.setKnownMovement('opening');
|
|
198
|
+
this.emitSnapshot('closed-sensor-opening-edge');
|
|
171
199
|
}
|
|
172
200
|
async applyHomeKitTarget(target) {
|
|
173
201
|
if (!this.isAvailableForHomeKit()) {
|
|
174
|
-
throw new
|
|
175
|
-
}
|
|
176
|
-
this.requestedTarget = target;
|
|
177
|
-
if (target === 'open') {
|
|
178
|
-
await this.handleOpenRequest();
|
|
179
|
-
return;
|
|
202
|
+
throw new FrontGateError('unavailable', 'front gate controller is not available');
|
|
180
203
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (this.plan.kind === 'moving' && this.plan.direction === 'opening') {
|
|
186
|
-
this.emitSnapshot('open-request-already-opening-from-closed');
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
if (this.plan.kind === 'waitingSecondPulse' && this.plan.finalDirection === 'opening') {
|
|
190
|
-
this.emitSnapshot('open-request-already-reversing-to-open');
|
|
204
|
+
if (this.operation.kind !== 'none') {
|
|
205
|
+
if (this.operation.target === target) {
|
|
206
|
+
this.desiredTarget = target;
|
|
207
|
+
this.emitSnapshot(`homekit-${target}-already-accepted`);
|
|
191
208
|
return;
|
|
192
209
|
}
|
|
193
|
-
|
|
210
|
+
this.cancelConflictingOperation();
|
|
211
|
+
}
|
|
212
|
+
const previousTarget = this.desiredTarget;
|
|
213
|
+
try {
|
|
214
|
+
const note = target === 'open'
|
|
215
|
+
? await this.acceptOpenRequest()
|
|
216
|
+
: await this.acceptCloseRequest();
|
|
217
|
+
this.desiredTarget = this.position.kind === 'closed' && target === 'closed' ? null : target;
|
|
218
|
+
this.emitSnapshot(note);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
this.desiredTarget = previousTarget;
|
|
222
|
+
this.emitSnapshot(`homekit-${target}-rejected`);
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async acceptOpenRequest() {
|
|
227
|
+
switch (this.position.kind) {
|
|
228
|
+
case 'closed':
|
|
229
|
+
return this.acceptPulse({ kind: 'startOpening' }, 'open');
|
|
230
|
+
case 'openingKnown':
|
|
231
|
+
case 'openAssumed':
|
|
232
|
+
return 'open-request-already-satisfied-or-in-progress';
|
|
233
|
+
case 'closingKnown':
|
|
234
|
+
return this.acceptPulse({ kind: 'stopAndReverse', finalDirection: 'opening' }, 'open');
|
|
235
|
+
case 'reversalPause':
|
|
236
|
+
case 'notClosedUnknown':
|
|
237
|
+
if (this.config.unknownOpenPolicy === 'accept_non_closed') {
|
|
238
|
+
return 'unknown-open-request-accepted-as-non-closed';
|
|
239
|
+
}
|
|
240
|
+
throw new FrontGateError('not_allowed', 'cannot safely open the front gate because its direction and position are unknown');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async acceptCloseRequest() {
|
|
244
|
+
switch (this.position.kind) {
|
|
245
|
+
case 'closed':
|
|
246
|
+
return 'close-request-already-satisfied';
|
|
247
|
+
case 'closingKnown':
|
|
248
|
+
return 'close-request-already-in-progress';
|
|
249
|
+
case 'openingKnown':
|
|
250
|
+
if (this.facts.closedSensor === true) {
|
|
251
|
+
return this.acceptPulse({ kind: 'stopOpeningAtClosed' }, 'closed');
|
|
252
|
+
}
|
|
253
|
+
return this.acceptPulse({ kind: 'stopAndReverse', finalDirection: 'closing' }, 'closed');
|
|
254
|
+
case 'openAssumed':
|
|
255
|
+
return this.acceptPulse({ kind: 'startClosing' }, 'closed');
|
|
256
|
+
case 'reversalPause':
|
|
257
|
+
case 'notClosedUnknown':
|
|
258
|
+
return this.acceptUnknownCloseRequest();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async acceptUnknownCloseRequest() {
|
|
262
|
+
if (this.desiredTarget === 'closed') {
|
|
263
|
+
throw new FrontGateError('not_allowed', 'the previous close request remains unconfirmed; refusing to repeat a non-idempotent toggle');
|
|
264
|
+
}
|
|
265
|
+
switch (this.config.unknownClosePolicy) {
|
|
266
|
+
case 'single_pulse_best_effort':
|
|
267
|
+
return this.acceptPulse({ kind: 'bestEffortClose' }, 'closed');
|
|
268
|
+
case 'seek_closed':
|
|
269
|
+
return this.acceptPulse({
|
|
270
|
+
kind: 'seekClosed',
|
|
271
|
+
pulseNumber: 1,
|
|
272
|
+
maxPulses: this.config.seekClosedMaxPulses,
|
|
273
|
+
}, 'closed');
|
|
274
|
+
case 'reject':
|
|
275
|
+
throw new FrontGateError('not_allowed', 'cannot safely close the front gate because its direction and position are unknown');
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async acceptPulse(purpose, target) {
|
|
279
|
+
const now = this.scheduler.now();
|
|
280
|
+
const dueAt = Math.max(now, this.lastPublishedPulseAt + this.config.minimumPulseGapMs);
|
|
281
|
+
const operation = {
|
|
282
|
+
kind: 'scheduledPulse',
|
|
283
|
+
id: ++this.nextOperationId,
|
|
284
|
+
epoch: this.observationEpoch,
|
|
285
|
+
dueAt,
|
|
286
|
+
target,
|
|
287
|
+
expectedPosition: this.position.kind,
|
|
288
|
+
purpose,
|
|
289
|
+
};
|
|
290
|
+
this.setOperation(operation);
|
|
291
|
+
if (dueAt > now) {
|
|
292
|
+
this.scheduleOperationTimer(operation);
|
|
293
|
+
return `${purpose.kind}-scheduled`;
|
|
294
|
+
}
|
|
295
|
+
await this.executeScheduledPulse(operation, true);
|
|
296
|
+
return `${purpose.kind}-published`;
|
|
297
|
+
}
|
|
298
|
+
async executeScheduledPulse(operation, propagateError) {
|
|
299
|
+
if (!this.validateScheduledPulse(operation, !propagateError)) {
|
|
194
300
|
return;
|
|
195
301
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
302
|
+
try {
|
|
303
|
+
this.io.log.info(`motor pulse -> ${operation.purpose.kind}`);
|
|
304
|
+
await this.io.pulseMotor(operation.purpose.kind);
|
|
305
|
+
if (this.disposed || !this.hasCurrentOperation(operation.id)) {
|
|
199
306
|
return;
|
|
200
307
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
308
|
+
this.lastPublishedPulseAt = this.scheduler.now();
|
|
309
|
+
this.applySuccessfulPulse(operation);
|
|
310
|
+
if (!propagateError) {
|
|
311
|
+
this.emitSnapshot(`${operation.purpose.kind}-published`);
|
|
204
312
|
}
|
|
205
|
-
this.io.log.warn('open requested during ambiguous close attempt; not sending more pulses because the actual motion direction is unknown');
|
|
206
|
-
this.clearTimers();
|
|
207
|
-
this.plan = { kind: 'idle' };
|
|
208
|
-
this.idleNotClosedMode = 'openishUnknown';
|
|
209
|
-
this.idleNextPulseDirection = 'unknown';
|
|
210
|
-
this.requestedTarget = null;
|
|
211
|
-
this.emitSnapshot('open-request-during-ambiguous-close-attempt');
|
|
212
|
-
return;
|
|
213
313
|
}
|
|
214
|
-
|
|
215
|
-
if (this.
|
|
216
|
-
this.
|
|
217
|
-
|
|
314
|
+
catch (error) {
|
|
315
|
+
if (!this.disposed && this.hasCurrentOperation(operation.id)) {
|
|
316
|
+
this.clearOperation();
|
|
317
|
+
if (operation.purpose.kind === 'stopAndReverse' || this.facts.closedSensor !== true) {
|
|
318
|
+
this.setPosition({ kind: 'notClosedUnknown' });
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
this.setPosition({ kind: 'closed' });
|
|
322
|
+
}
|
|
323
|
+
if (!propagateError) {
|
|
324
|
+
this.io.log.warn(`scheduled front-gate pulse failed: ${this.errorMessage(error)}`);
|
|
325
|
+
this.emitSnapshot(`${operation.purpose.kind}-publish-failed`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (propagateError) {
|
|
329
|
+
if (error instanceof FrontGateError) {
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
throw new FrontGateError('communication_failure', `failed to publish front-gate pulse: ${this.errorMessage(error)}`, { cause: error });
|
|
218
333
|
}
|
|
219
|
-
// We already stopped an opening run, so the next pulse can only start closing.
|
|
220
|
-
// To honor the latest OPEN request without leaving the gate stopped in the middle,
|
|
221
|
-
// keep the pending close, let the gate reach the closed end-stop, then auto-open.
|
|
222
|
-
this.io.log.info('open requested while waiting to restart towards close; keeping the pending close and will auto-open from closed');
|
|
223
|
-
this.emitSnapshot('open-request-deferred-until-closed');
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
if (this.idleNextPulseDirection === 'opening') {
|
|
227
|
-
await this.startOpeningFromStoppedState('homekit-open-from-stopped-after-closing');
|
|
228
|
-
return;
|
|
229
334
|
}
|
|
230
|
-
this.requestedTarget = null;
|
|
231
|
-
this.emitSnapshot('open-request-already-satisfied-openish');
|
|
232
335
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
await this.pulseMotor('cancel-opening-while-still-closed');
|
|
239
|
-
this.clearTimers();
|
|
240
|
-
this.plan = { kind: 'idle' };
|
|
241
|
-
this.idleNextPulseDirection = 'opening';
|
|
242
|
-
this.requestedTarget = null;
|
|
243
|
-
this.emitSnapshot('opening-cancelled-before-leaving-closed');
|
|
336
|
+
applySuccessfulPulse(operation) {
|
|
337
|
+
switch (operation.purpose.kind) {
|
|
338
|
+
case 'startOpening':
|
|
339
|
+
this.clearOperation();
|
|
340
|
+
this.setKnownMovement('opening');
|
|
244
341
|
return;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
if (this.plan.kind === 'moving') {
|
|
251
|
-
if (this.plan.direction === 'closing') {
|
|
252
|
-
this.emitSnapshot(this.plan.certainty === 'known' ? 'close-request-already-closing' : 'close-request-already-close-seeking');
|
|
342
|
+
case 'startClosing':
|
|
343
|
+
this.clearOperation();
|
|
344
|
+
this.setKnownMovement('closing');
|
|
253
345
|
return;
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
if (this.plan.kind === 'waitingSecondPulse') {
|
|
259
|
-
if (this.plan.finalDirection === 'closing') {
|
|
260
|
-
this.emitSnapshot('close-request-already-reversing-to-close');
|
|
346
|
+
case 'bestEffortClose':
|
|
347
|
+
this.clearOperation();
|
|
348
|
+
this.setPosition({ kind: 'notClosedUnknown' });
|
|
261
349
|
return;
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
350
|
+
case 'stopOpeningAtClosed':
|
|
351
|
+
this.clearOperation();
|
|
352
|
+
this.setPosition({ kind: 'closed' });
|
|
353
|
+
return;
|
|
354
|
+
case 'stopAndReverse':
|
|
355
|
+
this.startReversalPause(operation.purpose.finalDirection, operation.target);
|
|
356
|
+
return;
|
|
357
|
+
case 'seekClosed':
|
|
358
|
+
this.setPosition({ kind: 'notClosedUnknown' });
|
|
359
|
+
this.startSeekClosedWait(operation.purpose.pulseNumber, operation.purpose.maxPulses);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
startReversalPause(finalDirection, target) {
|
|
363
|
+
this.setPosition({ kind: 'reversalPause' });
|
|
364
|
+
const now = this.scheduler.now();
|
|
365
|
+
const operation = {
|
|
366
|
+
kind: 'reversalPause',
|
|
367
|
+
id: ++this.nextOperationId,
|
|
368
|
+
epoch: this.observationEpoch,
|
|
369
|
+
dueAt: Math.max(now + this.config.reversePauseMs, this.lastPublishedPulseAt + this.config.minimumPulseGapMs),
|
|
370
|
+
target,
|
|
371
|
+
finalDirection,
|
|
372
|
+
};
|
|
373
|
+
this.setOperation(operation);
|
|
374
|
+
this.scheduleOperationTimer(operation);
|
|
375
|
+
}
|
|
376
|
+
startSeekClosedWait(pulsesUsed, maxPulses) {
|
|
377
|
+
const operation = {
|
|
378
|
+
kind: 'seekClosed',
|
|
379
|
+
id: ++this.nextOperationId,
|
|
380
|
+
epoch: this.observationEpoch,
|
|
381
|
+
dueAt: this.scheduler.now() + this.config.fullTravelMs,
|
|
382
|
+
target: 'closed',
|
|
383
|
+
pulsesUsed,
|
|
384
|
+
maxPulses,
|
|
385
|
+
};
|
|
386
|
+
this.setOperation(operation);
|
|
387
|
+
this.scheduleOperationTimer(operation);
|
|
388
|
+
}
|
|
389
|
+
scheduleOperationTimer(operation) {
|
|
390
|
+
this.clearOperationTimer();
|
|
391
|
+
const delayMs = Math.max(0, operation.dueAt - this.scheduler.now());
|
|
392
|
+
this.cancelOperationTimer = this.scheduler.schedule(() => {
|
|
393
|
+
void this.enqueue(`operation-timer-${operation.id}`, async () => {
|
|
394
|
+
await this.handleOperationTimer(operation.id);
|
|
395
|
+
});
|
|
396
|
+
}, delayMs);
|
|
397
|
+
}
|
|
398
|
+
async handleOperationTimer(operationId) {
|
|
399
|
+
if (this.operation.kind === 'none' || this.operation.id !== operationId) {
|
|
268
400
|
return;
|
|
269
401
|
}
|
|
270
|
-
|
|
271
|
-
|
|
402
|
+
const operation = this.operation;
|
|
403
|
+
if (!this.validateOperationContext(operation)) {
|
|
404
|
+
this.cancelInvalidOperation('scheduled-operation-guard-failed');
|
|
272
405
|
return;
|
|
273
406
|
}
|
|
274
|
-
if (
|
|
275
|
-
this.
|
|
276
|
-
this.requestedTarget = null;
|
|
277
|
-
this.emitSnapshot('close-request-not-directly-actionable-from-stopped-closing');
|
|
407
|
+
if (operation.kind === 'scheduledPulse') {
|
|
408
|
+
await this.executeScheduledPulse(operation, false);
|
|
278
409
|
return;
|
|
279
410
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
async startOpeningFromClosed(reason) {
|
|
283
|
-
if (this.facts.closedSensor !== true) {
|
|
284
|
-
this.requestedTarget = null;
|
|
285
|
-
this.emitSnapshot(`${reason}-already-openish`);
|
|
411
|
+
if (operation.kind === 'reversalPause') {
|
|
412
|
+
await this.executeReversalPulse(operation);
|
|
286
413
|
return;
|
|
287
414
|
}
|
|
288
|
-
await this.
|
|
289
|
-
this.startOpeningMotion('homekit', `${reason}-pulse-sent`);
|
|
415
|
+
await this.advanceSeekClosed(operation);
|
|
290
416
|
}
|
|
291
|
-
async
|
|
292
|
-
if (this.
|
|
293
|
-
|
|
417
|
+
async executeReversalPulse(operation) {
|
|
418
|
+
if (this.position.kind !== 'reversalPause') {
|
|
419
|
+
this.cancelInvalidOperation('reversal-position-changed');
|
|
294
420
|
return;
|
|
295
421
|
}
|
|
296
|
-
|
|
297
|
-
|
|
422
|
+
try {
|
|
423
|
+
this.io.log.info(`motor pulse -> finish-reversal-${operation.finalDirection}`);
|
|
424
|
+
await this.io.pulseMotor(`finish-reversal-${operation.finalDirection}`);
|
|
425
|
+
if (this.disposed || !this.hasCurrentOperation(operation.id)) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
this.lastPublishedPulseAt = this.scheduler.now();
|
|
429
|
+
this.clearOperation();
|
|
430
|
+
this.setKnownMovement(operation.finalDirection);
|
|
431
|
+
this.emitSnapshot(`reversal-second-pulse-${operation.finalDirection}`);
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
if (this.disposed || !this.hasCurrentOperation(operation.id)) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
this.io.log.warn(`front-gate reversal pulse failed: ${this.errorMessage(error)}`);
|
|
438
|
+
this.clearOperation();
|
|
439
|
+
this.setPosition({ kind: 'notClosedUnknown' });
|
|
440
|
+
this.emitSnapshot('reversal-second-pulse-failed');
|
|
441
|
+
}
|
|
298
442
|
}
|
|
299
|
-
async
|
|
443
|
+
async advanceSeekClosed(operation) {
|
|
300
444
|
if (this.facts.closedSensor === true) {
|
|
301
|
-
this.
|
|
302
|
-
this.
|
|
303
|
-
|
|
445
|
+
this.clearOperation();
|
|
446
|
+
this.setPosition({ kind: 'closed' });
|
|
447
|
+
this.desiredTarget = null;
|
|
448
|
+
this.emitSnapshot('seek-closed-succeeded');
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (operation.pulsesUsed >= operation.maxPulses) {
|
|
452
|
+
this.io.log.warn(`seek-closed exhausted ${operation.maxPulses} pulse(s) without reaching the sensor`);
|
|
453
|
+
this.clearOperation();
|
|
454
|
+
this.setPosition({ kind: 'notClosedUnknown' });
|
|
455
|
+
this.emitSnapshot('seek-closed-exhausted');
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
await this.acceptPulse({
|
|
460
|
+
kind: 'seekClosed',
|
|
461
|
+
pulseNumber: operation.pulsesUsed + 1,
|
|
462
|
+
maxPulses: operation.maxPulses,
|
|
463
|
+
}, 'closed');
|
|
464
|
+
this.emitSnapshot('seek-closed-next-pulse-accepted');
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
this.io.log.warn(`seek-closed pulse failed: ${this.errorMessage(error)}`);
|
|
468
|
+
this.clearOperation();
|
|
469
|
+
this.setPosition({ kind: 'notClosedUnknown' });
|
|
470
|
+
this.emitSnapshot('seek-closed-pulse-failed');
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
validateScheduledPulse(operation, requireCommittedTarget) {
|
|
474
|
+
if (this.disposed || this.operation.kind !== 'scheduledPulse' || this.operation.id !== operation.id) {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
if (!this.validateOperationContext(operation, requireCommittedTarget)) {
|
|
478
|
+
this.cancelInvalidOperation('scheduled-pulse-context-invalid');
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
if (this.position.kind !== operation.expectedPosition) {
|
|
482
|
+
this.cancelInvalidOperation('scheduled-pulse-position-invalid');
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
switch (operation.purpose.kind) {
|
|
486
|
+
case 'startOpening':
|
|
487
|
+
case 'stopOpeningAtClosed':
|
|
488
|
+
if (this.facts.closedSensor !== true) {
|
|
489
|
+
this.cancelInvalidOperation('scheduled-pulse-closed-sensor-invalid');
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
break;
|
|
493
|
+
case 'startClosing':
|
|
494
|
+
case 'bestEffortClose':
|
|
495
|
+
case 'stopAndReverse':
|
|
496
|
+
case 'seekClosed':
|
|
497
|
+
if (this.facts.closedSensor !== false) {
|
|
498
|
+
this.cancelInvalidOperation('scheduled-pulse-not-closed-sensor-invalid');
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
break;
|
|
304
502
|
}
|
|
305
|
-
|
|
306
|
-
this.startClosingMotion('known', 'homekit', `${reason}-pulse-sent`);
|
|
503
|
+
return true;
|
|
307
504
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
this.
|
|
312
|
-
|
|
505
|
+
validateOperationContext(operation, requireCommittedTarget = true) {
|
|
506
|
+
return !this.disposed
|
|
507
|
+
&& operation.epoch === this.observationEpoch
|
|
508
|
+
&& this.isAvailableForHomeKit()
|
|
509
|
+
&& (!requireCommittedTarget || this.desiredTarget === operation.target);
|
|
510
|
+
}
|
|
511
|
+
hasCurrentOperation(operationId) {
|
|
512
|
+
return this.operation.kind !== 'none' && this.operation.id === operationId;
|
|
513
|
+
}
|
|
514
|
+
cancelConflictingOperation() {
|
|
515
|
+
const operation = this.operation;
|
|
516
|
+
this.clearOperation();
|
|
517
|
+
if (operation.kind === 'reversalPause') {
|
|
518
|
+
this.setPosition({ kind: 'notClosedUnknown' });
|
|
313
519
|
}
|
|
314
|
-
await this.pulseMotor(reason);
|
|
315
|
-
this.startClosingMotion('goalOnly', 'homekit', `${reason}-pulse-sent`);
|
|
316
520
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
521
|
+
cancelInvalidOperation(note) {
|
|
522
|
+
const operation = this.operation;
|
|
523
|
+
this.clearOperation();
|
|
524
|
+
if (operation.kind === 'reversalPause') {
|
|
525
|
+
this.setPosition({ kind: 'notClosedUnknown' });
|
|
320
526
|
}
|
|
321
|
-
|
|
322
|
-
await this.pulseMotor(`${reason}-stop-current-motion`);
|
|
323
|
-
const deadlineAt = Date.now() + this.pulseGapMs + this.travelMs;
|
|
324
|
-
this.clearMovementTimer();
|
|
325
|
-
this.plan = {
|
|
326
|
-
kind: 'waitingSecondPulse',
|
|
327
|
-
stoppedFrom,
|
|
328
|
-
finalDirection,
|
|
329
|
-
source: 'homekit',
|
|
330
|
-
dueAt: Date.now() + this.pulseGapMs,
|
|
331
|
-
deadlineAt,
|
|
332
|
-
reason: finalDirection === 'opening' ? 'reverseToOpen' : 'reverseToClose',
|
|
333
|
-
};
|
|
334
|
-
this.schedulePhaseTimer(this.plan.dueAt);
|
|
335
|
-
this.emitSnapshot(`${reason}-waiting-second-pulse`);
|
|
336
|
-
}
|
|
337
|
-
startOpeningMotion(source, note) {
|
|
338
|
-
this.clearPhaseTimer();
|
|
339
|
-
this.plan = {
|
|
340
|
-
kind: 'moving',
|
|
341
|
-
direction: 'opening',
|
|
342
|
-
certainty: 'known',
|
|
343
|
-
source,
|
|
344
|
-
startedAt: Date.now(),
|
|
345
|
-
deadlineAt: Date.now() + this.travelMs,
|
|
346
|
-
};
|
|
347
|
-
this.scheduleMovementTimer(this.plan.deadlineAt);
|
|
527
|
+
this.io.log.warn(`front-gate scheduled operation cancelled: ${note}`);
|
|
348
528
|
this.emitSnapshot(note);
|
|
349
529
|
}
|
|
350
|
-
|
|
351
|
-
this.
|
|
352
|
-
this.
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
};
|
|
360
|
-
|
|
530
|
+
applyObservedExternalPulse(reason) {
|
|
531
|
+
this.clearOperation();
|
|
532
|
+
this.desiredTarget = null;
|
|
533
|
+
if (this.facts.closedSensor === true && this.sensorSampleEpoch === this.observationEpoch) {
|
|
534
|
+
this.setKnownMovement('opening');
|
|
535
|
+
this.emitSnapshot(`external-pulse-from-closed-${reason}`);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
this.setPosition({ kind: 'notClosedUnknown' });
|
|
539
|
+
this.emitSnapshot(`external-pulse-direction-unknown-${reason}`);
|
|
540
|
+
}
|
|
541
|
+
advanceObservationEpoch(note) {
|
|
542
|
+
this.observationEpoch += 1;
|
|
543
|
+
this.sensorSampleEpoch = null;
|
|
544
|
+
this.previousSensorInEpoch = null;
|
|
545
|
+
this.desiredTarget = null;
|
|
546
|
+
this.clearOperation();
|
|
547
|
+
this.setPosition({ kind: 'notClosedUnknown' });
|
|
548
|
+
this.io.log.warn(`front-gate observation epoch invalidated: ${note}`);
|
|
361
549
|
this.emitSnapshot(note);
|
|
362
550
|
}
|
|
363
|
-
|
|
364
|
-
this.
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
void this.enqueue(`movement-timeout-${token}`, async () => {
|
|
369
|
-
if (token !== this.movementTimerToken) {
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
await this.handleMovementTimeout();
|
|
373
|
-
});
|
|
374
|
-
}, delayMs);
|
|
375
|
-
}
|
|
376
|
-
schedulePhaseTimer(dueAt) {
|
|
377
|
-
this.clearPhaseTimer();
|
|
378
|
-
const delayMs = Math.max(0, dueAt - Date.now());
|
|
379
|
-
const token = ++this.phaseTimerToken;
|
|
380
|
-
this.phaseTimer = setTimeout(() => {
|
|
381
|
-
void this.enqueue(`phase-timer-${token}`, async () => {
|
|
382
|
-
if (token !== this.phaseTimerToken) {
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
await this.handlePhaseTimer();
|
|
386
|
-
});
|
|
387
|
-
}, delayMs);
|
|
551
|
+
setKnownMovement(direction) {
|
|
552
|
+
const now = this.scheduler.now();
|
|
553
|
+
this.setPosition(direction === 'opening'
|
|
554
|
+
? { kind: 'openingKnown', startedAt: now, epoch: this.observationEpoch }
|
|
555
|
+
: { kind: 'closingKnown', startedAt: now, epoch: this.observationEpoch });
|
|
388
556
|
}
|
|
389
|
-
|
|
390
|
-
if (this.movementTimer) {
|
|
391
|
-
clearTimeout(this.movementTimer);
|
|
392
|
-
this.movementTimer = undefined;
|
|
393
|
-
}
|
|
394
|
-
this.movementTimerToken += 1;
|
|
395
|
-
}
|
|
396
|
-
clearPhaseTimer() {
|
|
397
|
-
if (this.phaseTimer) {
|
|
398
|
-
clearTimeout(this.phaseTimer);
|
|
399
|
-
this.phaseTimer = undefined;
|
|
400
|
-
}
|
|
401
|
-
this.phaseTimerToken += 1;
|
|
402
|
-
}
|
|
403
|
-
clearTimers() {
|
|
557
|
+
setPosition(position) {
|
|
404
558
|
this.clearMovementTimer();
|
|
405
|
-
this.
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if (this.plan.kind !== 'waitingSecondPulse') {
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
const finalDirection = this.plan.finalDirection;
|
|
412
|
-
await this.pulseMotor(`second-pulse-${finalDirection}`);
|
|
413
|
-
this.clearPhaseTimer();
|
|
414
|
-
if (finalDirection === 'opening') {
|
|
415
|
-
this.startOpeningMotion(this.plan.source, `second-pulse-fired-${finalDirection}`);
|
|
559
|
+
this.position = position;
|
|
560
|
+
const generation = ++this.positionGeneration;
|
|
561
|
+
if (position.kind !== 'openingKnown' && position.kind !== 'closingKnown') {
|
|
416
562
|
return;
|
|
417
563
|
}
|
|
418
|
-
|
|
564
|
+
const epoch = position.epoch;
|
|
565
|
+
this.cancelMovementTimer = this.scheduler.schedule(() => {
|
|
566
|
+
void this.enqueue(`movement-timer-${generation}`, () => {
|
|
567
|
+
this.handleMovementTimeout(generation, epoch);
|
|
568
|
+
});
|
|
569
|
+
}, this.config.fullTravelMs);
|
|
419
570
|
}
|
|
420
|
-
|
|
421
|
-
if (
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
if (this.facts.closedSensor === true) {
|
|
425
|
-
this.clearTimers();
|
|
426
|
-
this.plan = { kind: 'idle' };
|
|
427
|
-
this.idleNextPulseDirection = 'opening';
|
|
428
|
-
this.requestedTarget = null;
|
|
429
|
-
this.emitSnapshot('movement-timeout-but-already-closed');
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
if (this.plan.direction === 'opening') {
|
|
433
|
-
// Fully-open and partially-open look identical to us. Once the opening window
|
|
434
|
-
// expires without any contrary evidence, collapse to the stable open state.
|
|
435
|
-
this.clearTimers();
|
|
436
|
-
this.plan = { kind: 'idle' };
|
|
437
|
-
this.idleNotClosedMode = 'openKnown';
|
|
438
|
-
this.idleNextPulseDirection = 'closing';
|
|
439
|
-
if (this.requestedTarget === 'closed' && this.isAvailableForHomeKit()) {
|
|
440
|
-
this.emitSnapshot('opening-window-elapsed-but-close-still-requested');
|
|
441
|
-
void this.enqueue('auto-close-after-open', async () => {
|
|
442
|
-
await this.startKnownCloseFromIdle('auto-close-after-open');
|
|
443
|
-
});
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
this.requestedTarget = null;
|
|
447
|
-
this.emitSnapshot('opening-window-elapsed-open');
|
|
571
|
+
handleMovementTimeout(generation, epoch) {
|
|
572
|
+
if (generation !== this.positionGeneration
|
|
573
|
+
|| epoch !== this.observationEpoch
|
|
574
|
+
|| (this.position.kind !== 'openingKnown' && this.position.kind !== 'closingKnown')) {
|
|
448
575
|
return;
|
|
449
576
|
}
|
|
450
|
-
this.
|
|
451
|
-
|
|
452
|
-
: 'ambiguous close attempt elapsed without reaching the closed sensor; leaving gate in generic open-ish state');
|
|
453
|
-
this.clearTimers();
|
|
454
|
-
this.plan = { kind: 'idle' };
|
|
455
|
-
this.idleNotClosedMode = 'openishUnknown';
|
|
456
|
-
this.idleNextPulseDirection = 'unknown';
|
|
457
|
-
this.requestedTarget = null;
|
|
458
|
-
this.emitSnapshot('closing-window-elapsed-openish');
|
|
459
|
-
}
|
|
460
|
-
applyObservedExternalPulse(reason) {
|
|
461
|
-
this.lastPulseLikeActivityAt = Date.now();
|
|
462
|
-
this.requestedTarget = null;
|
|
577
|
+
const direction = this.position.kind === 'openingKnown' ? 'opening' : 'closing';
|
|
578
|
+
this.clearOperation();
|
|
463
579
|
if (this.facts.closedSensor === true) {
|
|
464
|
-
this.
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
if (this.plan.kind === 'moving') {
|
|
468
|
-
const currentDirection = this.plan.direction;
|
|
469
|
-
const currentCertainty = this.plan.certainty;
|
|
470
|
-
this.clearTimers();
|
|
471
|
-
this.plan = { kind: 'idle' };
|
|
472
|
-
this.idleNotClosedMode = 'openishUnknown';
|
|
473
|
-
this.idleNextPulseDirection = currentCertainty === 'known'
|
|
474
|
-
? oppositeDirection(currentDirection)
|
|
475
|
-
: 'unknown';
|
|
476
|
-
this.emitSnapshot(`external-pulse-stopped-${currentDirection}-${reason}`);
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
if (this.plan.kind === 'waitingSecondPulse') {
|
|
480
|
-
const finalDirection = this.plan.finalDirection;
|
|
481
|
-
this.clearTimers();
|
|
482
|
-
if (finalDirection === 'opening') {
|
|
483
|
-
this.startOpeningMotion('external', `external-pulse-fired-pending-opening-${reason}`);
|
|
484
|
-
return;
|
|
580
|
+
this.setPosition({ kind: 'closed' });
|
|
581
|
+
if (this.desiredTarget === 'closed') {
|
|
582
|
+
this.desiredTarget = null;
|
|
485
583
|
}
|
|
486
|
-
this.
|
|
584
|
+
this.emitSnapshot(`${direction}-timeout-at-closed-sensor`);
|
|
487
585
|
return;
|
|
488
586
|
}
|
|
489
|
-
if (
|
|
490
|
-
this.
|
|
587
|
+
if (direction === 'opening' && this.config.assumeOpenAfterTravel) {
|
|
588
|
+
this.setPosition({ kind: 'openAssumed' });
|
|
589
|
+
this.emitSnapshot('opening-timeout-open-assumed');
|
|
491
590
|
return;
|
|
492
591
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
592
|
+
this.setPosition({ kind: 'notClosedUnknown' });
|
|
593
|
+
this.emitSnapshot(`${direction}-timeout-position-unknown`);
|
|
594
|
+
}
|
|
595
|
+
setOperation(operation) {
|
|
596
|
+
this.clearOperationTimer();
|
|
597
|
+
this.operation = operation;
|
|
598
|
+
}
|
|
599
|
+
clearOperation() {
|
|
600
|
+
this.clearOperationTimer();
|
|
601
|
+
this.operation = { kind: 'none' };
|
|
602
|
+
}
|
|
603
|
+
clearMovementTimer() {
|
|
604
|
+
if (this.cancelMovementTimer) {
|
|
605
|
+
this.cancelMovementTimer();
|
|
606
|
+
this.cancelMovementTimer = undefined;
|
|
496
607
|
}
|
|
497
|
-
this.emitSnapshot(`external-pulse-observed-direction-unknown-${reason}`);
|
|
498
608
|
}
|
|
499
|
-
|
|
500
|
-
this.
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
this.idleNotClosedMode = 'openishUnknown';
|
|
505
|
-
this.idleNextPulseDirection = 'unknown';
|
|
506
|
-
this.emitSnapshot(note);
|
|
609
|
+
clearOperationTimer() {
|
|
610
|
+
if (this.cancelOperationTimer) {
|
|
611
|
+
this.cancelOperationTimer();
|
|
612
|
+
this.cancelOperationTimer = undefined;
|
|
613
|
+
}
|
|
507
614
|
}
|
|
508
615
|
isAvailableForHomeKit() {
|
|
509
|
-
return this.facts.
|
|
616
|
+
return this.facts.transportConnected
|
|
617
|
+
&& this.facts.controlConnected === true
|
|
510
618
|
&& this.facts.sensorConnected === true
|
|
511
|
-
&& this.
|
|
619
|
+
&& this.sensorSampleEpoch === this.observationEpoch
|
|
512
620
|
&& this.facts.closedSensor !== null;
|
|
513
621
|
}
|
|
514
|
-
async pulseMotor(reason) {
|
|
515
|
-
const elapsed = Date.now() - this.lastPulseLikeActivityAt;
|
|
516
|
-
if (elapsed < this.pulseGapMs) {
|
|
517
|
-
await delay(this.pulseGapMs - elapsed);
|
|
518
|
-
}
|
|
519
|
-
this.io.log.info(`motor pulse -> ${reason}`);
|
|
520
|
-
await this.io.pulseMotor(reason);
|
|
521
|
-
this.lastPulseLikeActivityAt = Date.now();
|
|
522
|
-
}
|
|
523
622
|
emitSnapshot(note) {
|
|
524
|
-
if (this.disposed) {
|
|
525
|
-
|
|
623
|
+
if (!this.disposed) {
|
|
624
|
+
this.io.publishSnapshot(this.buildSnapshot(note));
|
|
526
625
|
}
|
|
527
|
-
this.io.publishSnapshot(this.buildSnapshot(note));
|
|
528
626
|
}
|
|
529
627
|
buildSnapshot(note) {
|
|
530
628
|
const available = this.isAvailableForHomeKit();
|
|
531
629
|
return {
|
|
532
630
|
available,
|
|
631
|
+
transportConnected: this.facts.transportConnected,
|
|
533
632
|
controlConnected: this.facts.controlConnected,
|
|
534
633
|
sensorConnected: this.facts.sensorConnected,
|
|
535
634
|
closedSensor: this.facts.closedSensor,
|
|
536
|
-
|
|
635
|
+
observationEpoch: this.observationEpoch,
|
|
636
|
+
sensorSampleEpoch: this.sensorSampleEpoch,
|
|
537
637
|
currentDoorState: available ? this.computeCurrentDoorState() : undefined,
|
|
538
638
|
targetDoorState: available ? this.computeTargetDoorState() : undefined,
|
|
539
|
-
|
|
639
|
+
desiredTarget: this.desiredTarget,
|
|
640
|
+
positionEstimate: this.position.kind,
|
|
540
641
|
motionDirection: this.getMotionDirection(),
|
|
541
|
-
|
|
542
|
-
planKind: this.plan.kind,
|
|
543
|
-
idleNotClosedMode: this.idleNotClosedMode,
|
|
544
|
-
nextPulseDirection: this.idleNextPulseDirection,
|
|
642
|
+
operationKind: this.operation.kind,
|
|
545
643
|
note,
|
|
546
644
|
};
|
|
547
645
|
}
|
|
548
646
|
getMotionDirection() {
|
|
549
|
-
if (this.
|
|
550
|
-
return
|
|
551
|
-
}
|
|
552
|
-
if (this.plan.kind === 'waitingSecondPulse') {
|
|
553
|
-
return this.plan.finalDirection;
|
|
554
|
-
}
|
|
555
|
-
return 'none';
|
|
556
|
-
}
|
|
557
|
-
getMotionCertainty() {
|
|
558
|
-
if (this.plan.kind === 'moving') {
|
|
559
|
-
return this.plan.certainty;
|
|
647
|
+
if (this.position.kind === 'openingKnown') {
|
|
648
|
+
return 'opening';
|
|
560
649
|
}
|
|
561
|
-
if (this.
|
|
562
|
-
return '
|
|
650
|
+
if (this.position.kind === 'closingKnown') {
|
|
651
|
+
return 'closing';
|
|
563
652
|
}
|
|
564
653
|
return 'none';
|
|
565
654
|
}
|
|
566
655
|
computeCurrentDoorState() {
|
|
567
|
-
|
|
568
|
-
|
|
656
|
+
switch (this.position.kind) {
|
|
657
|
+
case 'closed':
|
|
658
|
+
return 1 /* DoorCurrentState.CLOSED */;
|
|
659
|
+
case 'openingKnown':
|
|
660
|
+
return 2 /* DoorCurrentState.OPENING */;
|
|
661
|
+
case 'closingKnown':
|
|
662
|
+
return 3 /* DoorCurrentState.CLOSING */;
|
|
663
|
+
case 'openAssumed':
|
|
664
|
+
return 0 /* DoorCurrentState.OPEN */;
|
|
665
|
+
case 'reversalPause':
|
|
666
|
+
case 'notClosedUnknown':
|
|
667
|
+
return 4 /* DoorCurrentState.STOPPED */;
|
|
569
668
|
}
|
|
570
|
-
if (this.plan.kind === 'waitingSecondPulse') {
|
|
571
|
-
return this.plan.finalDirection === 'closing' ? 3 /* DoorCurrentState.CLOSING */ : 2 /* DoorCurrentState.OPENING */;
|
|
572
|
-
}
|
|
573
|
-
return this.facts.closedSensor ? 1 /* DoorCurrentState.CLOSED */ : 0 /* DoorCurrentState.OPEN */;
|
|
574
669
|
}
|
|
575
670
|
computeTargetDoorState() {
|
|
576
|
-
if (this.
|
|
577
|
-
return this.
|
|
671
|
+
if (this.desiredTarget) {
|
|
672
|
+
return this.desiredTarget === 'closed' ? 1 /* DoorTargetState.CLOSED */ : 0 /* DoorTargetState.OPEN */;
|
|
578
673
|
}
|
|
579
|
-
if (this.
|
|
580
|
-
return
|
|
674
|
+
if (this.position.kind === 'closed') {
|
|
675
|
+
return 1 /* DoorTargetState.CLOSED */;
|
|
581
676
|
}
|
|
582
|
-
if (this.
|
|
583
|
-
return this.
|
|
677
|
+
if (this.position.kind === 'openingKnown' || this.position.kind === 'closingKnown') {
|
|
678
|
+
return targetForDirection(this.position.kind === 'openingKnown' ? 'opening' : 'closing') === 'closed'
|
|
679
|
+
? 1 /* DoorTargetState.CLOSED */
|
|
680
|
+
: 0 /* DoorTargetState.OPEN */;
|
|
584
681
|
}
|
|
585
|
-
return
|
|
682
|
+
return 0 /* DoorTargetState.OPEN */;
|
|
683
|
+
}
|
|
684
|
+
errorMessage(error) {
|
|
685
|
+
return error instanceof Error ? error.message : String(error);
|
|
586
686
|
}
|
|
587
687
|
}
|
|
588
688
|
exports.FrontGateFsm = FrontGateFsm;
|