@marcel2215/homebridge-supla-plugin 2.1.28 → 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 (120) hide show
  1. package/README.md +67 -1
  2. package/config.schema.json +92 -75
  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 +74 -68
  36. package/dist/Accesories/FrontGateFsm.d.ts.map +1 -1
  37. package/dist/Accesories/FrontGateFsm.js +563 -403
  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 +12 -2
  44. package/dist/Accesories/GateAccessory.d.ts.map +1 -1
  45. package/dist/Accesories/GateAccessory.js +245 -90
  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 +221 -75
  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 +11 -3
  108. package/dist/Heplers/SuplaMqttClient.d.ts.map +1 -1
  109. package/dist/Heplers/SuplaMqttClient.js +215 -112
  110. package/dist/Heplers/SuplaMqttClient.js.map +1 -1
  111. package/dist/Heplers/SuplaMqttClientContext.d.ts +2 -1
  112. package/dist/Heplers/SuplaMqttClientContext.d.ts.map +1 -1
  113. package/dist/Heplers/SuplaMqttClientContext.js +2 -1
  114. package/dist/Heplers/SuplaMqttClientContext.js.map +1 -1
  115. package/dist/platform.d.ts +92 -36
  116. package/dist/platform.d.ts.map +1 -1
  117. package/dist/platform.js +1253 -235
  118. package/dist/platform.js.map +1 -1
  119. package/package.json +10 -5
  120. 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
@@ -74,91 +77,194 @@ class SuplaPlatform {
74
77
  this.mqttHandlers = new Map();
75
78
  this.mqttHandlerOwners = new Map();
76
79
  this.mqttWildcardHandlers = new Map();
80
+ this.mqttNoLocalTopics = new Set();
81
+ this.mqttTransportHandlers = new Map();
82
+ this.channelRegistry = new Map();
77
83
  this.ownerCleanups = new Map();
84
+ this.shutdownDrains = new Map();
85
+ this.gateLockFailSafeTasks = new Map();
78
86
  this.mqttDesiredSubscriptions = new Set();
79
87
  this.mqttSubscriptions = new Set();
80
88
  this.mqttPendingSubscriptions = new Set();
89
+ this.mqttPendingSubscriptionOperations = new Map();
81
90
  this.mqttRetryTimers = new Map();
82
91
  this.mqttRetryState = new Map();
83
92
  this.mqttRouterAttached = false;
93
+ this.mqttTransportConnected = false;
94
+ this.mqttTransportEpoch = 0;
95
+ this.nextMqttSubscriptionOperationId = 0;
96
+ this.shuttingDown = false;
97
+ this.publishTimeoutMs = 5000;
84
98
  this.log.debug('Finished initializing platform:', this.config.name);
99
+ this.accountNamespace = this.computeAccountNamespace();
85
100
  const configView = this.config;
86
101
  this.coveringControlMode = this.normalizeCoveringControlMode(configView.coveringControlMode);
87
102
  this.coveringSetTopicSuffix = this.normalizeTopicSuffix(configView.coveringSetTopicSuffix || 'set/closing_percentage');
88
103
  this.coveringTiltTopicSuffix = this.normalizeTopicSuffix(configView.coveringTiltTopicSuffix || 'set/tilt');
89
- this.coveringExecuteActionOpen = (configView.coveringExecuteActionOpen || 'reveal').toString();
90
- this.coveringExecuteActionClose = (configView.coveringExecuteActionClose || 'shut').toString();
91
- this.coveringExecuteActionStop = (configView.coveringExecuteActionStop || 'stop').toString();
92
- this.coveringTravelTimeSeconds = Number(configView.coveringTravelTimeSeconds) || 0;
93
- this.gateControlMode = this.normalizeGateControlMode(configView.gateControlMode);
94
- this.gateExecuteActionOpen = (configView.gateExecuteActionOpen || 'open_close').toString();
95
- this.gateExecuteActionClose = (configView.gateExecuteActionClose || 'open_close').toString();
96
- this.gateExecuteActionToggle = (configView.gateExecuteActionToggle || 'open_close').toString();
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';
97
109
  this.gateLockControlMode = this.normalizeGateLockControlMode(configView.gateLockControlMode);
98
- this.gateLockExecuteAction = (configView.gateLockExecuteAction || 'open').toString();
110
+ this.gateLockExecuteAction = String(configView.gateLockExecuteAction ?? '').trim() || 'open';
99
111
  this.gateLockSetTopicSuffix = this.normalizeTopicSuffix(configView.gateLockSetTopicSuffix || 'set/on');
100
- 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);
101
113
  this.gateLockSetOnPayload = (configView.gateLockSetOnPayload ?? 'true').toString();
102
114
  this.gateLockSetOffPayload = (configView.gateLockSetOffPayload ?? 'false').toString();
103
- this.gatePartialHiMode = this.normalizeGatePartialHiMode(configView.gatePartialHiMode);
104
- this.gateReverseFollowUpDelayMs = this.normalizeGateReverseFollowUpDelayMs(configView.gateReverseFollowUpDelayMs);
105
- this.gateOpenAssumeDelayMs = this.normalizeGateOpenAssumeDelayMs(configView.gateOpenAssumeDelayMs);
106
- this.gateCommandCooldownMs = this.normalizeGateCommandCooldownMs(configView.gateCommandCooldownMs);
107
- this.gatePublishRetryDelayMs = this.normalizeGatePublishRetryDelayMs(configView.gatePublishRetryDelayMs);
108
- this.gateStrictReverseDoublePulse = this.parseBoolean(configView.gateStrictReverseDoublePulse ?? true);
109
- this.gateDebugTimeline = this.parseBoolean(configView.gateDebugTimeline ?? false);
110
- this.frontGateFullTravelMs = this.normalizeFrontGateFullTravelMs(configView.frontGateFullTravelMs ?? configView.gateOpenAssumeDelayMs);
115
+ this.frontGateFullTravelMs = this.normalizeFrontGateFullTravelMs(configView.frontGateFullTravelMs);
111
116
  this.frontGateReversePauseMs = this.normalizeFrontGateReversePauseMs(configView.frontGateReversePauseMs);
