@marcel2215/homebridge-supla-plugin 2.1.31 → 2.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +1 -67
  2. package/config.json +3 -0
  3. package/config.schema.json +75 -92
  4. package/dist/Accesories/ActionTriggerAccessory.d.ts +1 -0
  5. package/dist/Accesories/ActionTriggerAccessory.d.ts.map +1 -1
  6. package/dist/Accesories/ActionTriggerAccessory.js +9 -15
  7. package/dist/Accesories/ActionTriggerAccessory.js.map +1 -1
  8. package/dist/Accesories/AirQualityAccessory.d.ts +1 -3
  9. package/dist/Accesories/AirQualityAccessory.d.ts.map +1 -1
  10. package/dist/Accesories/AirQualityAccessory.js +5 -47
  11. package/dist/Accesories/AirQualityAccessory.js.map +1 -1
  12. package/dist/Accesories/ContactSensorAccessory.d.ts +1 -3
  13. package/dist/Accesories/ContactSensorAccessory.d.ts.map +1 -1
  14. package/dist/Accesories/ContactSensorAccessory.js +4 -32
  15. package/dist/Accesories/ContactSensorAccessory.js.map +1 -1
  16. package/dist/Accesories/DimmerAccessory.d.ts +3 -5
  17. package/dist/Accesories/DimmerAccessory.d.ts.map +1 -1
  18. package/dist/Accesories/DimmerAccessory.js +14 -50
  19. package/dist/Accesories/DimmerAccessory.js.map +1 -1
  20. package/dist/Accesories/DimmerRgbLightAccessory.d.ts +3 -11
  21. package/dist/Accesories/DimmerRgbLightAccessory.d.ts.map +1 -1
  22. package/dist/Accesories/DimmerRgbLightAccessory.js +26 -148
  23. package/dist/Accesories/DimmerRgbLightAccessory.js.map +1 -1
  24. package/dist/Accesories/DoorAccessory.d.ts +1 -14
  25. package/dist/Accesories/DoorAccessory.d.ts.map +1 -1
  26. package/dist/Accesories/DoorAccessory.js +20 -146
  27. package/dist/Accesories/DoorAccessory.js.map +1 -1
  28. package/dist/Accesories/ElectricityMeterAccessory.d.ts +1 -13
  29. package/dist/Accesories/ElectricityMeterAccessory.d.ts.map +1 -1
  30. package/dist/Accesories/ElectricityMeterAccessory.js +12 -127
  31. package/dist/Accesories/ElectricityMeterAccessory.js.map +1 -1
  32. package/dist/Accesories/FacadeBlindAccessory.d.ts +0 -6
  33. package/dist/Accesories/FacadeBlindAccessory.d.ts.map +1 -1
  34. package/dist/Accesories/FacadeBlindAccessory.js +64 -205
  35. package/dist/Accesories/FacadeBlindAccessory.js.map +1 -1
  36. package/dist/Accesories/FrontGateFsm.d.ts +68 -74
  37. package/dist/Accesories/FrontGateFsm.d.ts.map +1 -1
  38. package/dist/Accesories/FrontGateFsm.js +403 -563
  39. package/dist/Accesories/FrontGateFsm.js.map +1 -1
  40. package/dist/Accesories/GarageDoorOpenerAccesory.d.ts +3 -12
  41. package/dist/Accesories/GarageDoorOpenerAccesory.d.ts.map +1 -1
  42. package/dist/Accesories/GarageDoorOpenerAccesory.js +21 -168
  43. package/dist/Accesories/GarageDoorOpenerAccesory.js.map +1 -1
  44. package/dist/Accesories/GateAccessory.d.ts +2 -12
  45. package/dist/Accesories/GateAccessory.d.ts.map +1 -1
  46. package/dist/Accesories/GateAccessory.js +89 -258
  47. package/dist/Accesories/GateAccessory.js.map +1 -1
  48. package/dist/Accesories/GateLockAccessory.d.ts +1 -17
  49. package/dist/Accesories/GateLockAccessory.d.ts.map +1 -1
  50. package/dist/Accesories/GateLockAccessory.js +50 -375
  51. package/dist/Accesories/GateLockAccessory.js.map +1 -1
  52. package/dist/Accesories/LeakSensorAccessory.d.ts +1 -3
  53. package/dist/Accesories/LeakSensorAccessory.d.ts.map +1 -1
  54. package/dist/Accesories/LeakSensorAccessory.js +4 -32
  55. package/dist/Accesories/LeakSensorAccessory.js.map +1 -1
  56. package/dist/Accesories/LightBulbAccesory.d.ts +3 -4
  57. package/dist/Accesories/LightBulbAccesory.d.ts.map +1 -1
  58. package/dist/Accesories/LightBulbAccesory.js +12 -40
  59. package/dist/Accesories/LightBulbAccesory.js.map +1 -1
  60. package/dist/Accesories/PressureAccessory.d.ts +1 -3
  61. package/dist/Accesories/PressureAccessory.d.ts.map +1 -1
  62. package/dist/Accesories/PressureAccessory.js +5 -28
  63. package/dist/Accesories/PressureAccessory.js.map +1 -1
  64. package/dist/Accesories/RGBLightBulbAccesory.d.ts +1 -10
  65. package/dist/Accesories/RGBLightBulbAccesory.d.ts.map +1 -1
  66. package/dist/Accesories/RGBLightBulbAccesory.js +17 -136
  67. package/dist/Accesories/RGBLightBulbAccesory.js.map +1 -1
  68. package/dist/Accesories/RollerShutterAccessory.d.ts +0 -6
  69. package/dist/Accesories/RollerShutterAccessory.d.ts.map +1 -1
  70. package/dist/Accesories/RollerShutterAccessory.js +75 -221
  71. package/dist/Accesories/RollerShutterAccessory.js.map +1 -1
  72. package/dist/Accesories/SwitchAccessory.d.ts +3 -4
  73. package/dist/Accesories/SwitchAccessory.d.ts.map +1 -1
  74. package/dist/Accesories/SwitchAccessory.js +12 -40
  75. package/dist/Accesories/SwitchAccessory.js.map +1 -1
  76. package/dist/Accesories/TemperatureAccessory.d.ts +1 -3
  77. package/dist/Accesories/TemperatureAccessory.d.ts.map +1 -1
  78. package/dist/Accesories/TemperatureAccessory.js +5 -28
  79. package/dist/Accesories/TemperatureAccessory.js.map +1 -1
  80. package/dist/Accesories/TemperatureHumidityAccessory.d.ts +1 -5
  81. package/dist/Accesories/TemperatureHumidityAccessory.d.ts.map +1 -1
  82. package/dist/Accesories/TemperatureHumidityAccessory.js +9 -40
  83. package/dist/Accesories/TemperatureHumidityAccessory.js.map +1 -1
  84. package/dist/Accesories/ThermostatAccessory.d.ts +1 -18
  85. package/dist/Accesories/ThermostatAccessory.d.ts.map +1 -1
  86. package/dist/Accesories/ThermostatAccessory.js +34 -193
  87. package/dist/Accesories/ThermostatAccessory.js.map +1 -1
  88. package/dist/Accesories/ValveAccessory.d.ts +2 -5
  89. package/dist/Accesories/ValveAccessory.d.ts.map +1 -1
  90. package/dist/Accesories/ValveAccessory.js +8 -47
  91. package/dist/Accesories/ValveAccessory.js.map +1 -1
  92. package/dist/Accesories/WicketAccesory.d.ts +3 -18
  93. package/dist/Accesories/WicketAccesory.d.ts.map +1 -1
  94. package/dist/Accesories/WicketAccesory.js +14 -154
  95. package/dist/Accesories/WicketAccesory.js.map +1 -1
  96. package/dist/Heplers/ColorConverters.d.ts +2 -2
  97. package/dist/Heplers/ColorConverters.d.ts.map +1 -1
  98. package/dist/Heplers/ColorConverters.js +3 -7
  99. package/dist/Heplers/ColorConverters.js.map +1 -1
  100. package/dist/Heplers/SuplaChannelContext.d.ts +1 -3
  101. package/dist/Heplers/SuplaChannelContext.d.ts.map +1 -1
  102. package/dist/Heplers/SuplaChannelContext.js +1 -3
  103. package/dist/Heplers/SuplaChannelContext.js.map +1 -1
  104. package/dist/Heplers/SuplaMqttClient.d.ts +3 -11
  105. package/dist/Heplers/SuplaMqttClient.d.ts.map +1 -1
  106. package/dist/Heplers/SuplaMqttClient.js +112 -215
  107. package/dist/Heplers/SuplaMqttClient.js.map +1 -1
  108. package/dist/Heplers/SuplaMqttClientContext.d.ts +1 -2
  109. package/dist/Heplers/SuplaMqttClientContext.d.ts.map +1 -1
  110. package/dist/Heplers/SuplaMqttClientContext.js +1 -2
  111. package/dist/Heplers/SuplaMqttClientContext.js.map +1 -1
  112. package/dist/platform.d.ts +36 -93
  113. package/dist/platform.d.ts.map +1 -1
  114. package/dist/platform.js +236 -1285
  115. package/dist/platform.js.map +1 -1
  116. package/package.json +5 -10
  117. package/dist/Heplers/DisplayName.d.ts +0 -15
  118. package/dist/Heplers/DisplayName.d.ts.map +0 -1
  119. package/dist/Heplers/DisplayName.js +0 -174
  120. package/dist/Heplers/DisplayName.js.map +0 -1
package/dist/platform.js CHANGED
@@ -38,8 +38,6 @@ const settings_1 = require("./settings");
38
38
  const GarageDoorOpenerAccesory_1 = require("./Accesories/GarageDoorOpenerAccesory");
39
39
  const LightBulbAccesory_1 = require("./Accesories/LightBulbAccesory");
40
40
  const fs = __importStar(require("fs"));
41
- const path = __importStar(require("path"));
42
- const crypto_1 = require("crypto");
43
41
  const SuplaMqttClient_1 = require("./Heplers/SuplaMqttClient");
44
42
  const RGBLightBulbAccesory_1 = require("./Accesories/RGBLightBulbAccesory");
45
43
  const GateAccessory_1 = require("./Accesories/GateAccessory");
@@ -60,7 +58,6 @@ const ThermostatAccessory_1 = require("./Accesories/ThermostatAccessory");
60
58
  const ElectricityMeterAccessory_1 = require("./Accesories/ElectricityMeterAccessory");
61
59
  const DimmerRgbLightAccessory_1 = require("./Accesories/DimmerRgbLightAccessory");
62
60
  const ActionTriggerAccessory_1 = require("./Accesories/ActionTriggerAccessory");
