@marcel2215/homebridge-supla-plugin 2.1.29 → 2.1.31

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 +5 -3
  16. package/dist/Accesories/DimmerAccessory.d.ts.map +1 -1
  17. package/dist/Accesories/DimmerAccessory.js +50 -14
  18. package/dist/Accesories/DimmerAccessory.js.map +1 -1
  19. package/dist/Accesories/DimmerRgbLightAccessory.d.ts +11 -3
  20. package/dist/Accesories/DimmerRgbLightAccessory.d.ts.map +1 -1
  21. package/dist/Accesories/DimmerRgbLightAccessory.js +148 -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 +140 -18
  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 +4 -3
  56. package/dist/Accesories/LightBulbAccesory.d.ts.map +1 -1
  57. package/dist/Accesories/LightBulbAccesory.js +40 -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 +10 -1
  64. package/dist/Accesories/RGBLightBulbAccesory.d.ts.map +1 -1
  65. package/dist/Accesories/RGBLightBulbAccesory.js +136 -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 +4 -3
  72. package/dist/Accesories/SwitchAccessory.d.ts.map +1 -1
  73. package/dist/Accesories/SwitchAccessory.js +40 -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 +59 -3
  112. package/dist/platform.d.ts.map +1 -1
  113. package/dist/platform.js +1119 -116
  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, 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
+ }
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, 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;
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.');
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}`);
304
637
  }
305
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;
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,364 @@ 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, 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);
969
+ for (const service of accessory.services) {
970
+ if (!expectedServices.has(service.UUID)) {
971
+ continue;
972
+ }
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
+ }
1020
+ }
1021
+ }
1022
+ revokeAccessoryHandlers(accessory) {
1023
+ this.unregisterMqttHandlers(accessory.UUID);
1024
+ const unavailable = new this.api.hap.HapStatusError(-70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
401
1025
  for (const service of accessory.services) {
402
- if (service.UUID === keepUuid) {
1026
+ if (service.UUID === this.Service.AccessoryInformation.UUID) {
403
1027
  continue;
404
1028
  }
405
- accessory.removeService(service);
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
+ }
406
1102
  }
407
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
+ }
408
1176
  setupAccessory(channel, accessory) {
409
1177
  this.unregisterMqttHandlers(accessory.UUID);
410
1178
  this.log.debug(`Mapping channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
@@ -480,13 +1248,21 @@ class SuplaPlatform {
480
1248
  new DimmerAccessory_1.DimmerAccessory(this, accessory, channel);
481
1249
  return true;
482
1250
  case 'RELAY':
483
- new SwitchAccessory_1.SwitchAccessory(this, accessory, channel);
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
+ }
484
1258
  return true;
485
1259
  case 'BINARYSENSOR':
