@marcel2215/homebridge-supla-plugin 2.1.32 → 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.
- package/README.md +1 -67
- package/config.json +3 -0
- package/config.schema.json +75 -92
- package/dist/Accesories/ActionTriggerAccessory.d.ts +1 -0
- package/dist/Accesories/ActionTriggerAccessory.d.ts.map +1 -1
- package/dist/Accesories/ActionTriggerAccessory.js +9 -15
- package/dist/Accesories/ActionTriggerAccessory.js.map +1 -1
- package/dist/Accesories/AirQualityAccessory.d.ts +1 -3
- package/dist/Accesories/AirQualityAccessory.d.ts.map +1 -1
- package/dist/Accesories/AirQualityAccessory.js +5 -47
- package/dist/Accesories/AirQualityAccessory.js.map +1 -1
- package/dist/Accesories/ContactSensorAccessory.d.ts +1 -3
- package/dist/Accesories/ContactSensorAccessory.d.ts.map +1 -1
- package/dist/Accesories/ContactSensorAccessory.js +4 -32
- package/dist/Accesories/ContactSensorAccessory.js.map +1 -1
- package/dist/Accesories/DimmerAccessory.d.ts +3 -7
- package/dist/Accesories/DimmerAccessory.d.ts.map +1 -1
- package/dist/Accesories/DimmerAccessory.js +16 -84
- package/dist/Accesories/DimmerAccessory.js.map +1 -1
- package/dist/Accesories/DimmerRgbLightAccessory.d.ts +3 -11
- package/dist/Accesories/DimmerRgbLightAccessory.d.ts.map +1 -1
- package/dist/Accesories/DimmerRgbLightAccessory.js +26 -148
- package/dist/Accesories/DimmerRgbLightAccessory.js.map +1 -1
- package/dist/Accesories/DoorAccessory.d.ts +1 -14
- package/dist/Accesories/DoorAccessory.d.ts.map +1 -1
- package/dist/Accesories/DoorAccessory.js +20 -146
- package/dist/Accesories/DoorAccessory.js.map +1 -1
- package/dist/Accesories/ElectricityMeterAccessory.d.ts +1 -13
- package/dist/Accesories/ElectricityMeterAccessory.d.ts.map +1 -1
- package/dist/Accesories/ElectricityMeterAccessory.js +12 -127
- package/dist/Accesories/ElectricityMeterAccessory.js.map +1 -1
- package/dist/Accesories/FacadeBlindAccessory.d.ts +0 -6
- package/dist/Accesories/FacadeBlindAccessory.d.ts.map +1 -1
- package/dist/Accesories/FacadeBlindAccessory.js +64 -205
- package/dist/Accesories/FacadeBlindAccessory.js.map +1 -1
- package/dist/Accesories/FrontGateFsm.d.ts +68 -74
- package/dist/Accesories/FrontGateFsm.d.ts.map +1 -1
- package/dist/Accesories/FrontGateFsm.js +403 -563
- package/dist/Accesories/FrontGateFsm.js.map +1 -1
- package/dist/Accesories/GarageDoorOpenerAccesory.d.ts +3 -12
- package/dist/Accesories/GarageDoorOpenerAccesory.d.ts.map +1 -1
- package/dist/Accesories/GarageDoorOpenerAccesory.js +21 -168
- package/dist/Accesories/GarageDoorOpenerAccesory.js.map +1 -1
- package/dist/Accesories/GateAccessory.d.ts +2 -12
- package/dist/Accesories/GateAccessory.d.ts.map +1 -1
- package/dist/Accesories/GateAccessory.js +89 -258
- package/dist/Accesories/GateAccessory.js.map +1 -1
- package/dist/Accesories/GateLockAccessory.d.ts +1 -17
- package/dist/Accesories/GateLockAccessory.d.ts.map +1 -1
- package/dist/Accesories/GateLockAccessory.js +50 -375
- package/dist/Accesories/GateLockAccessory.js.map +1 -1
- package/dist/Accesories/LeakSensorAccessory.d.ts +1 -3
- package/dist/Accesories/LeakSensorAccessory.d.ts.map +1 -1
- package/dist/Accesories/LeakSensorAccessory.js +4 -32
- package/dist/Accesories/LeakSensorAccessory.js.map +1 -1
- package/dist/Accesories/LightBulbAccesory.d.ts +3 -4
- package/dist/Accesories/LightBulbAccesory.d.ts.map +1 -1
- package/dist/Accesories/LightBulbAccesory.js +12 -40
- package/dist/Accesories/LightBulbAccesory.js.map +1 -1
- package/dist/Accesories/PressureAccessory.d.ts +1 -3
- package/dist/Accesories/PressureAccessory.d.ts.map +1 -1
- package/dist/Accesories/PressureAccessory.js +5 -28
- package/dist/Accesories/PressureAccessory.js.map +1 -1
- package/dist/Accesories/RGBLightBulbAccesory.d.ts +1 -10
- package/dist/Accesories/RGBLightBulbAccesory.d.ts.map +1 -1
- package/dist/Accesories/RGBLightBulbAccesory.js +17 -136
- package/dist/Accesories/RGBLightBulbAccesory.js.map +1 -1
- package/dist/Accesories/RollerShutterAccessory.d.ts +0 -6
- package/dist/Accesories/RollerShutterAccessory.d.ts.map +1 -1
- package/dist/Accesories/RollerShutterAccessory.js +75 -221
- package/dist/Accesories/RollerShutterAccessory.js.map +1 -1
- package/dist/Accesories/SwitchAccessory.d.ts +3 -4
- package/dist/Accesories/SwitchAccessory.d.ts.map +1 -1
- package/dist/Accesories/SwitchAccessory.js +12 -40
- package/dist/Accesories/SwitchAccessory.js.map +1 -1
- package/dist/Accesories/TemperatureAccessory.d.ts +1 -3
- package/dist/Accesories/TemperatureAccessory.d.ts.map +1 -1
- package/dist/Accesories/TemperatureAccessory.js +5 -28
- package/dist/Accesories/TemperatureAccessory.js.map +1 -1
- package/dist/Accesories/TemperatureHumidityAccessory.d.ts +1 -5
- package/dist/Accesories/TemperatureHumidityAccessory.d.ts.map +1 -1
- package/dist/Accesories/TemperatureHumidityAccessory.js +9 -40
- package/dist/Accesories/TemperatureHumidityAccessory.js.map +1 -1
- package/dist/Accesories/ThermostatAccessory.d.ts +1 -18
- package/dist/Accesories/ThermostatAccessory.d.ts.map +1 -1
- package/dist/Accesories/ThermostatAccessory.js +34 -193
- package/dist/Accesories/ThermostatAccessory.js.map +1 -1
- package/dist/Accesories/ValveAccessory.d.ts +2 -5
- package/dist/Accesories/ValveAccessory.d.ts.map +1 -1
- package/dist/Accesories/ValveAccessory.js +8 -47
- package/dist/Accesories/ValveAccessory.js.map +1 -1
- package/dist/Accesories/WicketAccesory.d.ts +3 -18
- package/dist/Accesories/WicketAccesory.d.ts.map +1 -1
- package/dist/Accesories/WicketAccesory.js +14 -154
- package/dist/Accesories/WicketAccesory.js.map +1 -1
- package/dist/Heplers/ColorConverters.d.ts +2 -2
- package/dist/Heplers/ColorConverters.d.ts.map +1 -1
- package/dist/Heplers/ColorConverters.js +3 -7
- package/dist/Heplers/ColorConverters.js.map +1 -1
- package/dist/Heplers/SuplaChannelContext.d.ts +1 -3
- package/dist/Heplers/SuplaChannelContext.d.ts.map +1 -1
- package/dist/Heplers/SuplaChannelContext.js +1 -3
- package/dist/Heplers/SuplaChannelContext.js.map +1 -1
- package/dist/Heplers/SuplaMqttClient.d.ts +3 -11
- package/dist/Heplers/SuplaMqttClient.d.ts.map +1 -1
- package/dist/Heplers/SuplaMqttClient.js +112 -215
- package/dist/Heplers/SuplaMqttClient.js.map +1 -1
- package/dist/Heplers/SuplaMqttClientContext.d.ts +1 -2
- package/dist/Heplers/SuplaMqttClientContext.d.ts.map +1 -1
- package/dist/Heplers/SuplaMqttClientContext.js +1 -2
- package/dist/Heplers/SuplaMqttClientContext.js.map +1 -1
- package/dist/platform.d.ts +36 -93
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +236 -1285
- package/dist/platform.js.map +1 -1
- package/package.json +5 -10
- package/dist/Heplers/DisplayName.d.ts +0 -15
- package/dist/Heplers/DisplayName.d.ts.map +0 -1
- package/dist/Heplers/DisplayName.js +0 -174
- 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.
|
|
4
|
-
exports.
|
|
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
|
-
|
|
9
|
-
unknownClosePolicy: 'reject',
|
|
10
|
-
seekClosedMaxPulses: 3,
|
|
11
|
-
assumeOpenAfterTravel: false,
|
|
9
|
+
closeRetryLimit: 0,
|
|
12
10
|
};
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
16
|
+
return min;
|
|
31
17
|
}
|
|
32
18
|
return Math.min(max, Math.max(min, Math.round(value)));
|
|
33
19
|
}
|
|
34
|
-
function
|
|
35
|
-
return direction === 'opening' ? '
|
|
20
|
+
function oppositeDirection(direction) {
|
|
21
|
+
return direction === 'opening' ? 'closing' : 'opening';
|
|
36
22
|
}
|
|
37
23
|
class FrontGateFsm {
|
|
38
|
-
constructor(io,
|
|
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.
|
|
47
|
-
this.
|
|
48
|
-
this.
|
|
49
|
-
this.
|
|
50
|
-
this.
|
|
51
|
-
this.
|
|
52
|
-
this.
|
|
53
|
-
this.
|
|
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.
|
|
58
|
-
|
|
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.
|
|
72
|
-
this.clearOperation();
|
|
47
|
+
this.clearTimers();
|
|
73
48
|
}
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
62
|
+
handleClosedSensorChange(closed) {
|
|
90
63
|
void this.enqueue(`closed-sensor=${closed}`, () => {
|
|
91
|
-
this.applyClosedSensorChange(closed
|
|
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 (
|
|
125
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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.
|
|
182
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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.
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
174
|
+
throw new Error('front gate controller is not available');
|
|
223
175
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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.
|
|
318
|
-
return `${purpose.kind}-published`;
|
|
181
|
+
await this.handleCloseRequest();
|
|
319
182
|
}
|
|
320
|
-
async
|
|
321
|
-
if (
|
|
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
|
-
|
|
325
|
-
this.
|
|
326
|
-
|
|
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.
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
337
|
-
if (
|
|
338
|
-
this.
|
|
339
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
this.
|
|
370
|
-
this.
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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 (
|
|
434
|
-
await this.
|
|
270
|
+
if (this.idleNotClosedMode === 'openKnown' || this.idleNextPulseDirection === 'closing') {
|
|
271
|
+
await this.startKnownCloseFromIdle('homekit-close-from-openish-known');
|
|
435
272
|
return;
|
|
436
273
|
}
|
|
437
|
-
if (
|
|
438
|
-
|
|
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.
|
|
280
|
+
await this.startAmbiguousCloseAttempt('homekit-close-from-openish-ambiguous');
|
|
442
281
|
}
|
|
443
|
-
async
|
|
444
|
-
if (this.
|
|
445
|
-
this.
|
|
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
|
-
|
|
449
|
-
|
|
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
|
|
291
|
+
async startOpeningFromStoppedState(reason) {
|
|
476
292
|
if (this.facts.closedSensor === true) {
|
|
477
|
-
this.
|
|
478
|
-
|
|
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
|
-
|
|
511
|
-
if (this.
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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.
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
403
|
+
clearTimers() {
|
|
606
404
|
this.clearMovementTimer();
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
if (
|
|
611
|
-
|
|
405
|
+
this.clearPhaseTimer();
|
|
406
|
+
}
|
|
407
|
+
async handlePhaseTimer() {
|
|
408
|
+
if (this.plan.kind !== 'waitingSecondPulse') {
|
|
409
|
+
return;
|
|
612
410
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
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(
|
|
626
|
-
if (
|
|
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.
|
|
635
|
-
this.
|
|
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'
|
|
639
|
-
|
|
640
|
-
|
|
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.
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
this.
|
|
648
|
-
this.
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
this.
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
this.
|
|
663
|
-
this.
|
|
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.
|
|
668
|
-
&& this.facts.controlConnected === true
|
|
509
|
+
return this.facts.controlConnected === true
|
|
669
510
|
&& this.facts.sensorConnected === true
|
|
670
|
-
&& this.
|
|
671
|
-
&& this.facts.closedSensor !== null
|
|
672
|
-
|
|
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 (
|
|
676
|
-
|
|
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
|
-
|
|
688
|
-
sensorSampleEpoch: this.sensorSampleEpoch,
|
|
536
|
+
sensorFreshSinceOnline: this.sensorFreshSinceOnline,
|
|
689
537
|
currentDoorState: available ? this.computeCurrentDoorState() : undefined,
|
|
690
538
|
targetDoorState: available ? this.computeTargetDoorState() : undefined,
|
|
691
|
-
|
|
692
|
-
positionEstimate: this.position.kind,
|
|
539
|
+
requestedTarget: this.requestedTarget,
|
|
693
540
|
motionDirection: this.getMotionDirection(),
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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.
|
|
702
|
-
return
|
|
549
|
+
if (this.plan.kind === 'moving') {
|
|
550
|
+
return this.plan.direction;
|
|
703
551
|
}
|
|
704
|
-
if (this.
|
|
705
|
-
return
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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.
|
|
727
|
-
return this.
|
|
576
|
+
if (this.requestedTarget) {
|
|
577
|
+
return this.requestedTarget === 'closed' ? 1 /* DoorTargetState.CLOSED */ : 0 /* DoorTargetState.OPEN */;
|
|
728
578
|
}
|
|
729
|
-
if (this.
|
|
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.
|
|
733
|
-
return
|
|
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;
|