63
- const DisplayName_1 = require("./Heplers/DisplayName");
64
61
  /**
65
62
  * HomebridgePlatform
66
63
  * This class is the main constructor for your plugin, this is where you should
@@ -77,165 +74,80 @@ class SuplaPlatform {
77
74
  this.mqttHandlers = new Map();
78
75
  this.mqttHandlerOwners = new Map();
79
76
  this.mqttWildcardHandlers = new Map();
80
- this.mqttNoLocalTopics = new Set();
81
- this.mqttTransportHandlers = new Map();
82
- this.channelRegistry = new Map();
83
77
  this.ownerCleanups = new Map();
84
- this.shutdownDrains = new Map();
85
- this.gateLockFailSafeTasks = new Map();
86
78
  this.mqttDesiredSubscriptions = new Set();
87
79
  this.mqttSubscriptions = new Set();
88
80
  this.mqttPendingSubscriptions = new Set();
89
- this.mqttPendingSubscriptionOperations = new Map();
90
81
  this.mqttRetryTimers = new Map();
91
82
  this.mqttRetryState = new Map();
92
83
  this.mqttRouterAttached = false;
93
- this.mqttTransportConnected = false;
94
- this.mqttTransportEpoch = 0;
95
- this.nextMqttSubscriptionOperationId = 0;
96
- this.shuttingDown = false;
97
- this.publishTimeoutMs = 5000;
98
84
  this.log.debug('Finished initializing platform:', this.config.name);
99
- this.accountNamespace = this.computeAccountNamespace();
100
85
  const configView = this.config;
101
86
  this.coveringControlMode = this.normalizeCoveringControlMode(configView.coveringControlMode);
102
87
  this.coveringSetTopicSuffix = this.normalizeTopicSuffix(configView.coveringSetTopicSuffix || 'set/closing_percentage');
103
88
  this.coveringTiltTopicSuffix = this.normalizeTopicSuffix(configView.coveringTiltTopicSuffix || 'set/tilt');
104
- this.coveringExecuteActionOpen = String(configView.coveringExecuteActionOpen ?? '').trim() || 'reveal';
105
- this.coveringExecuteActionClose = String(configView.coveringExecuteActionClose ?? '').trim() || 'shut';
106
- this.coveringExecuteActionStop = String(configView.coveringExecuteActionStop ?? '').trim() || 'stop';
107
- this.coveringTravelTimeSeconds = this.normalizeDurationSeconds(configView.coveringTravelTimeSeconds, 0, 0, 600);
108
- this.gateExecuteActionToggle = String(configView.gateExecuteActionToggle ?? '').trim() || 'open_close';
89
+ this.coveringExecuteActionOpen = (configView.coveringExecuteActionOpen || 'reveal').toString();
90
+ this.coveringExecuteActionClose = (configView.coveringExecuteActionClose || 'shut').toString();
91
+ this.coveringExecuteActionStop = (configView.coveringExecuteActionStop || 'stop').toString();
92
+ this.coveringTravelTimeSeconds = Number(configView.coveringTravelTimeSeconds) || 0;
93
+ this.gateControlMode = this.normalizeGateControlMode(configView.gateControlMode);
94
+ this.gateExecuteActionOpen = (configView.gateExecuteActionOpen || 'open_close').toString();
95
+ this.gateExecuteActionClose = (configView.gateExecuteActionClose || 'open_close').toString();
96
+ this.gateExecuteActionToggle = (configView.gateExecuteActionToggle || 'open_close').toString();
109
97
  this.gateLockControlMode = this.normalizeGateLockControlMode(configView.gateLockControlMode);
110
- this.gateLockExecuteAction = String(configView.gateLockExecuteAction ?? '').trim() || 'open';
98
+ this.gateLockExecuteAction = (configView.gateLockExecuteAction || 'open').toString();
111
99
  this.gateLockSetTopicSuffix = this.normalizeTopicSuffix(configView.gateLockSetTopicSuffix || 'set/on');
112
- this.gateLockPulseSeconds = this.normalizeDurationSeconds(configView.gateLockPulseSeconds, this.gateLockControlMode === 'set_on_pulse' ? 1 : 0, this.gateLockControlMode === 'set_on_pulse' ? 0.1 : 0, 60);
100
+ this.gateLockPulseSeconds = Number(configView.gateLockPulseSeconds) || 0;
113
101
  this.gateLockSetOnPayload = (configView.gateLockSetOnPayload ?? 'true').toString();
114
102
  this.gateLockSetOffPayload = (configView.gateLockSetOffPayload ?? 'false').toString();
115
- this.frontGateFullTravelMs = this.normalizeFrontGateFullTravelMs(configView.frontGateFullTravelMs);
103
+ this.gatePartialHiMode = this.normalizeGatePartialHiMode(configView.gatePartialHiMode);
104
+ this.gateReverseFollowUpDelayMs = this.normalizeGateReverseFollowUpDelayMs(configView.gateReverseFollowUpDelayMs);
105
+ this.gateOpenAssumeDelayMs = this.normalizeGateOpenAssumeDelayMs(configView.gateOpenAssumeDelayMs);
106
+ this.gateCommandCooldownMs = this.normalizeGateCommandCooldownMs(configView.gateCommandCooldownMs);
107
+ this.gatePublishRetryDelayMs = this.normalizeGatePublishRetryDelayMs(configView.gatePublishRetryDelayMs);
108
+ this.gateStrictReverseDoublePulse = this.parseBoolean(configView.gateStrictReverseDoublePulse ?? true);
109
+ this.gateDebugTimeline = this.parseBoolean(configView.gateDebugTimeline ?? false);
110
+ this.frontGateFullTravelMs = this.normalizeFrontGateFullTravelMs(configView.frontGateFullTravelMs ?? configView.gateOpenAssumeDelayMs);
116
111
  this.frontGateReversePauseMs = this.normalizeFrontGateReversePauseMs(configView.frontGateReversePauseMs);
117
- this.frontGateMinimumPulseGapMs = this.normalizeFrontGateMinimumPulseGapMs(configView.frontGateMinimumPulseGapMs);
118
- this.frontGateUnknownOpenPolicy = this.normalizeFrontGateUnknownOpenPolicy(configView.frontGateUnknownOpenPolicy);
119
- this.frontGateUnknownClosePolicy = this.normalizeFrontGateUnknownClosePolicy(configView.frontGateUnknownClosePolicy);
120
- this.frontGateSeekClosedMaxPulses = this.normalizeFrontGateSeekClosedMaxPulses(configView.frontGateSeekClosedMaxPulses);
121
- this.frontGateAssumeOpenAfterTravel = this.parseBoolean(configView.frontGateAssumeOpenAfterTravel ?? false);
122
- this.frontGateSensorFallbackToControlChannel = this.parseBoolean(configView.frontGateSensorFallbackToControlChannel ?? false);
123
- this.mqttProtocolVersion = this.normalizeMqttProtocolVersion(configView.mqttProtocolVersion);
112
+ this.frontGateWrongDirectionRunMs = this.normalizeFrontGateWrongDirectionRunMs(configView.frontGateWrongDirectionRunMs);
113
+ this.frontGateMinimumPulseGapMs = this.normalizeFrontGateMinimumPulseGapMs(configView.frontGateMinimumPulseGapMs ?? configView.gateCommandCooldownMs);
114
+ this.frontGateCloseRetryLimit = this.normalizeFrontGateCloseRetryLimit(configView.frontGateCloseRetryLimit);
124
115
  this.commandQos = this.normalizeCommandQos(configView.commandQos);
125
116
  this.commandRetain = this.parseBoolean(configView.commandRetain ?? false);
126
117
  this.api.on('shutdown', () => {
127
- void this.shutdownGracefully();
118
+ this.unregisterAllMqttHandlers();
119
+ if (this.MqttClient) {
120
+ this.MqttClient.client.end(true);
121
+ }
128
122
  });
129
123
  this.api.on('didFinishLaunching', () => {
130
124
  log.debug('Executed didFinishLaunching callback');
131
- void this.start().catch((error) => {
132
- this.log.error(`Supla startup failed: ${error.message}`);
125
+ const mqttSettings = this.config;
126
+ this.MqttClient = new SuplaMqttClient_1.SuplaMqttClient(mqttSettings, this.log);
127
+ this.startMqttRouter();
128
+ this.MqttClient.client.on('connect', () => {
129
+ this.resubscribeAll(true);
130
+ });
131
+ this.MqttClient.client.on('close', () => {
132
+ this.clearActiveSubscriptions();
133
+ });
134
+ this.MqttClient.client.on('offline', () => {
135
+ this.clearActiveSubscriptions();
136
+ });
137
+ this.MqttClient.client.on('end', () => {
138
+ this.clearActiveSubscriptions();
139
+ });
140
+ if (this.MqttClient.client.connected) {
141
+ this.resubscribeAll(true);
142
+ }
143
+ this.discoverDevices();
144
+ this.MqttClient.discoverChannelsAsync((topic, handler) => (this.registerMqttHandler(topic, handler, 'discovery'))).then((channels) => {
145
+ this.persistChannels(channels);
146
+ this.discoverDevices(channels);
147
+ this.log.info('Channels discovered and saved to config file');
148
+ }).catch((error) => {
149
+ this.log.error(`Channel discovery failed: ${error.message}`);
133
150
  });
134
- });
135
- }
136
- async start() {
137
- const mqttSettings = this.config;
138
- this.MqttClient = new SuplaMqttClient_1.SuplaMqttClient(mqttSettings, this.log);
139
- this.startMqttRouter();
140
- this.MqttClient.client.on('connect', () => {
141
- if (this.shuttingDown) {
142
- return;
143
- }
144
- this.advanceMqttTransportEpoch();
145
- this.updateMqttTransportState(true);
146
- this.resubscribeAll(true);
147
- });
148
- this.MqttClient.client.on('close', () => {
149
- this.updateMqttTransportState(false);
150
- this.clearActiveSubscriptions();
151
- });
152
- this.MqttClient.client.on('offline', () => {
153
- this.updateMqttTransportState(false);
154
- this.clearActiveSubscriptions();
155
- });
156
- this.MqttClient.client.on('end', () => {
157
- this.updateMqttTransportState(false);
158
- this.clearActiveSubscriptions();
159
- });
160
- if (this.MqttClient.client.connected) {
161
- this.advanceMqttTransportEpoch();
162
- this.updateMqttTransportState(true);
163
- this.resubscribeAll(true);
164
- }
165
- this.discoverDevices();
166
- const snapshot = await this.MqttClient.discoverChannelsSnapshotAsync();
167
- if (this.shuttingDown) {
168
- return;
169
- }
170
- this.discoverDevices(snapshot.items, snapshot);
171
- this.log.info(`MQTT discovery merged ${snapshot.items.length} channel(s); ` +
172
- `authoritative=${snapshot.authoritative} complete=${snapshot.complete}.`);
173
- }
174
- shutdownGracefully() {
175
- if (this.shutdownPromise) {
176
- return this.shutdownPromise;
177
- }
178
- this.shutdownPromise = this.performGracefulShutdown();
179
- return this.shutdownPromise;
180
- }
181
- async performGracefulShutdown() {
182
- const drains = Array.from(this.shutdownDrains.values())
183
- .flatMap(ownerDrains => Array.from(ownerDrains));
184
- if (drains.length > 0) {
185
- const drainTimeoutMs = 1500;
186
- let timeout;
187
- try {
188
- await Promise.race([
189
- Promise.allSettled(drains.map(drain => Promise.resolve().then(drain))),
190
- new Promise((resolve) => {
191
- timeout = setTimeout(resolve, drainTimeoutMs);
192
- }),
193
- ]);
194
- }
195
- finally {
196
- if (timeout) {
197
- clearTimeout(timeout);
198
- }
199
- }
200
- }
201
- this.shuttingDown = true;
202
- this.unregisterAllMqttHandlers();
203
- const client = this.MqttClient?.client;
204
- if (!client) {
205
- return;
206
- }
207
- await new Promise((resolve) => {
208
- let settled = false;
209
- const finish = () => {
210
- if (settled) {
211
- return;
212
- }
213
- settled = true;
214
- clearTimeout(forceTimer);
215
- resolve();
216
- };
217
- const forceTimer = setTimeout(() => {
218
- try {
219
- client.end(true);
220
- }
221
- catch (error) {
222
- this.log.debug(`Forced MQTT shutdown failed: ${error.message}`);
223
- }
224
- finish();
225
- }, 1000);
226
- try {
227
- client.end(false, {}, finish);
228
- }
229
- catch (error) {
230
- this.log.debug(`Graceful MQTT shutdown failed: ${error.message}`);
231
- try {
232
- client.end(true);
233
- }
234
- catch {
235
- // The client is already closing; the bounded timer/finish path owns completion.
236
- }
237
- finish();
238
- }
239
151
  });
240
152
  }
241
153
  /**
@@ -244,27 +156,9 @@ class SuplaPlatform {
244
156
  */