112
- this.frontGateWrongDirectionRunMs = this.normalizeFrontGateWrongDirectionRunMs(configView.frontGateWrongDirectionRunMs);
113
- this.frontGateMinimumPulseGapMs = this.normalizeFrontGateMinimumPulseGapMs(configView.frontGateMinimumPulseGapMs ?? configView.gateCommandCooldownMs);
114
- this.frontGateCloseRetryLimit = this.normalizeFrontGateCloseRetryLimit(configView.frontGateCloseRetryLimit);
117
+ this.frontGateMinimumPulseGapMs = this.normalizeFrontGateMinimumPulseGapMs(configView.frontGateMinimumPulseGapMs);
118
+ this.frontGateUnknownOpenPolicy = this.normalizeFrontGateUnknownOpenPolicy(configView.frontGateUnknownOpenPolicy);
119
+ this.frontGateUnknownClosePolicy = this.normalizeFrontGateUnknownClosePolicy(configView.frontGateUnknownClosePolicy);
120
+ this.frontGateSeekClosedMaxPulses = this.normalizeFrontGateSeekClosedMaxPulses(configView.frontGateSeekClosedMaxPulses);
121
+ this.frontGateAssumeOpenAfterTravel = this.parseBoolean(configView.frontGateAssumeOpenAfterTravel ?? false);
122
+ this.frontGateSensorFallbackToControlChannel = this.parseBoolean(configView.frontGateSensorFallbackToControlChannel ?? false);
123
+ this.mqttProtocolVersion = this.normalizeMqttProtocolVersion(configView.mqttProtocolVersion);
115
124
  this.commandQos = this.normalizeCommandQos(configView.commandQos);
116
125
  this.commandRetain = this.parseBoolean(configView.commandRetain ?? false);
117
126
  this.api.on('shutdown', () => {
118
- this.unregisterAllMqttHandlers();
119
- if (this.MqttClient) {
120
- this.MqttClient.client.end(true);
121
- }
127
+ void this.shutdownGracefully();
122
128
  });
123
129
  this.api.on('didFinishLaunching', () => {
124
130
  log.debug('Executed didFinishLaunching callback');
125
- const mqttSettings = this.config;
126
- this.MqttClient = new SuplaMqttClient_1.SuplaMqttClient(mqttSettings, this.log);
127
- this.startMqttRouter();
128
- this.MqttClient.client.on('connect', () => {
129
- this.resubscribeAll(true);
130
- });
131
- this.MqttClient.client.on('close', () => {
132
- this.clearActiveSubscriptions();
133
- });
134
- this.MqttClient.client.on('offline', () => {
135
- this.clearActiveSubscriptions();
136
- });
137
- this.MqttClient.client.on('end', () => {
138
- this.clearActiveSubscriptions();
139
- });
140
- if (this.MqttClient.client.connected) {
141
- this.resubscribeAll(true);
142
- }
143
- this.discoverDevices();
144
- this.MqttClient.discoverChannelsAsync((topic, handler) => (this.registerMqttHandler(topic, handler, 'discovery'))).then((channels) => {
145
- this.persistChannels(channels);
146
- this.discoverDevices(channels);
147
- this.log.info('Channels discovered and saved to config file');
148
- }).catch((error) => {
149
- this.log.error(`Channel discovery failed: ${error.message}`);
131
+ void this.start().catch((error) => {
132
+ this.log.error(`Supla startup failed: ${error.message}`);
150
133
  });
151
134
  });
152
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
+ }
153
241
  /**
154
242
  * This function is invoked when homebridge restores cached accessories from disk at startup.
155
243
  * It should be used to setup event handlers for characteristics and update respective values.
156
244
  */
157
245
  configureAccessory(accessory) {
158
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
+ }
159
266
  // add the restored accessory to the accessories cache so we can track if it has already been registered
160
267
  this.accessories.push(accessory);
161
- const cachedDevice = accessory.context.device;
162
268
  if (!cachedDevice || typeof cachedDevice !== 'object') {
163
269
  this.log.warn(`Cached accessory ${accessory.displayName} missing device context; will configure after discovery.`);
164
270
  accessory.context.deviceConfigured = false;
@@ -166,27 +272,75 @@ class SuplaPlatform {
166
272
  }
167
273
  const normalized = this.normalizeChannelContext(cachedDevice);
168
274
  accessory.context.device = normalized;
169
- accessory.context.deviceSignature = this.getChannelSignature(normalized);
170
- const configured = this.setupAccessory(normalized, accessory);
171
- accessory.context.deviceConfigured = configured;
275
+ if (!this.isAccessoryAccountCompatible(accessory, normalized)) {
276
+ this.log.warn(`Cached accessory ${accessory.displayName} belongs to a different Supla account; ` +
277
+ 'leaving it disabled until an authoritative discovery can retire it.');
278
+ accessory.context.deviceConfigured = false;
279
+ if (!mismatchRetirementFailed) {
280
+ this.revokeAccessoryHandlers(accessory);
281
+ }
282
+ return;
283
+ }
284
+ this.stampAccessoryIdentity(accessory);
285
+ this.addChannelToRegistry(normalized);
286
+ // Accessory construction is intentionally deferred until didFinishLaunching.
287
+ // Homebridge restores cached accessories one at a time, so configuring a gate
288
+ // here could pair it before its separate contact-sensor accessory is known.
289
+ accessory.context.deviceConfigured = false;
172
290
  }
173
291
  /**
174
292
  * This is an example method showing how to register discovered accessories.
175
293
  * Accessories must only be registered once, previously created accessories
176
294
  * must not be registered again to prevent "duplicate UUID" errors.
177
295
  */
178
- discoverDevices(channelsOverride) {
179
- const rawChannels = channelsOverride ?? this.loadChannelsFromConfig();
180
- const 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));
311
+ if (channels.length === 0 && channelsOverride === undefined) {
312
+ channels = this.accessories
313
+ .filter(accessory => this.isAccessoryAccountCompatible(accessory, accessory.context.device))
314
+ .map(accessory => accessory.context.device)
315
+ .filter((channel) => Boolean(channel))
316
+ .map(channel => this.normalizeChannelContext(channel));
317
+ }
318
+ channels = this.deduplicateChannels(channels);
319
+ this.replaceChannelRegistry(channels);
320
+ const channelsToPersist = [...channels];
321
+ const retainPreviousChannel = (incoming, previous) => {
322
+ if (!previous) {
323
+ return;
324
+ }
325
+ const normalizedPrevious = this.normalizeChannelContext(previous);
326
+ const key = this.getChannelRegistryKey(incoming);
327
+ const index = channelsToPersist.findIndex(candidate => this.getChannelRegistryKey(candidate) === key);
328
+ if (index !== -1) {
329
+ channelsToPersist[index] = normalizedPrevious;
330
+ }
331
+ this.addChannelToRegistry(normalizedPrevious);
332
+ };
181
333
  this.log.info('Channels discovered:', channels.length);
