@marcel2215/homebridge-supla-plugin 2.1.28 → 2.1.30

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 +67 -1
  2. package/config.schema.json +92 -75
  3. package/dist/Accesories/ActionTriggerAccessory.d.ts +0 -1
  4. package/dist/Accesories/ActionTriggerAccessory.d.ts.map +1 -1
  5. package/dist/Accesories/ActionTriggerAccessory.js +15 -9
  6. package/dist/Accesories/ActionTriggerAccessory.js.map +1 -1
  7. package/dist/Accesories/AirQualityAccessory.d.ts +3 -1
  8. package/dist/Accesories/AirQualityAccessory.d.ts.map +1 -1
  9. package/dist/Accesories/AirQualityAccessory.js +47 -5
  10. package/dist/Accesories/AirQualityAccessory.js.map +1 -1
  11. package/dist/Accesories/ContactSensorAccessory.d.ts +3 -1
  12. package/dist/Accesories/ContactSensorAccessory.d.ts.map +1 -1
  13. package/dist/Accesories/ContactSensorAccessory.js +32 -4
  14. package/dist/Accesories/ContactSensorAccessory.js.map +1 -1
  15. package/dist/Accesories/DimmerAccessory.d.ts +4 -2
  16. package/dist/Accesories/DimmerAccessory.d.ts.map +1 -1
  17. package/dist/Accesories/DimmerAccessory.js +51 -14
  18. package/dist/Accesories/DimmerAccessory.js.map +1 -1
  19. package/dist/Accesories/DimmerRgbLightAccessory.d.ts +10 -2
  20. package/dist/Accesories/DimmerRgbLightAccessory.d.ts.map +1 -1
  21. package/dist/Accesories/DimmerRgbLightAccessory.js +149 -26
  22. package/dist/Accesories/DimmerRgbLightAccessory.js.map +1 -1
  23. package/dist/Accesories/DoorAccessory.d.ts +14 -1
  24. package/dist/Accesories/DoorAccessory.d.ts.map +1 -1
  25. package/dist/Accesories/DoorAccessory.js +146 -20
  26. package/dist/Accesories/DoorAccessory.js.map +1 -1
  27. package/dist/Accesories/ElectricityMeterAccessory.d.ts +13 -1
  28. package/dist/Accesories/ElectricityMeterAccessory.d.ts.map +1 -1
  29. package/dist/Accesories/ElectricityMeterAccessory.js +127 -12
  30. package/dist/Accesories/ElectricityMeterAccessory.js.map +1 -1
  31. package/dist/Accesories/FacadeBlindAccessory.d.ts +6 -0
  32. package/dist/Accesories/FacadeBlindAccessory.d.ts.map +1 -1
  33. package/dist/Accesories/FacadeBlindAccessory.js +205 -64
  34. package/dist/Accesories/FacadeBlindAccessory.js.map +1 -1
  35. package/dist/Accesories/FrontGateFsm.d.ts +74 -68
  36. package/dist/Accesories/FrontGateFsm.d.ts.map +1 -1
  37. package/dist/Accesories/FrontGateFsm.js +563 -403
  38. package/dist/Accesories/FrontGateFsm.js.map +1 -1
  39. package/dist/Accesories/GarageDoorOpenerAccesory.d.ts +12 -3
  40. package/dist/Accesories/GarageDoorOpenerAccesory.d.ts.map +1 -1
  41. package/dist/Accesories/GarageDoorOpenerAccesory.js +168 -21
  42. package/dist/Accesories/GarageDoorOpenerAccesory.js.map +1 -1
  43. package/dist/Accesories/GateAccessory.d.ts +12 -2
  44. package/dist/Accesories/GateAccessory.d.ts.map +1 -1
  45. package/dist/Accesories/GateAccessory.js +245 -90
  46. package/dist/Accesories/GateAccessory.js.map +1 -1
  47. package/dist/Accesories/GateLockAccessory.d.ts +17 -1
  48. package/dist/Accesories/GateLockAccessory.d.ts.map +1 -1
  49. package/dist/Accesories/GateLockAccessory.js +375 -50
  50. package/dist/Accesories/GateLockAccessory.js.map +1 -1
  51. package/dist/Accesories/LeakSensorAccessory.d.ts +3 -1
  52. package/dist/Accesories/LeakSensorAccessory.d.ts.map +1 -1
  53. package/dist/Accesories/LeakSensorAccessory.js +32 -4
  54. package/dist/Accesories/LeakSensorAccessory.js.map +1 -1
  55. package/dist/Accesories/LightBulbAccesory.d.ts +3 -2
  56. package/dist/Accesories/LightBulbAccesory.d.ts.map +1 -1
  57. package/dist/Accesories/LightBulbAccesory.js +41 -12
  58. package/dist/Accesories/LightBulbAccesory.js.map +1 -1
  59. package/dist/Accesories/PressureAccessory.d.ts +3 -1
  60. package/dist/Accesories/PressureAccessory.d.ts.map +1 -1
  61. package/dist/Accesories/PressureAccessory.js +28 -5
  62. package/dist/Accesories/PressureAccessory.js.map +1 -1
  63. package/dist/Accesories/RGBLightBulbAccesory.d.ts +9 -0
  64. package/dist/Accesories/RGBLightBulbAccesory.d.ts.map +1 -1
  65. package/dist/Accesories/RGBLightBulbAccesory.js +137 -17
  66. package/dist/Accesories/RGBLightBulbAccesory.js.map +1 -1
  67. package/dist/Accesories/RollerShutterAccessory.d.ts +6 -0
  68. package/dist/Accesories/RollerShutterAccessory.d.ts.map +1 -1
  69. package/dist/Accesories/RollerShutterAccessory.js +221 -75
  70. package/dist/Accesories/RollerShutterAccessory.js.map +1 -1
  71. package/dist/Accesories/SwitchAccessory.d.ts +3 -2
  72. package/dist/Accesories/SwitchAccessory.d.ts.map +1 -1
  73. package/dist/Accesories/SwitchAccessory.js +41 -12
  74. package/dist/Accesories/SwitchAccessory.js.map +1 -1
  75. package/dist/Accesories/TemperatureAccessory.d.ts +3 -1
  76. package/dist/Accesories/TemperatureAccessory.d.ts.map +1 -1
  77. package/dist/Accesories/TemperatureAccessory.js +28 -5
  78. package/dist/Accesories/TemperatureAccessory.js.map +1 -1
  79. package/dist/Accesories/TemperatureHumidityAccessory.d.ts +5 -1
  80. package/dist/Accesories/TemperatureHumidityAccessory.d.ts.map +1 -1
  81. package/dist/Accesories/TemperatureHumidityAccessory.js +40 -9
  82. package/dist/Accesories/TemperatureHumidityAccessory.js.map +1 -1
  83. package/dist/Accesories/ThermostatAccessory.d.ts +18 -1
  84. package/dist/Accesories/ThermostatAccessory.d.ts.map +1 -1
  85. package/dist/Accesories/ThermostatAccessory.js +193 -34
  86. package/dist/Accesories/ThermostatAccessory.js.map +1 -1
  87. package/dist/Accesories/ValveAccessory.d.ts +5 -2
  88. package/dist/Accesories/ValveAccessory.d.ts.map +1 -1
  89. package/dist/Accesories/ValveAccessory.js +47 -8
  90. package/dist/Accesories/ValveAccessory.js.map +1 -1
  91. package/dist/Accesories/WicketAccesory.d.ts +18 -3
  92. package/dist/Accesories/WicketAccesory.d.ts.map +1 -1
  93. package/dist/Accesories/WicketAccesory.js +154 -14
  94. package/dist/Accesories/WicketAccesory.js.map +1 -1
  95. package/dist/Heplers/ColorConverters.d.ts +2 -2
  96. package/dist/Heplers/ColorConverters.d.ts.map +1 -1
  97. package/dist/Heplers/ColorConverters.js +7 -3
  98. package/dist/Heplers/ColorConverters.js.map +1 -1
  99. package/dist/Heplers/DisplayName.d.ts +15 -0
  100. package/dist/Heplers/DisplayName.d.ts.map +1 -0
  101. package/dist/Heplers/DisplayName.js +174 -0
  102. package/dist/Heplers/DisplayName.js.map +1 -0
  103. package/dist/Heplers/SuplaChannelContext.d.ts +3 -1
  104. package/dist/Heplers/SuplaChannelContext.d.ts.map +1 -1
  105. package/dist/Heplers/SuplaChannelContext.js +3 -1
  106. package/dist/Heplers/SuplaChannelContext.js.map +1 -1
  107. package/dist/Heplers/SuplaMqttClient.d.ts +11 -3
  108. package/dist/Heplers/SuplaMqttClient.d.ts.map +1 -1
  109. package/dist/Heplers/SuplaMqttClient.js +215 -112
  110. package/dist/Heplers/SuplaMqttClient.js.map +1 -1
  111. package/dist/Heplers/SuplaMqttClientContext.d.ts +2 -1
  112. package/dist/Heplers/SuplaMqttClientContext.d.ts.map +1 -1
  113. package/dist/Heplers/SuplaMqttClientContext.js +2 -1
  114. package/dist/Heplers/SuplaMqttClientContext.js.map +1 -1
  115. package/dist/platform.d.ts +92 -36
  116. package/dist/platform.d.ts.map +1 -1
  117. package/dist/platform.js +1253 -235
  118. package/dist/platform.js.map +1 -1
  119. package/package.json +10 -5
  120. package/config.json +0 -3
