@marcel2215/homebridge-supla-plugin 2.1.29 → 2.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +25 -1
  2. package/config.schema.json +26 -5
  3. package/dist/Accesories/ActionTriggerAccessory.d.ts +0 -1
  4. package/dist/Accesories/ActionTriggerAccessory.d.ts.map +1 -1
  5. package/dist/Accesories/ActionTriggerAccessory.js +15 -9
  6. package/dist/Accesories/ActionTriggerAccessory.js.map +1 -1
  7. package/dist/Accesories/AirQualityAccessory.d.ts +3 -1
  8. package/dist/Accesories/AirQualityAccessory.d.ts.map +1 -1
  9. package/dist/Accesories/AirQualityAccessory.js +47 -5
  10. package/dist/Accesories/AirQualityAccessory.js.map +1 -1
  11. package/dist/Accesories/ContactSensorAccessory.d.ts +3 -1
  12. package/dist/Accesories/ContactSensorAccessory.d.ts.map +1 -1
  13. package/dist/Accesories/ContactSensorAccessory.js +32 -4
  14. package/dist/Accesories/ContactSensorAccessory.js.map +1 -1
  15. package/dist/Accesories/DimmerAccessory.d.ts +4 -2
  16. package/dist/Accesories/DimmerAccessory.d.ts.map +1 -1
  17. package/dist/Accesories/DimmerAccessory.js +51 -14
  18. package/dist/Accesories/DimmerAccessory.js.map +1 -1
  19. package/dist/Accesories/DimmerRgbLightAccessory.d.ts +10 -2
  20. package/dist/Accesories/DimmerRgbLightAccessory.d.ts.map +1 -1
  21. package/dist/Accesories/DimmerRgbLightAccessory.js +149 -26
  22. package/dist/Accesories/DimmerRgbLightAccessory.js.map +1 -1
  23. package/dist/Accesories/DoorAccessory.d.ts +14 -1
  24. package/dist/Accesories/DoorAccessory.d.ts.map +1 -1
  25. package/dist/Accesories/DoorAccessory.js +146 -20
  26. package/dist/Accesories/DoorAccessory.js.map +1 -1
  27. package/dist/Accesories/ElectricityMeterAccessory.d.ts +13 -1
  28. package/dist/Accesories/ElectricityMeterAccessory.d.ts.map +1 -1
  29. package/dist/Accesories/ElectricityMeterAccessory.js +127 -12
  30. package/dist/Accesories/ElectricityMeterAccessory.js.map +1 -1
  31. package/dist/Accesories/FacadeBlindAccessory.d.ts +6 -0
  32. package/dist/Accesories/FacadeBlindAccessory.d.ts.map +1 -1
  33. package/dist/Accesories/FacadeBlindAccessory.js +205 -64
  34. package/dist/Accesories/FacadeBlindAccessory.js.map +1 -1
  35. package/dist/Accesories/FrontGateFsm.d.ts +8 -2
  36. package/dist/Accesories/FrontGateFsm.d.ts.map +1 -1
  37. package/dist/Accesories/FrontGateFsm.js +79 -19
  38. package/dist/Accesories/FrontGateFsm.js.map +1 -1
  39. package/dist/Accesories/GarageDoorOpenerAccesory.d.ts +12 -3
  40. package/dist/Accesories/GarageDoorOpenerAccesory.d.ts.map +1 -1
  41. package/dist/Accesories/GarageDoorOpenerAccesory.js +168 -21
  42. package/dist/Accesories/GarageDoorOpenerAccesory.js.map +1 -1
  43. package/dist/Accesories/GateAccessory.d.ts +6 -0
  44. package/dist/Accesories/GateAccessory.d.ts.map +1 -1
  45. package/dist/Accesories/GateAccessory.js +127 -19
  46. package/dist/Accesories/GateAccessory.js.map +1 -1
  47. package/dist/Accesories/GateLockAccessory.d.ts +17 -1
  48. package/dist/Accesories/GateLockAccessory.d.ts.map +1 -1
  49. package/dist/Accesories/GateLockAccessory.js +375 -50
  50. package/dist/Accesories/GateLockAccessory.js.map +1 -1
  51. package/dist/Accesories/LeakSensorAccessory.d.ts +3 -1
  52. package/dist/Accesories/LeakSensorAccessory.d.ts.map +1 -1
  53. package/dist/Accesories/LeakSensorAccessory.js +32 -4
  54. package/dist/Accesories/LeakSensorAccessory.js.map +1 -1
  55. package/dist/Accesories/LightBulbAccesory.d.ts +3 -2
  56. package/dist/Accesories/LightBulbAccesory.d.ts.map +1 -1
  57. package/dist/Accesories/LightBulbAccesory.js +41 -12
  58. package/dist/Accesories/LightBulbAccesory.js.map +1 -1
  59. package/dist/Accesories/PressureAccessory.d.ts +3 -1
  60. package/dist/Accesories/PressureAccessory.d.ts.map +1 -1
  61. package/dist/Accesories/PressureAccessory.js +28 -5
  62. package/dist/Accesories/PressureAccessory.js.map +1 -1
  63. package/dist/Accesories/RGBLightBulbAccesory.d.ts +9 -0
  64. package/dist/Accesories/RGBLightBulbAccesory.d.ts.map +1 -1
  65. package/dist/Accesories/RGBLightBulbAccesory.js +137 -17
  66. package/dist/Accesories/RGBLightBulbAccesory.js.map +1 -1
  67. package/dist/Accesories/RollerShutterAccessory.d.ts +6 -0
  68. package/dist/Accesories/RollerShutterAccessory.d.ts.map +1 -1
  69. package/dist/Accesories/RollerShutterAccessory.js +219 -74
  70. package/dist/Accesories/RollerShutterAccessory.js.map +1 -1
  71. package/dist/Accesories/SwitchAccessory.d.ts +3 -2
  72. package/dist/Accesories/SwitchAccessory.d.ts.map +1 -1
  73. package/dist/Accesories/SwitchAccessory.js +41 -12
  74. package/dist/Accesories/SwitchAccessory.js.map +1 -1
  75. package/dist/Accesories/TemperatureAccessory.d.ts +3 -1
  76. package/dist/Accesories/TemperatureAccessory.d.ts.map +1 -1
  77. package/dist/Accesories/TemperatureAccessory.js +28 -5
  78. package/dist/Accesories/TemperatureAccessory.js.map +1 -1
  79. package/dist/Accesories/TemperatureHumidityAccessory.d.ts +5 -1
  80. package/dist/Accesories/TemperatureHumidityAccessory.d.ts.map +1 -1
  81. package/dist/Accesories/TemperatureHumidityAccessory.js +40 -9
  82. package/dist/Accesories/TemperatureHumidityAccessory.js.map +1 -1
  83. package/dist/Accesories/ThermostatAccessory.d.ts +18 -1
  84. package/dist/Accesories/ThermostatAccessory.d.ts.map +1 -1
  85. package/dist/Accesories/ThermostatAccessory.js +193 -34
  86. package/dist/Accesories/ThermostatAccessory.js.map +1 -1
  87. package/dist/Accesories/ValveAccessory.d.ts +5 -2
  88. package/dist/Accesories/ValveAccessory.d.ts.map +1 -1
  89. package/dist/Accesories/ValveAccessory.js +47 -8
  90. package/dist/Accesories/ValveAccessory.js.map +1 -1
  91. package/dist/Accesories/WicketAccesory.d.ts +18 -3
  92. package/dist/Accesories/WicketAccesory.d.ts.map +1 -1
  93. package/dist/Accesories/WicketAccesory.js +154 -14
  94. package/dist/Accesories/WicketAccesory.js.map +1 -1
  95. package/dist/Heplers/ColorConverters.d.ts +2 -2
  96. package/dist/Heplers/ColorConverters.d.ts.map +1 -1
  97. package/dist/Heplers/ColorConverters.js +7 -3
  98. package/dist/Heplers/ColorConverters.js.map +1 -1
  99. package/dist/Heplers/DisplayName.d.ts +15 -0
  100. package/dist/Heplers/DisplayName.d.ts.map +1 -0
  101. package/dist/Heplers/DisplayName.js +174 -0
  102. package/dist/Heplers/DisplayName.js.map +1 -0
  103. package/dist/Heplers/SuplaChannelContext.d.ts +3 -1
  104. package/dist/Heplers/SuplaChannelContext.d.ts.map +1 -1
  105. package/dist/Heplers/SuplaChannelContext.js +3 -1
  106. package/dist/Heplers/SuplaChannelContext.js.map +1 -1
  107. package/dist/Heplers/SuplaMqttClient.d.ts +12 -4
  108. package/dist/Heplers/SuplaMqttClient.d.ts.map +1 -1
  109. package/dist/Heplers/SuplaMqttClient.js +214 -112
  110. package/dist/Heplers/SuplaMqttClient.js.map +1 -1
  111. package/dist/platform.d.ts +58 -3
  112. package/dist/platform.d.ts.map +1 -1
  113. package/dist/platform.js +1087 -115
  114. package/dist/platform.js.map +1 -1
  115. package/package.json +7 -3
  116. package/config.json +0 -3