486
- if (this.isLeakSensorChannel(channel)) {
1260
+ if (this.isLeakSensorChannel(channel, accessory)) {
1261
+ accessory.context.suplaBinarySensorKind = 'leak';
487
1262
  new LeakSensorAccessory_1.LeakSensorAccessory(this, accessory, channel);
488
1263
  }
489
1264
  else {
1265
+ accessory.context.suplaBinarySensorKind = 'contact';
490
1266
  new ContactSensorAccessory_1.ContactSensorAccessory(this, accessory, channel);
491
1267
  }
492
1268
  return true;
@@ -515,9 +1291,21 @@ class SuplaPlatform {
515
1291
  this.log.warn(`Unsupported channel ${channel.channelCaption} (${channel.channelFunction}/${channel.channelType})`);
516
1292
  return false;
517
1293
  }
518
- isLeakSensorChannel(channel) {
519
- const caption = (channel.channelCaption ?? '').toLowerCase();
520
- return ['leak', 'flood', 'water', 'zalania'].some(term => caption.includes(term));
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;
521
1309
  }
522
1310
  getCoveringControlMode() {
523
1311
  return this.coveringControlMode;
@@ -561,6 +1349,97 @@ class SuplaPlatform {
561
1349
  getGateLockSetOffPayload() {
562
1350
  return this.gateLockSetOffPayload;
563
1351
  }
1352
+ claimGateLockFailSafe(accessoryUuid) {
1353
+ const task = this.gateLockFailSafeTasks.get(accessoryUuid);
1354
+ if (task) {
1355
+ this.unregisterMqttHandlers(task.ownerId);
1356
+ }
1357
+ }
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);
1411
+ }
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
+ });
1431
+ }
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);
1442
+ }
564
1443
  getFrontGateConfig() {
565
1444
  return {
566
1445
  fullTravelMs: this.frontGateFullTravelMs,
@@ -598,6 +1477,13 @@ class SuplaPlatform {
598
1477
  }
599
1478
  return 'execute_action';
600
1479
  }
1480
+ normalizeDurationSeconds(value, fallback, min, max) {
1481
+ const parsed = Number(value);
1482
+ if (!Number.isFinite(parsed)) {
1483
+ return fallback;
1484
+ }
1485
+ return Math.min(max, Math.max(min, parsed));
1486
+ }
601
1487
  normalizeFrontGateFullTravelMs(value) {
602
1488
  const fallbackMs = 25000;
603
1489
  const parsed = Number(value);
@@ -724,6 +1610,20 @@ class SuplaPlatform {
724
1610
  }
725
1611
  };
726
1612
  }
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
+ }
727
1627
  unregisterMqttHandlers(ownerId) {
728
1628
  this.runOwnerCleanup(ownerId);
729
1629
  const ownerTopics = this.mqttHandlerOwners.get(ownerId);
@@ -755,14 +1655,70 @@ class SuplaPlatform {
755
1655
  this.mqttDesiredSubscriptions.clear();
756
1656
  this.mqttSubscriptions.clear();
757
1657
  this.mqttPendingSubscriptions.clear();
1658
+ this.mqttPendingSubscriptionOperations.clear();
758
1659
  this.clearAllSubscriptionRetries();
1660
+ this.shutdownDrains.clear();
759
1661
  this.ownerCleanups.clear();
760
1662
  }
761
1663
  publishCommand(topic, payload, callback) {
762
- this.publishMqtt(topic, payload, { qos: this.commandQos, retain: this.commandRetain }, 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 });
763
1668
  }
764
1669
  publishGateAction(topic, payload, callback) {
765
- this.publishMqtt(topic, payload, { qos: 0, retain: false }, 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
+ });
766
1722
  }
767
1723
  publishMqtt(topic, payload, options, callback) {
768
1724
  const client = this.MqttClient?.client;
@@ -770,19 +1726,30 @@ class SuplaPlatform {
770
1726
  const error = new Error('MQTT not connected');
771
1727
  this.log.error(`MQTT not connected; cannot publish ${topic}`);
772
1728
  if (callback) {
773
- callback(error);
1729
+ callback(error, 'preflight');
774
1730
  }
775
1731
  return;
776
1732
  }
777
- client.publish(topic, payload, options, (error) => {
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));
778
1746
  if (callback) {
779
- callback(error);
780
- return;
1747
+ callback(publishError, 'preflight');
781
1748
  }
782
- if (error) {
783
- this.log.error(`Publish failed for ${topic}: ${error.message}`);
1749
+ else {
1750
+ this.log.error(`Publish failed for ${topic}: ${publishError.message}`);
784
1751
  }
785
- });
1752
+ }
786
1753
  }