@@ -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}`, () => {
@@ -59,17 +86,26 @@ class FrontGateFsm {
59
86
  this.applySensorConnectedChange(connected);
60
87
  });
61
88
  }
62
- handleClosedSensorChange(closed) {
89
+ handleClosedSensorChange(closed, evidence = {}) {
63
90
  void this.enqueue(`closed-sensor=${closed}`, () => {
64
- this.applyClosedSensorChange(closed);
91
+ this.applyClosedSensorChange(closed, evidence);
65
92
  });
66
93
  }
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
+ }
67
103
  handleObservedExternalPulse(reason = 'mqtt-execute_action') {
68
104
  void this.enqueue(`external-pulse=${reason}`, () => {
69
105
  this.applyObservedExternalPulse(reason);
70
106
  });
71
107
  }
72
- async requestHomeKitTarget(target) {
108
+ requestHomeKitTarget(target) {
73
109
  return this.enqueue(`homekit-target=${target}`, async () => {
74
110
  await this.applyHomeKitTarget(target);
75
111
  });
@@ -77,15 +113,17 @@ class FrontGateFsm {
77
113
  getSnapshot() {
78
114
  return this.buildSnapshot('snapshot-requested');
79
115
  }
116
+ whenIdle() {
117
+ return this.sequence;
118
+ }
80
119
  enqueue(label, task) {
81
120
  if (this.disposed) {
82
121
  return Promise.resolve();
83
122
  }
84
123
  const run = this.sequence.then(async () => {
85
- if (this.disposed) {
86
- return;
124
+ if (!this.disposed) {
125
+ await task();
87
126
  }
88
- await task();
89
127
  });
90
128
  this.sequence = run.catch(error => {
91
129
  const message = error instanceof Error ? error.message : String(error);
@@ -93,21 +131,31 @@ class FrontGateFsm {
93
131
  });
94
132
  return run;
95
133
  }
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
+ }
96
148
  applyControlConnectedChange(connected) {
97
149
  if (this.facts.controlConnected === connected) {
98
150
  return;
99
151
  }
100
152
  this.facts.controlConnected = connected;
101
153
  if (!connected) {
102
- this.io.log.warn('front gate control channel went offline');
103
- this.enterUnavailable('control-offline');
154
+ this.advanceObservationEpoch('control-channel-offline');
104
155
  return;
105
156
  }
106
157
  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');
158
+ this.emitSnapshot('control-channel-online');
111
159
  }
112
160
  applySensorConnectedChange(connected) {
113
161
  if (this.facts.sensorConnected === connected) {
@@ -115,474 +163,586 @@ class FrontGateFsm {
115
163
  }
116
164
  this.facts.sensorConnected = connected;
117
165
  if (!connected) {
118
- this.io.log.warn('front gate sensor channel went offline');
119
- this.enterUnavailable('sensor-offline');
166
+ this.advanceObservationEpoch('sensor-channel-offline');
120
167
  return;
121
168
  }
122
169
  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');
127
- }
128
- applyClosedSensorChange(closed) {
129
- const previous = this.facts.closedSensor;
130
- const wasFresh = this.sensorFreshSinceOnline;
131
- this.facts.closedSensor = closed;
132
- if (this.facts.controlConnected === true && this.facts.sensorConnected === true) {
133
- this.sensorFreshSinceOnline = true;
134
- }
135
- const becameFresh = !wasFresh && this.sensorFreshSinceOnline;
136
- if (previous === closed && !becameFresh) {
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');
137
176
  return;
138
177
  }
139
- if (closed) {
140
- 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;
178
+ const hadSampleInEpoch = this.sensorSampleEpoch === this.observationEpoch;
179
+ const previous = hadSampleInEpoch ? this.previousSensorInEpoch : null;
180
+ 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' });
151
187
  }
152
- this.requestedTarget = null;
153
- this.emitSnapshot('closed-sensor-true');
188
+ else {
189
+ this.desiredTarget = null;
190
+ this.setPosition({ kind: 'notClosedUnknown' });
191
+ }
192
+ this.emitSnapshot('command-uncertainty-cleared-by-fresh-sensor');
154
193
  return;
155
194
  }
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;
195
+ if (!hadSampleInEpoch) {
196
+ this.clearOperation();
197
+ if (closed) {
198
+ this.setPosition({ kind: 'closed' });
163
199
  }
164
- this.startOpeningMotion(this.requestedTarget === 'open' ? 'homekit' : 'external', 'closed-sensor-fell-from-true-to-false');
200
+ else {
201
+ this.setPosition({ kind: 'notClosedUnknown' });
202
+ }
203
+ this.emitSnapshot('sensor-baseline-established');
204
+ return;
205
+ }
206
+ if (previous === closed) {
165
207
  return;
166
208
  }
167
- if (this.plan.kind === 'idle' && this.requestedTarget === 'open') {
168
- this.requestedTarget = null;
209
+ this.clearOperation();
210
+ if (closed) {
211
+ this.io.log.info('closed sensor is TRUE -> gate is fully closed');
212
+ this.setPosition({ kind: 'closed' });
213
+ this.emitSnapshot('closed-sensor-true');
214
+ return;
169
215
  }
170
- this.emitSnapshot('closed-sensor-false');
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');
171
219
  }
172
220
  async applyHomeKitTarget(target) {
173
221
  if (!this.isAvailableForHomeKit()) {
174
- throw new Error('front gate controller is not available');
222
+ throw new FrontGateError('unavailable', 'front gate controller is not available');
175
223
  }
176
- this.requestedTarget = target;
177
- if (target === 'open') {
178
- await this.handleOpenRequest();
179
- return;
180
- }
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');
224
+ if (this.operation.kind !== 'none') {
225
+ if (this.operation.target === target) {
226
+ this.desiredTarget = target;
227
+ this.emitSnapshot(`homekit-${target}-already-accepted`);
187
228
  return;
188
229
  }
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');
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`;
316
+ }
317
+ await this.executeScheduledPulse(operation, true);
318
+ return `${purpose.kind}-published`;
319
+ }
320
+ async executeScheduledPulse(operation, propagateError) {
321
+ if (!this.validateScheduledPulse(operation, !propagateError)) {
194
322
  return;
195
323
  }
196
- if (this.plan.kind === 'moving') {
197
- if (this.plan.direction === 'opening') {
198
- this.emitSnapshot('open-request-already-opening');
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)) {
199
328
  return;
200
329
  }
201
- if (this.plan.certainty === 'known') {
202
- await this.reverseKnownMotion('opening', 'homekit-reverse-known-closing-to-open');
203
- return;
330
+ this.lastPublishedPulseAt = this.scheduler.now();
331
+ this.applySuccessfulPulse(operation);
332
+ if (!propagateError) {
333
+ this.emitSnapshot(`${operation.purpose.kind}-published`);
204
334
  }
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
335
  }
214
- if (this.plan.kind === 'waitingSecondPulse') {
215
- if (this.plan.finalDirection === 'opening') {
216
- this.emitSnapshot('open-request-already-reversing-to-open');
217
- return;
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 });
218
359
  }
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
360
  }