package/dist/platform.js CHANGED
@@ -38,6 +38,8 @@ 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");
41
43
  const SuplaMqttClient_1 = require("./Heplers/SuplaMqttClient");
42
44
  const RGBLightBulbAccesory_1 = require("./Accesories/RGBLightBulbAccesory");
43
45
  const GateAccessory_1 = require("./Accesories/GateAccessory");
@@ -58,6 +60,7 @@ const ThermostatAccessory_1 = require("./Accesories/ThermostatAccessory");
58
60
  const ElectricityMeterAccessory_1 = require("./Accesories/ElectricityMeterAccessory");
59
61
  const DimmerRgbLightAccessory_1 = require("./Accesories/DimmerRgbLightAccessory");
60
62
  const ActionTriggerAccessory_1 = require("./Accesories/ActionTriggerAccessory");
63
+ const DisplayName_1 = require("./Heplers/DisplayName");
61
64
  /**
62
65
  * HomebridgePlatform
63
66
  * This class is the main constructor for your plugin, this is where you should
@@ -78,27 +81,35 @@ class SuplaPlatform {
78
81
  this.mqttTransportHandlers = new Map();
79
82
  this.channelRegistry = new Map();
80
83
  this.ownerCleanups = new Map();
84
+ this.shutdownDrains = new Map();
85
+ this.gateLockFailSafeTasks = new Map();
81
86
  this.mqttDesiredSubscriptions = new Set();
82
87
  this.mqttSubscriptions = new Set();
83
88
  this.mqttPendingSubscriptions = new Set();
89
+ this.mqttPendingSubscriptionOperations = new Map();
84
90
  this.mqttRetryTimers = new Map();
85
91
  this.mqttRetryState = new Map();
86
92
  this.mqttRouterAttached = false;
87
93
  this.mqttTransportConnected = false;
94
+ this.mqttTransportEpoch = 0;
95
+ this.nextMqttSubscriptionOperationId = 0;
96
+ this.shuttingDown = false;
97
+ this.publishTimeoutMs = 5000;
88
98
  this.log.debug('Finished initializing platform:', this.config.name);
99
+ this.accountNamespace = this.computeAccountNamespace();
89
100
  const configView = this.config;
90
101
  this.coveringControlMode = this.normalizeCoveringControlMode(configView.coveringControlMode);
91
102
  this.coveringSetTopicSuffix = this.normalizeTopicSuffix(configView.coveringSetTopicSuffix || 'set/closing_percentage');
92
103
  this.coveringTiltTopicSuffix = this.normalizeTopicSuffix(configView.coveringTiltTopicSuffix || 'set/tilt');
93
- this.coveringExecuteActionOpen = (configView.coveringExecuteActionOpen || 'reveal').toString();
94
- this.coveringExecuteActionClose = (configView.coveringExecuteActionClose || 'shut').toString();
95
- this.coveringExecuteActionStop = (configView.coveringExecuteActionStop || 'stop').toString();
96
- this.coveringTravelTimeSeconds = Number(configView.coveringTravelTimeSeconds) || 0;
97
- this.gateExecuteActionToggle = (configView.gateExecuteActionToggle || 'open_close').toString();
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';
98
109
  this.gateLockControlMode = this.normalizeGateLockControlMode(configView.gateLockControlMode);
99
- this.gateLockExecuteAction = (configView.gateLockExecuteAction || 'open').toString();
110
+ this.gateLockExecuteAction = String(configView.gateLockExecuteAction ?? '').trim() || 'open';
100
111
  this.gateLockSetTopicSuffix = this.normalizeTopicSuffix(configView.gateLockSetTopicSuffix || 'set/on');
101
- this.gateLockPulseSeconds = Number(configView.gateLockPulseSeconds) || 0;
112
+ this.gateLockPulseSeconds = this.normalizeDurationSeconds(configView.gateLockPulseSeconds, this.gateLockControlMode === 'set_on_pulse' ? 1 : 0, this.gateLockControlMode === 'set_on_pulse' ? 0.1 : 0, 60);
102
113
  this.gateLockSetOnPayload = (configView.gateLockSetOnPayload ?? 'true').toString();
103
114
  this.gateLockSetOffPayload = (configView.gateLockSetOffPayload ?? 'false').toString();
104
115
  this.frontGateFullTravelMs = this.normalizeFrontGateFullTravelMs(configView.frontGateFullTravelMs);
@@ -113,55 +124,147 @@ class SuplaPlatform {
113
124
  this.commandQos = this.normalizeCommandQos(configView.commandQos);
114
125
  this.commandRetain = this.parseBoolean(configView.commandRetain ?? false);
115
126
  this.api.on('shutdown', () => {
116
- this.unregisterAllMqttHandlers();
117
- if (this.MqttClient) {
118
- this.MqttClient.client.end(true);
119
- }
127
+ void this.shutdownGracefully();
120
128
  });
121
129
  this.api.on('didFinishLaunching', () => {
122
130
  log.debug('Executed didFinishLaunching callback');
123
- const mqttSettings = this.config;
124
- this.MqttClient = new SuplaMqttClient_1.SuplaMqttClient(mqttSettings, this.log);
125
- this.startMqttRouter();
126
- this.MqttClient.client.on('connect', () => {
127
- this.updateMqttTransportState(true);
128
- this.resubscribeAll(true);
129
- });
130
- this.MqttClient.client.on('close', () => {
131
- this.updateMqttTransportState(false);
132
- this.clearActiveSubscriptions();
133
- });
134
- this.MqttClient.client.on('offline', () => {
135
- this.updateMqttTransportState(false);
136
- this.clearActiveSubscriptions();
137
- });
138
- this.MqttClient.client.on('end', () => {
139
- this.updateMqttTransportState(false);
140
- this.clearActiveSubscriptions();
141
- });
142
- if (this.MqttClient.client.connected) {
143
- this.updateMqttTransportState(true);
144
- this.resubscribeAll(true);
145
- }
146
- this.discoverDevices();
147
- this.MqttClient.discoverChannelsAsync((topic, handler) => (this.registerMqttHandler(topic, handler, 'discovery'))).then((channels) => {
148
- this.persistChannels(channels);
149
- this.discoverDevices(channels);
150
- this.log.info('Channels discovered and saved to config file');
151
- }).catch((error) => {
152
- this.log.error(`Channel discovery failed: ${error.message}`);
131
+ void this.start().catch((error) => {
132
+ this.log.error(`Supla startup failed: ${error.message}`);
153
133
  });
154
134
  });
155
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
+ });
240
+ }
156
241
  /**
157
242
  * This function is invoked when homebridge restores cached accessories from disk at startup.
158
243
  * It should be used to setup event handlers for characteristics and update respective values.
159
244
  */