245
157
  configureAccessory(accessory) {
246
158
  this.log.info('Loading accessory from cache:', accessory.displayName);
247
- const cachedDevice = accessory.context.device;
248
- const storedNamespace = accessory.context.suplaAccountNamespace;
249
- let mismatchRetirementFailed = false;
250
- if (typeof storedNamespace === 'string'
251
- && storedNamespace.length > 0
252
- && storedNamespace !== this.accountNamespace) {
253
- try {
254
- this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
255
- this.log.info(`Retired cached accessory ${accessory.displayName} because its stored Supla account identity differs.`);
256
- return;
257
- }
258
- catch (error) {
259
- this.log.error(`Failed to retire account-incompatible accessory ${accessory.displayName}: ${error.message}`);
260
- // Keep tracking the accessory so it can be made unavailable safely.
261
- mismatchRetirementFailed = true;
262
- accessory.context.deviceConfigured = false;
263
- this.revokeAccessoryHandlers(accessory);
264
- }
265
- }
266
159
  // add the restored accessory to the accessories cache so we can track if it has already been registered
267
160
  this.accessories.push(accessory);
161
+ const cachedDevice = accessory.context.device;
268
162
  if (!cachedDevice || typeof cachedDevice !== 'object') {
269
163
  this.log.warn(`Cached accessory ${accessory.displayName} missing device context; will configure after discovery.`);
270
164
  accessory.context.deviceConfigured = false;
@@ -272,75 +166,27 @@ class SuplaPlatform {
272
166
  }
273
167
  const normalized = this.normalizeChannelContext(cachedDevice);
274
168
  accessory.context.device = normalized;
275
- if (!this.isAccessoryAccountCompatible(accessory, normalized)) {
276
- this.log.warn(`Cached accessory ${accessory.displayName} belongs to a different Supla account; ` +
277
- 'leaving it disabled until an authoritative discovery can retire it.');
278
- accessory.context.deviceConfigured = false;
279
- if (!mismatchRetirementFailed) {
280
- this.revokeAccessoryHandlers(accessory);
281
- }
282
- return;
283
- }
284
- this.stampAccessoryIdentity(accessory);
285
- this.addChannelToRegistry(normalized);
286
- // Accessory construction is intentionally deferred until didFinishLaunching.
287
- // Homebridge restores cached accessories one at a time, so configuring a gate
288
- // here could pair it before its separate contact-sensor accessory is known.
289
- accessory.context.deviceConfigured = false;
169
+ accessory.context.deviceSignature = this.getChannelSignature(normalized);
170
+ const configured = this.setupAccessory(normalized, accessory);
171
+ accessory.context.deviceConfigured = configured;
290
172
  }
291
173
  /**
292
174
  * This is an example method showing how to register discovered accessories.
293
175
  * Accessories must only be registered once, previously created accessories
294
176
  * must not be registered again to prevent "duplicate UUID" errors.
295
177
  */
296
- discoverDevices(channelsOverride, snapshot) {
297
- const cachedChannels = this.deduplicateChannels([
298
- ...this.loadPersistedChannels(),
299
- ...this.accessories
300
- .filter(accessory => this.isAccessoryAccountCompatible(accessory, accessory.context.device))
301
- .map(accessory => accessory.context.device)
302
- .filter((channel) => Boolean(channel)),
303
- ].map(channel => this.normalizeChannelContext(channel)));
304
- const shouldPrune = snapshot?.authoritative === true && snapshot.complete === true;
305
- const rawChannels = channelsOverride === undefined
306
- ? cachedChannels
307
- : this.mergeChannels(cachedChannels, channelsOverride, !shouldPrune, shouldPrune);
308
- let channels = rawChannels
309
- .map(channel => this.normalizeChannelContext(channel))
310
- .filter(channel => this.isChannelAccountCompatible(channel));
311
- if (channels.length === 0 && channelsOverride === undefined) {
312
- channels = this.accessories
313
- .filter(accessory => this.isAccessoryAccountCompatible(accessory, accessory.context.device))
314
- .map(accessory => accessory.context.device)
315
- .filter((channel) => Boolean(channel))
316
- .map(channel => this.normalizeChannelContext(channel));
317
- }
318
- channels = this.deduplicateChannels(channels);
319
- this.replaceChannelRegistry(channels);
320
- const channelsToPersist = [...channels];
321
- const retainPreviousChannel = (incoming, previous) => {
322
- if (!previous) {
323
- return;
324
- }
325
- const normalizedPrevious = this.normalizeChannelContext(previous);
326
- const key = this.getChannelRegistryKey(incoming);
327
- const index = channelsToPersist.findIndex(candidate => this.getChannelRegistryKey(candidate) === key);
328
- if (index !== -1) {
329
- channelsToPersist[index] = normalizedPrevious;
330
- }
331
- this.addChannelToRegistry(normalizedPrevious);
332
- };
178
+ discoverDevices(channelsOverride) {
179
+ const rawChannels = channelsOverride ?? this.loadChannelsFromConfig();
180
+ const channels = rawChannels.map(channel => this.normalizeChannelContext(channel));
333
181
  this.log.info('Channels discovered:', channels.length);
334
182
  this.log.debug(`Discovery mode: ${channelsOverride ? 'live' : 'cached'} channels`);
335
183
  const channelUuids = new Set();
336
- const findExistingByIds = (candidate, scopedUuid) => {
184
+ const shouldPrune = channelsOverride !== undefined && channels.length > 0;
185
+ const findExistingByIds = (candidate) => {
337
186
  if (candidate.deviceId === 'unknown' || candidate.channelId === 'unknown') {
338
187
  return undefined;
339
188
  }
340
- const matches = this.accessories.filter((accessory) => {
341
- if (!this.isAccessoryAccountCompatible(accessory, accessory.context.device)) {
342
- return false;
343
- }
189
+ return this.accessories.find((accessory) => {
344
190
  const existing = accessory.context.device;
345
191
  if (!existing) {
346
192
  return false;
@@ -350,32 +196,11 @@ class SuplaPlatform {
350
196
  }
351
197
  return existing.deviceId === candidate.deviceId && existing.channelId === candidate.channelId;
352
198
  });
353
- if (matches.length <= 1) {
354
- return matches[0];
355
- }
356
- const scopedMatch = matches.find(accessory => accessory.UUID === scopedUuid);
357
- if (scopedMatch) {
358
- this.log.warn(`Found both scoped and legacy accessories for channel ${candidate.deviceId}/${candidate.channelId}; ` +
359
- 'using the scoped identity and leaving the duplicate disabled.');
360
- for (const duplicate of matches) {
361
- if (duplicate !== scopedMatch) {
362
- this.revokeAccessoryHandlers(duplicate);
363
- }
364
- }
365
- return scopedMatch;
366
- }
367
- this.log.error(`Ambiguous cached identities for channel ${candidate.deviceId}/${candidate.channelId}; ` +
368
- 'refusing to bind either accessory.');
369
- for (const match of matches) {
370
- this.revokeAccessoryHandlers(match);
371
- }
372
- return undefined;
373
199
  };
374
200
  // loop over the discovered devices and register each one if it has not already been registered
375
201
  for (const channel of channels) {
376
- const scopedUuid = this.getChannelUuid(channel);
377
- const existingByIds = findExistingByIds(channel, scopedUuid);
378
- const uuid = existingByIds?.UUID ?? scopedUuid;
202
+ const existingByIds = findExistingByIds(channel);
203
+ const uuid = existingByIds?.UUID ?? this.getChannelUuid(channel);
379
204
  channelUuids.add(uuid);
380
205
  const existingAccessory = existingByIds ?? this.accessories.find(accessory => accessory.UUID === uuid);
381
206
  if (existingAccessory) {
@@ -383,163 +208,45 @@ class SuplaPlatform {
383
208
  const signature = this.getChannelSignature(channel);
384
209
  const previousSignature = existingAccessory.context.deviceSignature;
385
210
  const wasConfigured = existingAccessory.context.deviceConfigured === true;
211
+ existingAccessory.context.device = channel;
212
+ existingAccessory.context.deviceSignature = signature;
386
213
  this.log.debug(`Restoring channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
387
214
  `function=${channel.channelFunction} type=${channel.channelType}`);
388
- if (!this.isSupportedChannel(channel, existingAccessory)) {
389
- if (this.isExplicitlyUnsupportedChannel(channel, existingAccessory)) {
390
- this.log.warn(`Channel ${channel.channelCaption} is now explicitly unsupported ` +
391
- `(${channel.channelFunction}/${channel.channelType}); revoking its handlers.`);
392
- this.revokeAccessoryHandlers(existingAccessory);
393
- existingAccessory.context.device = channel;
394
- existingAccessory.context.deviceSignature = signature;
395
- existingAccessory.context.deviceConfigured = false;
396
- this.stampAccessoryIdentity(existingAccessory);
397
- this.api.updatePlatformAccessories([existingAccessory]);
398
- }
399
- else {
400
- this.log.warn(`Deferring incomplete channel ${channel.channelCaption} ` +
401
- `(${channel.channelFunction}/${channel.channelType}); retaining last configured context.`);
402
- }
403
- continue;
404
- }
405
215
  if (previousSignature !== signature || !wasConfigured) {
406
- const previousDevice = existingAccessory.context.device;
407
- const previousContext = { ...existingAccessory.context };
408
- const previousNames = this.captureAccessoryNames(existingAccessory);
409
- try {
410
- const safeDisplayName = this.resolveHomeKitDisplayName(channel, existingAccessory.displayName);
411
- if (existingAccessory.displayName !== safeDisplayName) {
412
- existingAccessory.updateDisplayName(safeDisplayName);
413
- }
414
- this.prepareAccessoryForReconfiguration(existingAccessory, channel);
415
- const configured = this.setupAccessory(channel, existingAccessory);
416
- if (!configured) {
417
- throw new Error('No compatible accessory handler');
418
- }
419
- existingAccessory.context.device = channel;
420
- existingAccessory.context.deviceSignature = signature;
421
- existingAccessory.context.deviceConfigured = true;
422
- this.stampAccessoryIdentity(existingAccessory);
423
- this.cleanupObsoleteServices(existingAccessory, channel);
424
- this.updateAccessoryNames(existingAccessory, channel);
425
- this.api.updatePlatformAccessories([existingAccessory]);
426
- }
427
- catch (error) {
428
- this.unregisterMqttHandlers(existingAccessory.UUID);
429
- this.restoreAccessoryContext(existingAccessory, previousContext);
430
- this.restoreAccessoryNames(existingAccessory, previousNames);
431
- retainPreviousChannel(channel, previousDevice);
432
- if (previousDevice && this.isSupportedChannel(previousDevice, existingAccessory)) {
433
- try {
434
- this.prepareAccessoryForReconfiguration(existingAccessory, previousDevice);
435
- existingAccessory.context.deviceConfigured = this.setupAccessory(previousDevice, existingAccessory);
436
- }
437
- catch (restoreError) {
438
- this.log.error(`Failed to restore previous handler for ${existingAccessory.displayName}: ` +
439
- `${restoreError.message}`);
440
- }
441
- }
442
- this.log.error(`Failed to configure ${channel.channelCaption}; previous context retained: ${error.message}`);
443
- continue;
444
- }
445
- }
446
- else {
447
- const previousDevice = existingAccessory.context.device;
448
- const previousContext = { ...existingAccessory.context };
449
- const previousNames = this.captureAccessoryNames(existingAccessory);
450
- try {
451
- const metadataChanged = !this.areChannelContextsEqual(previousDevice, channel);
452
- const identityChanged = existingAccessory.context.suplaIdentityVersion !== 2
453
- || existingAccessory.context.suplaAccountNamespace !== this.accountNamespace;
454
- existingAccessory.context.device = channel;
455
- existingAccessory.context.deviceSignature = signature;
456
- this.stampAccessoryIdentity(existingAccessory);
457
- const namesChanged = this.updateAccessoryNames(existingAccessory, channel);
458
- if (metadataChanged || identityChanged || namesChanged) {
459
- this.api.updatePlatformAccessories([existingAccessory]);
460
- }
461
- }
462
- catch (error) {
463
- this.restoreAccessoryContext(existingAccessory, previousContext);
464
- this.restoreAccessoryNames(existingAccessory, previousNames);
465
- retainPreviousChannel(channel, previousDevice);
466
- this.log.error(`Failed to commit metadata for ${channel.channelCaption}; previous name and context retained: ` +
467
- `${error.message}`);
468
- continue;
216
+ if (previousSignature && previousSignature !== signature) {
217
+ this.resetAccessoryServices(existingAccessory);
469
218
  }
219
+ const configured = this.setupAccessory(channel, existingAccessory);
220
+ existingAccessory.context.deviceConfigured = configured;
470
221
  }
222
+ this.api.updatePlatformAccessories([existingAccessory]);
471
223
  continue;
472
224
  }
473
225
  this.log.info('Adding new accessory:', channel.channelCaption);
474
226
  this.log.debug(`Registering channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
475
227
  `function=${channel.channelFunction} type=${channel.channelType}`);
476
- if (!this.isSupportedChannel(channel)) {
477
- this.log.warn(`Skipping unsupported/incomplete channel ${channel.channelCaption} ` +
478
- `(${channel.channelFunction}/${channel.channelType}).`);
479
- continue;
480
- }
481
- const accessory = new this.api.platformAccessory(this.resolveHomeKitDisplayName(channel), uuid);
482
- let registered = false;
483
- try {
484
- if (!this.setupAccessory(channel, accessory)) {
485
- throw new Error('No compatible accessory handler');
486
- }
487
- accessory.context.device = channel;
488
- accessory.context.deviceSignature = this.getChannelSignature(channel);
228
+ const accessory = new this.api.platformAccessory(channel.channelCaption, uuid);
229
+ accessory.context.device = channel;
230
+ accessory.context.deviceSignature = this.getChannelSignature(channel);
231
+ if (this.setupAccessory(channel, accessory)) {
489
232
  accessory.context.deviceConfigured = true;
490
- this.stampAccessoryIdentity(accessory);
491
- this.updateAccessoryNames(accessory, channel);
492
233
  this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
493
- registered = true;
494
234
  this.accessories.push(accessory);
495
235
  this.api.updatePlatformAccessories([accessory]);
496
236
  }
497
- catch (error) {
498
- this.unregisterMqttHandlers(accessory.UUID);
499
- const index = this.accessories.indexOf(accessory);
500
- if (index !== -1) {
501
- this.accessories.splice(index, 1);
502
- }
503
- if (registered) {
504
- try {
505
- this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
506
- }
507
- catch (rollbackError) {
508
- this.log.error(`Failed to roll back registration for ${channel.channelCaption}: ` +
509
- `${rollbackError.message}`);
510
- }
511
- }
512
- this.log.error(`Failed to register ${channel.channelCaption}: ${error.message}`);
513
- }
514
237
  }
515
238
  if (shouldPrune) {
516
239
  const accessoriesToRemove = this.accessories.filter(accessory => !channelUuids.has(accessory.UUID));
517
240
  for (const accessory of accessoriesToRemove) {
518
241
  this.log.info('Removing existing accessory from cache:', accessory.displayName);
519
- try {
520
- // Homebridge owns the durable registration. Do not tear down the live
521
- // handler until that external mutation has committed successfully.
522
- this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
523
- }
524
- catch (error) {
525
- this.log.error(`Failed to remove ${accessory.displayName}; retaining its active handler: ` +
526
- `${error.message}`);
527
- const retainedDevice = accessory.context.device;
528
- if (retainedDevice && typeof retainedDevice === 'object') {
529
- this.addChannelToRegistry(this.normalizeChannelContext(retainedDevice));
530
- }
531
- continue;
532
- }
533
242
  this.unregisterMqttHandlers(accessory.UUID);
243
+ this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
534
244
  const index = this.accessories.indexOf(accessory);
535
245
  if (index !== -1) {
536
246
  this.accessories.splice(index, 1);
537
247
  }
538
248
  }
539
249
  }
540
- if (channelsOverride !== undefined) {
541
- this.persistChannels(channelsToPersist);
542
- }
543
250
  }
544
251
  loadChannelsFromConfig() {
545
252
  const rawChannels = this.config.channels;
@@ -562,159 +269,31 @@ class SuplaPlatform {
562
269
  this.log.warn('Cached channels format is invalid; expected string or array.');
563
270
  return [];
564
271
  }
565
- loadPersistedChannels() {
566
- const currentStoragePath = this.getChannelCachePath();
567
- const legacyStoragePath = this.getLegacyChannelCachePath();
568
- const shouldMigrateLegacyPath = currentStoragePath !== legacyStoragePath
569
- && !fs.existsSync(currentStoragePath)
570
- && fs.existsSync(legacyStoragePath);
571
- const storagePath = shouldMigrateLegacyPath ? legacyStoragePath : currentStoragePath;
572
- try {
573
- if (fs.existsSync(storagePath)) {
574
- const parsed = JSON.parse(fs.readFileSync(storagePath, 'utf8'));
575
- if (parsed.version === 2
576
- && parsed.accountNamespace === this.accountNamespace
577
- && Array.isArray(parsed.channels)) {
578
- if (shouldMigrateLegacyPath) {
579
- this.log.info('Migrating Supla channel cache to the port/protocol-scoped filename.');
580
- this.persistChannels(parsed.channels);
581
- }
582
- return parsed.channels;
583
- }
584
- if (parsed.version === 1 && Array.isArray(parsed.channels)) {
585
- const compatible = parsed.channels.filter(channel => this.isSafeLegacyCacheMigration(channel));
586
- if (compatible.length === parsed.channels.length) {
587
- this.log.info('Migrating version 1 Supla channel cache to account-scoped format.');
588
- this.persistChannels(compatible);
589
- return compatible;
590
- }
591
- this.log.warn('Version 1 Supla cache contains account-unscoped legacy channels; ' +
592
- 'live discovery or explicit migrateUnscopedLegacyAccessories=true is required.');
593
- if (compatible.length > 0) {
594
- this.persistChannels(compatible);
595
- return compatible;
596
- }
597
- }
598
- this.log.warn(`Ignoring unsupported Supla channel cache format at ${storagePath}.`);
599
- }
600
- }
601
- catch (error) {
602
- this.log.warn(`Failed to load channel cache: ${error.message}`);
603
- }
604
- const legacyChannels = this.loadChannelsFromConfig();
605
- const compatibleLegacyChannels = legacyChannels.filter(channel => this.isSafeLegacyCacheMigration(channel));
606
- if (compatibleLegacyChannels.length > 0) {
607
- this.log.info('Migrating legacy Supla channels cache from config to Homebridge storage.');
608
- this.persistChannels(compatibleLegacyChannels);
609
- }
610
- if (compatibleLegacyChannels.length !== legacyChannels.length) {
611
- this.log.warn('Skipped account-unscoped legacy channels from config cache; ' +
612
- 'they will be recreated from authenticated live discovery.');
613
- }
614
- return compatibleLegacyChannels;
615
- }
616
- isSafeLegacyCacheMigration(channel) {
617
- const normalized = this.normalizeChannelContext(channel);
618
- return this.isChannelAccountCompatible(normalized)
619
- && (!this.isUnscopedLegacyChannel(normalized) || this.shouldMigrateUnscopedLegacyAccessories());
620
- }
621
272
  persistChannels(channels) {
622
273
  try {
623
- const cachePath = this.getChannelCachePath();
624
- fs.mkdirSync(path.dirname(cachePath), { recursive: true });
625
- const payload = {
626
- version: 2,
627
- accountNamespace: this.accountNamespace,
628
- channels: channels.filter(channel => this.isChannelAccountCompatible(channel)),
629
- };
630
- const tempPath = `${cachePath}.tmp-${process.pid}`;
631
- fs.writeFileSync(tempPath, JSON.stringify(payload, null, 2), { mode: 0o600 });
632
- fs.renameSync(tempPath, cachePath);
633
- this.log.debug(`Saved ${channels.length} channels to Homebridge storage.`);
274
+ const configPath = this.api.user.configPath();
275
+ const config = JSON.parse(fs.readFileSync(configPath).toString());
276
+ const platformConfig = config.platforms?.find((platform) => platform.platform === settings_1.PLATFORM_NAME);
277
+ if (!platformConfig) {
278
+ this.log.warn(`Failed to save channels: ${settings_1.PLATFORM_NAME} not found in config.`);
279
+ return;
280
+ }
281
+ platformConfig.channels = JSON.stringify(channels);
282
+ const payload = JSON.stringify(config, null, 2);
283
+ const tempPath = `${configPath}.tmp`;
284
+ fs.writeFileSync(tempPath, payload);
285
+ fs.renameSync(tempPath, configPath);
286
+ this.log.debug(`Saved ${channels.length} channels to config.`);
634
287
  }
635
288
  catch (error) {
636
- this.log.error(`Failed to save channel cache: ${error.message}`);
289
+ this.log.error(`Failed to save channels: ${error.message}`);
637
290
  }
638
291
  }
639
- getChannelCachePath() {
640
- const configView = this.config;
641
- const identity = [
642
- settings_1.PLATFORM_NAME,
643
- String(configView.name ?? ''),
644
- String(configView.host ?? '').trim().toLowerCase(),
645
- String(configView.port ?? '').trim(),
646
- String(configView.protocol ?? '').trim().toLowerCase(),
647
- String(configView.tls ?? '').trim().toLowerCase(),
648
- String(configView.username ?? '').trim(),
649
- this.accountNamespace,
650
- ].join('\u0000');
651
- const instanceKey = (0, crypto_1.createHash)('sha256').update(identity).digest('hex').slice(0, 20);
652
- return path.join(this.api.user.storagePath(), `supla-channels-${instanceKey}.json`);
653
- }
654
- getLegacyChannelCachePath() {
655
- const configView = this.config;
656
- const identity = [
657
- settings_1.PLATFORM_NAME,
658
- String(configView.name ?? ''),
659
- String(configView.host ?? ''),
660
- String(configView.username ?? ''),
661
- ].join('\u0000');
662
- const instanceKey = (0, crypto_1.createHash)('sha256').update(identity).digest('hex').slice(0, 20);
663
- return path.join(this.api.user.storagePath(), `supla-channels-${instanceKey}.json`);
664
- }
665
- computeAccountNamespace() {
666
- const configView = this.config;
667
- const identity = [
668
- String(configView.host ?? '').trim().toLowerCase(),
669
- String(configView.port ?? '').trim(),
670
- String(configView.username ?? '').trim(),
671
- ].join('\u0000');
672
- return (0, crypto_1.createHash)('sha256').update(identity).digest('hex');
673
- }
674
- stampAccessoryIdentity(accessory) {
675
- accessory.context.suplaIdentityVersion = 2;
676
- accessory.context.suplaAccountNamespace = this.accountNamespace;
677
- // Retain this legacy field for consumers that used the first remediation schema.
678
- accessory.context.schemaVersion = 2;
679
- }
680
- isAccessoryAccountCompatible(accessory, channel) {
681
- const storedNamespace = accessory.context.suplaAccountNamespace;
682
- if (typeof storedNamespace === 'string' && storedNamespace.length > 0) {
683
- return storedNamespace === this.accountNamespace;
684
- }
685
- if (channel === undefined) {
686
- return true;
687
- }
688
- if (this.isUnscopedLegacyChannel(channel)) {
689
- return this.shouldMigrateUnscopedLegacyAccessories();
690
- }
691
- return this.isChannelAccountCompatible(channel);
692
- }
693
- isChannelAccountCompatible(channel) {
694
- const topic = this.normalizeTopicBase(channel.topic ?? channel.rawTopic ?? '');
695
- const match = topic.match(/^supla\/([^/]+)\/devices\//);
696
- if (!match) {
697
- // Legacy rollershutter topics contain no account identifier. They are
698
- // recognized here, but migration requires the explicit opt-in checked by
699
- // isSafeLegacyCacheMigration/isAccessoryAccountCompatible.
700
- return topic.startsWith('supla/channels/status/rollershutter/');
701
- }
702
- const configuredUsername = String(this.config.username ?? '');
703
- return match[1] === configuredUsername;
704
- }
705
- isUnscopedLegacyChannel(channel) {
706
- const topic = this.normalizeTopicBase(channel.topic ?? channel.rawTopic ?? '');
707
- return topic.startsWith('supla/channels/status/rollershutter/');
708
- }
709
- shouldMigrateUnscopedLegacyAccessories() {
710
- const configured = this.config.migrateUnscopedLegacyAccessories;
711
- return this.parseBooleanStrict(configured) === true;
712
- }
713
292
  normalizeChannelContext(channel) {
714
293
  const rawTopic = (channel.rawTopic ?? channel.topic ?? '').toString();
715
294
  const baseFromTopic = this.normalizeTopicBase(rawTopic || (channel.topic ?? '').toString());
716
- let deviceId = channel.deviceId?.toString().trim();
717
- let channelId = channel.channelId?.toString().trim();
295
+ let deviceId = channel.deviceId;
296
+ let channelId = channel.channelId;
718
297
  if (!deviceId || !channelId) {
719
298
  const match = baseFromTopic.match(/\/devices\/(\d+)\/channels\/(\d+)(?:\/|$)/);
720
299
  if (match) {
@@ -728,451 +307,43 @@ class SuplaPlatform {
728
307
  if (!channelId) {
729
308
  channelId = 'unknown';
730
309
  }
731
- const suppliedCaption = (0, DisplayName_1.normalizeVendorCaption)(channel.channelCaption);
732
- const caption = suppliedCaption || (0, DisplayName_1.generatedChannelCaption)(deviceId, channelId);
733
- const captionAuthoritative = Boolean(suppliedCaption)
734
- && channel.channelCaptionAuthoritative !== false
735
- && !(0, DisplayName_1.isGeneratedChannelCaption)(suppliedCaption, deviceId, channelId);
736
- const captionSource = captionAuthoritative
737
- ? channel.channelCaptionSource ?? 'cache'
738
- : 'generated';
739
- const channelType = channel.channelType?.toString().trim().toUpperCase() || 'UNKNOWN';
740
- const channelFunction = channel.channelFunction?.toString().trim().toUpperCase() || 'UNKNOWN';
310
+ const caption = channel.channelCaption || `Device ${deviceId} Channel ${channelId}`;
311
+ const channelType = channel.channelType || 'UNKNOWN';
312
+ const channelFunction = channel.channelFunction || 'UNKNOWN';
741
313
  const mqttContext = this.config;
742
- const hasStableIds = deviceId !== 'unknown' && channelId !== 'unknown';
743
- const baseTopic = baseFromTopic || (hasStableIds
744
- ? `supla/${mqttContext.username}/devices/${deviceId}/channels/${channelId}`
745
- : '');
314
+ const baseTopic = baseFromTopic || `supla/${mqttContext.username}/devices/${deviceId}/channels/${channelId}`;
746
315
  if (deviceId === 'unknown' || channelId === 'unknown') {
747
316
  this.log.warn(`Channel missing device/channel id for topic ${rawTopic}`);
748
317
  }
749
- return new SuplaChannelContext_1.SuplaChannelContext(baseTopic, channelType, channelFunction, caption, deviceId, channelId, rawTopic || undefined, captionAuthoritative, captionSource);
318
+ return new SuplaChannelContext_1.SuplaChannelContext(baseTopic, channelType, channelFunction, caption, deviceId, channelId, rawTopic || undefined);
750
319
  }
751
320
  getChannelUuid(channel) {
752
321
  const deviceId = channel.deviceId;
753
322
  const channelId = channel.channelId;
754
323
  const hasIds = deviceId && channelId && deviceId !== 'unknown' && channelId !== 'unknown';
755
- const stableTopic = this.normalizeTopicBase(channel.topic || channel.rawTopic || '');
756
- const channelKey = hasIds
324
+ const key = hasIds
757
325
  ? `${deviceId}:${channelId}`
758
- : stableTopic;
759
- if (!channelKey) {
760
- throw new Error('Cannot derive a stable Supla channel identity without IDs or a topic');
761
- }
762
- const key = `${this.accountNamespace}:${channelKey}`;
326
+ : (channel.topic || channel.channelCaption || `${deviceId}:${channelId}`);
763
327
  return this.api.hap.uuid.generate(key);
764
328
  }
765
329
  getChannelSignature(channel) {
766
- const signature = [
330
+ return [
767
331
  channel.topic ?? '',
768
332
  channel.channelFunction ?? '',
769
333
  channel.channelType ?? '',
770
334
  channel.deviceId ?? '',
771
335
  channel.channelId ?? '',
772
- ];
773
- if (channel.channelFunction === 'CONTROLLINGTHEGATE') {
774
- const configView = this.config;
775
- const sensorRegistrySignature = Array.from(this.channelRegistry.values())
776
- .filter(candidate => {
777
- const functionName = (candidate.channelFunction ?? '').toUpperCase();
778
- const typeName = (candidate.channelType ?? '').toUpperCase();
779
- return functionName.startsWith('OPENINGSENSOR_') || typeName === 'BINARYSENSOR';
780
- })
781
- .map(candidate => [
782
- candidate.topic,
783
- candidate.channelFunction,
784
- candidate.channelType,
785
- candidate.deviceId,
786
- candidate.channelId,
787
- candidate.channelCaption,
788
- ].join(':'))
789
- .sort()
790
- .join(';');
791
- signature.push(configView.frontGateSensorTopic ?? '', String(configView.frontGateSensorDeviceId ?? ''), String(configView.frontGateSensorChannelId ?? ''), String(this.frontGateSensorFallbackToControlChannel), sensorRegistrySignature);
792
- }
793
- return signature.join('|');
794
- }
795
- getChannelRegistryKey(channel) {
796
- return [
797
- channel.deviceId ?? '',
798
- channel.channelId ?? '',
799
- this.normalizeTopicBase(channel.topic),
800
336
  ].join('|');
801
337
  }
802
- addChannelToRegistry(channel) {
803
- this.channelRegistry.set(this.getChannelRegistryKey(channel), channel);
804
- }
805
- replaceChannelRegistry(channels) {
806
- this.channelRegistry.clear();
807
- for (const channel of channels) {
808
- this.addChannelToRegistry(channel);
809
- }
810
- }
811
- deduplicateChannels(channels) {
812
- const byKey = new Map();
813
- for (const channel of channels) {
814
- byKey.set(this.getChannelRegistryKey(channel), channel);
815
- }
816
- return Array.from(byKey.values());
817
- }
818
- mergeChannels(cachedChannels, liveChannels, preserveCachedOnly = true, snapshotAuthoritative = false) {
819
- const cachedByKey = new Map();
820
- for (const rawChannel of cachedChannels) {
821
- const channel = this.normalizeChannelContext(rawChannel);
822
- cachedByKey.set(this.getChannelRegistryKey(channel), channel);
823
- }
824
- const merged = preserveCachedOnly
825
- ? new Map(cachedByKey)
826
- : new Map();
827
- for (const rawChannel of liveChannels) {
828
- const channel = this.normalizeChannelContext(rawChannel);
829
- const key = this.getChannelRegistryKey(channel);
830
- const cached = cachedByKey.get(key);
831
- if (cached) {
832
- const useLiveCaption = channel.channelCaptionAuthoritative === true
833
- && (snapshotAuthoritative
834
- || channel.channelCaptionSource === 'retained'
835
- || channel.channelCaptionSource === 'authoritative_snapshot');
836
- merged.set(key, new SuplaChannelContext_1.SuplaChannelContext(channel.topic || cached.topic, this.isKnownMetadata(channel.channelType) ? channel.channelType : cached.channelType, this.isKnownMetadata(channel.channelFunction) ? channel.channelFunction : cached.channelFunction, useLiveCaption ? channel.channelCaption : cached.channelCaption, channel.deviceId || cached.deviceId, channel.channelId || cached.channelId, channel.rawTopic || cached.rawTopic, useLiveCaption
837
- ? true
838
- : cached.channelCaptionAuthoritative, useLiveCaption
839
- ? snapshotAuthoritative
840
- ? 'authoritative_snapshot'
841
- : channel.channelCaptionSource
842
- : cached.channelCaptionSource));
843
- }
844
- else if (this.isKnownMetadata(channel.channelType)
845
- && this.isKnownMetadata(channel.channelFunction)) {
846
- merged.set(key, channel);
847
- }
848
- else {
849
- this.log.warn(`Deferring incomplete new channel ${channel.deviceId}/${channel.channelId} ` +
850
- `(function=${channel.channelFunction}, type=${channel.channelType}).`);
851
- }
852
- }
853
- return Array.from(merged.values());
854
- }
855
- isKnownMetadata(value) {
856
- return Boolean(value && value.trim() && value.toUpperCase() !== 'UNKNOWN');
857
- }
858
- getKnownChannels() {
859
- return Array.from(this.channelRegistry.values());
860
- }
861
- isSupportedChannel(channel, accessory) {
862
- if (this.isExplicitNoFunction(channel.channelFunction)) {
863
- return this.getLegacyNoFunctionServiceUuids(channel, accessory).size > 0;
864
- }
865
- if (!this.isKnownMetadata(channel.channelFunction) || !this.isKnownMetadata(channel.channelType)) {
866
- return false;
867
- }
868
- return this.getExpectedServiceUuids(channel).size > 0;
869
- }
870
- isExplicitlyUnsupportedChannel(channel, accessory) {
871
- if (this.isExplicitNoFunction(channel.channelFunction)) {
872
- return this.getLegacyNoFunctionServiceUuids(channel, accessory).size === 0;
873
- }
874
- return this.isKnownMetadata(channel.channelFunction)
875
- && this.isKnownMetadata(channel.channelType)
876
- && this.getExpectedServiceUuids(channel).size === 0;
877
- }
878
- getExpectedServiceUuids(channel, accessory) {
879
- if (this.isExplicitNoFunction(channel.channelFunction)) {
880
- return this.getLegacyNoFunctionServiceUuids(channel, accessory);
881
- }
882
- const byFunction = {
883
- CONTROLLINGTHEGARAGEDOOR: [this.Service.GarageDoorOpener.UUID],
884
- CONTROLLINGTHEGATE: [this.Service.GarageDoorOpener.UUID],
885
- CONTROLLINGTHEGATEWAYLOCK: [this.Service.LockMechanism.UUID],
886
- LIGHTSWITCH: [this.Service.Lightbulb.UUID],
887
- POWERSWITCH: [this.Service.Switch.UUID],
888
- RGBLIGHTING: [this.Service.Lightbulb.UUID],
889
- DIMMER: [this.Service.Lightbulb.UUID],
890
- DIMMERANDRGBLIGHTING: [this.Service.Lightbulb.UUID],
891
- CONTROLLINGTHEROLLERSHUTTER: [this.Service.WindowCovering.UUID],
892
- CONTROLLINGTHEFACADEBLIND: [this.Service.WindowCovering.UUID],
893
- OPENINGSENSOR_GATE: [this.Service.ContactSensor.UUID],
894
- OPENINGSENSOR_GATEWAY: [this.Service.ContactSensor.UUID],
895
- OPENINGSENSOR_WINDOW: [this.Service.ContactSensor.UUID],
896
- GENERAL_PURPOSE_MEASUREMENT: [this.Service.AirQualitySensor.UUID],
897
- THERMOMETER: [this.Service.TemperatureSensor.UUID],
898
- HUMIDITYANDTEMPERATURE: [this.Service.TemperatureSensor.UUID, this.Service.HumiditySensor.UUID],
899
- PRESSURESENSOR: [this.Service.LightSensor.UUID],
900
- VALVEOPENCLOSE: [this.Service.Valve.UUID],
901
- HVAC_THERMOSTAT: [this.Service.Thermostat.UUID],
902
- ELECTRICITYMETER: [this.Service.LightSensor.UUID],
903
- ACTION_TRIGGER: [this.Service.StatelessProgrammableSwitch.UUID],
904
- };
905
- const byType = {
906
- BINARYSENSOR: [
907
- this.isLeakSensorChannel(channel, accessory)
908
- ? this.Service.LeakSensor.UUID
909
- : this.Service.ContactSensor.UUID,
910
- ],
911
- THERMOMETER: [this.Service.TemperatureSensor.UUID],
912
- THERMOMETERDS18B20: [this.Service.TemperatureSensor.UUID],
913
- HUMIDITYANDTEMPSENSOR: [this.Service.TemperatureSensor.UUID, this.Service.HumiditySensor.UUID],
914
- GENERAL_PURPOSE_MEASUREMENT: [this.Service.AirQualitySensor.UUID],
915
- PRESSURESENSOR: [this.Service.LightSensor.UUID],
916
- ELECTRICITYMETER: [this.Service.LightSensor.UUID],
917
- ACTION_TRIGGER: [this.Service.StatelessProgrammableSwitch.UUID],
918
- };
919
- return new Set(byFunction[channel.channelFunction] ?? byType[channel.channelType] ?? []);
920
- }
921
- getLegacyNoFunctionServiceUuids(channel, accessory) {
922
- if (!accessory) {
923
- return new Set();
924
- }
925
- const compatibleByType = {
926
- RELAY: [this.Service.Switch.UUID, this.Service.Lightbulb.UUID],
927
- DIMMER: [this.Service.Lightbulb.UUID],
928
- DIMMERANDRGBLED: [this.Service.Lightbulb.UUID],
929
- BINARYSENSOR: [this.Service.LeakSensor.UUID, this.Service.ContactSensor.UUID],
930
- THERMOMETER: [this.Service.TemperatureSensor.UUID],
931
- THERMOMETERDS18B20: [this.Service.TemperatureSensor.UUID],
932
- HUMIDITYANDTEMPSENSOR: [this.Service.TemperatureSensor.UUID, this.Service.HumiditySensor.UUID],
933
- };
934
- const compatible = compatibleByType[channel.channelType] ?? [];
935
- return new Set(compatible.filter(uuid => accessory.services.some(service => service.UUID === uuid)));
936
- }
937
- isExplicitNoFunction(value) {
938
- return value?.trim().toUpperCase() === 'NONE';
939
- }
940
- cleanupObsoleteServices(accessory, channel) {
941
- const expected = this.getExpectedServiceUuids(channel, accessory);
942
- const knownPluginServiceUuids = new Set([
943
- this.Service.GarageDoorOpener.UUID,
944
- this.Service.LockMechanism.UUID,
945
- this.Service.Lightbulb.UUID,
946
- this.Service.Switch.UUID,
947
- this.Service.WindowCovering.UUID,
948
- this.Service.Door.UUID,
949
- this.Service.ContactSensor.UUID,
950
- this.Service.LeakSensor.UUID,
951
- this.Service.TemperatureSensor.UUID,
952
- this.Service.HumiditySensor.UUID,
953
- this.Service.AirQualitySensor.UUID,
954
- this.Service.LightSensor.UUID,
955
- this.Service.Valve.UUID,
956
- this.Service.Thermostat.UUID,
957
- this.Service.StatelessProgrammableSwitch.UUID,
958
- ]);
959
- for (const service of [...accessory.services]) {
960
- if (knownPluginServiceUuids.has(service.UUID) && !expected.has(service.UUID)) {
961
- accessory.removeService(service);
962
- }
963
- }
964
- }
965
- prepareAccessoryForReconfiguration(accessory, channel) {
966
- this.unregisterMqttHandlers(accessory.UUID);
967
- this.cleanupObsoleteServices(accessory, channel);
968
- const expectedServices = this.getExpectedServiceUuids(channel, accessory);
338
+ resetAccessoryServices(accessory) {
339
+ const keepUuid = this.Service.AccessoryInformation.UUID;
969
340
  for (const service of accessory.services) {
970
- if (!expectedServices.has(service.UUID)) {
341
+ if (service.UUID === keepUuid) {
971
342
  continue;
972
343
  }
973
- for (const characteristic of service.characteristics) {
974
- characteristic.removeOnGet();
975
- characteristic.removeOnSet();
976
- }
977
- this.removeObsoleteOptionalCharacteristics(service, channel);
978
- }
979
- }
980
- removeObsoleteOptionalCharacteristics(service, channel) {
981
- const obsolete = new Set();
982
- const servicesWithoutStatusFault = new Set([
983
- this.Service.GarageDoorOpener.UUID,
984
- this.Service.LockMechanism.UUID,
985
- this.Service.Lightbulb.UUID,
986
- this.Service.Switch.UUID,
987
- this.Service.WindowCovering.UUID,
988
- this.Service.Door.UUID,
989
- this.Service.Thermostat.UUID,
990
- this.Service.StatelessProgrammableSwitch.UUID,
991
- ]);
992
- if (servicesWithoutStatusFault.has(service.UUID)) {
993
- obsolete.add(this.Characteristic.StatusFault.UUID);
994
- }
995
- if (service.UUID === this.Service.Lightbulb.UUID) {
996
- if (channel.channelFunction === 'LIGHTSWITCH') {
997
- obsolete.add(this.Characteristic.Brightness.UUID);
998
- obsolete.add(this.Characteristic.Hue.UUID);
999
- obsolete.add(this.Characteristic.Saturation.UUID);
1000
- }
1001
- else if (channel.channelFunction === 'DIMMER') {
1002
- obsolete.add(this.Characteristic.Hue.UUID);
1003
- obsolete.add(this.Characteristic.Saturation.UUID);
1004
- }
1005
- }
1006
- if (service.UUID === this.Service.WindowCovering.UUID
1007
- && channel.channelFunction === 'CONTROLLINGTHEROLLERSHUTTER') {
1008
- obsolete.add(this.Characteristic.CurrentHorizontalTiltAngle.UUID);
1009
- obsolete.add(this.Characteristic.TargetHorizontalTiltAngle.UUID);
1010
- }
1011
- if (service.UUID === this.Service.WindowCovering.UUID) {
1012
- // StatusJammed is not part of the HAP WindowCovering service. Older
1013
- // versions added it dynamically; remove that invalid composition.
1014
- obsolete.add(this.Characteristic.StatusJammed.UUID);
1015
- }
1016
- for (const characteristic of [...service.characteristics]) {
1017
- if (obsolete.has(characteristic.UUID)) {
1018
- service.removeCharacteristic(characteristic);
1019
- }
344
+ accessory.removeService(service);
1020
345
  }
1021
346
  }
1022
- revokeAccessoryHandlers(accessory) {
1023
- this.unregisterMqttHandlers(accessory.UUID);
1024
- const unavailable = new this.api.hap.HapStatusError(-70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
1025
- for (const service of accessory.services) {
1026
- if (service.UUID === this.Service.AccessoryInformation.UUID) {
1027
- continue;
1028
- }
1029
- for (const characteristic of service.characteristics) {
1030
- characteristic.removeOnGet();
1031
- characteristic.removeOnSet();
1032
- if (characteristic.props.perms.includes("pr" /* this.api.hap.Perms.PAIRED_READ */)) {
1033
- characteristic.updateValue(unavailable);
1034
- }
1035
- if (characteristic.props.perms.includes("pw" /* this.api.hap.Perms.PAIRED_WRITE */)) {
1036
- characteristic.onSet(() => {
1037
- throw new this.api.hap.HapStatusError(-70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
1038
- });
1039
- }
1040
- }
1041
- }
1042
- }
1043
- resolveHomeKitDisplayName(channel, currentName) {
1044
- const captionName = channel.channelCaptionAuthoritative === true
1045
- ? (0, DisplayName_1.toHomeKitDisplayName)(channel.channelCaption)
1046
- : undefined;
1047
- const hasRenameEvidence = channel.channelCaptionSource === 'retained'
1048
- || channel.channelCaptionSource === 'authoritative_snapshot';
1049
- if (captionName && hasRenameEvidence) {
1050
- return captionName;
1051
- }
1052
- const safeCurrentName = (0, DisplayName_1.toHomeKitDisplayName)(currentName);
1053
- if (safeCurrentName) {
1054
- return safeCurrentName;
1055
- }
1056
- // A new accessory (or a damaged empty legacy display name) may use its
1057
- // complete cached caption as an initial/recovery label. This is not allowed
1058
- // to replace an already meaningful existing name.
1059
- if (captionName) {
1060
- return captionName;
1061
- }
1062
- return (0, DisplayName_1.toHomeKitDisplayName)((0, DisplayName_1.generatedChannelCaption)(channel.deviceId, channel.channelId))
1063
- ?? 'Supla Device';
1064
- }
1065
- captureAccessoryNames(accessory) {
1066
- return {
1067
- displayName: accessory.displayName,
1068
- services: accessory.services.map((service) => {
1069
- const characteristic = service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
1070
- return {
1071
- service,
1072
- displayName: service.displayName,
1073
- hadNameCharacteristic: characteristic !== undefined,
1074
- nameValue: typeof characteristic?.value === 'string' ? characteristic.value : undefined,
1075
- };
1076
- }),
1077
- };
1078
- }
1079
- restoreAccessoryNames(accessory, snapshot) {
1080
- try {
1081
- accessory.updateDisplayName(snapshot.displayName);
1082
- }
1083
- catch (error) {
1084
- this.log.error(`Failed to restore accessory display name ${snapshot.displayName}: ${error.message}`);
1085
- }
1086
- for (const entry of snapshot.services) {
1087
- try {
1088
- entry.service.displayName = entry.displayName;
1089
- const current = entry.service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
1090
- if (!entry.hadNameCharacteristic) {
1091
- if (current) {
1092
- entry.service.removeCharacteristic(current);
1093
- }
1094
- }
1095
- else if (entry.nameValue !== undefined && current?.value !== entry.nameValue) {
1096
- entry.service.setCharacteristic(this.Characteristic.Name, entry.nameValue);
1097
- }
1098
- }
1099
- catch (error) {
1100
- this.log.error(`Failed to restore service name for ${snapshot.displayName}: ${error.message}`);
1101
- }
1102
- }
1103
- }
1104
- restoreAccessoryContext(accessory, snapshot) {
1105
- for (const key of Object.keys(accessory.context)) {
1106
- delete accessory.context[key];
1107
- }
1108
- Object.assign(accessory.context, snapshot);
1109
- }
1110
- areChannelContextsEqual(current, incoming) {
1111
- if (!current) {
1112
- return false;
1113
- }
1114
- const fields = [
1115
- 'topic',
1116
- 'channelType',
1117
- 'channelFunction',
1118
- 'channelCaption',
1119
- 'deviceId',
1120
- 'channelId',
1121
- 'rawTopic',
1122
- 'channelCaptionAuthoritative',
1123
- 'channelCaptionSource',
1124
- ];
1125
- return fields.every(field => current[field] === incoming[field]);
1126
- }
1127
- updateAccessoryNames(accessory, channel) {
1128
- let changed = false;
1129
- const caption = (0, DisplayName_1.normalizeVendorCaption)(channel.channelCaption);
1130
- const homeKitName = this.resolveHomeKitDisplayName(channel, accessory.displayName);
1131
- if (accessory.displayName !== homeKitName) {
1132
- accessory.updateDisplayName(homeKitName);
1133
- changed = true;
1134
- }
1135
- const informationService = accessory.getService(this.Service.AccessoryInformation);
1136
- const informationName = informationService?.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
1137
- if (informationService) {
1138
- if (informationService.displayName !== homeKitName) {
1139
- informationService.displayName = homeKitName;
1140
- changed = true;
1141
- }
1142
- if (informationName?.value !== homeKitName) {
1143
- informationService.setCharacteristic(this.Characteristic.Name, homeKitName);
1144
- changed = true;
1145
- }
1146
- }
1147
- const expected = this.getExpectedServiceUuids(channel, accessory);
1148
- const electricityMetric = accessory.context.suplaElectricityMetric;
1149
- for (const service of accessory.services) {
1150
- if (expected.has(service.UUID)) {
1151
- const serviceName = channel.channelFunction === 'ELECTRICITYMETER'
1152
- && typeof electricityMetric === 'string'
1153
- ? (0, DisplayName_1.homeKitElectricityServiceName)(homeKitName, electricityMetric)
1154
- : homeKitName;
1155
- const nameCharacteristic = service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
1156
- if (service.displayName !== serviceName) {
1157
- service.displayName = serviceName;
1158
- changed = true;
1159
- }
1160
- if (nameCharacteristic?.value !== serviceName) {
1161
- service.setCharacteristic(this.Characteristic.Name, serviceName);
1162
- changed = true;
1163
- }
1164
- }
1165
- }
1166
- if (accessory.context.suplaVendorCaption !== caption
1167
- || accessory.context.suplaVendorCaptionAuthoritative !== (channel.channelCaptionAuthoritative === true)
1168
- || accessory.context.suplaHomeKitDisplayName !== homeKitName) {
1169
- changed = true;
1170
- }
1171
- accessory.context.suplaVendorCaption = caption;
1172
- accessory.context.suplaVendorCaptionAuthoritative = channel.channelCaptionAuthoritative === true;
1173
- accessory.context.suplaHomeKitDisplayName = homeKitName;
1174
- return changed;
1175
- }
1176
347
  setupAccessory(channel, accessory) {
1177
348
  this.unregisterMqttHandlers(accessory.UUID);
1178
349
  this.log.debug(`Mapping channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
@@ -1248,21 +419,13 @@ class SuplaPlatform {
1248
419
  new DimmerAccessory_1.DimmerAccessory(this, accessory, channel);
1249
420
  return true;
1250
421
  case 'RELAY':
1251
- if (!accessory.getService(this.Service.Switch)
1252
- && accessory.getService(this.Service.Lightbulb)) {
1253
- new LightBulbAccesory_1.LightAccesory(this, accessory, channel);
1254
- }
1255
- else {
1256
- new SwitchAccessory_1.SwitchAccessory(this, accessory, channel);
1257
- }
422
+ new SwitchAccessory_1.SwitchAccessory(this, accessory, channel);
1258
423
  return true;
1259
424
  case 'BINARYSENSOR':
1260
- if (this.isLeakSensorChannel(channel, accessory)) {
1261
- accessory.context.suplaBinarySensorKind = 'leak';
425
+ if (this.isLeakSensorChannel(channel)) {
1262
426
  new LeakSensorAccessory_1.LeakSensorAccessory(this, accessory, channel);
1263
427
  }
1264
428
  else {
1265
- accessory.context.suplaBinarySensorKind = 'contact';
1266
429
  new ContactSensorAccessory_1.ContactSensorAccessory(this, accessory, channel);
1267
430
  }
1268
431
  return true;
@@ -1291,21 +454,9 @@ class SuplaPlatform {
1291
454
  this.log.warn(`Unsupported channel ${channel.channelCaption} (${channel.channelFunction}/${channel.channelType})`);
1292
455
  return false;
1293
456
  }
1294
- isLeakSensorChannel(channel, accessory) {
1295
- const functionName = (channel.channelFunction ?? '').trim().toUpperCase();
1296
- if (functionName.includes('FLOOD')
1297
- || functionName.includes('WATERLEAK')
1298
- || functionName === 'LEAKSENSOR') {
1299
- return true;
1300
- }
1301
- const persistedKind = accessory?.context?.suplaBinarySensorKind;
1302
- if (persistedKind === 'leak' || persistedKind === 'contact') {
1303
- return persistedKind === 'leak';
1304
- }
1305
- // Preserve the topology of accessories created by older releases, which
1306
- // inferred this type from a caption. The migrated kind is persisted by
1307
- // setupAccessory and subsequent captions no longer participate.
1308
- return accessory?.services?.some(service => service.UUID === this.Service.LeakSensor.UUID) === true;
457
+ isLeakSensorChannel(channel) {
458
+ const caption = (channel.channelCaption ?? '').toLowerCase();
459
+ return ['leak', 'flood', 'water', 'zalania'].some(term => caption.includes(term));
1309
460
  }
1310
461
  getCoveringControlMode() {
1311
462
  return this.coveringControlMode;
@@ -1328,6 +479,15 @@ class SuplaPlatform {
1328
479
  getCoveringTravelTimeSeconds() {
1329
480
  return this.coveringTravelTimeSeconds;
1330
481
  }
482
+ getGateControlMode() {
483
+ return this.gateControlMode;
484
+ }
485
+ getGateExecuteActionOpen() {
486
+ return this.gateExecuteActionOpen;
487
+ }
488
+ getGateExecuteActionClose() {
489
+ return this.gateExecuteActionClose;
490
+ }
1331
491
  getGateExecuteActionToggle() {
1332
492
  return this.gateExecuteActionToggle;
1333
493
  }
@@ -1349,116 +509,50 @@ class SuplaPlatform {
1349
509
  getGateLockSetOffPayload() {
1350
510
  return this.gateLockSetOffPayload;
1351
511
  }
1352
- claimGateLockFailSafe(accessoryUuid) {
1353
- const task = this.gateLockFailSafeTasks.get(accessoryUuid);
1354
- if (task) {
1355
- this.unregisterMqttHandlers(task.ownerId);
1356
- }
512
+ getGatePartialHiMode() {
513
+ return this.gatePartialHiMode;
1357
514
  }
1358
- transferGateLockFailSafe(accessory, stateTopic, commandTopic, offPayload) {
1359
- if (this.shuttingDown || accessory.context.suplaGateLockPendingOff !== true) {
1360
- return;
1361
- }
1362
- if (this.gateLockFailSafeTasks.has(accessory.UUID)) {
1363
- return;
1364
- }
1365
- const task = {
1366
- ownerId: `gate-lock-failsafe:${accessory.UUID}`,
1367
- accessory,
1368
- stateTopic,
1369
- commandTopic,
1370
- offPayload,
1371
- attempted: false,
1372
- };
1373
- this.gateLockFailSafeTasks.set(accessory.UUID, task);
1374
- this.registerOwnerCleanup(task.ownerId, () => {
1375
- if (task.retryTimer) {
1376
- clearTimeout(task.retryTimer);
1377
- task.retryTimer = undefined;
1378
- }
1379
- if (this.gateLockFailSafeTasks.get(accessory.UUID) === task) {
1380
- this.gateLockFailSafeTasks.delete(accessory.UUID);
1381
- }
1382
- });
1383
- this.registerMqttHandler(stateTopic, (message, _topic, packet) => {
1384
- const relayOn = this.parseBooleanStrict(message.toString());
1385
- if (relayOn !== false || packet?.retain === true || !task.attempted) {
1386
- return;
1387
- }
1388
- accessory.context.suplaGateLockPendingOff = false;
1389
- const accessoryStillRegistered = Array.isArray(this.accessories)
1390
- && this.accessories.includes(accessory);
1391
- try {
1392
- if (accessoryStillRegistered) {
1393
- this.api.updatePlatformAccessories([accessory]);
1394
- }
1395
- }
1396
- catch (error) {
1397
- accessory.context.suplaGateLockPendingOff = true;
1398
- this.log.error(`Failed to persist transferred gate-lock OFF confirmation for ${accessory.displayName}: ` +
1399
- `${error.message}`);
1400
- this.scheduleGateLockFailSafeRetry(task);
1401
- return;
1402
- }
1403
- this.log.info(`Gate-lock fail-safe OFF confirmed for ${accessory.displayName}.`);
1404
- this.unregisterMqttHandlers(task.ownerId);
1405
- }, task.ownerId);
1406
- this.registerMqttTransportHandler((connected) => {
1407
- if (connected) {
1408
- this.attemptGateLockFailSafe(task);
1409
- }
1410
- }, task.ownerId);
515
+ getGateReverseFollowUpDelayMs() {
516
+ return this.gateReverseFollowUpDelayMs;
1411
517
  }
1412
- attemptGateLockFailSafe(task) {
1413
- if (this.shuttingDown
1414
- || this.gateLockFailSafeTasks.get(task.accessory.UUID) !== task
1415
- || task.publishInFlight) {
1416
- return;
1417
- }
1418
- task.attempted = true;
1419
- this.log.warn(`Publishing transferred gate-lock fail-safe OFF for ${task.accessory.displayName}.`);
1420
- const operation = this.publishActionAsync(task.commandTopic, task.offPayload);
1421
- task.publishInFlight = operation;
1422
- void operation.catch((error) => {
1423
- this.log.warn(`Transferred gate-lock OFF remains unconfirmed for ${task.accessory.displayName}: ` +
1424
- `${error.message}`);
1425
- }).finally(() => {
1426
- if (task.publishInFlight === operation) {
1427
- task.publishInFlight = undefined;
1428
- }
1429
- this.scheduleGateLockFailSafeRetry(task);
1430
- });
518
+ getGateOpenAssumeDelayMs() {
519
+ return this.gateOpenAssumeDelayMs;
1431
520
  }
1432
- scheduleGateLockFailSafeRetry(task) {
1433
- if (this.shuttingDown
1434
- || task.retryTimer
1435
- || this.gateLockFailSafeTasks.get(task.accessory.UUID) !== task) {
1436
- return;
1437
- }
1438
- task.retryTimer = setTimeout(() => {
1439
- task.retryTimer = undefined;
1440
- this.attemptGateLockFailSafe(task);
1441
- }, 5000);
521
+ getGateCommandCooldownMs() {
522
+ return this.gateCommandCooldownMs;
523
+ }
524
+ getGatePublishRetryDelayMs() {
525
+ return this.gatePublishRetryDelayMs;
526
+ }
527
+ getGateStrictReverseDoublePulse() {
528
+ return this.gateStrictReverseDoublePulse;
1442
529
  }
1443
- getFrontGateConfig() {
530
+ getGateDebugTimeline() {
531
+ return this.gateDebugTimeline;
532
+ }
533
+ getFrontGateTimings() {
1444
534
  return {
1445
535
  fullTravelMs: this.frontGateFullTravelMs,
1446
536
  reversePauseMs: this.frontGateReversePauseMs,
537
+ wrongDirectionRunMs: this.frontGateWrongDirectionRunMs,
1447
538
  minimumPulseGapMs: this.frontGateMinimumPulseGapMs,
1448
- unknownOpenPolicy: this.frontGateUnknownOpenPolicy,
1449
- unknownClosePolicy: this.frontGateUnknownClosePolicy,
1450
- seekClosedMaxPulses: this.frontGateSeekClosedMaxPulses,
1451
- assumeOpenAfterTravel: this.frontGateAssumeOpenAfterTravel,
539
+ closeRetryLimit: this.frontGateCloseRetryLimit,
1452
540
  };
1453
541
  }
1454
- shouldFallbackFrontGateSensorToControlChannel() {
1455
- return this.frontGateSensorFallbackToControlChannel;
1456
- }
1457
- isMqttNoLocalAvailable() {
1458
- return this.mqttProtocolVersion === 5;
1459
- }
1460
542
  getFrontGatePulseAction() {
1461
- return this.gateExecuteActionToggle.trim();
543
+ const toggleAction = this.gateExecuteActionToggle.trim();
544
+ if (toggleAction) {
545
+ return toggleAction;
546
+ }
547
+ const openAction = this.gateExecuteActionOpen.trim();
548
+ const closeAction = this.gateExecuteActionClose.trim();
549
+ if (openAction && openAction === closeAction) {
550
+ return openAction;
551
+ }
552
+ if (openAction) {
553
+ return openAction;
554
+ }
555
+ return closeAction;
1462
556
  }
1463
557
  normalizeCoveringControlMode(value) {
1464
558
  const normalized = (value ?? 'set').toString().toLowerCase();
@@ -1470,6 +564,13 @@ class SuplaPlatform {
1470
564
  }
1471
565
  return 'set';
1472
566
  }
567
+ normalizeGateControlMode(value) {
568
+ const normalized = (value ?? 'execute_action').toString().toLowerCase();
569
+ if (normalized === 'toggle') {
570
+ return 'toggle';
571
+ }
572
+ return 'execute_action';
573
+ }
1473
574
  normalizeGateLockControlMode(value) {
1474
575
  const normalized = (value ?? 'execute_action').toString().toLowerCase();
1475
576
  if (normalized === 'set_on_pulse') {
@@ -1477,12 +578,54 @@ class SuplaPlatform {
1477
578
  }
1478
579
  return 'execute_action';
1479
580
  }
1480
- normalizeDurationSeconds(value, fallback, min, max) {
581
+ normalizeGatePartialHiMode(value) {
582
+ const normalized = (value ?? 'moving').toString().toLowerCase();
583
+ if (normalized === 'open_endstop' || normalized === 'open') {
584
+ return 'open_endstop';
585
+ }
586
+ if (normalized === 'pedestrian_endstop' || normalized === 'pedestrian') {
587
+ return 'pedestrian_endstop';
588
+ }
589
+ if (normalized === 'ignore' || normalized === 'absent') {
590
+ return 'ignore';
591
+ }
592
+ return 'moving';
593
+ }
594
+ normalizeGateReverseFollowUpDelayMs(value) {
595
+ const fallbackMs = 3000;
1481
596
  const parsed = Number(value);
1482
597
  if (!Number.isFinite(parsed)) {
1483
- return fallback;
598
+ return fallbackMs;
599
+ }
600
+ const rounded = Math.round(parsed);
601
+ return Math.min(10000, Math.max(250, rounded));
602
+ }
603
+ normalizeGateOpenAssumeDelayMs(value) {
604
+ const fallbackMs = 22000;
605
+ const parsed = Number(value);
606
+ if (!Number.isFinite(parsed)) {
607
+ return fallbackMs;
1484
608
  }
1485
- return Math.min(max, Math.max(min, parsed));
609
+ const rounded = Math.round(parsed);
610
+ return Math.min(120000, Math.max(1000, rounded));
611
+ }
612
+ normalizeGateCommandCooldownMs(value) {
613
+ const fallbackMs = 700;
614
+ const parsed = Number(value);
615
+ if (!Number.isFinite(parsed)) {
616
+ return fallbackMs;
617
+ }
618
+ const rounded = Math.round(parsed);
619
+ return Math.min(15000, Math.max(3000, rounded));
620
+ }
621
+ normalizeGatePublishRetryDelayMs(value) {
622
+ const fallbackMs = 300;
623
+ const parsed = Number(value);
624
+ if (!Number.isFinite(parsed)) {
625
+ return fallbackMs;
626
+ }
627
+ const rounded = Math.round(parsed);
628
+ return Math.min(5000, Math.max(0, rounded));
1486
629
  }
1487
630
  normalizeFrontGateFullTravelMs(value) {
1488
631
  const fallbackMs = 25000;
@@ -1502,35 +645,32 @@ class SuplaPlatform {
1502
645
  const rounded = Math.round(parsed);
1503
646
  return Math.min(15000, Math.max(3000, rounded));
1504
647
  }
1505
- normalizeFrontGateMinimumPulseGapMs(value) {
1506
- const fallbackMs = 3000;
648
+ normalizeFrontGateWrongDirectionRunMs(value) {
649
+ const fallbackMs = 700;
1507
650
  const parsed = Number(value);
1508
651
  if (!Number.isFinite(parsed)) {
1509
652
  return fallbackMs;
1510
653
  }
1511
654
  const rounded = Math.round(parsed);
1512
- return Math.min(15000, Math.max(0, rounded));
655
+ return Math.min(5000, Math.max(100, rounded));
1513
656
  }
1514
- normalizeFrontGateUnknownOpenPolicy(value) {
1515
- return value === 'accept_non_closed' ? 'accept_non_closed' : 'reject';
1516
- }
1517
- normalizeFrontGateUnknownClosePolicy(value) {
1518
- if (value === 'single_pulse_best_effort' || value === 'seek_closed') {
1519
- return value;
657
+ normalizeFrontGateMinimumPulseGapMs(value) {
658
+ const fallbackMs = 3000;
659
+ const parsed = Number(value);
660
+ if (!Number.isFinite(parsed)) {
661
+ return fallbackMs;
1520
662
  }
1521
- return 'reject';
663
+ const rounded = Math.round(parsed);
664
+ return Math.min(10000, Math.max(0, rounded));
1522
665
  }
1523
- normalizeFrontGateSeekClosedMaxPulses(value) {
1524
- const fallbackValue = 3;
666
+ normalizeFrontGateCloseRetryLimit(value) {
667
+ const fallbackValue = 0;
1525
668
  const parsed = Number(value);
1526
669
  if (!Number.isFinite(parsed)) {
1527
670
  return fallbackValue;
1528
671
  }
1529
672
  const rounded = Math.round(parsed);
1530
- return Math.min(3, Math.max(1, rounded));
1531
- }
1532
- normalizeMqttProtocolVersion(value) {
1533
- return Number(value) === 5 ? 5 : 4;
673
+ return Math.min(3, Math.max(0, rounded));
1534
674
  }
1535
675
  normalizeTopicSuffix(value) {
1536
676
  return value.toString().replace(/^\/+/, '');
@@ -1546,7 +686,7 @@ class SuplaPlatform {
1546
686
  base = base.replace(/\/set\/.+$/, '');
1547
687
  return base.replace(/\/+$/, '');
1548
688
  }
1549
- registerMqttHandler(topic, handler, ownerId, options = {}) {
689
+ registerMqttHandler(topic, handler, ownerId) {
1550
690
  if (!topic) {
1551
691
  return () => undefined;
1552
692
  }
@@ -1561,9 +701,6 @@ class SuplaPlatform {
1561
701
  ownerTopics.set(topic, ownerHandlers);
1562
702
  this.mqttHandlerOwners.set(ownerId, ownerTopics);
1563
703
  this.mqttDesiredSubscriptions.add(topic);
1564
- if (options.noLocal && this.mqttProtocolVersion === 5) {
1565
- this.mqttNoLocalTopics.add(topic);
1566
- }
1567
704
  this.ensureSubscribed(topic, false);
1568
705
  return () => {
1569
706
  this.removeHandler(topic, handler);
@@ -1580,21 +717,6 @@ class SuplaPlatform {
1580
717
  }
1581
718
  };
1582
719
  }
1583
- registerMqttTransportHandler(handler, ownerId) {
1584
- const handlers = this.mqttTransportHandlers.get(ownerId) ?? new Set();
1585
- handlers.add(handler);
1586
- this.mqttTransportHandlers.set(ownerId, handlers);
1587
- handler(this.mqttTransportConnected);
1588
- const cleanup = () => {
1589
- const active = this.mqttTransportHandlers.get(ownerId);
1590
- active?.delete(handler);
1591
- if (active?.size === 0) {
1592
- this.mqttTransportHandlers.delete(ownerId);
1593
- }
1594
- };
1595
- this.registerOwnerCleanup(ownerId, cleanup);
1596
- return cleanup;
1597
- }
1598
720
  registerOwnerCleanup(ownerId, cleanup) {
1599
721
  const cleanups = this.ownerCleanups.get(ownerId) ?? new Set();
1600
722
  cleanups.add(cleanup);
@@ -1610,20 +732,6 @@ class SuplaPlatform {
1610
732
  }
1611
733
  };
1612
734
  }
1613
- registerShutdownDrain(ownerId, drain) {
1614
- const drains = this.shutdownDrains.get(ownerId) ?? new Set();
1615
- drains.add(drain);
1616
- this.shutdownDrains.set(ownerId, drains);
1617
- const cleanup = () => {
1618
- const active = this.shutdownDrains.get(ownerId);
1619
- active?.delete(drain);
1620
- if (active?.size === 0) {
1621
- this.shutdownDrains.delete(ownerId);
1622
- }
1623
- };
1624
- this.registerOwnerCleanup(ownerId, cleanup);
1625
- return cleanup;
1626
- }
1627
735
  unregisterMqttHandlers(ownerId) {
1628
736
  this.runOwnerCleanup(ownerId);
1629
737
  const ownerTopics = this.mqttHandlerOwners.get(ownerId);
@@ -1650,106 +758,31 @@ class SuplaPlatform {
1650
758
  }
1651
759
  this.mqttHandlers.clear();
1652
760
  this.mqttWildcardHandlers.clear();
1653
- this.mqttNoLocalTopics.clear();
1654
- this.mqttTransportHandlers.clear();
1655
761
  this.mqttDesiredSubscriptions.clear();
1656
762
  this.mqttSubscriptions.clear();
1657
763
  this.mqttPendingSubscriptions.clear();
1658
- this.mqttPendingSubscriptionOperations.clear();
1659
764
  this.clearAllSubscriptionRetries();
1660
- this.shutdownDrains.clear();
1661
765
  this.ownerCleanups.clear();
1662
766
  }
1663
767
  publishCommand(topic, payload, callback) {
1664
- this.publishMqtt(topic, payload, { qos: this.commandQos, retain: this.commandRetain }, error => callback?.(error));
1665
- }
1666
- publishCommandAsync(topic, payload) {
1667
- return this.publishMqttAsync(topic, payload, { qos: this.commandQos, retain: this.commandRetain });
1668
- }
1669
- publishGateAction(topic, payload, callback) {
1670
- this.publishMqtt(topic, payload, { qos: 0, retain: false }, (error, failureOrigin) => {
1671
- if (error && failureOrigin === 'callback') {
1672
- error.mqttPublishFailureKind = 'completion_unknown';
1673
- }
1674
- callback?.(error);
1675
- });
1676
- }
1677
- publishActionAsync(topic, payload) {
1678
- return this.publishMqttAsync(topic, payload, { qos: 0, retain: false }, true);
1679
- }
1680
- publishGateActionAsync(topic, payload) {
1681
- return this.publishActionAsync(topic, payload);
1682
- }
1683
- isPublishCompletionUnknown(error) {
1684
- return error instanceof Error
1685
- && error.mqttPublishFailureKind === 'completion_unknown';
1686
- }
1687
- publishMqttAsync(topic, payload, options, callbackFailureIsAmbiguous = false) {
1688
- return new Promise((resolve, reject) => {
1689
- let settled = false;
1690
- const finish = (kind, error) => {
1691
- if (settled) {
1692
- return;
1693
- }
1694
- settled = true;
1695
- clearTimeout(timer);
1696
- if (!kind || !error) {
1697
- resolve();
1698
- return;
1699
- }
1700
- this.log.error(`Publish failed for ${topic}: ${error.message}`);
1701
- const hapError = new this.api.hap.HapStatusError(kind === 'completion_unknown'
1702
- ? -70408 /* this.api.hap.HAPStatus.OPERATION_TIMED_OUT */
1703
- : -70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
1704
- hapError.mqttPublishFailureKind = kind;
1705
- hapError.cause = error;
1706
- reject(hapError);
1707
- };
1708
- const timer = setTimeout(() => {
1709
- finish('completion_unknown', new Error(`MQTT publish completion timed out after ${this.publishTimeoutMs}ms`));
1710
- }, this.publishTimeoutMs);
1711
- this.publishMqtt(topic, payload, options, (error, failureOrigin) => {
1712
- if (error) {
1713
- finish(callbackFailureIsAmbiguous && failureOrigin === 'callback'
1714
- ? 'completion_unknown'
1715
- : 'definite_failure', error);
1716
- }
1717
- else {
1718
- finish();
1719
- }
1720
- });
1721
- });
1722
- }
1723
- publishMqtt(topic, payload, options, callback) {
1724
768
  const client = this.MqttClient?.client;
1725
769
  if (!client || !client.connected) {
1726
770
  const error = new Error('MQTT not connected');
1727
771
  this.log.error(`MQTT not connected; cannot publish ${topic}`);
1728
772
  if (callback) {
1729
- callback(error, 'preflight');
773
+ callback(error);
1730
774
  }
1731
775
  return;
1732
776
  }
1733
- try {
1734
- client.publish(topic, payload, options, (error) => {
1735
- if (callback) {
1736
- callback(error, error ? 'callback' : undefined);
1737
- return;
1738
- }
1739
- if (error) {
1740
- this.log.error(`Publish failed for ${topic}: ${error.message}`);
1741
- }
1742
- });
1743
- }
1744
- catch (error) {
1745
- const publishError = error instanceof Error ? error : new Error(String(error));
777
+ client.publish(topic, payload, { qos: this.commandQos, retain: this.commandRetain }, (error) => {
1746
778
  if (callback) {
1747
- callback(publishError, 'preflight');
779
+ callback(error);
780
+ return;
1748
781
  }
1749
- else {
1750
- this.log.error(`Publish failed for ${topic}: ${publishError.message}`);
782
+ if (error) {
783
+ this.log.error(`Publish failed for ${topic}: ${error.message}`);
1751
784
  }
1752
- }
785
+ });
1753
786
  }
1754
787
  parseBoolean(value) {
1755
788
  if (typeof value === 'boolean') {
@@ -1767,46 +800,6 @@ class SuplaPlatform {
1767
800
  }
1768
801
  return false;
1769
802
  }
1770
- parseBooleanStrict(value) {
1771
- if (typeof value === 'boolean') {
1772
- return value;
1773
- }
1774
- if (typeof value === 'number') {
1775
- if (value === 1) {
1776
- return true;
1777
- }
1778
- if (value === 0) {
1779
- return false;
1780
- }
1781
- return undefined;
1782
- }
1783
- if (typeof value !== 'string') {
1784
- return undefined;
1785
- }
1786
- const normalized = value.trim().toLowerCase();
1787
- if (['1', 'true', 'on', 'yes'].includes(normalized)) {
1788
- return true;
1789
- }
1790
- if (['0', 'false', 'off', 'no'].includes(normalized)) {
1791
- return false;
1792
- }
1793
- return undefined;
1794
- }
1795
- parseFiniteNumberStrict(value) {
1796
- if (typeof value === 'number') {
1797
- return Number.isFinite(value) ? value : undefined;
1798
- }
1799
- if (typeof value !== 'string') {
1800
- return undefined;
1801
- }
1802
- const normalized = value.trim();
1803
- if (!normalized
1804
- || !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) {
1805
- return undefined;
1806
- }
1807
- const parsed = Number(normalized);
1808
- return Number.isFinite(parsed) ? parsed : undefined;
1809
- }
1810
803
  runOwnerCleanup(ownerId) {
1811
804
  const cleanups = this.ownerCleanups.get(ownerId);
1812
805
  if (!cleanups) {
@@ -1822,33 +815,12 @@ class SuplaPlatform {
1822
815
  }
1823
816
  this.ownerCleanups.delete(ownerId);
1824
817
  }
1825
- updateMqttTransportState(connected) {
1826
- if (this.mqttTransportConnected === connected) {
1827
- return;
1828
- }
1829
- this.mqttTransportConnected = connected;
1830
- for (const handlers of this.mqttTransportHandlers.values()) {
1831
- for (const handler of handlers) {
1832
- try {
1833
- handler(connected);
1834
- }
1835
- catch (error) {
1836
- this.log.error(`MQTT transport handler failed: ${error.message}`);
1837
- }
1838
- }
1839
- }
1840
- }
1841
- advanceMqttTransportEpoch() {
1842
- this.mqttTransportEpoch += 1;
1843
- }
1844
818
  clearActiveSubscriptions() {
1845
- this.advanceMqttTransportEpoch();
1846
819
  if (this.mqttSubscriptions.size > 0 || this.mqttPendingSubscriptions.size > 0) {
1847
820
  this.log.debug('MQTT connection lost; clearing active subscriptions.');
1848
821
  }
1849
822
  this.mqttSubscriptions.clear();
1850
823
  this.mqttPendingSubscriptions.clear();
1851
- this.mqttPendingSubscriptionOperations.clear();
1852
824
  this.clearAllSubscriptionRetries();
1853
825
  }
1854
826
  clearAllSubscriptionRetries() {
@@ -1903,11 +875,9 @@ class SuplaPlatform {
1903
875
  }
1904
876
  this.mqttRetryState.set(topic, state);
1905
877
  this.log.debug(`Retrying MQTT subscribe for ${topic} in ${scheduledDelayMs}ms (${reason}).`);
1906
- const scheduledEpoch = this.mqttTransportEpoch;
1907
878
  const timer = setTimeout(() => {
1908
879
  this.mqttRetryTimers.delete(topic);
1909
- if (scheduledEpoch !== this.mqttTransportEpoch
1910
- || !this.mqttDesiredSubscriptions.has(topic)) {
880
+ if (!this.mqttDesiredSubscriptions.has(topic)) {
1911
881
  return;
1912
882
  }
1913
883
  this.ensureSubscribed(topic, true);
@@ -1916,7 +886,7 @@ class SuplaPlatform {
1916
886
  }
1917
887
  isSubscriptionGranted(topic, granted) {
1918
888
  if (!Array.isArray(granted) || granted.length === 0) {
1919
- return false;
889
+ return true;
1920
890
  }
1921
891
  const entry = granted.find((item) => item.topic === topic);
1922
892
  if (!entry) {
@@ -1960,25 +930,7 @@ class SuplaPlatform {
1960
930
  return;
1961
931
  }
1962
932
  this.mqttPendingSubscriptions.add(topic);
1963
- const client = this.MqttClient.client;
1964
- const operation = {
1965
- client,
1966
- epoch: this.mqttTransportEpoch,
1967
- id: ++this.nextMqttSubscriptionOperationId,
1968
- };
1969
- this.mqttPendingSubscriptionOperations.set(topic, operation);
1970
- const subscribeOptions = {
1971
- qos: 0,
1972
- ...(this.mqttNoLocalTopics.has(topic) && this.mqttProtocolVersion === 5 ? { nl: true } : {}),
1973
- };
1974
- client.subscribe(topic, subscribeOptions, (err, granted) => {
1975
- if (this.mqttPendingSubscriptionOperations.get(topic) !== operation
1976
- || this.mqttTransportEpoch !== operation.epoch
1977
- || this.MqttClient?.client !== operation.client
1978
- || !operation.client.connected) {
1979
- return;
1980
- }
1981
- this.mqttPendingSubscriptionOperations.delete(topic);
933
+ this.MqttClient.client.subscribe(topic, (err, granted) => {
1982
934
  this.mqttPendingSubscriptions.delete(topic);
1983
935
  if (err) {
1984
936
  this.logSubscriptionIssue(topic, `MQTT subscribe failed for ${topic}: ${err.message}`);
@@ -2017,7 +969,6 @@ class SuplaPlatform {
2017
969
  }
2018
970
  if (force) {
2019
971
  this.mqttPendingSubscriptions.clear();
2020
- this.mqttPendingSubscriptionOperations.clear();
2021
972
  }
2022
973
  for (const topic of this.mqttDesiredSubscriptions) {
2023
974
  this.ensureSubscribed(topic, force);
@@ -2025,19 +976,19 @@ class SuplaPlatform {
2025
976
  }
2026
977
  removeSubscription(topic) {
2027
978
  this.mqttDesiredSubscriptions.delete(topic);
2028
- this.mqttNoLocalTopics.delete(topic);
2029
979
  this.mqttPendingSubscriptions.delete(topic);
2030
- this.mqttPendingSubscriptionOperations.delete(topic);
2031
980
  this.clearSubscriptionRetry(topic);
2032
- const wasSubscribed = this.mqttSubscriptions.delete(topic);
2033
- if (!wasSubscribed || !this.MqttClient || this.shuttingDown) {
981
+ if (!this.MqttClient) {
2034
982
  return;
2035
983
  }
2036
- this.MqttClient.client.unsubscribe(topic, (err) => {
2037
- if (err) {
2038
- this.log.error(`MQTT unsubscribe failed for ${topic}: ${err.message}`);
2039
- }
2040
- });
984
+ if (this.mqttSubscriptions.has(topic)) {
985
+ this.MqttClient.client.unsubscribe(topic, (err) => {
986
+ if (err) {
987
+ this.log.error(`MQTT unsubscribe failed for ${topic}: ${err.message}`);
988
+ }
989
+ });
990
+ this.mqttSubscriptions.delete(topic);
991
+ }
2041
992
  }
2042
993
  removeHandler(topic, handler) {
2043
994
  const handlerMap = this.isWildcardTopic(topic) ? this.mqttWildcardHandlers : this.mqttHandlers;
@@ -2090,7 +1041,7 @@ class SuplaPlatform {
2090
1041
  }
2091
1042
  this.mqttRouterAttached = true;
2092
1043
  this.resubscribeAll(false);
2093
- this.MqttClient.client.on('message', (topic, message, packet) => {
1044
+ this.MqttClient.client.on('message', (topic, message) => {
2094
1045
  const dispatched = new Set();
2095
1046
  const dispatch = (handler, label) => {
2096
1047
  if (dispatched.has(handler)) {
@@ -2098,7 +1049,7 @@ class SuplaPlatform {
2098
1049
  }
2099
1050
  dispatched.add(handler);
2100
1051
  try {
2101
- handler(message, topic, packet);
1052
+ handler(message, topic);
2102
1053
  }
2103
1054
  catch (error) {
2104
1055
  this.log.error(`MQTT handler error for ${label}: ${error.message}`);