@marcel2215/homebridge-supla-plugin 2.1.24 → 2.1.26

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.
@@ -1,25 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GateAccessory = void 0;
4
+ const FrontGateFsm_1 = require("./FrontGateFsm");
4
5
  class GateAccessory {
5
6
  constructor(platform, accessory, context) {
6
7
  this.platform = platform;
7
8
  this.accessory = accessory;
8
9
  this.context = context;
9
- this.currentState = this.platform.Characteristic.CurrentDoorState.CLOSED;
10
- this.targetState = this.platform.Characteristic.TargetDoorState.CLOSED;
11
- this.connected = true;
12
- this.obstructionDetected = false;
13
- this.isClosedSensorActive = false;
14
- this.isPartialSensorActive = false;
15
- this.hasClosedSensorState = false;
16
- this.hasPartialSensorState = false;
17
- this.transitionTimeoutMs = 60000;
18
- this.lastCommandAt = 0;
19
- this.duplicateSetWindowMs = 350;
20
- this.sawPartialMotionDuringPending = false;
21
- this.lastClosedReleaseAt = 0;
22
- this.externalDirectionHintWindowMs = 2500;
23
10
  this.accessory.getService(this.platform.Service.AccessoryInformation)
24
11
  .setCharacteristic(this.platform.Characteristic.Manufacturer, 'Supla')
25
12
  .setCharacteristic(this.platform.Characteristic.Model, 'GateController');
@@ -30,6 +17,19 @@ class GateAccessory {
30
17
  this.service = this.accessory.getService(this.platform.Service.GarageDoorOpener)
31
18
  || this.accessory.addService(this.platform.Service.GarageDoorOpener);
32
19
  this.service.setCharacteristic(this.platform.Characteristic.Name, accessory.displayName);
20
+ this.controlBaseTopic = this.platform.normalizeTopicBase(this.context.topic);
21
+ this.sensorBaseTopic = this.resolveSensorBaseTopic();
22
+ // Remove stale persisted direction state from the previous implementation.
23
+ delete this.accessory.context.frontGateFsm;
24
+ this.fsm = new FrontGateFsm_1.FrontGateFsm({
25
+ pulseMotor: async (reason) => this.publishPulse(reason),
26
+ publishSnapshot: (snapshot) => this.applySnapshot(snapshot),
27
+ log: {
28
+ debug: (message) => this.platform.log.debug(`[FrontGate ${this.accessory.displayName}] ${message}`),
29
+ info: (message) => this.platform.log.info(`[FrontGate ${this.accessory.displayName}] ${message}`),
30
+ warn: (message) => this.platform.log.warn(`[FrontGate ${this.accessory.displayName}] ${message}`),
31
+ },
32
+ }, this.platform.getFrontGateTimings(), {});
33
33
  this.service.getCharacteristic(this.platform.Characteristic.CurrentDoorState)
34
34
  .onGet(this.handleCurrentDoorStateGet.bind(this));
35
35
  this.service.getCharacteristic(this.platform.Characteristic.TargetDoorState)
@@ -37,659 +37,284 @@ class GateAccessory {
37
37
  .onSet(this.handleTargetDoorStateSet.bind(this));
38
38
  this.service.getCharacteristic(this.platform.Characteristic.ObstructionDetected)
39
39
  .onGet(this.handleObstructionDetectedGet.bind(this));
40
- this.service.setCharacteristic(this.platform.Characteristic.ObstructionDetected, false);
40
+ this.service.updateCharacteristic(this.platform.Characteristic.ObstructionDetected, false);
41
41
  this.platform.registerOwnerCleanup(this.accessory.UUID, () => {
42
- this.clearTransitionTimer();
43
- this.clearReverseToggleTimer();
44
- this.clearOpenArrivalDebounceTimer();
45
- this.clearPublishRetryTimer();
42
+ this.fsm.dispose();
46
43
  });
47
- this.partialHiMode = this.platform.getGatePartialHiMode();
48
- this.baseTopic = this.platform.normalizeTopicBase(this.context.topic);
49
- this.reverseToggleDelayMs = this.platform.getGateReverseFollowUpDelayMs();
50
- this.openArrivalDebounceMs = this.platform.getGateOpenAssumeDelayMs();
51
- this.commandCooldownMs = this.platform.getGateCommandCooldownMs();
52
- this.publishRetryDelayMs = this.platform.getGatePublishRetryDelayMs();
53
- this.strictReverseDoublePulse = this.platform.getGateStrictReverseDoublePulse();
54
- this.debugTimeline = this.platform.getGateDebugTimeline();
55
- this.platform.registerMqttHandler(`${this.baseTopic}/state/hi`, (message) => {
56
- const previous = this.hasClosedSensorState ? this.isClosedSensorActive : undefined;
57
- const next = this.platform.parseBoolean(message.toString());
58
- const changed = !this.hasClosedSensorState || next !== this.isClosedSensorActive;
59
- if (previous === true && next === false) {
60
- this.lastClosedReleaseAt = Date.now();
61
- }
62
- else if (next) {
63
- this.lastClosedReleaseAt = 0;
64
- }
65
- this.isClosedSensorActive = next;
66
- this.hasClosedSensorState = true;
67
- this.updateStatesFromSensors({
68
- closedPrevious: changed ? previous : undefined,
69
- });
44
+ this.platform.registerMqttHandler(`${this.controlBaseTopic}/state/connected`, (message) => {
45
+ this.fsm.handleControlConnectedChange(this.platform.parseBoolean(message.toString()));
70
46
  }, this.accessory.UUID);
71
- this.platform.registerMqttHandler(`${this.baseTopic}/state/partial_hi`, (message) => {
72
- const previous = this.hasPartialSensorState ? this.isPartialSensorActive : undefined;
73
- const next = this.platform.parseBoolean(message.toString());
74
- const changed = !this.hasPartialSensorState || next !== this.isPartialSensorActive;
75
- if (previous === true && next === false) {
76
- this.lastClosedReleaseAt = 0;
77
- }
78
- this.isPartialSensorActive = next;
79
- this.hasPartialSensorState = true;
80
- if (this.pendingTarget !== undefined && this.isPartialSensorActive) {
81
- this.sawPartialMotionDuringPending = true;
82
- }
83
- this.updateStatesFromSensors({
84
- partialPrevious: changed ? previous : undefined,
85
- });
47
+ this.platform.registerMqttHandler(`${this.sensorBaseTopic}/state/connected`, (message) => {
48
+ this.fsm.handleSensorConnectedChange(this.platform.parseBoolean(message.toString()));
86
49
  }, this.accessory.UUID);
87
- this.platform.registerMqttHandler(`${this.baseTopic}/state/connected`, (message) => {
88
- this.connected = this.platform.parseBoolean(message.toString());
89
- this.updateStatusFault();
90
- if (!this.connected) {
91
- this.setPendingTarget(undefined);
92
- this.clearTransitionTimer();
93
- this.clearReverseToggleTimer();
94
- this.clearOpenArrivalDebounceTimer();
95
- this.clearPublishRetryTimer();
96
- }
50
+ this.platform.registerMqttHandler(`${this.sensorBaseTopic}/state/hi`, (message) => {
51
+ this.fsm.handleClosedSensorChange(this.platform.parseBoolean(message.toString()));
97
52
  }, this.accessory.UUID);
98
- this.updateStatusFault();
53
+ this.applySnapshot(this.fsm.getSnapshot());
99
54
  }
100
55
  async handleCurrentDoorStateGet() {
101
- return this.currentState;
56
+ const snapshot = this.fsm.getSnapshot();
57
+ if (!snapshot.available || snapshot.currentDoorState === undefined) {
58
+ throw this.createCommunicationError();
59
+ }
60
+ return snapshot.currentDoorState;
102
61
  }
103
62
  async handleTargetDoorStateGet() {
104
- return this.targetState;
63
+ const snapshot = this.fsm.getSnapshot();
64
+ if (!snapshot.available || snapshot.targetDoorState === undefined) {
65
+ throw this.createCommunicationError();
66
+ }
67
+ return snapshot.targetDoorState;
105
68
  }
106
69
  async handleTargetDoorStateSet(value) {
107
- const requestedTarget = value;
108
- const mode = this.platform.getGateControlMode();
109
- const motionTarget = this.getMotionTarget();
110
- const isMoving = motionTarget !== undefined;
111
- let target = requestedTarget;
112
- this.logGateTimeline('set-request', {
113
- requested: this.describeTargetState(requestedTarget),
114
- motion: this.describeTargetState(motionTarget),
115
- mode,
116
- });
117
- if (isMoving && motionTarget !== undefined && requestedTarget === motionTarget) {
118
- if (this.isLikelyDuplicateSet(requestedTarget)) {
119
- this.logGateTimeline('set-ignored-duplicate', {
120
- requested: this.describeTargetState(requestedTarget),
121
- });
122
- return;
123
- }
124
- target = this.oppositeTarget(requestedTarget);
125
- }
126
- const previousTarget = this.targetState;
127
- this.setTargetState(target);
128
- this.clearReverseToggleTimer();
129
- this.clearPublishRetryTimer();
130
- if (!this.connected) {
131
- this.platform.log.warn(`Gate ${this.accessory.displayName} is offline; ignoring command.`);
132
- this.setTargetState(previousTarget);
133
- this.updateStatusFault();
134
- this.logGateTimeline('set-ignored-offline', {
135
- requested: this.describeTargetState(requestedTarget),
136
- });
137
- return;
138
- }
139
- if (!isMoving && this.isAtTarget(target)) {
140
- this.logGateTimeline('set-ignored-at-target', {
141
- target: this.describeTargetState(target),
142
- });
143
- return;
144
- }
145
- if (this.pendingTarget === target) {
146
- this.logGateTimeline('set-ignored-pending', {
147
- target: this.describeTargetState(target),
148
- });
149
- return;
150
- }
151
- if (this.isCommandInCooldown(target, motionTarget)) {
152
- this.setTargetState(previousTarget);
153
- this.logGateTimeline('set-ignored-cooldown', {
154
- target: this.describeTargetState(target),
155
- cooldownMs: this.commandCooldownMs,
156
- });
157
- return;
158
- }
159
- let action = '';
160
- if (mode === 'toggle') {
161
- action = this.platform.getGateExecuteActionToggle();
162
- }
163
- else {
164
- action = target === this.platform.Characteristic.TargetDoorState.OPEN
165
- ? this.platform.getGateExecuteActionOpen()
166
- : this.platform.getGateExecuteActionClose();
167
- }
168
- if (!action) {
169
- this.platform.log.warn(`Gate action not configured for ${this.accessory.displayName}`);
170
- this.setTargetState(previousTarget);
171
- return;
70
+ const target = Number(value) === 0 /* DoorTargetState.OPEN */ ? 'open' : 'closed';
71
+ try {
72
+ await this.fsm.requestHomeKitTarget(target);
172
73
  }
173
- const isReversing = motionTarget !== undefined && motionTarget !== target;
174
- this.publishGateAction(action, isReversing ? 'reverse' : undefined, target);
175
- if (isReversing && this.shouldScheduleReverseFollowUp(mode)) {
176
- this.scheduleReverseToggle(action, target, mode);
74
+ catch (error) {
75
+ const message = error instanceof Error ? error.message : String(error);
76
+ this.platform.log.warn(`Gate ${this.accessory.displayName} command failed: ${message}`);
77
+ throw this.createCommunicationError();
177
78
  }
178
- this.clearFaults();
179
- this.setPendingTarget(target);
180
- this.markCommand(target);
181
- this.armTransitionTimer();
182
- this.setCurrentState(this.resolveMovingState(target));
183
- this.logGateTimeline('set-applied', {
184
- action,
185
- target: this.describeTargetState(target),
186
- reversing: isReversing,
187
- });
188
79
  }
189
80
  async handleObstructionDetectedGet() {
190
- return this.obstructionDetected;
191
- }
192
- updateStatesFromSensors(context) {
193
- const changed = ((context.closedPrevious !== undefined && context.closedPrevious !== this.isClosedSensorActive)
194
- || (context.partialPrevious !== undefined && context.partialPrevious !== this.isPartialSensorActive));
195
- if (changed) {
196
- this.logGateTimeline('sensor-change', {
197
- closed: this.describeSensorValue(this.hasClosedSensorState, this.isClosedSensorActive),
198
- partial: this.describeSensorValue(this.hasPartialSensorState, this.isPartialSensorActive),
199
- });
200
- }
201
- this.clearFaults();
202
- if (changed) {
203
- this.touchTransitionTimer();
204
- }
205
- if (this.isClosedSensorActive) {
206
- if (this.pendingTarget === this.platform.Characteristic.TargetDoorState.OPEN) {
207
- this.setTargetState(this.platform.Characteristic.TargetDoorState.OPEN);
208
- this.setCurrentState(this.platform.Characteristic.CurrentDoorState.OPENING);
209
- return;
210
- }
211
- this.setPendingTarget(undefined);
212
- this.clearTransitionTimer();
213
- this.clearReverseToggleTimer();
214
- this.clearOpenArrivalDebounceTimer();
215
- this.applyDoorState(this.platform.Characteristic.CurrentDoorState.CLOSED, this.platform.Characteristic.TargetDoorState.CLOSED);
216
- return;
217
- }
218
- if (this.pendingTarget === this.platform.Characteristic.TargetDoorState.OPEN
219
- && this.shouldDebounceOpenArrival()) {
220
- this.scheduleOpenArrivalDebounce();
221
- return;
222
- }
223
- if (this.pendingTarget === this.platform.Characteristic.TargetDoorState.OPEN
224
- && this.isOpenArrivalSignal()) {
225
- this.setPendingTarget(undefined);
226
- this.clearTransitionTimer();
227
- this.clearReverseToggleTimer();
228
- this.clearOpenArrivalDebounceTimer();
229
- this.applyDoorState(this.platform.Characteristic.CurrentDoorState.OPEN, this.platform.Characteristic.TargetDoorState.OPEN);
230
- return;
231
- }
232
- if (this.pendingTarget !== undefined) {
233
- this.setTargetState(this.pendingTarget);
234
- this.setCurrentState(this.resolveMovingState(this.pendingTarget));
235
- return;
236
- }
237
- if (this.shouldAssumeExternalOpeningFromClosed(context)) {
238
- this.setPendingTarget(this.platform.Characteristic.TargetDoorState.OPEN);
239
- this.setTargetState(this.platform.Characteristic.TargetDoorState.OPEN);
240
- this.setCurrentState(this.platform.Characteristic.CurrentDoorState.OPENING);
241
- this.armTransitionTimer();
242
- this.scheduleOpenArrivalDebounce();
243
- return;
244
- }
245
- const inferredExternalMotionTarget = this.resolveExternalMotionTarget(context);
246
- if (inferredExternalMotionTarget !== undefined) {
247
- this.clearTransitionTimer();
248
- this.clearOpenArrivalDebounceTimer();
249
- this.setTargetState(inferredExternalMotionTarget);
250
- this.setCurrentState(this.resolveMovingState(inferredExternalMotionTarget));
251
- return;
252
- }
253
- this.clearTransitionTimer();
254
- this.clearReverseToggleTimer();
255
- this.clearOpenArrivalDebounceTimer();
256
- this.applyDoorState(this.platform.Characteristic.CurrentDoorState.OPEN, this.platform.Characteristic.TargetDoorState.OPEN);
257
- }
258
- isAtTarget(target) {
259
- if (target === this.platform.Characteristic.TargetDoorState.CLOSED) {
260
- if (!this.hasClosedSensorState) {
261
- return false;
262
- }
263
- return this.isClosedSensorActive;
264
- }
265
- if (target === this.platform.Characteristic.TargetDoorState.OPEN) {
266
- return this.isKnownNotClosed()
267
- && this.currentState === this.platform.Characteristic.CurrentDoorState.OPEN;
268
- }
269
81
  return false;
270
82
  }
271
- applyDoorState(current, target) {
272
- this.setCurrentState(current);
273
- this.setTargetState(target);
274
- }
275
- setCurrentState(next) {
276
- if (this.currentState === next) {
83
+ applySnapshot(snapshot) {
84
+ this.accessory.updateReachability(snapshot.available);
85
+ if (this.service.testCharacteristic(this.platform.Characteristic.StatusActive)) {
86
+ this.service.updateCharacteristic(this.platform.Characteristic.StatusActive, snapshot.available);
87
+ }
88
+ if (this.service.testCharacteristic(this.platform.Characteristic.StatusFault)) {
89
+ this.service.updateCharacteristic(this.platform.Characteristic.StatusFault, snapshot.available
90
+ ? this.platform.Characteristic.StatusFault.NO_FAULT
91
+ : this.platform.Characteristic.StatusFault.GENERAL_FAULT);
92
+ }
93
+ this.service.updateCharacteristic(this.platform.Characteristic.ObstructionDetected, false);
94
+ if (!snapshot.available || snapshot.currentDoorState === undefined || snapshot.targetDoorState === undefined) {
95
+ const error = this.createCommunicationError();
96
+ this.service.updateCharacteristic(this.platform.Characteristic.CurrentDoorState, error);
97
+ this.service.updateCharacteristic(this.platform.Characteristic.TargetDoorState, error);
277
98
  return;
278
99
  }
279
- this.currentState = next;
280
- this.service.updateCharacteristic(this.platform.Characteristic.CurrentDoorState, this.currentState);
100
+ this.service.updateCharacteristic(this.platform.Characteristic.CurrentDoorState, snapshot.currentDoorState);
101
+ this.service.updateCharacteristic(this.platform.Characteristic.TargetDoorState, snapshot.targetDoorState);
281
102
  }
282
- setTargetState(next) {
283
- if (this.targetState === next) {
284
- return;
285
- }
286
- this.targetState = next;
287
- this.service.updateCharacteristic(this.platform.Characteristic.TargetDoorState, this.targetState);
103
+ createCommunicationError() {
104
+ return new Error('Front gate controller is unavailable');
288
105
  }
289
- isKnownNotClosed() {
290
- if (this.hasClosedSensorState) {
291
- return !this.isClosedSensorActive;
292
- }
293
- if (this.hasPartialSensorState && this.isPartialSensorActive) {
294
- return true;
295
- }
296
- return this.currentState !== this.platform.Characteristic.CurrentDoorState.CLOSED;
297
- }
298
- updateStatusFault() {
299
- const fault = !this.connected;
300
- this.service.updateCharacteristic(this.platform.Characteristic.StatusFault, fault
301
- ? this.platform.Characteristic.StatusFault.GENERAL_FAULT
302
- : this.platform.Characteristic.StatusFault.NO_FAULT);
303
- }
304
- clearFaults() {
305
- if (this.obstructionDetected) {
306
- this.obstructionDetected = false;
307
- this.service.updateCharacteristic(this.platform.Characteristic.ObstructionDetected, false);
308
- }
309
- this.updateStatusFault();
310
- }
311
- armTransitionTimer() {
312
- this.clearTransitionTimer();
313
- this.transitionTimer = setTimeout(() => {
314
- this.transitionTimer = undefined;
315
- const target = this.pendingTarget;
316
- this.setPendingTarget(undefined);
317
- this.clearReverseToggleTimer();
318
- this.clearOpenArrivalDebounceTimer();
319
- if (target === undefined) {
320
- return;
321
- }
322
- const settled = this.resolveTerminalStateFromSensors();
323
- if (settled !== undefined) {
324
- this.applyDoorState(settled.current, settled.target);
325
- return;
326
- }
327
- const assumedCurrent = target === this.platform.Characteristic.TargetDoorState.OPEN
328
- ? this.platform.Characteristic.CurrentDoorState.OPEN
329
- : this.platform.Characteristic.CurrentDoorState.CLOSED;
330
- this.applyDoorState(assumedCurrent, target);
331
- this.platform.log.warn(`Gate ${this.accessory.displayName} did not confirm target within ${this.transitionTimeoutMs}ms; assuming ` +
332
- `${target === this.platform.Characteristic.TargetDoorState.OPEN ? 'open' : 'closed'}.`);
333
- this.logGateTimeline('transition-timeout-assumed', {
334
- assumedTarget: this.describeTargetState(target),
106
+ async publishPulse(reason) {
107
+ const action = this.platform.getFrontGatePulseAction();
108
+ if (!action) {
109
+ throw new Error('front gate pulse action is not configured');
110
+ }
111
+ this.platform.log.debug(`Publishing ${this.controlBaseTopic}/execute_action = ${action} (${reason})`);
112
+ return new Promise((resolve, reject) => {
113
+ this.platform.publishCommand(`${this.controlBaseTopic}/execute_action`, action, (error) => {
114
+ if (error) {
115
+ reject(error);
116
+ return;
117
+ }
118
+ resolve();
335
119
  });
336
- }, this.transitionTimeoutMs);
337
- }
338
- clearTransitionTimer() {
339
- if (this.transitionTimer) {
340
- clearTimeout(this.transitionTimer);
341
- this.transitionTimer = undefined;
342
- }
343
- }
344
- clearReverseToggleTimer() {
345
- if (this.reverseToggleTimer) {
346
- clearTimeout(this.reverseToggleTimer);
347
- this.reverseToggleTimer = undefined;
348
- }
349
- }
350
- clearOpenArrivalDebounceTimer() {
351
- if (this.openArrivalDebounceTimer) {
352
- clearTimeout(this.openArrivalDebounceTimer);
353
- this.openArrivalDebounceTimer = undefined;
354
- }
355
- }
356
- clearPublishRetryTimer() {
357
- if (this.publishRetryTimer) {
358
- clearTimeout(this.publishRetryTimer);
359
- this.publishRetryTimer = undefined;
360
- }
361
- }
362
- touchTransitionTimer() {
363
- if (this.pendingTarget === undefined) {
364
- return;
365
- }
366
- this.armTransitionTimer();
367
- }
368
- resolveMovingState(target) {
369
- if (target === this.platform.Characteristic.TargetDoorState.OPEN) {
370
- return this.platform.Characteristic.CurrentDoorState.OPENING;
371
- }
372
- if (target === this.platform.Characteristic.TargetDoorState.CLOSED) {
373
- return this.platform.Characteristic.CurrentDoorState.CLOSING;
374
- }
375
- return this.currentState;
120
+ });
376
121
  }
377
- getMotionTarget() {
378
- if (this.pendingTarget !== undefined) {
379
- return this.pendingTarget;
380
- }
381
- if (this.currentState === this.platform.Characteristic.CurrentDoorState.OPENING) {
382
- return this.platform.Characteristic.TargetDoorState.OPEN;
383
- }
384
- if (this.currentState === this.platform.Characteristic.CurrentDoorState.CLOSING) {
385
- return this.platform.Characteristic.TargetDoorState.CLOSED;
122
+ resolveSensorBaseTopic() {
123
+ const explicit = this.resolveSensorOverrideFromConfig();
124
+ if (explicit) {
125
+ this.platform.log.info(`[FrontGate ${this.accessory.displayName}] using explicit front-gate sensor topic override: ${explicit}`);
126
+ return explicit;
127
+ }
128
+ const candidates = this.findSensorCandidates();
129
+ if (candidates.length === 0) {
130
+ this.platform.log.warn(`[FrontGate ${this.accessory.displayName}] no dedicated gate sensor channel found; falling back to ${this.controlBaseTopic}/state/hi`);
131
+ return this.controlBaseTopic;
132
+ }
133
+ const winner = candidates[0];
134
+ this.platform.log.info(`[FrontGate ${this.accessory.displayName}] resolved sensor channel ${winner.channel.channelCaption} `
135
+ + `(${winner.channel.deviceId}/${winner.channel.channelId}) -> ${winner.baseTopic} `
136
+ + `[${winner.reasons.join(', ')}]`);
137
+ return winner.baseTopic;
138
+ }
139
+ resolveSensorOverrideFromConfig() {
140
+ const config = this.platform.config;
141
+ if (typeof config.frontGateSensorTopic === 'string' && config.frontGateSensorTopic.trim()) {
142
+ return this.platform.normalizeTopicBase(config.frontGateSensorTopic);
143
+ }
144
+ const requestedDeviceId = this.normalizeOptionalId(config.frontGateSensorDeviceId);
145
+ const requestedChannelId = this.normalizeOptionalId(config.frontGateSensorChannelId);
146
+ if (!requestedDeviceId && !requestedChannelId) {
147
+ return undefined;
386
148
  }
387
- return undefined;
388
- }
389
- isOpenArrivalSignal() {
390
- if (this.partialHiMode === 'moving') {
391
- if (!this.hasPartialSensorState) {
392
- return true;
149
+ const channel = this.collectKnownChannels().find(candidate => {
150
+ if (requestedDeviceId && candidate.deviceId !== requestedDeviceId) {
151
+ return false;
152
+ }
153
+ if (requestedChannelId && candidate.channelId !== requestedChannelId) {
154
+ return false;
393
155
  }
394
- return this.hasPartialSensorState
395
- && this.sawPartialMotionDuringPending
396
- && !this.isPartialSensorActive;
397
- }
398
- if (!this.hasPartialSensorState) {
399
- return false;
400
- }
401
- if (this.partialHiMode === 'open_endstop') {
402
- return this.isPartialSensorActive;
403
- }
404
- if (this.partialHiMode === 'pedestrian_endstop') {
405
- return this.isPartialSensorActive;
406
- }
407
- return false;
408
- }
409
- shouldDebounceOpenArrival() {
410
- if (this.isClosedSensorActive) {
411
- return false;
412
- }
413
- if (this.partialHiMode === 'ignore') {
414
156
  return true;
157
+ });
158
+ if (!channel) {
159
+ this.platform.log.warn(`[FrontGate ${this.accessory.displayName}] configured frontGateSensorDeviceId/frontGateSensorChannelId did not match any known channel`);
160
+ return undefined;
415
161
  }
416
- return this.partialHiMode === 'moving' && !this.hasPartialSensorState;
162
+ return this.platform.normalizeTopicBase(channel.topic);
417
163
  }
418
- shouldAssumeExternalOpeningFromClosed(context) {
419
- return context.closedPrevious === true
420
- && !this.isClosedSensorActive
421
- && this.shouldDebounceOpenArrival();
422
- }
423
- scheduleOpenArrivalDebounce() {
424
- if (this.openArrivalDebounceTimer) {
425
- return;
426
- }
427
- this.openArrivalDebounceTimer = setTimeout(() => {
428
- this.openArrivalDebounceTimer = undefined;
429
- if (!this.connected) {
430
- return;
431
- }
432
- if (this.pendingTarget !== this.platform.Characteristic.TargetDoorState.OPEN) {
433
- return;
164
+ findSensorCandidates() {
165
+ const channels = this.collectKnownChannels();
166
+ const candidates = [];
167
+ for (const channel of channels) {
168
+ if (channel.channelId === this.context.channelId && channel.deviceId === this.context.deviceId) {
169
+ continue;
434
170
  }
435
- if (this.isClosedSensorActive) {
436
- return;
171
+ const score = this.scoreSensorCandidate(channel);
172
+ if (score.score <= 0) {
173
+ continue;
437
174
  }
438
- this.setPendingTarget(undefined);
439
- this.clearTransitionTimer();
440
- this.clearReverseToggleTimer();
441
- this.applyDoorState(this.platform.Characteristic.CurrentDoorState.OPEN, this.platform.Characteristic.TargetDoorState.OPEN);
442
- this.logGateTimeline('open-assumed-arrival', {
443
- debounceMs: this.openArrivalDebounceMs,
175
+ candidates.push({
176
+ channel,
177
+ baseTopic: this.platform.normalizeTopicBase(channel.topic),
178
+ score: score.score,
179
+ reasons: score.reasons,
444
180
  });
445
- }, this.openArrivalDebounceMs);
446
- }
447
- resolveTerminalStateFromSensors() {
448
- if (!this.hasClosedSensorState) {
449
- return undefined;
450
181
  }
451
- if (this.isClosedSensorActive) {
452
- return {
453
- current: this.platform.Characteristic.CurrentDoorState.CLOSED,
454
- target: this.platform.Characteristic.TargetDoorState.CLOSED,
455
- };
456
- }
457
- return {
458
- current: this.platform.Characteristic.CurrentDoorState.OPEN,
459
- target: this.platform.Characteristic.TargetDoorState.OPEN,
460
- };
461
- }
462
- oppositeTarget(target) {
463
- return target === this.platform.Characteristic.TargetDoorState.OPEN
464
- ? this.platform.Characteristic.TargetDoorState.CLOSED
465
- : this.platform.Characteristic.TargetDoorState.OPEN;
466
- }
467
- markCommand(target) {
468
- this.lastCommandTarget = target;
469
- this.lastCommandAt = Date.now();
470
- }
471
- setPendingTarget(target) {
472
- this.pendingTarget = target;
473
- if (target !== this.platform.Characteristic.TargetDoorState.OPEN) {
474
- this.clearOpenArrivalDebounceTimer();
182
+ candidates.sort((left, right) => right.score - left.score);
183
+ return candidates;
184
+ }
185
+ scoreSensorCandidate(channel) {
186
+ const reasons = [];
187
+ let score = 0;
188
+ const functionName = (channel.channelFunction || '').toUpperCase();
189
+ const typeName = (channel.channelType || '').toUpperCase();
190
+ const isGateSensorFunction = functionName === 'OPENINGSENSOR_GATE' || functionName === 'OPENINGSENSOR_GATEWAY';
191
+ const isGenericOpeningSensor = functionName.startsWith('OPENINGSENSOR_');
192
+ const isBinarySensor = typeName === 'BINARYSENSOR';
193
+ const sameDevice = Boolean(channel.deviceId && channel.deviceId === this.context.deviceId);
194
+ const captionScore = this.computeCaptionSimilarity(this.context.channelCaption || this.accessory.displayName, channel.channelCaption || '');
195
+ if (isGateSensorFunction) {
196
+ score += 100;
197
+ reasons.push('gate-sensor-function');
198
+ }
199
+ else if (isGenericOpeningSensor) {
200
+ score += 70;
201
+ reasons.push('opening-sensor-function');
202
+ }
203
+ else if (isBinarySensor) {
204
+ if (!sameDevice && captionScore === 0) {
205
+ return { score: 0, reasons: [] };
206
+ }
207
+ score += 20;
208
+ reasons.push('binary-sensor');
475
209
  }
476
- if (target === undefined) {
477
- this.sawPartialMotionDuringPending = false;
478
- return;
210
+ else {
211
+ return { score: 0, reasons: [] };
212
+ }
213
+ if (sameDevice) {
214
+ score += 50;
215
+ reasons.push('same-device');
216
+ }
217
+ if (captionScore > 0) {
218
+ score += captionScore;
219
+ reasons.push(`caption+${captionScore}`);
220
+ }
221
+ const controlBase = this.platform.normalizeTopicBase(this.context.topic);
222
+ const candidateBase = this.platform.normalizeTopicBase(channel.topic);
223
+ if (controlBase && candidateBase && controlBase !== candidateBase) {
224
+ const controlPrefix = controlBase.replace(/\/channels\/[^/]+$/, '');
225
+ const candidatePrefix = candidateBase.replace(/\/channels\/[^/]+$/, '');
226
+ if (controlPrefix === candidatePrefix) {
227
+ score += 15;
228
+ reasons.push('same-device-topic-prefix');
229
+ }
479
230
  }
480
- this.sawPartialMotionDuringPending = this.hasPartialSensorState && this.isPartialSensorActive;
231
+ return { score, reasons };
481
232
  }
482
- isLikelyDuplicateSet(target) {
483
- if (this.lastCommandTarget !== target) {
484
- return false;
233
+ computeCaptionSimilarity(left, right) {
234
+ const leftTokens = this.tokenizeCaption(left);
235
+ const rightTokens = this.tokenizeCaption(right);
236
+ if (leftTokens.length === 0 || rightTokens.length === 0) {
237
+ return 0;
485
238
  }
486
- return Date.now() - this.lastCommandAt <= this.duplicateSetWindowMs;
487
- }
488
- publishGateAction(action, note, expectedTarget, isRetry = false) {
489
- const suffix = note ? ` (${note})` : '';
490
- this.platform.log.debug(`Publishing ${this.baseTopic}/execute_action = ${action}${suffix}`);
491
- this.logGateTimeline(isRetry ? 'publish-retry' : 'publish', {
492
- action,
493
- note: note !== null && note !== void 0 ? note : 'none',
494
- expected: this.describeTargetState(expectedTarget),
495
- });
496
- this.platform.publishCommand(`${this.baseTopic}/execute_action`, action, (error) => {
497
- if (!error) {
498
- return;
239
+ const rightSet = new Set(rightTokens);
240
+ let overlap = 0;
241
+ for (const token of leftTokens) {
242
+ if (rightSet.has(token)) {
243
+ overlap += 1;
499
244
  }
500
- this.platform.log.warn(`Gate ${this.accessory.displayName} publish failed (${action}): ${error.message}`);
501
- this.logGateTimeline('publish-failed', {
502
- action,
503
- retry: !isRetry && this.publishRetryDelayMs > 0,
504
- });
505
- if (isRetry || this.publishRetryDelayMs <= 0) {
506
- return;
507
- }
508
- this.schedulePublishRetry(action, note, expectedTarget);
509
- });
245
+ }
246
+ if (overlap === 0) {
247
+ return 0;
248
+ }
249
+ return Math.min(40, overlap * 10);
250
+ }
251
+ tokenizeCaption(value) {
252
+ return value
253
+ .toLowerCase()
254
+ .normalize('NFD')
255
+ .replace(/[\u0300-\u036f]/g, '')
256
+ .split(/[^a-z0-9]+/)
257
+ .filter(Boolean)
258
+ .filter(token => !new Set([
259
+ 'gate',
260
+ 'sensor',
261
+ 'contact',
262
+ 'opening',
263
+ 'open',
264
+ 'close',
265
+ 'controller',
266
+ ]).has(token));
267
+ }
268
+ normalizeOptionalId(value) {
269
+ if (value === undefined || value === null) {
270
+ return undefined;
271
+ }
272
+ const normalized = String(value).trim();
273
+ return normalized || undefined;
510
274
  }
511
- schedulePublishRetry(action, note, expectedTarget) {
512
- this.clearPublishRetryTimer();
513
- this.publishRetryTimer = setTimeout(() => {
514
- this.publishRetryTimer = undefined;
515
- if (!this.connected) {
275
+ collectKnownChannels() {
276
+ const byKey = new Map();
277
+ const push = (candidate) => {
278
+ var _a, _b;
279
+ if (!candidate || typeof candidate !== 'object') {
516
280
  return;
517
281
  }
518
- if (expectedTarget !== undefined && this.pendingTarget !== expectedTarget) {
282
+ const channel = candidate;
283
+ if (typeof channel.topic !== 'string') {
519
284
  return;
520
285
  }
521
- this.publishGateAction(action, note, expectedTarget, true);
522
- }, this.publishRetryDelayMs);
523
- this.logGateTimeline('publish-retry-scheduled', {
524
- action,
525
- retryDelayMs: this.publishRetryDelayMs,
526
- expected: this.describeTargetState(expectedTarget),
527
- });
528
- }
529
- scheduleReverseToggle(action, expectedTarget, mode) {
530
- this.clearReverseToggleTimer();
531
- this.reverseToggleTimer = setTimeout(() => {
532
- this.reverseToggleTimer = undefined;
533
- if (!this.connected) {
534
- return;
286
+ const key = [
287
+ (_a = channel.deviceId) !== null && _a !== void 0 ? _a : '',
288
+ (_b = channel.channelId) !== null && _b !== void 0 ? _b : '',
289
+ this.platform.normalizeTopicBase(channel.topic),
290
+ ].join('|');
291
+ byKey.set(key, channel);
292
+ };
293
+ for (const knownAccessory of this.platform.accessories) {
294
+ push(knownAccessory.context.device);
295
+ }
296
+ const config = this.platform.config;
297
+ const rawChannels = config.channels;
298
+ if (Array.isArray(rawChannels)) {
299
+ for (const channel of rawChannels) {
300
+ push(channel);
535
301
  }
536
- if (this.pendingTarget !== expectedTarget) {
537
- return;
302
+ }
303
+ else if (typeof rawChannels === 'string' && rawChannels.trim()) {
304
+ try {
305
+ const parsed = JSON.parse(rawChannels);
306
+ if (Array.isArray(parsed)) {
307
+ for (const channel of parsed) {
308
+ push(channel);
309
+ }
310
+ }
538
311
  }
539
- if (!this.strictReverseDoublePulse
540
- && mode === 'execute_action'
541
- && this.isOpenCloseExecuteActionPair()
542
- && !this.shouldPublishExecuteActionReverseFollowUp()) {
543
- this.logGateTimeline('reverse-2-skipped', {
544
- reason: 'motion-confirmed',
545
- strict: this.strictReverseDoublePulse,
546
- });
547
- return;
312
+ catch (error) {
313
+ const message = error instanceof Error ? error.message : String(error);
314
+ this.platform.log.warn(`[FrontGate ${this.accessory.displayName}] failed to parse cached channels: ${message}`);
548
315
  }
549
- this.publishGateAction(action, 'reverse-2', expectedTarget);
550
- }, this.reverseToggleDelayMs);
551
- this.logGateTimeline('reverse-2-scheduled', {
552
- action,
553
- mode,
554
- delayMs: this.reverseToggleDelayMs,
555
- expected: this.describeTargetState(expectedTarget),
556
- });
557
- }
558
- shouldScheduleReverseFollowUp(mode) {
559
- if (mode === 'toggle') {
560
- return true;
561
- }
562
- return this.isOpenCloseExecuteActionPair() || this.isSingleExecuteActionPair();
563
- }
564
- isOpenCloseExecuteActionPair() {
565
- return this.normalizeAction(this.platform.getGateExecuteActionOpen()) === 'open'
566
- && this.normalizeAction(this.platform.getGateExecuteActionClose()) === 'close';
567
- }
568
- isSingleExecuteActionPair() {
569
- const openAction = this.normalizeAction(this.platform.getGateExecuteActionOpen());
570
- if (!openAction) {
571
- return false;
572
- }
573
- return openAction === this.normalizeAction(this.platform.getGateExecuteActionClose());
574
- }
575
- normalizeAction(value) {
576
- return value.trim().toLowerCase();
577
- }
578
- shouldPublishExecuteActionReverseFollowUp() {
579
- if (this.partialHiMode !== 'moving') {
580
- return true;
581
- }
582
- if (!this.hasPartialSensorState) {
583
- return true;
584
- }
585
- return !this.isPartialSensorActive;
586
- }
587
- resolveExternalMotionTarget(context) {
588
- if (this.partialHiMode !== 'moving') {
589
- return undefined;
590
- }
591
- if (!this.hasPartialSensorState || !this.isPartialSensorActive) {
592
- return undefined;
593
- }
594
- if (this.hasClosedSensorState && this.isClosedSensorActive) {
595
- return undefined;
596
- }
597
- const closedJustOpened = context.closedPrevious !== undefined
598
- && context.closedPrevious
599
- && !this.isClosedSensorActive;
600
- if (closedJustOpened) {
601
- return this.platform.Characteristic.TargetDoorState.OPEN;
602
- }
603
- if (this.wasClosedReleasedRecently()) {
604
- return this.platform.Characteristic.TargetDoorState.OPEN;
605
- }
606
- const existingMotionTarget = this.getMotionTarget();
607
- if (existingMotionTarget !== undefined) {
608
- return existingMotionTarget;
609
- }
610
- if (this.currentState === this.platform.Characteristic.CurrentDoorState.CLOSED) {
611
- return this.platform.Characteristic.TargetDoorState.OPEN;
612
- }
613
- if (this.currentState === this.platform.Characteristic.CurrentDoorState.OPEN) {
614
- return this.platform.Characteristic.TargetDoorState.CLOSED;
615
316
  }
616
- return this.targetState === this.platform.Characteristic.TargetDoorState.OPEN
617
- ? this.platform.Characteristic.TargetDoorState.CLOSED
618
- : this.platform.Characteristic.TargetDoorState.OPEN;
619
- }
620
- wasClosedReleasedRecently() {
621
- if (!this.lastClosedReleaseAt) {
622
- return false;
623
- }
624
- return Date.now() - this.lastClosedReleaseAt <= this.externalDirectionHintWindowMs;
625
- }
626
- isCommandInCooldown(target, motionTarget) {
627
- if (this.commandCooldownMs <= 0 || this.lastCommandAt === 0) {
628
- return false;
629
- }
630
- if (Date.now() - this.lastCommandAt > this.commandCooldownMs) {
631
- return false;
632
- }
633
- const isReverse = motionTarget !== undefined && motionTarget !== target;
634
- return !isReverse;
635
- }
636
- describeTargetState(value) {
637
- if (value === undefined) {
638
- return 'none';
639
- }
640
- if (value === this.platform.Characteristic.TargetDoorState.OPEN) {
641
- return 'open';
642
- }
643
- if (value === this.platform.Characteristic.TargetDoorState.CLOSED) {
644
- return 'closed';
645
- }
646
- return `unknown(${value})`;
647
- }
648
- describeCurrentState(value) {
649
- if (value === this.platform.Characteristic.CurrentDoorState.OPEN) {
650
- return 'open';
651
- }
652
- if (value === this.platform.Characteristic.CurrentDoorState.CLOSED) {
653
- return 'closed';
654
- }
655
- if (value === this.platform.Characteristic.CurrentDoorState.OPENING) {
656
- return 'opening';
657
- }
658
- if (value === this.platform.Characteristic.CurrentDoorState.CLOSING) {
659
- return 'closing';
660
- }
661
- if (value === this.platform.Characteristic.CurrentDoorState.STOPPED) {
662
- return 'stopped';
663
- }
664
- return `unknown(${value})`;
665
- }
666
- describeSensorValue(hasValue, value) {
667
- if (!hasValue) {
668
- return 'unknown';
669
- }
670
- return value ? 'true' : 'false';
671
- }
672
- logGateTimeline(event, context) {
673
- if (!this.debugTimeline) {
674
- return;
675
- }
676
- const snapshot = {
677
- event,
678
- current: this.describeCurrentState(this.currentState),
679
- target: this.describeTargetState(this.targetState),
680
- pending: this.describeTargetState(this.pendingTarget),
681
- connected: this.connected,
682
- closed: this.describeSensorValue(this.hasClosedSensorState, this.isClosedSensorActive),
683
- partial: this.describeSensorValue(this.hasPartialSensorState, this.isPartialSensorActive),
684
- };
685
- const merged = {
686
- ...snapshot,
687
- ...context,
688
- };
689
- const details = Object.entries(merged)
690
- .map(([key, value]) => `${key}=${String(value)}`)
691
- .join(' ');
692
- this.platform.log.info(`[GateDebug ${this.accessory.displayName}] ${details}`);
317
+ return Array.from(byKey.values());
693
318
  }
694
319
  }
695
320
  exports.GateAccessory = GateAccessory;