@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.
Files changed (49) hide show
  1. package/README.md +43 -0
  2. package/config.schema.json +67 -71
  3. package/dist/Accesories/ActionTriggerAccessory.js.map +1 -1
  4. package/dist/Accesories/AirQualityAccessory.js +1 -2
  5. package/dist/Accesories/AirQualityAccessory.js.map +1 -1
  6. package/dist/Accesories/DimmerAccessory.js.map +1 -1
  7. package/dist/Accesories/DimmerRgbLightAccessory.js.map +1 -1
  8. package/dist/Accesories/DoorAccessory.js.map +1 -1
  9. package/dist/Accesories/ElectricityMeterAccessory.js.map +1 -1
  10. package/dist/Accesories/FacadeBlindAccessory.js +1 -1
  11. package/dist/Accesories/FacadeBlindAccessory.js.map +1 -1
  12. package/dist/Accesories/FrontGateFsm.d.ts +67 -67
  13. package/dist/Accesories/FrontGateFsm.d.ts.map +1 -1
  14. package/dist/Accesories/FrontGateFsm.js +499 -399
  15. package/dist/Accesories/FrontGateFsm.js.map +1 -1
  16. package/dist/Accesories/GarageDoorOpenerAccesory.js.map +1 -1
  17. package/dist/Accesories/GateAccessory.d.ts +6 -2
  18. package/dist/Accesories/GateAccessory.d.ts.map +1 -1
  19. package/dist/Accesories/GateAccessory.js +127 -82
  20. package/dist/Accesories/GateAccessory.js.map +1 -1
  21. package/dist/Accesories/GateLockAccessory.js.map +1 -1
  22. package/dist/Accesories/LightBulbAccesory.js.map +1 -1
  23. package/dist/Accesories/PressureAccessory.js.map +1 -1
  24. package/dist/Accesories/RollerShutterAccessory.d.ts.map +1 -1
  25. package/dist/Accesories/RollerShutterAccessory.js +6 -5
  26. package/dist/Accesories/RollerShutterAccessory.js.map +1 -1
  27. package/dist/Accesories/SwitchAccessory.js.map +1 -1
  28. package/dist/Accesories/TemperatureAccessory.js.map +1 -1
  29. package/dist/Accesories/TemperatureHumidityAccessory.js.map +1 -1
  30. package/dist/Accesories/ThermostatAccessory.js +2 -2
  31. package/dist/Accesories/ThermostatAccessory.js.map +1 -1
  32. package/dist/Heplers/ColorConverters.js +4 -5
  33. package/dist/Heplers/ColorConverters.js.map +1 -1
  34. package/dist/Heplers/SuplaChannelContext.d.ts.map +1 -1
  35. package/dist/Heplers/SuplaMqttClient.d.ts +2 -3
  36. package/dist/Heplers/SuplaMqttClient.d.ts.map +1 -1
  37. package/dist/Heplers/SuplaMqttClient.js +34 -28
  38. package/dist/Heplers/SuplaMqttClient.js.map +1 -1
  39. package/dist/Heplers/SuplaMqttClientContext.d.ts +5 -4
  40. package/dist/Heplers/SuplaMqttClientContext.d.ts.map +1 -1
  41. package/dist/Heplers/SuplaMqttClientContext.js +2 -1
  42. package/dist/Heplers/SuplaMqttClientContext.js.map +1 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/platform.d.ts +34 -34
  45. package/dist/platform.d.ts.map +1 -1
  46. package/dist/platform.js +228 -183
  47. package/dist/platform.js.map +1 -1
  48. package/package.json +9 -8
  49. 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.DEFAULT_FRONT_GATE_TIMINGS = void 0;
4
- exports.DEFAULT_FRONT_GATE_TIMINGS = {
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
- closeRetryLimit: 0,
8
+ unknownOpenPolicy: 'reject',
9
+ unknownClosePolicy: 'reject',
10
+ seekClosedMaxPulses: 3,
11
+ assumeOpenAfterTravel: false,
10
12
  };