787
1754
  parseBoolean(value) {
788
1755
  if (typeof value === 'boolean') {
@@ -825,6 +1792,21 @@ class SuplaPlatform {
825
1792
  }
826
1793
  return undefined;
827
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
+ }
828
1810
  runOwnerCleanup(ownerId) {
829
1811
  const cleanups = this.ownerCleanups.get(ownerId);
830
1812
  if (!cleanups) {
@@ -856,12 +1838,17 @@ class SuplaPlatform {
856
1838
  }
857
1839
  }
858
1840
  }
1841
+ advanceMqttTransportEpoch() {
1842
+ this.mqttTransportEpoch += 1;
1843
+ }
859
1844
  clearActiveSubscriptions() {
1845
+ this.advanceMqttTransportEpoch();
860
1846
  if (this.mqttSubscriptions.size > 0 || this.mqttPendingSubscriptions.size > 0) {
861
1847
  this.log.debug('MQTT connection lost; clearing active subscriptions.');
862
1848
  }
863
1849
  this.mqttSubscriptions.clear();
864
1850
  this.mqttPendingSubscriptions.clear();
1851
+ this.mqttPendingSubscriptionOperations.clear();
865
1852
  this.clearAllSubscriptionRetries();
866
1853
  }
867
1854
  clearAllSubscriptionRetries() {
@@ -916,9 +1903,11 @@ class SuplaPlatform {
916
1903
  }
917
1904
  this.mqttRetryState.set(topic, state);
918
1905
  this.log.debug(`Retrying MQTT subscribe for ${topic} in ${scheduledDelayMs}ms (${reason}).`);
1906
+ const scheduledEpoch = this.mqttTransportEpoch;
919
1907
  const timer = setTimeout(() => {
920
1908
  this.mqttRetryTimers.delete(topic);
921
- if (!this.mqttDesiredSubscriptions.has(topic)) {
1909
+ if (scheduledEpoch !== this.mqttTransportEpoch
1910
+ || !this.mqttDesiredSubscriptions.has(topic)) {
922
1911
  return;
923
1912
  }
924
1913
  this.ensureSubscribed(topic, true);
@@ -927,7 +1916,7 @@ class SuplaPlatform {
927
1916
  }
928
1917
  isSubscriptionGranted(topic, granted) {
929
1918
  if (!Array.isArray(granted) || granted.length === 0) {
930
- return true;
1919
+ return false;
931
1920
  }
932
1921
  const entry = granted.find((item) => item.topic === topic);
933
1922
  if (!entry) {
@@ -971,11 +1960,25 @@ class SuplaPlatform {
971
1960
  return;
972
1961
  }
973
1962
  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);
974
1970
  const subscribeOptions = {
975
1971
  qos: 0,
976
1972
  ...(this.mqttNoLocalTopics.has(topic) && this.mqttProtocolVersion === 5 ? { nl: true } : {}),
977
1973
  };
978
- this.MqttClient.client.subscribe(topic, subscribeOptions, (err, granted) => {
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);
979
1982
  this.mqttPendingSubscriptions.delete(topic);
980
1983
  if (err) {
981
1984
  this.logSubscriptionIssue(topic, `MQTT subscribe failed for ${topic}: ${err.message}`);
@@ -1014,6 +2017,7 @@ class SuplaPlatform {
1014
2017
  }
1015
2018
  if (force) {
1016
2019
  this.mqttPendingSubscriptions.clear();
2020
+ this.mqttPendingSubscriptionOperations.clear();
1017
2021
  }
1018
2022
  for (const topic of this.mqttDesiredSubscriptions) {
1019
2023
  this.ensureSubscribed(topic, force);
@@ -1023,18 +2027,17 @@ class SuplaPlatform {
1023
2027
  this.mqttDesiredSubscriptions.delete(topic);
1024
2028
  this.mqttNoLocalTopics.delete(topic);
1025
2029
  this.mqttPendingSubscriptions.delete(topic);
2030
+ this.mqttPendingSubscriptionOperations.delete(topic);
1026
2031
  this.clearSubscriptionRetry(topic);
1027
- if (!this.MqttClient) {
2032
+ const wasSubscribed = this.mqttSubscriptions.delete(topic);
2033
+ if (!wasSubscribed || !this.MqttClient || this.shuttingDown) {
1028
2034
  return;
1029
2035
  }
1030
- if (this.mqttSubscriptions.has(topic)) {
1031
- this.MqttClient.client.unsubscribe(topic, (err) => {
1032
- if (err) {
1033
- this.log.error(`MQTT unsubscribe failed for ${topic}: ${err.message}`);
1034
- }
1035
- });
1036
- this.mqttSubscriptions.delete(topic);
1037
- }
2036
+ this.MqttClient.client.unsubscribe(topic, (err) => {
2037
+ if (err) {
2038
+ this.log.error(`MQTT unsubscribe failed for ${topic}: ${err.message}`);
2039
+ }
2040
+ });
1038
2041
  }
1039
2042
  removeHandler(topic, handler) {
1040
2043
  const handlerMap = this.isWildcardTopic(topic) ? this.mqttWildcardHandlers : this.mqttHandlers;