160
245
  configureAccessory(accessory) {
161
246
  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
+ }
162
266
  // add the restored accessory to the accessories cache so we can track if it has already been registered
163
267
  this.accessories.push(accessory);
164
- const cachedDevice = accessory.context.device;
165
268
  if (!cachedDevice || typeof cachedDevice !== 'object') {
166
269
  this.log.warn(`Cached accessory ${accessory.displayName} missing device context; will configure after discovery.`);
167
270
  accessory.context.deviceConfigured = false;
@@ -169,6 +272,16 @@ class SuplaPlatform {
169
272
  }
170
273
  const normalized = this.normalizeChannelContext(cachedDevice);
171
274
  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);
172
285
  this.addChannelToRegistry(normalized);
173
286
  // Accessory construction is intentionally deferred until didFinishLaunching.
174
287
  // Homebridge restores cached accessories one at a time, so configuring a gate
@@ -180,26 +293,54 @@ class SuplaPlatform {
180
293
  * Accessories must only be registered once, previously created accessories
181
294
  * must not be registered again to prevent "duplicate UUID" errors.
182
295
  */
183
- discoverDevices(channelsOverride) {
184
- const rawChannels = channelsOverride ?? this.loadChannelsFromConfig();
185
- let channels = rawChannels.map(channel => this.normalizeChannelContext(channel));
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));
186
311
  if (channels.length === 0 && channelsOverride === undefined) {
187
312
  channels = this.accessories
313
+ .filter(accessory => this.isAccessoryAccountCompatible(accessory, accessory.context.device))
188
314
  .map(accessory => accessory.context.device)
189
315
  .filter((channel) => Boolean(channel))
190
316
  .map(channel => this.normalizeChannelContext(channel));
191
317
  }
192
318
  channels = this.deduplicateChannels(channels);
193
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
+ };
194
333
  this.log.info('Channels discovered:', channels.length);
195
334
  this.log.debug(`Discovery mode: ${channelsOverride ? 'live' : 'cached'} channels`);
196
335
  const channelUuids = new Set();