230
- this.requestedTarget = null;
231
- this.emitSnapshot('open-request-already-satisfied-openish');
232
361
  }
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');
362
+ applySuccessfulPulse(operation) {
363
+ switch (operation.purpose.kind) {
364
+ case 'startOpening':
365
+ this.clearOperation();
366
+ this.setKnownMovement('opening');
244
367
  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');
368
+ case 'startClosing':
369
+ this.clearOperation();
370
+ this.setKnownMovement('closing');
253
371
  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');
372
+ case 'bestEffortClose':
373
+ this.clearOperation();
374
+ this.setPosition({ kind: 'notClosedUnknown' });
261
375
  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');
376
+ case 'stopOpeningAtClosed':
377
+ this.clearOperation();
378
+ this.setPosition({ kind: 'closed' });
379
+ return;
380
+ case 'stopAndReverse':
381
+ this.startReversalPause(operation.purpose.finalDirection, operation.target);
382
+ 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) {
268
426
  return;
269
427
  }
270
- if (this.idleNotClosedMode === 'openKnown' || this.idleNextPulseDirection === 'closing') {
271
- await this.startKnownCloseFromIdle('homekit-close-from-openish-known');
428
+ const operation = this.operation;
429
+ if (!this.validateOperationContext(operation)) {
430
+ this.cancelInvalidOperation('scheduled-operation-guard-failed');
272
431
  return;
273
432
  }
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');
433
+ if (operation.kind === 'scheduledPulse') {
434
+ await this.executeScheduledPulse(operation, false);
278
435
  return;
279
436
  }
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`);
437
+ if (operation.kind === 'reversalPause') {
438
+ await this.executeReversalPulse(operation);
286
439
  return;
287
440
  }
288
- await this.pulseMotor(reason);
289
- this.startOpeningMotion('homekit', `${reason}-pulse-sent`);
441
+ await this.advanceSeekClosed(operation);
290
442
  }
291
- async startOpeningFromStoppedState(reason) {
292
- if (this.facts.closedSensor === true) {
293
- await this.startOpeningFromClosed(`${reason}-closed-fallback`);
443
+ async executeReversalPulse(operation) {
444
+ if (this.position.kind !== 'reversalPause') {
445
+ this.cancelInvalidOperation('reversal-position-changed');
294
446
  return;
295
447
  }
296
- await this.pulseMotor(reason);
297
- this.startOpeningMotion('homekit', `${reason}-pulse-sent`);
298
- }
299
- async startKnownCloseFromIdle(reason) {
300
- if (this.facts.closedSensor === true) {
301
- this.requestedTarget = null;
302
- this.emitSnapshot(`${reason}-already-closed`);
303
- return;
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');
304
473
  }
305
- await this.pulseMotor(reason);
306
- this.startClosingMotion('known', 'homekit', `${reason}-pulse-sent`);
307
474
  }
308
- async startAmbiguousCloseAttempt(reason) {
475
+ async advanceSeekClosed(operation) {
309
476
  if (this.facts.closedSensor === true) {
310
- this.requestedTarget = null;
311
- this.emitSnapshot(`${reason}-already-closed`);
312
- return;
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');
313
508
  }
314
- await this.pulseMotor(reason);
315
- this.startClosingMotion('goalOnly', 'homekit', `${reason}-pulse-sent`);
316
509
  }
317
- async reverseKnownMotion(finalDirection, reason) {
318
- if (this.plan.kind !== 'moving') {
319
- return;
510
+ validateScheduledPulse(operation, requireCommittedTarget) {
511
+ if (this.disposed || this.operation.kind !== 'scheduledPulse' || this.operation.id !== operation.id) {
512
+ return false;
320
513
  }
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);
348
- this.emitSnapshot(note);
349
- }
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);
361
- this.emitSnapshot(note);
362
- }
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;
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;
371
528
  }
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;
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;
384
537
  }
385
- await this.handlePhaseTimer();
386
- });
387
- }, delayMs);
538
+ break;
539
+ }
540
+ return true;
388
541
  }
389
- clearMovementTimer() {
390
- if (this.movementTimer) {
391
- clearTimeout(this.movementTimer);
392
- this.movementTimer = undefined;
542
+ validateOperationContext(operation, requireCommittedTarget = true) {
543
+ return !this.disposed
544
+ && operation.epoch === this.observationEpoch
545
+ && this.isAvailableForHomeKit()
546
+ && (!requireCommittedTarget || this.desiredTarget === operation.target);
547
+ }
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' });
393
556
  }
394
- this.movementTimerToken += 1;
395
557
  }
396
- clearPhaseTimer() {
397
- if (this.phaseTimer) {
398
- clearTimeout(this.phaseTimer);
399
- this.phaseTimer = undefined;
558
+ cancelInvalidOperation(note) {
559
+ const operation = this.operation;
560
+ this.clearOperation();
561
+ if (operation.kind === 'reversalPause') {
562
+ this.setPosition({ kind: 'notClosedUnknown' });
400
563
  }
401
- this.phaseTimerToken += 1;
564
+ this.io.log.warn(`front-gate scheduled operation cancelled: ${note}`);
565
+ this.emitSnapshot(note);
402
566
  }
403
- clearTimers() {
404
- this.clearMovementTimer();
405
- this.clearPhaseTimer();
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}`);
597
+ this.emitSnapshot(note);
406
598
  }