182
334
  this.log.debug(`Discovery mode: ${channelsOverride ? 'live' : 'cached'} channels`);
183
335
  const channelUuids = new Set();
184
- const shouldPrune = channelsOverride !== undefined && channels.length > 0;
185
- const findExistingByIds = (candidate) => {
336
+ const findExistingByIds = (candidate, scopedUuid) => {
186
337
  if (candidate.deviceId === 'unknown' || candidate.channelId === 'unknown') {
187
338
  return undefined;
188
339
  }
189
- return this.accessories.find((accessory) => {
340
+ const matches = this.accessories.filter((accessory) => {
341
+ if (!this.isAccessoryAccountCompatible(accessory, accessory.context.device)) {
342
+ return false;
343
+ }
190
344
  const existing = accessory.context.device;
191
345
  if (!existing) {
192
346
  return false;
@@ -196,11 +350,32 @@ class SuplaPlatform {
196
350
  }
197
351
  return existing.deviceId === candidate.deviceId && existing.channelId === candidate.channelId;
198
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;
199
373
  };
200
374
  // loop over the discovered devices and register each one if it has not already been registered
201
375
  for (const channel of channels) {
202
- const existingByIds = findExistingByIds(channel);
203
- 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;
204
379
  channelUuids.add(uuid);
205
380
  const existingAccessory = existingByIds ?? this.accessories.find(accessory => accessory.UUID === uuid);
206
381
  if (existingAccessory) {
@@ -208,45 +383,163 @@ class SuplaPlatform {
208
383
  const signature = this.getChannelSignature(channel);
209
384
  const previousSignature = existingAccessory.context.deviceSignature;
210
385
  const wasConfigured = existingAccessory.context.deviceConfigured === true;
211
- existingAccessory.context.device = channel;
212
- existingAccessory.context.deviceSignature = signature;
213
386
  this.log.debug(`Restoring channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
214
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
+ }
215
405
  if (previousSignature !== signature || !wasConfigured) {
216
- if (previousSignature && previousSignature !== signature) {
217
- 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;
218
469
  }
219
- const configured = this.setupAccessory(channel, existingAccessory);
220
- existingAccessory.context.deviceConfigured = configured;
221
470
  }
222
- this.api.updatePlatformAccessories([existingAccessory]);
223
471
  continue;
224
472
  }
225
473
  this.log.info('Adding new accessory:', channel.channelCaption);
226
474
  this.log.debug(`Registering channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
227
475
  `function=${channel.channelFunction} type=${channel.channelType}`);
228
- const accessory = new this.api.platformAccessory(channel.channelCaption, uuid);
229
- accessory.context.device = channel;
230
- accessory.context.deviceSignature = this.getChannelSignature(channel);
231
- if (this.setupAccessory(channel, accessory)) {
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);
232
489
  accessory.context.deviceConfigured = true;
490
+ this.stampAccessoryIdentity(accessory);
491
+ this.updateAccessoryNames(accessory, channel);
233
492
  this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
493
+ registered = true;
234
494
  this.accessories.push(accessory);
235
495
  this.api.updatePlatformAccessories([accessory]);
236
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
+ }
237
514
  }
238
515
  if (shouldPrune) {
239
516
  const accessoriesToRemove = this.accessories.filter(accessory => !channelUuids.has(accessory.UUID));
240
517
  for (const accessory of accessoriesToRemove) {
241
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
+ }
242
533
  this.unregisterMqttHandlers(accessory.UUID);
243
- this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
244
534
  const index = this.accessories.indexOf(accessory);
245
535
  if (index !== -1) {
246
536
  this.accessories.splice(index, 1);
247
537
  }
248
538
  }
249
539
  }
540
+ if (channelsOverride !== undefined) {
541
+ this.persistChannels(channelsToPersist);
542
+ }
250
543
  }
251
544
  loadChannelsFromConfig() {
252
545
  const rawChannels = this.config.channels;
@@ -269,31 +562,159 @@ class SuplaPlatform {
269
562
  this.log.warn('Cached channels format is invalid; expected string or array.');
270
563
  return [];
271
564
  }
272
- 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;
273
572
  try {
274
- const configPath = this.api.user.configPath();
275
- const config = JSON.parse(fs.readFileSync(configPath).toString());
276
- const platformConfig = config.platforms?.find((platform) => platform.platform === settings_1.PLATFORM_NAME);
277
- if (!platformConfig) {
278
- this.log.warn(`Failed to save channels: ${settings_1.PLATFORM_NAME} not found in config.`);
279
- return;
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}.`);
280
599
  }
281
- platformConfig.channels = JSON.stringify(channels);
282
- const payload = JSON.stringify(config, null, 2);
283
- const tempPath = `${configPath}.tmp`;
284
- fs.writeFileSync(tempPath, payload);
285
- fs.renameSync(tempPath, configPath);
286
- this.log.debug(`Saved ${channels.length} channels to config.`);
287
600
  }
288
601
  catch (error) {
289
- 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}`);
290
637
  }
291
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
+ }
292
713
  normalizeChannelContext(channel) {
293
714
  const rawTopic = (channel.rawTopic ?? channel.topic ?? '').toString();
294
715
  const baseFromTopic = this.normalizeTopicBase(rawTopic || (channel.topic ?? '').toString());
295
- let deviceId = channel.deviceId;
296
- let channelId = channel.channelId;
716
+ let deviceId = channel.deviceId?.toString().trim();
717
+ let channelId = channel.channelId?.toString().trim();
297
718
  if (!deviceId || !channelId) {
298
719
  const match = baseFromTopic.match(/\/devices\/(\d+)\/channels\/(\d+)(?:\/|$)/);
299
720
  if (match) {
@@ -307,43 +728,433 @@ class SuplaPlatform {
307
728
  if (!channelId) {
308
729
  channelId = 'unknown';
309
730
  }
310
- const caption = channel.channelCaption || `Device ${deviceId} Channel ${channelId}`;
311
- const channelType = channel.channelType || 'UNKNOWN';
312
- 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';
313
741
  const mqttContext = this.config;
314
- 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
+ : '');
315
746
  if (deviceId === 'unknown' || channelId === 'unknown') {
316
747
  this.log.warn(`Channel missing device/channel id for topic ${rawTopic}`);
317
748
  }
318
- 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);
319
750
  }
320
751
  getChannelUuid(channel) {
321
752
  const deviceId = channel.deviceId;
322
753
  const channelId = channel.channelId;
323
754
  const hasIds = deviceId && channelId && deviceId !== 'unknown' && channelId !== 'unknown';
324
- const key = hasIds
755
+ const stableTopic = this.normalizeTopicBase(channel.topic || channel.rawTopic || '');
756
+ const channelKey = hasIds
325
757
  ? `${deviceId}:${channelId}`
326
- : (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}`;
327
763
  return this.api.hap.uuid.generate(key);
328
764
  }
329
765
  getChannelSignature(channel) {
330
- return [
766
+ const signature = [
331
767
  channel.topic ?? '',
332
768
  channel.channelFunction ?? '',
333
769
  channel.channelType ?? '',
334
770
  channel.deviceId ?? '',
335
771
  channel.channelId ?? '',
772
+ ];
773
+ if (channel.channelFunction === 'CONTROLLINGTHEGATE') {
774
+ const configView = this.config;
775
+ const sensorRegistrySignature = Array.from(this.channelRegistry.values())
776
+ .filter(candidate => {
777
+ const functionName = (candidate.channelFunction ?? '').toUpperCase();
778
+ const typeName = (candidate.channelType ?? '').toUpperCase();
779
+ return functionName.startsWith('OPENINGSENSOR_') || typeName === 'BINARYSENSOR';
780
+ })
781
+ .map(candidate => [
782
+ candidate.topic,
783
+ candidate.channelFunction,
784
+ candidate.channelType,
785
+ candidate.deviceId,
786
+ candidate.channelId,
787
+ candidate.channelCaption,
788
+ ].join(':'))
789
+ .sort()
790
+ .join(';');
791
+ signature.push(configView.frontGateSensorTopic ?? '', String(configView.frontGateSensorDeviceId ?? ''), String(configView.frontGateSensorChannelId ?? ''), String(this.frontGateSensorFallbackToControlChannel), sensorRegistrySignature);
792
+ }
793
+ return signature.join('|');
794
+ }
795
+ getChannelRegistryKey(channel) {
796
+ return [
797
+ channel.deviceId ?? '',
798
+ channel.channelId ?? '',
799
+ this.normalizeTopicBase(channel.topic),
336
800
  ].join('|');
337
801
  }
338
- resetAccessoryServices(accessory) {
339
- const keepUuid = this.Service.AccessoryInformation.UUID;
802
+ addChannelToRegistry(channel) {
803
+ this.channelRegistry.set(this.getChannelRegistryKey(channel), channel);
804
+ }
805
+ replaceChannelRegistry(channels) {
806
+ this.channelRegistry.clear();
807
+ for (const channel of channels) {
808
+ this.addChannelToRegistry(channel);
809
+ }
810
+ }
811
+ deduplicateChannels(channels) {
812
+ const byKey = new Map();
813
+ for (const channel of channels) {
814
+ byKey.set(this.getChannelRegistryKey(channel), channel);
815
+ }
816
+ return Array.from(byKey.values());
817
+ }
818
+ mergeChannels(cachedChannels, liveChannels, preserveCachedOnly = true, snapshotAuthoritative = false) {
819
+ const cachedByKey = new Map();
820
+ for (const rawChannel of cachedChannels) {
821
+ const channel = this.normalizeChannelContext(rawChannel);
822
+ cachedByKey.set(this.getChannelRegistryKey(channel), channel);
823
+ }
824
+ const merged = preserveCachedOnly
825
+ ? new Map(cachedByKey)
826
+ : new Map();
827
+ for (const rawChannel of liveChannels) {
828
+ const channel = this.normalizeChannelContext(rawChannel);
829
+ const key = this.getChannelRegistryKey(channel);
830
+ const cached = cachedByKey.get(key);
831
+ if (cached) {
832
+ const useLiveCaption = channel.channelCaptionAuthoritative === true
833
+ && (snapshotAuthoritative
834
+ || channel.channelCaptionSource === 'retained'
835
+ || channel.channelCaptionSource === 'authoritative_snapshot');
836
+ merged.set(key, new SuplaChannelContext_1.SuplaChannelContext(channel.topic || cached.topic, this.isKnownMetadata(channel.channelType) ? channel.channelType : cached.channelType, this.isKnownMetadata(channel.channelFunction) ? channel.channelFunction : cached.channelFunction, useLiveCaption ? channel.channelCaption : cached.channelCaption, channel.deviceId || cached.deviceId, channel.channelId || cached.channelId, channel.rawTopic || cached.rawTopic, useLiveCaption
837
+ ? true
838
+ : cached.channelCaptionAuthoritative, useLiveCaption
839
+ ? snapshotAuthoritative
840
+ ? 'authoritative_snapshot'
841
+ : channel.channelCaptionSource
842
+ : cached.channelCaptionSource));
843
+ }
844
+ else if (this.isKnownMetadata(channel.channelType)
845
+ && this.isKnownMetadata(channel.channelFunction)) {
846
+ merged.set(key, channel);
847
+ }
848
+ else {
849
+ this.log.warn(`Deferring incomplete new channel ${channel.deviceId}/${channel.channelId} ` +
850
+ `(function=${channel.channelFunction}, type=${channel.channelType}).`);
851
+ }
852
+ }
853
+ return Array.from(merged.values());
854
+ }
855
+ isKnownMetadata(value) {
856
+ return Boolean(value && value.trim() && value.toUpperCase() !== 'UNKNOWN');
857
+ }
858
+ getKnownChannels() {
859
+ return Array.from(this.channelRegistry.values());
860
+ }
861
+ isSupportedChannel(channel) {
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);
951
+ for (const service of accessory.services) {
952
+ if (!expectedServices.has(service.UUID)) {
953
+ continue;
954
+ }
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 */);
340
1007
  for (const service of accessory.services) {
341
- if (service.UUID === keepUuid) {
1008
+ if (service.UUID === this.Service.AccessoryInformation.UUID) {
342
1009
  continue;
343
1010
  }
344
- accessory.removeService(service);
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
+ }
345
1023
  }
346
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
+ }
1084
+ }
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
+ }
347
1158
  setupAccessory(channel, accessory) {
348
1159
  this.unregisterMqttHandlers(accessory.UUID);
349
1160
  this.log.debug(`Mapping channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
@@ -412,20 +1223,13 @@ class SuplaPlatform {
412
1223
  break;
413
1224
  }
414
1225
  switch (channel.channelType) {
415
- case 'DIMMERANDRGBLED':
416
- new DimmerRgbLightAccessory_1.DimmerRgbLightAccessory(this, accessory, channel);
417
- return true;
418
- case 'DIMMER':
419
- new DimmerAccessory_1.DimmerAccessory(this, accessory, channel);
420
- return true;
421
- case 'RELAY':
422
- new SwitchAccessory_1.SwitchAccessory(this, accessory, channel);
423
- return true;
424
1226
  case 'BINARYSENSOR':
425
- if (this.isLeakSensorChannel(channel)) {
1227
+ if (this.isLeakSensorChannel(channel, accessory)) {
1228
+ accessory.context.suplaBinarySensorKind = 'leak';
426
1229
  new LeakSensorAccessory_1.LeakSensorAccessory(this, accessory, channel);
427
1230
  }
428
1231
  else {
1232
+ accessory.context.suplaBinarySensorKind = 'contact';
429
1233
  new ContactSensorAccessory_1.ContactSensorAccessory(this, accessory, channel);
430
1234
  }
431
1235
  return true;
@@ -454,9 +1258,21 @@ class SuplaPlatform {
454
1258
  this.log.warn(`Unsupported channel ${channel.channelCaption} (${channel.channelFunction}/${channel.channelType})`);
455
1259
  return false;
456
1260
  }
457
- isLeakSensorChannel(channel) {
458
- const caption = (channel.channelCaption ?? '').toLowerCase();
459
- 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;
460
1276
  }
461
1277
  getCoveringControlMode() {
462
1278
  return this.coveringControlMode;
@@ -479,15 +1295,6 @@ class SuplaPlatform {
479
1295
  getCoveringTravelTimeSeconds() {
480
1296
  return this.coveringTravelTimeSeconds;
481
1297
  }
482
- getGateControlMode() {
483
- return this.gateControlMode;
484
- }
485
- getGateExecuteActionOpen() {
486
- return this.gateExecuteActionOpen;
487
- }
488
- getGateExecuteActionClose() {
489
- return this.gateExecuteActionClose;
490
- }
491
1298
  getGateExecuteActionToggle() {
492
1299
  return this.gateExecuteActionToggle;
493
1300
  }
@@ -509,50 +1316,116 @@ class SuplaPlatform {
509
1316
  getGateLockSetOffPayload() {
510
1317
  return this.gateLockSetOffPayload;
511
1318
  }
512
- getGatePartialHiMode() {
513
- return this.gatePartialHiMode;
514
- }
515
- getGateReverseFollowUpDelayMs() {
516
- return this.gateReverseFollowUpDelayMs;
517
- }
518
- getGateOpenAssumeDelayMs() {
519
- return this.gateOpenAssumeDelayMs;
520
- }
521
- getGateCommandCooldownMs() {
522
- return this.gateCommandCooldownMs;
1319
+ claimGateLockFailSafe(accessoryUuid) {
1320
+ const task = this.gateLockFailSafeTasks.get(accessoryUuid);
1321
+ if (task) {
1322
+ this.unregisterMqttHandlers(task.ownerId);
1323
+ }
523
1324
  }
524
- getGatePublishRetryDelayMs() {
525
- return this.gatePublishRetryDelayMs;
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);
526
1378
  }
527
- getGateStrictReverseDoublePulse() {
528
- return this.gateStrictReverseDoublePulse;
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
+ });
529
1398
  }
530
- getGateDebugTimeline() {
531
- return this.gateDebugTimeline;
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);
532
1409
  }
533
- getFrontGateTimings() {
1410
+ getFrontGateConfig() {
534
1411
  return {
535
1412
  fullTravelMs: this.frontGateFullTravelMs,
536
1413
  reversePauseMs: this.frontGateReversePauseMs,
537
- wrongDirectionRunMs: this.frontGateWrongDirectionRunMs,
538
1414
  minimumPulseGapMs: this.frontGateMinimumPulseGapMs,
539
- closeRetryLimit: this.frontGateCloseRetryLimit,
1415
+ unknownOpenPolicy: this.frontGateUnknownOpenPolicy,
1416
+ unknownClosePolicy: this.frontGateUnknownClosePolicy,
1417
+ seekClosedMaxPulses: this.frontGateSeekClosedMaxPulses,
1418
+ assumeOpenAfterTravel: this.frontGateAssumeOpenAfterTravel,
540
1419
  };
541
1420
  }
1421
+ shouldFallbackFrontGateSensorToControlChannel() {
1422
+ return this.frontGateSensorFallbackToControlChannel;
1423
+ }
1424
+ isMqttNoLocalAvailable() {
1425
+ return this.mqttProtocolVersion === 5;
1426
+ }
542
1427
  getFrontGatePulseAction() {
543
- const toggleAction = this.gateExecuteActionToggle.trim();
544
- if (toggleAction) {
545
- return toggleAction;
546
- }
547
- const openAction = this.gateExecuteActionOpen.trim();
548
- const closeAction = this.gateExecuteActionClose.trim();
549
- if (openAction && openAction === closeAction) {
550
- return openAction;
551
- }
552
- if (openAction) {
553
- return openAction;
554
- }
555
- return closeAction;
1428
+ return this.gateExecuteActionToggle.trim();
556
1429
  }
557
1430
  normalizeCoveringControlMode(value) {
558
1431
  const normalized = (value ?? 'set').toString().toLowerCase();
@@ -564,13 +1437,6 @@ class SuplaPlatform {
564
1437
  }
565
1438
  return 'set';
566
1439
  }
567
- normalizeGateControlMode(value) {
568
- const normalized = (value ?? 'execute_action').toString().toLowerCase();
569
- if (normalized === 'toggle') {
570
- return 'toggle';
571
- }
572
- return 'execute_action';
573
- }
574
1440
  normalizeGateLockControlMode(value) {
575
1441
  const normalized = (value ?? 'execute_action').toString().toLowerCase();
576
1442
  if (normalized === 'set_on_pulse') {
@@ -578,54 +1444,12 @@ class SuplaPlatform {
578
1444
  }
579
1445
  return 'execute_action';
580
1446
  }
581
- normalizeGatePartialHiMode(value) {
582
- const normalized = (value ?? 'moving').toString().toLowerCase();
583
- if (normalized === 'open_endstop' || normalized === 'open') {
584
- return 'open_endstop';
585
- }
586
- if (normalized === 'pedestrian_endstop' || normalized === 'pedestrian') {
587
- return 'pedestrian_endstop';
588
- }
589
- if (normalized === 'ignore' || normalized === 'absent') {
590
- return 'ignore';
591
- }
592
- return 'moving';
593
- }
594
- normalizeGateReverseFollowUpDelayMs(value) {
595
- const fallbackMs = 3000;
1447
+ normalizeDurationSeconds(value, fallback, min, max) {
596
1448
  const parsed = Number(value);
597
1449
  if (!Number.isFinite(parsed)) {
598
- return fallbackMs;
1450
+ return fallback;
599
1451
  }
600
- const rounded = Math.round(parsed);
601
- return Math.min(10000, Math.max(250, rounded));
602
- }
603
- normalizeGateOpenAssumeDelayMs(value) {
604
- const fallbackMs = 22000;
605
- const parsed = Number(value);
606
- if (!Number.isFinite(parsed)) {
607
- return fallbackMs;
608
- }
609
- const rounded = Math.round(parsed);
610
- return Math.min(120000, Math.max(1000, rounded));
611
- }
612
- normalizeGateCommandCooldownMs(value) {
613
- const fallbackMs = 700;
614
- const parsed = Number(value);
615
- if (!Number.isFinite(parsed)) {
616
- return fallbackMs;
617
- }
618
- const rounded = Math.round(parsed);
619
- return Math.min(15000, Math.max(3000, rounded));
620
- }
621
- normalizeGatePublishRetryDelayMs(value) {
622
- const fallbackMs = 300;
623
- const parsed = Number(value);
624
- if (!Number.isFinite(parsed)) {
625
- return fallbackMs;
626
- }
627
- const rounded = Math.round(parsed);
628
- return Math.min(5000, Math.max(0, rounded));
1452
+ return Math.min(max, Math.max(min, parsed));
629
1453
  }
630
1454
  normalizeFrontGateFullTravelMs(value) {
631
1455
  const fallbackMs = 25000;
@@ -645,15 +1469,6 @@ class SuplaPlatform {
645
1469
  const rounded = Math.round(parsed);
646
1470
  return Math.min(15000, Math.max(3000, rounded));
647
1471
  }
648
- normalizeFrontGateWrongDirectionRunMs(value) {
649
- const fallbackMs = 700;
650
- const parsed = Number(value);
651
- if (!Number.isFinite(parsed)) {
652
- return fallbackMs;
653
- }
654
- const rounded = Math.round(parsed);
655
- return Math.min(5000, Math.max(100, rounded));
656
- }
657
1472
  normalizeFrontGateMinimumPulseGapMs(value) {
658
1473
  const fallbackMs = 3000;
659
1474
  const parsed = Number(value);
@@ -661,16 +1476,28 @@ class SuplaPlatform {
661
1476
  return fallbackMs;
662
1477
  }
663
1478
  const rounded = Math.round(parsed);
664
- return Math.min(10000, Math.max(0, rounded));
1479
+ return Math.min(15000, Math.max(0, rounded));
1480
+ }
1481
+ normalizeFrontGateUnknownOpenPolicy(value) {
1482
+ return value === 'accept_non_closed' ? 'accept_non_closed' : 'reject';
665
1483
  }
666
- normalizeFrontGateCloseRetryLimit(value) {
667
- const fallbackValue = 0;
1484
+ normalizeFrontGateUnknownClosePolicy(value) {
1485
+ if (value === 'single_pulse_best_effort' || value === 'seek_closed') {
1486
+ return value;
1487
+ }
1488
+ return 'reject';
1489
+ }
1490
+ normalizeFrontGateSeekClosedMaxPulses(value) {
1491
+ const fallbackValue = 3;
668
1492
  const parsed = Number(value);
669
1493
  if (!Number.isFinite(parsed)) {
670
1494
  return fallbackValue;
671
1495
  }
672
1496
  const rounded = Math.round(parsed);
673
- return Math.min(3, Math.max(0, rounded));
1497
+ return Math.min(3, Math.max(1, rounded));
1498
+ }
1499
+ normalizeMqttProtocolVersion(value) {
1500
+ return Number(value) === 5 ? 5 : 4;
674
1501
  }
675
1502
  normalizeTopicSuffix(value) {
676
1503
  return value.toString().replace(/^\/+/, '');
@@ -686,7 +1513,7 @@ class SuplaPlatform {
686
1513
  base = base.replace(/\/set\/.+$/, '');
687
1514
  return base.replace(/\/+$/, '');
688
1515
  }
689
- registerMqttHandler(topic, handler, ownerId) {
1516
+ registerMqttHandler(topic, handler, ownerId, options = {}) {
690
1517
  if (!topic) {
691
1518
  return () => undefined;
692
1519
  }
@@ -701,6 +1528,9 @@ class SuplaPlatform {
701
1528
  ownerTopics.set(topic, ownerHandlers);
702
1529
  this.mqttHandlerOwners.set(ownerId, ownerTopics);
703
1530
  this.mqttDesiredSubscriptions.add(topic);
1531
+ if (options.noLocal && this.mqttProtocolVersion === 5) {
1532
+ this.mqttNoLocalTopics.add(topic);
1533
+ }
704
1534
  this.ensureSubscribed(topic, false);
705
1535
  return () => {
706
1536
  this.removeHandler(topic, handler);
@@ -717,6 +1547,21 @@ class SuplaPlatform {
717
1547
  }
718
1548
  };
719
1549
  }
1550
+ registerMqttTransportHandler(handler, ownerId) {
1551
+ const handlers = this.mqttTransportHandlers.get(ownerId) ?? new Set();
1552
+ handlers.add(handler);
1553
+ this.mqttTransportHandlers.set(ownerId, handlers);
1554
+ handler(this.mqttTransportConnected);
1555
+ const cleanup = () => {
1556
+ const active = this.mqttTransportHandlers.get(ownerId);
1557
+ active?.delete(handler);
1558
+ if (active?.size === 0) {
1559
+ this.mqttTransportHandlers.delete(ownerId);
1560
+ }
1561
+ };
1562
+ this.registerOwnerCleanup(ownerId, cleanup);
1563
+ return cleanup;
1564
+ }
720
1565
  registerOwnerCleanup(ownerId, cleanup) {
721
1566
  const cleanups = this.ownerCleanups.get(ownerId) ?? new Set();
722
1567
  cleanups.add(cleanup);
@@ -732,6 +1577,20 @@ class SuplaPlatform {
732
1577
  }
733
1578
  };
734
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
+ }
735
1594
  unregisterMqttHandlers(ownerId) {
736
1595
  this.runOwnerCleanup(ownerId);
737
1596
  const ownerTopics = this.mqttHandlerOwners.get(ownerId);
@@ -758,31 +1617,106 @@ class SuplaPlatform {
758
1617
  }
759
1618
  this.mqttHandlers.clear();
760
1619
  this.mqttWildcardHandlers.clear();
1620
+ this.mqttNoLocalTopics.clear();
1621
+ this.mqttTransportHandlers.clear();
761
1622
  this.mqttDesiredSubscriptions.clear();
762
1623
  this.mqttSubscriptions.clear();
763
1624
  this.mqttPendingSubscriptions.clear();
1625
+ this.mqttPendingSubscriptionOperations.clear();
764
1626
  this.clearAllSubscriptionRetries();
1627
+ this.shutdownDrains.clear();
765
1628
  this.ownerCleanups.clear();
766
1629
  }
767
1630
  publishCommand(topic, payload, 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 });
1635
+ }
1636
+ publishGateAction(topic, payload, 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
+ });
1689
+ }
1690
+ publishMqtt(topic, payload, options, callback) {
768
1691
  const client = this.MqttClient?.client;
769
1692
  if (!client || !client.connected) {
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, { qos: this.commandQos, retain: this.commandRetain }, (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') {
@@ -800,6 +1734,46 @@ class SuplaPlatform {
800
1734
  }
801
1735
  return false;
802
1736
  }
1737
+ parseBooleanStrict(value) {
1738
+ if (typeof value === 'boolean') {
1739
+ return value;
1740
+ }
1741
+ if (typeof value === 'number') {
1742
+ if (value === 1) {
1743
+ return true;
1744
+ }
1745
+ if (value === 0) {
1746
+ return false;
1747
+ }
1748
+ return undefined;
1749
+ }
1750
+ if (typeof value !== 'string') {
1751
+ return undefined;
1752
+ }
1753
+ const normalized = value.trim().toLowerCase();
1754
+ if (['1', 'true', 'on', 'yes'].includes(normalized)) {
1755
+ return true;
1756
+ }
1757
+ if (['0', 'false', 'off', 'no'].includes(normalized)) {
1758
+ return false;
1759
+ }
1760
+ return undefined;
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
+ }
803
1777
  runOwnerCleanup(ownerId) {
804
1778
  const cleanups = this.ownerCleanups.get(ownerId);
805
1779
  if (!cleanups) {
@@ -815,12 +1789,33 @@ class SuplaPlatform {
815
1789
  }
816
1790
  this.ownerCleanups.delete(ownerId);
817
1791
  }
1792
+ updateMqttTransportState(connected) {
1793
+ if (this.mqttTransportConnected === connected) {
1794
+ return;
1795
+ }
1796
+ this.mqttTransportConnected = connected;
1797
+ for (const handlers of this.mqttTransportHandlers.values()) {
1798
+ for (const handler of handlers) {
1799
+ try {
1800
+ handler(connected);
1801
+ }
1802
+ catch (error) {
1803
+ this.log.error(`MQTT transport handler failed: ${error.message}`);
1804
+ }
1805
+ }
1806
+ }
1807
+ }
1808
+ advanceMqttTransportEpoch() {
1809
+ this.mqttTransportEpoch += 1;
1810
+ }
818
1811
  clearActiveSubscriptions() {
1812
+ this.advanceMqttTransportEpoch();
819
1813
  if (this.mqttSubscriptions.size > 0 || this.mqttPendingSubscriptions.size > 0) {
820
1814
  this.log.debug('MQTT connection lost; clearing active subscriptions.');
821
1815
  }
822
1816
  this.mqttSubscriptions.clear();
823
1817
  this.mqttPendingSubscriptions.clear();
1818
+ this.mqttPendingSubscriptionOperations.clear();
824
1819
  this.clearAllSubscriptionRetries();
825
1820
  }
826
1821
  clearAllSubscriptionRetries() {
@@ -875,9 +1870,11 @@ class SuplaPlatform {
875
1870
  }
876
1871
  this.mqttRetryState.set(topic, state);
877
1872
  this.log.debug(`Retrying MQTT subscribe for ${topic} in ${scheduledDelayMs}ms (${reason}).`);
1873
+ const scheduledEpoch = this.mqttTransportEpoch;
878
1874
  const timer = setTimeout(() => {
879
1875
  this.mqttRetryTimers.delete(topic);
880
- if (!this.mqttDesiredSubscriptions.has(topic)) {
1876
+ if (scheduledEpoch !== this.mqttTransportEpoch
1877
+ || !this.mqttDesiredSubscriptions.has(topic)) {
881
1878
  return;
882
1879
  }
883
1880
  this.ensureSubscribed(topic, true);
@@ -886,7 +1883,7 @@ class SuplaPlatform {
886
1883
  }
887
1884
  isSubscriptionGranted(topic, granted) {
888
1885
  if (!Array.isArray(granted) || granted.length === 0) {
889
- return true;
1886
+ return false;
890
1887
  }
891
1888
  const entry = granted.find((item) => item.topic === topic);
892
1889
  if (!entry) {
@@ -930,7 +1927,25 @@ class SuplaPlatform {
930
1927
  return;
931
1928
  }
932
1929
  this.mqttPendingSubscriptions.add(topic);
933
- this.MqttClient.client.subscribe(topic, (err, granted) => {
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);
1937
+ const subscribeOptions = {
1938
+ qos: 0,
1939
+ ...(this.mqttNoLocalTopics.has(topic) && this.mqttProtocolVersion === 5 ? { nl: true } : {}),
1940
+ };
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);
934
1949
  this.mqttPendingSubscriptions.delete(topic);
935
1950
  if (err) {
936
1951
  this.logSubscriptionIssue(topic, `MQTT subscribe failed for ${topic}: ${err.message}`);
@@ -969,6 +1984,7 @@ class SuplaPlatform {
969
1984
  }
970
1985
  if (force) {
971
1986
  this.mqttPendingSubscriptions.clear();
1987
+ this.mqttPendingSubscriptionOperations.clear();
972
1988
  }
973
1989
  for (const topic of this.mqttDesiredSubscriptions) {
974
1990
  this.ensureSubscribed(topic, force);
@@ -976,7 +1992,9 @@ class SuplaPlatform {
976
1992
  }
977
1993
  removeSubscription(topic) {
978
1994
  this.mqttDesiredSubscriptions.delete(topic);
1995
+ this.mqttNoLocalTopics.delete(topic);
979
1996
  this.mqttPendingSubscriptions.delete(topic);
1997
+ this.mqttPendingSubscriptionOperations.delete(topic);
980
1998
  this.clearSubscriptionRetry(topic);
981
1999
  if (!this.MqttClient) {
982
2000
  return;
@@ -1041,7 +2059,7 @@ class SuplaPlatform {
1041
2059
  }
1042
2060
  this.mqttRouterAttached = true;
1043
2061
  this.resubscribeAll(false);
1044
- this.MqttClient.client.on('message', (topic, message) => {
2062
+ this.MqttClient.client.on('message', (topic, message, packet) => {
1045
2063
  const dispatched = new Set();
1046
2064
  const dispatch = (handler, label) => {
1047
2065
  if (dispatched.has(handler)) {
@@ -1049,7 +2067,7 @@ class SuplaPlatform {
1049
2067
  }
1050
2068
  dispatched.add(handler);
1051
2069
  try {
1052
- handler(message, topic);
2070
+ handler(message, topic, packet);
1053
2071
  }
1054
2072
  catch (error) {
1055
2073
  this.log.error(`MQTT handler error for ${label}: ${error.message}`);