11
- function delay(ms) {
12
- return new Promise(resolve => setTimeout(resolve, ms));
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
- function clampInt(value, min, max) {
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 min;
30
+ return fallback;
17
31
  }
18
32
  return Math.min(max, Math.max(min, Math.round(value)));
19
33
  }
20
- function oppositeDirection(direction) {
21
- return direction === 'opening' ? 'closing' : 'opening';
34
+ function targetForDirection(direction) {
35
+ return direction === 'opening' ? 'open' : 'closed';
22
36
  }
23
37
  class FrontGateFsm {
24
- constructor(io, timings = exports.DEFAULT_FRONT_GATE_TIMINGS, _persisted = {}) {
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.sensorFreshSinceOnline = false;
32
- this.requestedTarget = null;
33
- this.idleNotClosedMode = 'openishUnknown';
34
- this.idleNextPulseDirection = 'unknown';
35
- this.plan = { kind: 'idle' };
36
- this.lastPulseLikeActivityAt = 0;
37
- this.movementTimerToken = 0;
38
- this.phaseTimerToken = 0;
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.travelMs = clampInt(timings.fullTravelMs || exports.DEFAULT_FRONT_GATE_TIMINGS.fullTravelMs, 5000, 120000);
42
- this.pulseGapMs = Math.max(3000, clampInt(timings.minimumPulseGapMs || exports.DEFAULT_FRONT_GATE_TIMINGS.minimumPulseGapMs, 0, 15000), clampInt(timings.reversePauseMs || exports.DEFAULT_FRONT_GATE_TIMINGS.reversePauseMs, 0, 15000));
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.clearTimers();
71
+ this.clearMovementTimer();
72
+ this.clearOperation();
48
73
  }
49
- handleConnectedChange(connected) {
50
- this.handleControlConnectedChange(connected);
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
- async requestHomeKitTarget(target) {
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
- return;
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.io.log.warn('front gate control channel went offline');
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.clearTimers();
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.io.log.warn('front gate sensor channel went offline');
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.clearTimers();
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 previous = this.facts.closedSensor;
130
- const wasFresh = this.sensorFreshSinceOnline;
164
+ const hadSampleInEpoch = this.sensorSampleEpoch === this.observationEpoch;
165
+ const previous = hadSampleInEpoch ? this.previousSensorInEpoch : null;
131
166
  this.facts.closedSensor = closed;
132
- if (this.facts.controlConnected === true && this.facts.sensorConnected === true) {
133
- this.sensorFreshSinceOnline = true;
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
- const becameFresh = !wasFresh && this.sensorFreshSinceOnline;
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.clearTimers();
142
- this.plan = { kind: 'idle' };
143
- this.idleNextPulseDirection = 'opening';
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 is FALSE -> gate is not fully closed');
157
- if (previous === true) {
158
- // Leaving the closed end-stop is the one fully reliable motion signal we have:
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 Error('front gate controller is not available');
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
- await this.handleCloseRequest();
182
- }
183
- async handleOpenRequest() {
184
- if (this.facts.closedSensor === true) {
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
- await this.startOpeningFromClosed('homekit-open-from-closed');
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
- if (this.plan.kind === 'moving') {
197
- if (this.plan.direction === 'opening') {
198
- this.emitSnapshot('open-request-already-opening');
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
- if (this.plan.certainty === 'known') {
202
- await this.reverseKnownMotion('opening', 'homekit-reverse-known-closing-to-open');
203
- return;
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
- if (this.plan.kind === 'waitingSecondPulse') {
215
- if (this.plan.finalDirection === 'opening') {
216
- this.emitSnapshot('open-request-already-reversing-to-open');
217
- return;
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
- async handleCloseRequest() {
234
- if (this.facts.closedSensor === true) {
235
- if (this.plan.kind === 'moving' && this.plan.direction === 'opening') {
236
- // We are still physically on the closed end-stop, so a single pulse cleanly
237
- // cancels the opening attempt and leaves the gate closed.
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
- this.requestedTarget = null;
247
- this.emitSnapshot('close-request-already-satisfied');
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
- await this.reverseKnownMotion('closing', 'homekit-reverse-known-opening-to-close');
256
- return;
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
- // We already stopped a closing run, so the next pulse can only start opening.
264
- // To honor the latest CLOSE request without leaving the gate stopped in the middle,
265
- // keep the pending open, let the gate become fully open-ish by timeout, then auto-close.
266
- this.io.log.info('close requested while waiting to restart towards open; keeping the pending open and will auto-close after the opening run settles');
267
- this.emitSnapshot('close-request-deferred-until-open');
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
- if (this.idleNotClosedMode === 'openKnown' || this.idleNextPulseDirection === 'closing') {
271
- await this.startKnownCloseFromIdle('homekit-close-from-openish-known');
402
+ const operation = this.operation;
403
+ if (!this.validateOperationContext(operation)) {
404
+ this.cancelInvalidOperation('scheduled-operation-guard-failed');
272
405
  return;
273
406
  }
274
- if (this.idleNextPulseDirection === 'opening') {
275
- this.io.log.warn('close requested while the gate is stopped after a closing run; the next pulse would open, so no corrective pulse is sent');
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
- await this.startAmbiguousCloseAttempt('homekit-close-from-openish-ambiguous');
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.pulseMotor(reason);
289
- this.startOpeningMotion('homekit', `${reason}-pulse-sent`);
415
+ await this.advanceSeekClosed(operation);
290
416
  }
291
- async startOpeningFromStoppedState(reason) {
292
- if (this.facts.closedSensor === true) {
293
- await this.startOpeningFromClosed(`${reason}-closed-fallback`);
417
+ async executeReversalPulse(operation) {
418
+ if (this.position.kind !== 'reversalPause') {
419
+ this.cancelInvalidOperation('reversal-position-changed');
294
420
  return;
295
421
  }
296
- await this.pulseMotor(reason);
297
- this.startOpeningMotion('homekit', `${reason}-pulse-sent`);
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 startKnownCloseFromIdle(reason) {
443
+ async advanceSeekClosed(operation) {
300
444
  if (this.facts.closedSensor === true) {
301
- this.requestedTarget = null;
302
- this.emitSnapshot(`${reason}-already-closed`);
303
- return;
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
- await this.pulseMotor(reason);
306
- this.startClosingMotion('known', 'homekit', `${reason}-pulse-sent`);
503
+ return true;
307
504
  }
308
- async startAmbiguousCloseAttempt(reason) {
309
- if (this.facts.closedSensor === true) {
310
- this.requestedTarget = null;
311
- this.emitSnapshot(`${reason}-already-closed`);
312
- return;
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
- async reverseKnownMotion(finalDirection, reason) {
318
- if (this.plan.kind !== 'moving') {
319
- return;
521
+ cancelInvalidOperation(note) {
522
+ const operation = this.operation;
523
+ this.clearOperation();
524
+ if (operation.kind === 'reversalPause') {
525
+ this.setPosition({ kind: 'notClosedUnknown' });
320
526
  }
321
- const stoppedFrom = this.plan.direction;
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
- startClosingMotion(certainty, source, note) {
351
- this.clearPhaseTimer();
352
- this.plan = {
353
- kind: 'moving',
354
- direction: 'closing',
355
- certainty,
356
- source,
357
- startedAt: Date.now(),
358
- deadlineAt: Date.now() + this.travelMs,
359
- };
360
- this.scheduleMovementTimer(this.plan.deadlineAt);
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
- scheduleMovementTimer(deadlineAt) {
364
- this.clearMovementTimer();
365
- const delayMs = Math.max(0, deadlineAt - Date.now());
366
- const token = ++this.movementTimerToken;
367
- this.movementTimer = setTimeout(() => {
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
- clearMovementTimer() {
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.clearPhaseTimer();
406
- }
407
- async handlePhaseTimer() {
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
- this.startClosingMotion('known', this.plan.source, `second-pulse-fired-${finalDirection}`);
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
- async handleMovementTimeout() {
421
- if (this.plan.kind !== 'moving') {
422
- return;
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.io.log.warn(this.plan.certainty === 'known'
451
- ? 'closing window elapsed without reaching the closed sensor; leaving gate in generic open-ish state'
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.startOpeningMotion('external', `external-pulse-observed-while-closed-${reason}`);
465
- return;
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.startClosingMotion('known', 'external', `external-pulse-fired-pending-closing-${reason}`);
584
+ this.emitSnapshot(`${direction}-timeout-at-closed-sensor`);
487
585
  return;
488
586
  }
489
- if (this.idleNextPulseDirection === 'opening') {
490
- this.startOpeningMotion('external', `external-pulse-started-opening-${reason}`);
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
- if (this.idleNextPulseDirection === 'closing') {
494
- this.startClosingMotion('known', 'external', `external-pulse-started-closing-${reason}`);
495
- return;
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
- enterUnavailable(note) {
500
- this.clearTimers();
501
- this.plan = { kind: 'idle' };
502
- this.requestedTarget = null;
503
- this.sensorFreshSinceOnline = false;
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.controlConnected === true
616
+ return this.facts.transportConnected
617
+ && this.facts.controlConnected === true
510
618
  && this.facts.sensorConnected === true
511
- && this.sensorFreshSinceOnline
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
- return;
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
- sensorFreshSinceOnline: this.sensorFreshSinceOnline,
635
+ observationEpoch: this.observationEpoch,
636
+ sensorSampleEpoch: this.sensorSampleEpoch,
537
637
  currentDoorState: available ? this.computeCurrentDoorState() : undefined,
538
638
  targetDoorState: available ? this.computeTargetDoorState() : undefined,
539
- requestedTarget: this.requestedTarget,
639
+ desiredTarget: this.desiredTarget,
640
+ positionEstimate: this.position.kind,
540
641
  motionDirection: this.getMotionDirection(),
541
- motionCertainty: this.getMotionCertainty(),
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.plan.kind === 'moving') {
550
- return this.plan.direction;
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.plan.kind === 'waitingSecondPulse') {
562
- return 'known';
650
+ if (this.position.kind === 'closingKnown') {
651
+ return 'closing';
563
652
  }
564
653
  return 'none';
565
654
  }
566
655
  computeCurrentDoorState() {
567
- if (this.plan.kind === 'moving') {
568
- return this.plan.direction === 'closing' ? 3 /* DoorCurrentState.CLOSING */ : 2 /* DoorCurrentState.OPENING */;
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.requestedTarget) {
577
- return this.requestedTarget === 'closed' ? 1 /* DoorTargetState.CLOSED */ : 0 /* DoorTargetState.OPEN */;
671
+ if (this.desiredTarget) {
672
+ return this.desiredTarget === 'closed' ? 1 /* DoorTargetState.CLOSED */ : 0 /* DoorTargetState.OPEN */;
578
673
  }
579
- if (this.plan.kind === 'moving') {
580
- return this.plan.direction === 'closing' ? 1 /* DoorTargetState.CLOSED */ : 0 /* DoorTargetState.OPEN */;
674
+ if (this.position.kind === 'closed') {
675
+ return 1 /* DoorTargetState.CLOSED */;
581
676
  }
582
- if (this.plan.kind === 'waitingSecondPulse') {
583
- return this.plan.finalDirection === 'closing' ? 1 /* DoorTargetState.CLOSED */ : 0 /* DoorTargetState.OPEN */;
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 this.facts.closedSensor ? 1 /* DoorTargetState.CLOSED */ : 0 /* DoorTargetState.OPEN */;
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;