197
- const shouldPrune = channelsOverride !== undefined && channels.length > 0;
198
- const findExistingByIds = (candidate) => {
336
+ const findExistingByIds = (candidate, scopedUuid) => {
199
337
  if (candidate.deviceId === 'unknown' || candidate.channelId === 'unknown') {
200
338
  return undefined;
201
339
  }
202
- return this.accessories.find((accessory) => {
340
+ const matches = this.accessories.filter((accessory) => {
341
+ if (!this.isAccessoryAccountCompatible(accessory, accessory.context.device)) {
342
+ return false;
343
+ }
203
344
  const existing = accessory.context.device;
204
345
  if (!existing) {
205
346
  return false;
@@ -209,11 +350,32 @@ class SuplaPlatform {
209
350
  }
210
351
  return existing.deviceId === candidate.deviceId && existing.channelId === candidate.channelId;
211
352
  });
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;
212
373
  };
213
374
  // loop over the discovered devices and register each one if it has not already been registered
214
375
  for (const channel of channels) {
215
- const existingByIds = findExistingByIds(channel);
216
- const uuid = existingByIds?.UUID ?? this.getChannelUuid(channel);
376
+ const scopedUuid = this.getChannelUuid(channel);
377
+ const existingByIds = findExistingByIds(channel, scopedUuid);
378
+ const uuid = existingByIds?.UUID ?? scopedUuid;
217
379
  channelUuids.add(uuid);
218
380
  const existingAccessory = existingByIds ?? this.accessories.find(accessory => accessory.UUID === uuid);
219
381
  if (existingAccessory) {
@@ -221,45 +383,163 @@ class SuplaPlatform {
221
383
  const signature = this.getChannelSignature(channel);
222
384
  const previousSignature = existingAccessory.context.deviceSignature;
223
385
  const wasConfigured = existingAccessory.context.deviceConfigured === true;
224
- existingAccessory.context.device = channel;
225
- existingAccessory.context.deviceSignature = signature;
226
386
  this.log.debug(`Restoring channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
227
387
  `function=${channel.channelFunction} type=${channel.channelType}`);
388
+ if (!this.isSupportedChannel(channel)) {
389
+ if (this.isExplicitlyUnsupportedChannel(channel)) {
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
+ }
228
405
  if (previousSignature !== signature || !wasConfigured) {
229
- if (previousSignature && previousSignature !== signature) {
230
- this.resetAccessoryServices(existingAccessory);
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)) {
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;
231
469
  }
232
- const configured = this.setupAccessory(channel, existingAccessory);
233
- existingAccessory.context.deviceConfigured = configured;
234
470
  }
235
- this.api.updatePlatformAccessories([existingAccessory]);
236
471
  continue;
237
472
  }
238
473
  this.log.info('Adding new accessory:', channel.channelCaption);
239
474
  this.log.debug(`Registering channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
240
475
  `function=${channel.channelFunction} type=${channel.channelType}`);
241
- const accessory = new this.api.platformAccessory(channel.channelCaption, uuid);
242
- accessory.context.device = channel;
243
- accessory.context.deviceSignature = this.getChannelSignature(channel);
244
- if (this.setupAccessory(channel, accessory)) {
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);
245
489
  accessory.context.deviceConfigured = true;
490
+ this.stampAccessoryIdentity(accessory);
491
+ this.updateAccessoryNames(accessory, channel);
246
492
  this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
493
+ registered = true;
247
494
  this.accessories.push(accessory);
248
495
  this.api.updatePlatformAccessories([accessory]);
249
496
  }
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
+ }
250
514
  }
251
515
  if (shouldPrune) {
252
516
  const accessoriesToRemove = this.accessories.filter(accessory => !channelUuids.has(accessory.UUID));
253
517
  for (const accessory of accessoriesToRemove) {
254
518
  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
+ }
255
533
  this.unregisterMqttHandlers(accessory.UUID);
256
- this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
257
534
  const index = this.accessories.indexOf(accessory);
258
535
  if (index !== -1) {
259
536
  this.accessories.splice(index, 1);
260
537
  }
261
538
  }
262
539
  }
540
+ if (channelsOverride !== undefined) {
541
+ this.persistChannels(channelsToPersist);
542
+ }
263
543
  }
264
544
  loadChannelsFromConfig() {
265
545
  const rawChannels = this.config.channels;
@@ -282,32 +562,159 @@ class SuplaPlatform {
282
562
  this.log.warn('Cached channels format is invalid; expected string or array.');
283
563
  return [];
284
564
  }
285
- persistChannels(channels) {
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;
286
572
  try {
287
- const configPath = this.api.user.configPath();
288
- const config = JSON.parse(fs.readFileSync(configPath).toString());
289
- const platformConfig = config.platforms?.find((platform) => platform.platform === settings_1.PLATFORM_NAME);
290
- if (!platformConfig) {
291
- this.log.warn(`Failed to save channels: ${settings_1.PLATFORM_NAME} not found in config.`);
292
- return;
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}.`);
293
599
  }
294
- platformConfig.channels = JSON.stringify(channels);
295
- this.config.channels = platformConfig.channels;
296
- const payload = JSON.stringify(config, null, 2);
297
- const tempPath = `${configPath}.tmp`;
298
- fs.writeFileSync(tempPath, payload);
299
- fs.renameSync(tempPath, configPath);
300
- this.log.debug(`Saved ${channels.length} channels to config.`);
301
600
  }
302
601
  catch (error) {
303
- this.log.error(`Failed to save channels: ${error.message}`);
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.');
304
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
+ persistChannels(channels) {
622
+ 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.`);
634
+ }
635
+ catch (error) {
636
+ this.log.error(`Failed to save channel cache: ${error.message}`);
637
+ }
638
+ }
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;
305
712
  }
306
713
  normalizeChannelContext(channel) {
307
714
  const rawTopic = (channel.rawTopic ?? channel.topic ?? '').toString();
308
715
  const baseFromTopic = this.normalizeTopicBase(rawTopic || (channel.topic ?? '').toString());
309
- let deviceId = channel.deviceId;
310
- let channelId = channel.channelId;
716
+ let deviceId = channel.deviceId?.toString().trim();
717
+ let channelId = channel.channelId?.toString().trim();
311
718
  if (!deviceId || !channelId) {
312
719
  const match = baseFromTopic.match(/\/devices\/(\d+)\/channels\/(\d+)(?:\/|$)/);
313
720
  if (match) {
@@ -321,23 +728,38 @@ class SuplaPlatform {
321
728
  if (!channelId) {
322
729
  channelId = 'unknown';
323
730
  }
324
- const caption = channel.channelCaption || `Device ${deviceId} Channel ${channelId}`;
325
- const channelType = channel.channelType || 'UNKNOWN';
326
- const channelFunction = channel.channelFunction || 'UNKNOWN';
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';
327
741
  const mqttContext = this.config;
328
- const baseTopic = baseFromTopic || `supla/${mqttContext.username}/devices/${deviceId}/channels/${channelId}`;
742
+ const hasStableIds = deviceId !== 'unknown' && channelId !== 'unknown';
743
+ const baseTopic = baseFromTopic || (hasStableIds
744
+ ? `supla/${mqttContext.username}/devices/${deviceId}/channels/${channelId}`
745
+ : '');
329
746
  if (deviceId === 'unknown' || channelId === 'unknown') {
330
747
  this.log.warn(`Channel missing device/channel id for topic ${rawTopic}`);
331
748
  }
332
- return new SuplaChannelContext_1.SuplaChannelContext(baseTopic, channelType, channelFunction, caption, deviceId, channelId, rawTopic || undefined);
749
+ return new SuplaChannelContext_1.SuplaChannelContext(baseTopic, channelType, channelFunction, caption, deviceId, channelId, rawTopic || undefined, captionAuthoritative, captionSource);
333
750
  }
334
751
  getChannelUuid(channel) {
335
752
  const deviceId = channel.deviceId;
336
753
  const channelId = channel.channelId;
337
754
  const hasIds = deviceId && channelId && deviceId !== 'unknown' && channelId !== 'unknown';
338
- const key = hasIds
755
+ const stableTopic = this.normalizeTopicBase(channel.topic || channel.rawTopic || '');
756
+ const channelKey = hasIds
339
757
  ? `${deviceId}:${channelId}`
340
- : (channel.topic || channel.channelCaption || `${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}`;
341
763
  return this.api.hap.uuid.generate(key);
342
764
  }
343
765
  getChannelSignature(channel) {
@@ -393,18 +815,346 @@ class SuplaPlatform {
393
815
  }
394
816
  return Array.from(byKey.values());
395
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
+ }
396
858
  getKnownChannels() {
397
859
  return Array.from(this.channelRegistry.values());
398
860
  }
399
- resetAccessoryServices(accessory) {
400
- const keepUuid = this.Service.AccessoryInformation.UUID;
861
+ isSupportedChannel(channel) {
862
+ if (this.isExplicitNoFunction(channel.channelFunction)) {
863
+ return false;
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) {
871
+ return this.isExplicitNoFunction(channel.channelFunction)
872
+ || (this.isKnownMetadata(channel.channelFunction)
873
+ && this.isKnownMetadata(channel.channelType)
874
+ && this.getExpectedServiceUuids(channel).size === 0);
875
+ }
876
+ getExpectedServiceUuids(channel, accessory) {
877
+ if (this.isExplicitNoFunction(channel.channelFunction)) {
878
+ return new Set();
879
+ }
880
+ const byFunction = {
881
+ CONTROLLINGTHEGARAGEDOOR: [this.Service.GarageDoorOpener.UUID],
882
+ CONTROLLINGTHEGATE: [this.Service.GarageDoorOpener.UUID],
883
+ CONTROLLINGTHEGATEWAYLOCK: [this.Service.LockMechanism.UUID],
884
+ LIGHTSWITCH: [this.Service.Lightbulb.UUID],
885
+ POWERSWITCH: [this.Service.Switch.UUID],
886
+ RGBLIGHTING: [this.Service.Lightbulb.UUID],
887
+ DIMMER: [this.Service.Lightbulb.UUID],
888
+ DIMMERANDRGBLIGHTING: [this.Service.Lightbulb.UUID],
889
+ CONTROLLINGTHEROLLERSHUTTER: [this.Service.WindowCovering.UUID],
890
+ CONTROLLINGTHEFACADEBLIND: [this.Service.WindowCovering.UUID],
891
+ OPENINGSENSOR_GATE: [this.Service.ContactSensor.UUID],
892
+ OPENINGSENSOR_GATEWAY: [this.Service.ContactSensor.UUID],
893
+ OPENINGSENSOR_WINDOW: [this.Service.ContactSensor.UUID],
894
+ GENERAL_PURPOSE_MEASUREMENT: [this.Service.AirQualitySensor.UUID],
895
+ THERMOMETER: [this.Service.TemperatureSensor.UUID],
896
+ HUMIDITYANDTEMPERATURE: [this.Service.TemperatureSensor.UUID, this.Service.HumiditySensor.UUID],
897
+ PRESSURESENSOR: [this.Service.LightSensor.UUID],
898
+ VALVEOPENCLOSE: [this.Service.Valve.UUID],
899
+ HVAC_THERMOSTAT: [this.Service.Thermostat.UUID],
900
+ ELECTRICITYMETER: [this.Service.LightSensor.UUID],
901
+ ACTION_TRIGGER: [this.Service.StatelessProgrammableSwitch.UUID],
902
+ };
903
+ const byType = {
904
+ BINARYSENSOR: [
905
+ this.isLeakSensorChannel(channel, accessory)
906
+ ? this.Service.LeakSensor.UUID
907
+ : this.Service.ContactSensor.UUID,
908
+ ],
909
+ THERMOMETER: [this.Service.TemperatureSensor.UUID],
910
+ THERMOMETERDS18B20: [this.Service.TemperatureSensor.UUID],
911
+ HUMIDITYANDTEMPSENSOR: [this.Service.TemperatureSensor.UUID, this.Service.HumiditySensor.UUID],
912
+ GENERAL_PURPOSE_MEASUREMENT: [this.Service.AirQualitySensor.UUID],
913
+ PRESSURESENSOR: [this.Service.LightSensor.UUID],
914
+ ELECTRICITYMETER: [this.Service.LightSensor.UUID],
915
+ ACTION_TRIGGER: [this.Service.StatelessProgrammableSwitch.UUID],
916
+ };
917
+ return new Set(byFunction[channel.channelFunction] ?? byType[channel.channelType] ?? []);
918
+ }
919
+ isExplicitNoFunction(value) {
920
+ return value?.trim().toUpperCase() === 'NONE';
921
+ }
922
+ cleanupObsoleteServices(accessory, channel) {
923
+ const expected = this.getExpectedServiceUuids(channel, accessory);
924
+ const knownPluginServiceUuids = new Set([
925
+ this.Service.GarageDoorOpener.UUID,
926
+ this.Service.LockMechanism.UUID,
927
+ this.Service.Lightbulb.UUID,
928
+ this.Service.Switch.UUID,
929
+ this.Service.WindowCovering.UUID,
930
+ this.Service.Door.UUID,
931
+ this.Service.ContactSensor.UUID,
932
+ this.Service.LeakSensor.UUID,
933
+ this.Service.TemperatureSensor.UUID,
934
+ this.Service.HumiditySensor.UUID,
935
+ this.Service.AirQualitySensor.UUID,
936
+ this.Service.LightSensor.UUID,
937
+ this.Service.Valve.UUID,
938
+ this.Service.Thermostat.UUID,
939
+ this.Service.StatelessProgrammableSwitch.UUID,
940
+ ]);
941
+ for (const service of [...accessory.services]) {
942
+ if (knownPluginServiceUuids.has(service.UUID) && !expected.has(service.UUID)) {
943
+ accessory.removeService(service);
944
+ }
945
+ }
946
+ }
947
+ prepareAccessoryForReconfiguration(accessory, channel) {
948
+ this.unregisterMqttHandlers(accessory.UUID);
949
+ this.cleanupObsoleteServices(accessory, channel);
950
+ const expectedServices = this.getExpectedServiceUuids(channel, accessory);
401
951
  for (const service of accessory.services) {
402
- if (service.UUID === keepUuid) {
952
+ if (!expectedServices.has(service.UUID)) {
403
953
  continue;
404
954
  }
405
- accessory.removeService(service);
955
+ for (const characteristic of service.characteristics) {
956
+ characteristic.removeOnGet();
957
+ characteristic.removeOnSet();
958
+ }
959
+ this.removeObsoleteOptionalCharacteristics(service, channel);
960
+ }
961
+ }
962
+ removeObsoleteOptionalCharacteristics(service, channel) {
963
+ const obsolete = new Set();
964
+ const servicesWithoutStatusFault = new Set([
965
+ this.Service.GarageDoorOpener.UUID,
966
+ this.Service.LockMechanism.UUID,
967
+ this.Service.Lightbulb.UUID,
968
+ this.Service.Switch.UUID,
969
+ this.Service.WindowCovering.UUID,
970
+ this.Service.Door.UUID,
971
+ this.Service.Thermostat.UUID,
972
+ this.Service.StatelessProgrammableSwitch.UUID,
973
+ ]);
974
+ if (servicesWithoutStatusFault.has(service.UUID)) {
975
+ obsolete.add(this.Characteristic.StatusFault.UUID);
976
+ }
977
+ if (service.UUID === this.Service.Lightbulb.UUID) {
978
+ if (channel.channelFunction === 'LIGHTSWITCH') {
979
+ obsolete.add(this.Characteristic.Brightness.UUID);
980
+ obsolete.add(this.Characteristic.Hue.UUID);
981
+ obsolete.add(this.Characteristic.Saturation.UUID);
982
+ }
983
+ else if (channel.channelFunction === 'DIMMER') {
984
+ obsolete.add(this.Characteristic.Hue.UUID);
985
+ obsolete.add(this.Characteristic.Saturation.UUID);
986
+ }
987
+ }
988
+ if (service.UUID === this.Service.WindowCovering.UUID
989
+ && channel.channelFunction === 'CONTROLLINGTHEROLLERSHUTTER') {
990
+ obsolete.add(this.Characteristic.CurrentHorizontalTiltAngle.UUID);
991
+ obsolete.add(this.Characteristic.TargetHorizontalTiltAngle.UUID);
992
+ }
993
+ if (service.UUID === this.Service.WindowCovering.UUID) {
994
+ // StatusJammed is not part of the HAP WindowCovering service. Older
995
+ // versions added it dynamically; remove that invalid composition.
996
+ obsolete.add(this.Characteristic.StatusJammed.UUID);
997
+ }
998
+ for (const characteristic of [...service.characteristics]) {
999
+ if (obsolete.has(characteristic.UUID)) {
1000
+ service.removeCharacteristic(characteristic);
1001
+ }
1002
+ }
1003
+ }
1004
+ revokeAccessoryHandlers(accessory) {
1005
+ this.unregisterMqttHandlers(accessory.UUID);
1006
+ const unavailable = new this.api.hap.HapStatusError(-70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
1007
+ for (const service of accessory.services) {
1008
+ if (service.UUID === this.Service.AccessoryInformation.UUID) {
1009
+ continue;
1010
+ }
1011
+ for (const characteristic of service.characteristics) {
1012
+ characteristic.removeOnGet();
1013
+ characteristic.removeOnSet();
1014
+ if (characteristic.props.perms.includes("pr" /* this.api.hap.Perms.PAIRED_READ */)) {
1015
+ characteristic.updateValue(unavailable);
1016
+ }
1017
+ if (characteristic.props.perms.includes("pw" /* this.api.hap.Perms.PAIRED_WRITE */)) {
1018
+ characteristic.onSet(() => {
1019
+ throw new this.api.hap.HapStatusError(-70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
1020
+ });
1021
+ }
1022
+ }
1023
+ }
1024
+ }
1025
+ resolveHomeKitDisplayName(channel, currentName) {
1026
+ const captionName = channel.channelCaptionAuthoritative === true
1027
+ ? (0, DisplayName_1.toHomeKitDisplayName)(channel.channelCaption)
1028
+ : undefined;
1029
+ const hasRenameEvidence = channel.channelCaptionSource === 'retained'
1030
+ || channel.channelCaptionSource === 'authoritative_snapshot';
1031
+ if (captionName && hasRenameEvidence) {
1032
+ return captionName;
1033
+ }
1034
+ const safeCurrentName = (0, DisplayName_1.toHomeKitDisplayName)(currentName);
1035
+ if (safeCurrentName) {
1036
+ return safeCurrentName;
1037
+ }
1038
+ // A new accessory (or a damaged empty legacy display name) may use its
1039
+ // complete cached caption as an initial/recovery label. This is not allowed
1040
+ // to replace an already meaningful existing name.
1041
+ if (captionName) {
1042
+ return captionName;
1043
+ }
1044
+ return (0, DisplayName_1.toHomeKitDisplayName)((0, DisplayName_1.generatedChannelCaption)(channel.deviceId, channel.channelId))
1045
+ ?? 'Supla Device';
1046
+ }
1047
+ captureAccessoryNames(accessory) {
1048
+ return {
1049
+ displayName: accessory.displayName,
1050
+ services: accessory.services.map((service) => {
1051
+ const characteristic = service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
1052
+ return {
1053
+ service,
1054
+ displayName: service.displayName,
1055
+ hadNameCharacteristic: characteristic !== undefined,
1056
+ nameValue: typeof characteristic?.value === 'string' ? characteristic.value : undefined,
1057
+ };
1058
+ }),
1059
+ };
1060
+ }
1061
+ restoreAccessoryNames(accessory, snapshot) {
1062
+ try {
1063
+ accessory.updateDisplayName(snapshot.displayName);
1064
+ }
1065
+ catch (error) {
1066
+ this.log.error(`Failed to restore accessory display name ${snapshot.displayName}: ${error.message}`);
1067
+ }
1068
+ for (const entry of snapshot.services) {
1069
+ try {
1070
+ entry.service.displayName = entry.displayName;
1071
+ const current = entry.service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
1072
+ if (!entry.hadNameCharacteristic) {
1073
+ if (current) {
1074
+ entry.service.removeCharacteristic(current);
1075
+ }
1076
+ }
1077
+ else if (entry.nameValue !== undefined && current?.value !== entry.nameValue) {
1078
+ entry.service.setCharacteristic(this.Characteristic.Name, entry.nameValue);
1079
+ }
1080
+ }
1081
+ catch (error) {
1082
+ this.log.error(`Failed to restore service name for ${snapshot.displayName}: ${error.message}`);
1083
+ }
406
1084
  }
407
1085
  }
1086
+ restoreAccessoryContext(accessory, snapshot) {
1087
+ for (const key of Object.keys(accessory.context)) {
1088
+ delete accessory.context[key];
1089
+ }
1090
+ Object.assign(accessory.context, snapshot);
1091
+ }
1092
+ areChannelContextsEqual(current, incoming) {
1093
+ if (!current) {
1094
+ return false;
1095
+ }
1096
+ const fields = [
1097
+ 'topic',
1098
+ 'channelType',
1099
+ 'channelFunction',
1100
+ 'channelCaption',
1101
+ 'deviceId',
1102
+ 'channelId',
1103
+ 'rawTopic',
1104
+ 'channelCaptionAuthoritative',
1105
+ 'channelCaptionSource',
1106
+ ];
1107
+ return fields.every(field => current[field] === incoming[field]);
1108
+ }
1109
+ updateAccessoryNames(accessory, channel) {
1110
+ let changed = false;
1111
+ const caption = (0, DisplayName_1.normalizeVendorCaption)(channel.channelCaption);
1112
+ const homeKitName = this.resolveHomeKitDisplayName(channel, accessory.displayName);
1113
+ if (accessory.displayName !== homeKitName) {
1114
+ accessory.updateDisplayName(homeKitName);
1115
+ changed = true;
1116
+ }
1117
+ const informationService = accessory.getService(this.Service.AccessoryInformation);
1118
+ const informationName = informationService?.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
1119
+ if (informationService) {
1120
+ if (informationService.displayName !== homeKitName) {
1121
+ informationService.displayName = homeKitName;
1122
+ changed = true;
1123
+ }
1124
+ if (informationName?.value !== homeKitName) {
1125
+ informationService.setCharacteristic(this.Characteristic.Name, homeKitName);
1126
+ changed = true;
1127
+ }
1128
+ }
1129
+ const expected = this.getExpectedServiceUuids(channel, accessory);
1130
+ const electricityMetric = accessory.context.suplaElectricityMetric;
1131
+ for (const service of accessory.services) {
1132
+ if (expected.has(service.UUID)) {
1133
+ const serviceName = channel.channelFunction === 'ELECTRICITYMETER'
1134
+ && typeof electricityMetric === 'string'
1135
+ ? (0, DisplayName_1.homeKitElectricityServiceName)(homeKitName, electricityMetric)
1136
+ : homeKitName;
1137
+ const nameCharacteristic = service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
1138
+ if (service.displayName !== serviceName) {
1139
+ service.displayName = serviceName;
1140
+ changed = true;
1141
+ }
1142
+ if (nameCharacteristic?.value !== serviceName) {
1143
+ service.setCharacteristic(this.Characteristic.Name, serviceName);
1144
+ changed = true;
1145
+ }
1146
+ }
1147
+ }
1148
+ if (accessory.context.suplaVendorCaption !== caption
1149
+ || accessory.context.suplaVendorCaptionAuthoritative !== (channel.channelCaptionAuthoritative === true)
1150
+ || accessory.context.suplaHomeKitDisplayName !== homeKitName) {
1151
+ changed = true;
1152
+ }
1153
+ accessory.context.suplaVendorCaption = caption;
1154
+ accessory.context.suplaVendorCaptionAuthoritative = channel.channelCaptionAuthoritative === true;
1155
+ accessory.context.suplaHomeKitDisplayName = homeKitName;
1156
+ return changed;
1157
+ }
408
1158
  setupAccessory(channel, accessory) {
409
1159
  this.unregisterMqttHandlers(accessory.UUID);
410
1160
  this.log.debug(`Mapping channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
@@ -473,20 +1223,13 @@ class SuplaPlatform {
473
1223
  break;
474
1224
  }
475
1225
  switch (channel.channelType) {
476
- case 'DIMMERANDRGBLED':
477
- new DimmerRgbLightAccessory_1.DimmerRgbLightAccessory(this, accessory, channel);
478
- return true;
479
- case 'DIMMER':
480
- new DimmerAccessory_1.DimmerAccessory(this, accessory, channel);
481
- return true;
482
- case 'RELAY':
483
- new SwitchAccessory_1.SwitchAccessory(this, accessory, channel);
484
- return true;
485
1226
  case 'BINARYSENSOR':
486
- if (this.isLeakSensorChannel(channel)) {
1227
+ if (this.isLeakSensorChannel(channel, accessory)) {
1228
+ accessory.context.suplaBinarySensorKind = 'leak';
487
1229
  new LeakSensorAccessory_1.LeakSensorAccessory(this, accessory, channel);
488
1230
  }
489
1231
  else {
1232
+ accessory.context.suplaBinarySensorKind = 'contact';
490
1233
  new ContactSensorAccessory_1.ContactSensorAccessory(this, accessory, channel);
491
1234
  }
492
1235
  return true;
@@ -515,9 +1258,21 @@ class SuplaPlatform {
515
1258
  this.log.warn(`Unsupported channel ${channel.channelCaption} (${channel.channelFunction}/${channel.channelType})`);
516
1259
  return false;
517
1260
  }
518
- isLeakSensorChannel(channel) {
519
- const caption = (channel.channelCaption ?? '').toLowerCase();
520
- return ['leak', 'flood', 'water', 'zalania'].some(term => caption.includes(term));
1261
+ isLeakSensorChannel(channel, accessory) {
1262
+ const functionName = (channel.channelFunction ?? '').trim().toUpperCase();
1263
+ if (functionName.includes('FLOOD')
1264
+ || functionName.includes('WATERLEAK')
1265
+ || functionName === 'LEAKSENSOR') {
1266
+ return true;
1267
+ }
1268
+ const persistedKind = accessory?.context?.suplaBinarySensorKind;
1269
+ if (persistedKind === 'leak' || persistedKind === 'contact') {
1270
+ return persistedKind === 'leak';
1271
+ }
1272
+ // Preserve the topology of accessories created by older releases, which
1273
+ // inferred this type from a caption. The migrated kind is persisted by
1274
+ // setupAccessory and subsequent captions no longer participate.
1275
+ return accessory?.services?.some(service => service.UUID === this.Service.LeakSensor.UUID) === true;
521
1276
  }
522
1277
  getCoveringControlMode() {
523
1278
  return this.coveringControlMode;
@@ -561,6 +1316,97 @@ class SuplaPlatform {
561
1316
  getGateLockSetOffPayload() {
562
1317
  return this.gateLockSetOffPayload;
563
1318
  }
1319
+ claimGateLockFailSafe(accessoryUuid) {
1320
+ const task = this.gateLockFailSafeTasks.get(accessoryUuid);
1321
+ if (task) {
1322
+ this.unregisterMqttHandlers(task.ownerId);
1323
+ }
1324
+ }
1325
+ transferGateLockFailSafe(accessory, stateTopic, commandTopic, offPayload) {
1326
+ if (this.shuttingDown || accessory.context.suplaGateLockPendingOff !== true) {
1327
+ return;
1328
+ }
1329
+ if (this.gateLockFailSafeTasks.has(accessory.UUID)) {
1330
+ return;
1331
+ }
1332
+ const task = {
1333
+ ownerId: `gate-lock-failsafe:${accessory.UUID}`,
1334
+ accessory,
1335
+ stateTopic,
1336
+ commandTopic,
1337
+ offPayload,
1338
+ attempted: false,
1339
+ };
1340
+ this.gateLockFailSafeTasks.set(accessory.UUID, task);
1341
+ this.registerOwnerCleanup(task.ownerId, () => {
1342
+ if (task.retryTimer) {
1343
+ clearTimeout(task.retryTimer);
1344
+ task.retryTimer = undefined;
1345
+ }
1346
+ if (this.gateLockFailSafeTasks.get(accessory.UUID) === task) {
1347
+ this.gateLockFailSafeTasks.delete(accessory.UUID);
1348
+ }
1349
+ });
1350
+ this.registerMqttHandler(stateTopic, (message, _topic, packet) => {
1351
+ const relayOn = this.parseBooleanStrict(message.toString());
1352
+ if (relayOn !== false || packet?.retain === true || !task.attempted) {
1353
+ return;
1354
+ }
1355
+ accessory.context.suplaGateLockPendingOff = false;
1356
+ const accessoryStillRegistered = Array.isArray(this.accessories)
1357
+ && this.accessories.includes(accessory);
1358
+ try {
1359
+ if (accessoryStillRegistered) {
1360
+ this.api.updatePlatformAccessories([accessory]);
1361
+ }
1362
+ }
1363
+ catch (error) {
1364
+ accessory.context.suplaGateLockPendingOff = true;
1365
+ this.log.error(`Failed to persist transferred gate-lock OFF confirmation for ${accessory.displayName}: ` +
1366
+ `${error.message}`);
1367
+ this.scheduleGateLockFailSafeRetry(task);
1368
+ return;
1369
+ }
1370
+ this.log.info(`Gate-lock fail-safe OFF confirmed for ${accessory.displayName}.`);
1371
+ this.unregisterMqttHandlers(task.ownerId);
1372
+ }, task.ownerId);
1373
+ this.registerMqttTransportHandler((connected) => {
1374
+ if (connected) {
1375
+ this.attemptGateLockFailSafe(task);
1376
+ }
1377
+ }, task.ownerId);
1378
+ }
1379
+ attemptGateLockFailSafe(task) {
1380
+ if (this.shuttingDown
1381
+ || this.gateLockFailSafeTasks.get(task.accessory.UUID) !== task
1382
+ || task.publishInFlight) {
1383
+ return;
1384
+ }
1385
+ task.attempted = true;
1386
+ this.log.warn(`Publishing transferred gate-lock fail-safe OFF for ${task.accessory.displayName}.`);
1387
+ const operation = this.publishActionAsync(task.commandTopic, task.offPayload);
1388
+ task.publishInFlight = operation;
1389
+ void operation.catch((error) => {
1390
+ this.log.warn(`Transferred gate-lock OFF remains unconfirmed for ${task.accessory.displayName}: ` +
1391
+ `${error.message}`);
1392
+ }).finally(() => {
1393
+ if (task.publishInFlight === operation) {
1394
+ task.publishInFlight = undefined;
1395
+ }
1396
+ this.scheduleGateLockFailSafeRetry(task);
1397
+ });
1398
+ }
1399
+ scheduleGateLockFailSafeRetry(task) {
1400
+ if (this.shuttingDown
1401
+ || task.retryTimer
1402
+ || this.gateLockFailSafeTasks.get(task.accessory.UUID) !== task) {
1403
+ return;
1404
+ }
1405
+ task.retryTimer = setTimeout(() => {
1406
+ task.retryTimer = undefined;
1407
+ this.attemptGateLockFailSafe(task);
1408
+ }, 5000);
1409
+ }
564
1410
  getFrontGateConfig() {
565
1411
  return {
566
1412
  fullTravelMs: this.frontGateFullTravelMs,
@@ -598,6 +1444,13 @@ class SuplaPlatform {
598
1444
  }
599
1445
  return 'execute_action';
600
1446
  }
1447
+ normalizeDurationSeconds(value, fallback, min, max) {
1448
+ const parsed = Number(value);
1449
+ if (!Number.isFinite(parsed)) {
1450
+ return fallback;
1451
+ }
1452
+ return Math.min(max, Math.max(min, parsed));
1453
+ }
601
1454
  normalizeFrontGateFullTravelMs(value) {
602
1455
  const fallbackMs = 25000;
603
1456
  const parsed = Number(value);
@@ -724,6 +1577,20 @@ class SuplaPlatform {
724
1577
  }
725
1578
  };
726
1579
  }
1580
+ registerShutdownDrain(ownerId, drain) {
1581
+ const drains = this.shutdownDrains.get(ownerId) ?? new Set();
1582
+ drains.add(drain);
1583
+ this.shutdownDrains.set(ownerId, drains);
1584
+ const cleanup = () => {
1585
+ const active = this.shutdownDrains.get(ownerId);
1586
+ active?.delete(drain);
1587
+ if (active?.size === 0) {
1588
+ this.shutdownDrains.delete(ownerId);
1589
+ }
1590
+ };
1591
+ this.registerOwnerCleanup(ownerId, cleanup);
1592
+ return cleanup;
1593
+ }
727
1594
  unregisterMqttHandlers(ownerId) {
728
1595
  this.runOwnerCleanup(ownerId);
729
1596
  const ownerTopics = this.mqttHandlerOwners.get(ownerId);
@@ -755,14 +1622,70 @@ class SuplaPlatform {
755
1622
  this.mqttDesiredSubscriptions.clear();
756
1623
  this.mqttSubscriptions.clear();
757
1624
  this.mqttPendingSubscriptions.clear();
1625
+ this.mqttPendingSubscriptionOperations.clear();
758
1626
  this.clearAllSubscriptionRetries();
1627
+ this.shutdownDrains.clear();
759
1628
  this.ownerCleanups.clear();
760
1629
  }
761
1630
  publishCommand(topic, payload, callback) {
762
- this.publishMqtt(topic, payload, { qos: this.commandQos, retain: this.commandRetain }, callback);
1631
+ this.publishMqtt(topic, payload, { qos: this.commandQos, retain: this.commandRetain }, error => callback?.(error));
1632
+ }
1633
+ publishCommandAsync(topic, payload) {
1634
+ return this.publishMqttAsync(topic, payload, { qos: this.commandQos, retain: this.commandRetain });
763
1635
  }
764
1636
  publishGateAction(topic, payload, callback) {
765
- this.publishMqtt(topic, payload, { qos: 0, retain: false }, callback);
1637
+ this.publishMqtt(topic, payload, { qos: 0, retain: false }, (error, failureOrigin) => {
1638
+ if (error && failureOrigin === 'callback') {
1639
+ error.mqttPublishFailureKind = 'completion_unknown';
1640
+ }
1641
+ callback?.(error);
1642
+ });
1643
+ }
1644
+ publishActionAsync(topic, payload) {
1645
+ return this.publishMqttAsync(topic, payload, { qos: 0, retain: false }, true);
1646
+ }
1647
+ publishGateActionAsync(topic, payload) {
1648
+ return this.publishActionAsync(topic, payload);
1649
+ }
1650
+ isPublishCompletionUnknown(error) {
1651
+ return error instanceof Error
1652
+ && error.mqttPublishFailureKind === 'completion_unknown';
1653
+ }
1654
+ publishMqttAsync(topic, payload, options, callbackFailureIsAmbiguous = false) {
1655
+ return new Promise((resolve, reject) => {
1656
+ let settled = false;
1657
+ const finish = (kind, error) => {
1658
+ if (settled) {
1659
+ return;
1660
+ }
1661
+ settled = true;
1662
+ clearTimeout(timer);
1663
+ if (!kind || !error) {
1664
+ resolve();
1665
+ return;
1666
+ }
1667
+ this.log.error(`Publish failed for ${topic}: ${error.message}`);
1668
+ const hapError = new this.api.hap.HapStatusError(kind === 'completion_unknown'
1669
+ ? -70408 /* this.api.hap.HAPStatus.OPERATION_TIMED_OUT */
1670
+ : -70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
1671
+ hapError.mqttPublishFailureKind = kind;
1672
+ hapError.cause = error;
1673
+ reject(hapError);
1674
+ };
1675
+ const timer = setTimeout(() => {
1676
+ finish('completion_unknown', new Error(`MQTT publish completion timed out after ${this.publishTimeoutMs}ms`));
1677
+ }, this.publishTimeoutMs);
1678
+ this.publishMqtt(topic, payload, options, (error, failureOrigin) => {
1679
+ if (error) {
1680
+ finish(callbackFailureIsAmbiguous && failureOrigin === 'callback'
1681
+ ? 'completion_unknown'
1682
+ : 'definite_failure', error);
1683
+ }
1684
+ else {
1685
+ finish();
1686
+ }
1687
+ });
1688
+ });
766
1689
  }
767
1690
  publishMqtt(topic, payload, options, callback) {
768
1691
  const client = this.MqttClient?.client;
@@ -770,19 +1693,30 @@ class SuplaPlatform {
770
1693
  const error = new Error('MQTT not connected');
771
1694
  this.log.error(`MQTT not connected; cannot publish ${topic}`);
772
1695
  if (callback) {
773
- callback(error);
1696
+ callback(error, 'preflight');
774
1697
  }
775
1698
  return;
776
1699
  }
777
- client.publish(topic, payload, options, (error) => {
1700
+ try {
1701
+ client.publish(topic, payload, options, (error) => {
1702
+ if (callback) {
1703
+ callback(error, error ? 'callback' : undefined);
1704
+ return;
1705
+ }
1706
+ if (error) {
1707
+ this.log.error(`Publish failed for ${topic}: ${error.message}`);
1708
+ }
1709
+ });
1710
+ }
1711
+ catch (error) {
1712
+ const publishError = error instanceof Error ? error : new Error(String(error));
778
1713
  if (callback) {
779
- callback(error);
780
- return;
1714
+ callback(publishError, 'preflight');
781
1715
  }
782
- if (error) {
783
- this.log.error(`Publish failed for ${topic}: ${error.message}`);
1716
+ else {
1717
+ this.log.error(`Publish failed for ${topic}: ${publishError.message}`);
784
1718
  }
785
- });
1719
+ }
786
1720
  }
787
1721
  parseBoolean(value) {
788
1722
  if (typeof value === 'boolean') {
@@ -825,6 +1759,21 @@ class SuplaPlatform {
825
1759
  }
826
1760
  return undefined;
827
1761
  }
1762
+ parseFiniteNumberStrict(value) {
1763
+ if (typeof value === 'number') {
1764
+ return Number.isFinite(value) ? value : undefined;
1765
+ }
1766
+ if (typeof value !== 'string') {
1767
+ return undefined;
1768
+ }
1769
+ const normalized = value.trim();
1770
+ if (!normalized
1771
+ || !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) {
1772
+ return undefined;
1773
+ }
1774
+ const parsed = Number(normalized);
1775
+ return Number.isFinite(parsed) ? parsed : undefined;
1776
+ }
828
1777
  runOwnerCleanup(ownerId) {
829
1778
  const cleanups = this.ownerCleanups.get(ownerId);
830
1779
  if (!cleanups) {
@@ -856,12 +1805,17 @@ class SuplaPlatform {
856
1805
  }
857
1806
  }
858
1807
  }
1808
+ advanceMqttTransportEpoch() {
1809
+ this.mqttTransportEpoch += 1;
1810
+ }
859
1811
  clearActiveSubscriptions() {
1812
+ this.advanceMqttTransportEpoch();
860
1813
  if (this.mqttSubscriptions.size > 0 || this.mqttPendingSubscriptions.size > 0) {
861
1814
  this.log.debug('MQTT connection lost; clearing active subscriptions.');
862
1815
  }
863
1816
  this.mqttSubscriptions.clear();
864
1817
  this.mqttPendingSubscriptions.clear();
1818
+ this.mqttPendingSubscriptionOperations.clear();
865
1819
  this.clearAllSubscriptionRetries();
866
1820
  }
867
1821
  clearAllSubscriptionRetries() {
@@ -916,9 +1870,11 @@ class SuplaPlatform {
916
1870
  }
917
1871
  this.mqttRetryState.set(topic, state);
918
1872
  this.log.debug(`Retrying MQTT subscribe for ${topic} in ${scheduledDelayMs}ms (${reason}).`);
1873
+ const scheduledEpoch = this.mqttTransportEpoch;
919
1874
  const timer = setTimeout(() => {
920
1875
  this.mqttRetryTimers.delete(topic);
921
- if (!this.mqttDesiredSubscriptions.has(topic)) {
1876
+ if (scheduledEpoch !== this.mqttTransportEpoch
1877
+ || !this.mqttDesiredSubscriptions.has(topic)) {
922
1878
  return;
923
1879
  }
924
1880
  this.ensureSubscribed(topic, true);
@@ -927,7 +1883,7 @@ class SuplaPlatform {
927
1883
  }
928
1884
  isSubscriptionGranted(topic, granted) {
929
1885
  if (!Array.isArray(granted) || granted.length === 0) {
930
- return true;
1886
+ return false;
931
1887
  }
932
1888
  const entry = granted.find((item) => item.topic === topic);
933
1889
  if (!entry) {
@@ -971,11 +1927,25 @@ class SuplaPlatform {
971
1927
  return;
972
1928
  }
973
1929
  this.mqttPendingSubscriptions.add(topic);
1930
+ const client = this.MqttClient.client;
1931
+ const operation = {
1932
+ client,
1933
+ epoch: this.mqttTransportEpoch,
1934
+ id: ++this.nextMqttSubscriptionOperationId,
1935
+ };
1936
+ this.mqttPendingSubscriptionOperations.set(topic, operation);
974
1937
  const subscribeOptions = {
975
1938
  qos: 0,
976
1939
  ...(this.mqttNoLocalTopics.has(topic) && this.mqttProtocolVersion === 5 ? { nl: true } : {}),
977
1940
  };
978
- this.MqttClient.client.subscribe(topic, subscribeOptions, (err, granted) => {
1941
+ client.subscribe(topic, subscribeOptions, (err, granted) => {
1942
+ if (this.mqttPendingSubscriptionOperations.get(topic) !== operation
1943
+ || this.mqttTransportEpoch !== operation.epoch
1944
+ || this.MqttClient?.client !== operation.client
1945
+ || !operation.client.connected) {
1946
+ return;
1947
+ }
1948
+ this.mqttPendingSubscriptionOperations.delete(topic);
979
1949
  this.mqttPendingSubscriptions.delete(topic);
980
1950
  if (err) {
981
1951
  this.logSubscriptionIssue(topic, `MQTT subscribe failed for ${topic}: ${err.message}`);
@@ -1014,6 +1984,7 @@ class SuplaPlatform {
1014
1984
  }
1015
1985
  if (force) {
1016
1986
  this.mqttPendingSubscriptions.clear();
1987
+ this.mqttPendingSubscriptionOperations.clear();
1017
1988
  }
1018
1989
  for (const topic of this.mqttDesiredSubscriptions) {
1019
1990
  this.ensureSubscribed(topic, force);
@@ -1023,6 +1994,7 @@ class SuplaPlatform {
1023
1994
  this.mqttDesiredSubscriptions.delete(topic);
1024
1995
  this.mqttNoLocalTopics.delete(topic);
1025
1996
  this.mqttPendingSubscriptions.delete(topic);
1997
+ this.mqttPendingSubscriptionOperations.delete(topic);
1026
1998
  this.clearSubscriptionRetry(topic);
1027
1999
  if (!this.MqttClient) {
1028
2000
  return;