407
- async handlePhaseTimer() {
408
- if (this.plan.kind !== 'waitingSecondPulse') {
409
- return;
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 });
604
+ }
605
+ setPosition(position) {
606
+ 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;
410
612
  }
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}`);
613
+ this.position = position;
614
+ const generation = ++this.positionGeneration;
615
+ if (position.kind !== 'openingKnown' && position.kind !== 'closingKnown') {
416
616
  return;
417
617
  }
418
- this.startClosingMotion('known', this.plan.source, `second-pulse-fired-${finalDirection}`);
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);
419
624
  }
420
- async handleMovementTimeout() {
421
- if (this.plan.kind !== 'moving') {
625
+ handleMovementTimeout(generation, epoch) {
626
+ if (generation !== this.positionGeneration
627
+ || epoch !== this.observationEpoch
628
+ || (this.position.kind !== 'openingKnown' && this.position.kind !== 'closingKnown')) {
422
629
  return;
423
630
  }
631
+ const direction = this.position.kind === 'openingKnown' ? 'opening' : 'closing';
632
+ this.clearOperation();
424
633
  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');
634
+ this.setPosition({ kind: 'closed' });
635
+ this.emitSnapshot(`${direction}-timeout-at-closed-sensor`);
430
636
  return;
431
637
  }
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');
638
+ if (direction === 'opening' && this.config.assumeOpenAfterTravel) {
639
+ this.setPosition({ kind: 'openAssumed' });
640
+ this.emitSnapshot('opening-timeout-open-assumed');
448
641
  return;
449
642
  }
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');
643
+ this.setPosition({ kind: 'notClosedUnknown' });
644
+ this.emitSnapshot(`${direction}-timeout-position-unknown`);
459
645
  }
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;
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;
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;
646
+ setOperation(operation) {
647
+ this.clearOperationTimer();
648
+ this.operation = operation;
649
+ }
650
+ clearOperation() {
651
+ this.clearOperationTimer();
652
+ this.operation = { kind: 'none' };
653
+ }
654
+ clearMovementTimer() {
655
+ if (this.cancelMovementTimer) {
656
+ this.cancelMovementTimer();
657
+ this.cancelMovementTimer = undefined;
496
658
  }
497
- this.emitSnapshot(`external-pulse-observed-direction-unknown-${reason}`);
498
659
  }
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);
660
+ clearOperationTimer() {
661
+ if (this.cancelOperationTimer) {
662
+ this.cancelOperationTimer();
663
+ this.cancelOperationTimer = undefined;
664
+ }
507
665
  }
508
666
  isAvailableForHomeKit() {
509
- return this.facts.controlConnected === true
667
+ return this.facts.transportConnected
668
+ && this.facts.controlConnected === true
510
669
  && this.facts.sensorConnected === true
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();
670
+ && this.sensorSampleEpoch === this.observationEpoch
671
+ && this.facts.closedSensor !== null
672
+ && this.position.kind !== 'commandUncertain';
522
673
  }
523
674
  emitSnapshot(note) {
524
- if (this.disposed) {
525
- return;
675
+ if (!this.disposed) {
676
+ this.io.publishSnapshot(this.buildSnapshot(note));
526
677
  }
527
- this.io.publishSnapshot(this.buildSnapshot(note));
528
678
  }
529
679
  buildSnapshot(note) {
530
680
  const available = this.isAvailableForHomeKit();
531
681
  return {
532
682
  available,
683
+ transportConnected: this.facts.transportConnected,
533
684
  controlConnected: this.facts.controlConnected,
534
685
  sensorConnected: this.facts.sensorConnected,
535
686
  closedSensor: this.facts.closedSensor,
536
- sensorFreshSinceOnline: this.sensorFreshSinceOnline,
687
+ observationEpoch: this.observationEpoch,
688
+ sensorSampleEpoch: this.sensorSampleEpoch,
537
689
  currentDoorState: available ? this.computeCurrentDoorState() : undefined,
538
690
  targetDoorState: available ? this.computeTargetDoorState() : undefined,
539
- requestedTarget: this.requestedTarget,
691
+ desiredTarget: this.desiredTarget,
692
+ positionEstimate: this.position.kind,
540
693
  motionDirection: this.getMotionDirection(),
541
- motionCertainty: this.getMotionCertainty(),
542
- planKind: this.plan.kind,
543
- idleNotClosedMode: this.idleNotClosedMode,
544
- nextPulseDirection: this.idleNextPulseDirection,
694
+ operationKind: this.operation.kind,
695
+ closedAfterTravelTimeout: this.position.kind === 'closed'
696
+ && note.endsWith('-timeout-at-closed-sensor'),
545
697
  note,
546
698
  };
547
699
  }
548
700
  getMotionDirection() {
549
- if (this.plan.kind === 'moving') {
550
- return this.plan.direction;
701
+ if (this.position.kind === 'openingKnown') {
702
+ return 'opening';
551
703
  }
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';
704
+ if (this.position.kind === 'closingKnown') {
705
+ return 'closing';
563
706
  }
564
707
  return 'none';
565
708
  }
566
709
  computeCurrentDoorState() {
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 */;
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 */;
572
723
  }
573
- return this.facts.closedSensor ? 1 /* DoorCurrentState.CLOSED */ : 0 /* DoorCurrentState.OPEN */;
574
724
  }
575
725
  computeTargetDoorState() {
576
- if (this.requestedTarget) {
577
- return this.requestedTarget === 'closed' ? 1 /* DoorTargetState.CLOSED */ : 0 /* DoorTargetState.OPEN */;
726
+ if (this.desiredTarget) {
727
+ return this.desiredTarget === 'closed' ? 1 /* DoorTargetState.CLOSED */ : 0 /* DoorTargetState.OPEN */;
578
728
  }
579
- if (this.plan.kind === 'moving') {
580
- return this.plan.direction === 'closing' ? 1 /* DoorTargetState.CLOSED */ : 0 /* DoorTargetState.OPEN */;
729
+ if (this.position.kind === 'closed') {
730
+ return 1 /* DoorTargetState.CLOSED */;
581
731
  }
582
- if (this.plan.kind === 'waitingSecondPulse') {
583
- return this.plan.finalDirection === 'closing' ? 1 /* DoorTargetState.CLOSED */ : 0 /* DoorTargetState.OPEN */;
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 */;
584
736
  }
585
- return this.facts.closedSensor ? 1 /* DoorTargetState.CLOSED */ : 0 /* DoorTargetState.OPEN */;
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');
586
746
  }
587
747
  }
588
748
  exports.FrontGateFsm = FrontGateFsm;