@marcel2215/homebridge-supla-plugin 2.1.31 → 2.1.33

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