@marcel2215/homebridge-supla-plugin 2.1.29 → 2.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -1
- package/config.schema.json +26 -5
- package/dist/Accesories/ActionTriggerAccessory.d.ts +0 -1
- package/dist/Accesories/ActionTriggerAccessory.d.ts.map +1 -1
- package/dist/Accesories/ActionTriggerAccessory.js +15 -9
- package/dist/Accesories/ActionTriggerAccessory.js.map +1 -1
- package/dist/Accesories/AirQualityAccessory.d.ts +3 -1
- package/dist/Accesories/AirQualityAccessory.d.ts.map +1 -1
- package/dist/Accesories/AirQualityAccessory.js +47 -5
- package/dist/Accesories/AirQualityAccessory.js.map +1 -1
- package/dist/Accesories/ContactSensorAccessory.d.ts +3 -1
- package/dist/Accesories/ContactSensorAccessory.d.ts.map +1 -1
- package/dist/Accesories/ContactSensorAccessory.js +32 -4
- package/dist/Accesories/ContactSensorAccessory.js.map +1 -1
- package/dist/Accesories/DimmerAccessory.d.ts +4 -2
- package/dist/Accesories/DimmerAccessory.d.ts.map +1 -1
- package/dist/Accesories/DimmerAccessory.js +51 -14
- package/dist/Accesories/DimmerAccessory.js.map +1 -1
- package/dist/Accesories/DimmerRgbLightAccessory.d.ts +10 -2
- package/dist/Accesories/DimmerRgbLightAccessory.d.ts.map +1 -1
- package/dist/Accesories/DimmerRgbLightAccessory.js +149 -26
- package/dist/Accesories/DimmerRgbLightAccessory.js.map +1 -1
- package/dist/Accesories/DoorAccessory.d.ts +14 -1
- package/dist/Accesories/DoorAccessory.d.ts.map +1 -1
- package/dist/Accesories/DoorAccessory.js +146 -20
- package/dist/Accesories/DoorAccessory.js.map +1 -1
- package/dist/Accesories/ElectricityMeterAccessory.d.ts +13 -1
- package/dist/Accesories/ElectricityMeterAccessory.d.ts.map +1 -1
- package/dist/Accesories/ElectricityMeterAccessory.js +127 -12
- package/dist/Accesories/ElectricityMeterAccessory.js.map +1 -1
- package/dist/Accesories/FacadeBlindAccessory.d.ts +6 -0
- package/dist/Accesories/FacadeBlindAccessory.d.ts.map +1 -1
- package/dist/Accesories/FacadeBlindAccessory.js +205 -64
- package/dist/Accesories/FacadeBlindAccessory.js.map +1 -1
- package/dist/Accesories/FrontGateFsm.d.ts +8 -2
- package/dist/Accesories/FrontGateFsm.d.ts.map +1 -1
- package/dist/Accesories/FrontGateFsm.js +79 -19
- package/dist/Accesories/FrontGateFsm.js.map +1 -1
- package/dist/Accesories/GarageDoorOpenerAccesory.d.ts +12 -3
- package/dist/Accesories/GarageDoorOpenerAccesory.d.ts.map +1 -1
- package/dist/Accesories/GarageDoorOpenerAccesory.js +168 -21
- package/dist/Accesories/GarageDoorOpenerAccesory.js.map +1 -1
- package/dist/Accesories/GateAccessory.d.ts +6 -0
- package/dist/Accesories/GateAccessory.d.ts.map +1 -1
- package/dist/Accesories/GateAccessory.js +127 -19
- package/dist/Accesories/GateAccessory.js.map +1 -1
- package/dist/Accesories/GateLockAccessory.d.ts +17 -1
- package/dist/Accesories/GateLockAccessory.d.ts.map +1 -1
- package/dist/Accesories/GateLockAccessory.js +375 -50
- package/dist/Accesories/GateLockAccessory.js.map +1 -1
- package/dist/Accesories/LeakSensorAccessory.d.ts +3 -1
- package/dist/Accesories/LeakSensorAccessory.d.ts.map +1 -1
- package/dist/Accesories/LeakSensorAccessory.js +32 -4
- package/dist/Accesories/LeakSensorAccessory.js.map +1 -1
- package/dist/Accesories/LightBulbAccesory.d.ts +3 -2
- package/dist/Accesories/LightBulbAccesory.d.ts.map +1 -1
- package/dist/Accesories/LightBulbAccesory.js +41 -12
- package/dist/Accesories/LightBulbAccesory.js.map +1 -1
- package/dist/Accesories/PressureAccessory.d.ts +3 -1
- package/dist/Accesories/PressureAccessory.d.ts.map +1 -1
- package/dist/Accesories/PressureAccessory.js +28 -5
- package/dist/Accesories/PressureAccessory.js.map +1 -1
- package/dist/Accesories/RGBLightBulbAccesory.d.ts +9 -0
- package/dist/Accesories/RGBLightBulbAccesory.d.ts.map +1 -1
- package/dist/Accesories/RGBLightBulbAccesory.js +137 -17
- package/dist/Accesories/RGBLightBulbAccesory.js.map +1 -1
- package/dist/Accesories/RollerShutterAccessory.d.ts +6 -0
- package/dist/Accesories/RollerShutterAccessory.d.ts.map +1 -1
- package/dist/Accesories/RollerShutterAccessory.js +219 -74
- package/dist/Accesories/RollerShutterAccessory.js.map +1 -1
- package/dist/Accesories/SwitchAccessory.d.ts +3 -2
- package/dist/Accesories/SwitchAccessory.d.ts.map +1 -1
- package/dist/Accesories/SwitchAccessory.js +41 -12
- package/dist/Accesories/SwitchAccessory.js.map +1 -1
- package/dist/Accesories/TemperatureAccessory.d.ts +3 -1
- package/dist/Accesories/TemperatureAccessory.d.ts.map +1 -1
- package/dist/Accesories/TemperatureAccessory.js +28 -5
- package/dist/Accesories/TemperatureAccessory.js.map +1 -1
- package/dist/Accesories/TemperatureHumidityAccessory.d.ts +5 -1
- package/dist/Accesories/TemperatureHumidityAccessory.d.ts.map +1 -1
- package/dist/Accesories/TemperatureHumidityAccessory.js +40 -9
- package/dist/Accesories/TemperatureHumidityAccessory.js.map +1 -1
- package/dist/Accesories/ThermostatAccessory.d.ts +18 -1
- package/dist/Accesories/ThermostatAccessory.d.ts.map +1 -1
- package/dist/Accesories/ThermostatAccessory.js +193 -34
- package/dist/Accesories/ThermostatAccessory.js.map +1 -1
- package/dist/Accesories/ValveAccessory.d.ts +5 -2
- package/dist/Accesories/ValveAccessory.d.ts.map +1 -1
- package/dist/Accesories/ValveAccessory.js +47 -8
- package/dist/Accesories/ValveAccessory.js.map +1 -1
- package/dist/Accesories/WicketAccesory.d.ts +18 -3
- package/dist/Accesories/WicketAccesory.d.ts.map +1 -1
- package/dist/Accesories/WicketAccesory.js +154 -14
- package/dist/Accesories/WicketAccesory.js.map +1 -1
- package/dist/Heplers/ColorConverters.d.ts +2 -2
- package/dist/Heplers/ColorConverters.d.ts.map +1 -1
- package/dist/Heplers/ColorConverters.js +7 -3
- package/dist/Heplers/ColorConverters.js.map +1 -1
- package/dist/Heplers/DisplayName.d.ts +15 -0
- package/dist/Heplers/DisplayName.d.ts.map +1 -0
- package/dist/Heplers/DisplayName.js +174 -0
- package/dist/Heplers/DisplayName.js.map +1 -0
- package/dist/Heplers/SuplaChannelContext.d.ts +3 -1
- package/dist/Heplers/SuplaChannelContext.d.ts.map +1 -1
- package/dist/Heplers/SuplaChannelContext.js +3 -1
- package/dist/Heplers/SuplaChannelContext.js.map +1 -1
- package/dist/Heplers/SuplaMqttClient.d.ts +12 -4
- package/dist/Heplers/SuplaMqttClient.d.ts.map +1 -1
- package/dist/Heplers/SuplaMqttClient.js +214 -112
- package/dist/Heplers/SuplaMqttClient.js.map +1 -1
- package/dist/platform.d.ts +58 -3
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +1087 -115
- package/dist/platform.js.map +1 -1
- package/package.json +7 -3
- 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
|
|
94
|
-
this.coveringExecuteActionClose = (configView.coveringExecuteActionClose
|
|
95
|
-
this.coveringExecuteActionStop = (configView.coveringExecuteActionStop
|
|
96
|
-
this.coveringTravelTimeSeconds =
|
|
97
|
-
this.gateExecuteActionToggle = (configView.gateExecuteActionToggle
|
|
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
|
|
110
|
+
this.gateLockExecuteAction = String(configView.gateLockExecuteAction ?? '').trim() || 'open';
|
|
100
111
|
this.gateLockSetTopicSuffix = this.normalizeTopicSuffix(configView.gateLockSetTopicSuffix || 'set/on');
|
|
101
|
-
this.gateLockPulseSeconds =
|
|
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.
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
185
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
216
|
-
const
|
|
376
|
+
const scopedUuid = this.getChannelUuid(channel);
|
|
377
|
+
const existingByIds = findExistingByIds(channel, scopedUuid);
|
|
378
|
+
const uuid = existingByIds?.UUID ?? scopedUuid;
|
|
217
379
|
channelUuids.add(uuid);
|
|
218
380
|
const existingAccessory = existingByIds ?? this.accessories.find(accessory => accessory.UUID === uuid);
|
|
219
381
|
if (existingAccessory) {
|
|
@@ -221,45 +383,163 @@ class SuplaPlatform {
|
|
|
221
383
|
const signature = this.getChannelSignature(channel);
|
|
222
384
|
const previousSignature = existingAccessory.context.deviceSignature;
|
|
223
385
|
const wasConfigured = existingAccessory.context.deviceConfigured === true;
|
|
224
|
-
existingAccessory.context.device = channel;
|
|
225
|
-
existingAccessory.context.deviceSignature = signature;
|
|
226
386
|
this.log.debug(`Restoring channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
|
|
227
387
|
`function=${channel.channelFunction} type=${channel.channelType}`);
|
|
388
|
+
if (!this.isSupportedChannel(channel)) {
|
|
389
|
+
if (this.isExplicitlyUnsupportedChannel(channel)) {
|
|
390
|
+
this.log.warn(`Channel ${channel.channelCaption} is now explicitly unsupported ` +
|
|
391
|
+
`(${channel.channelFunction}/${channel.channelType}); revoking its handlers.`);
|
|
392
|
+
this.revokeAccessoryHandlers(existingAccessory);
|
|
393
|
+
existingAccessory.context.device = channel;
|
|
394
|
+
existingAccessory.context.deviceSignature = signature;
|
|
395
|
+
existingAccessory.context.deviceConfigured = false;
|
|
396
|
+
this.stampAccessoryIdentity(existingAccessory);
|
|
397
|
+
this.api.updatePlatformAccessories([existingAccessory]);
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
this.log.warn(`Deferring incomplete channel ${channel.channelCaption} ` +
|
|
401
|
+
`(${channel.channelFunction}/${channel.channelType}); retaining last configured context.`);
|
|
402
|
+
}
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
228
405
|
if (previousSignature !== signature || !wasConfigured) {
|
|
229
|
-
|
|
230
|
-
|
|
406
|
+
const previousDevice = existingAccessory.context.device;
|
|
407
|
+
const previousContext = { ...existingAccessory.context };
|
|
408
|
+
const previousNames = this.captureAccessoryNames(existingAccessory);
|
|
409
|
+
try {
|
|
410
|
+
const safeDisplayName = this.resolveHomeKitDisplayName(channel, existingAccessory.displayName);
|
|
411
|
+
if (existingAccessory.displayName !== safeDisplayName) {
|
|
412
|
+
existingAccessory.updateDisplayName(safeDisplayName);
|
|
413
|
+
}
|
|
414
|
+
this.prepareAccessoryForReconfiguration(existingAccessory, channel);
|
|
415
|
+
const configured = this.setupAccessory(channel, existingAccessory);
|
|
416
|
+
if (!configured) {
|
|
417
|
+
throw new Error('No compatible accessory handler');
|
|
418
|
+
}
|
|
419
|
+
existingAccessory.context.device = channel;
|
|
420
|
+
existingAccessory.context.deviceSignature = signature;
|
|
421
|
+
existingAccessory.context.deviceConfigured = true;
|
|
422
|
+
this.stampAccessoryIdentity(existingAccessory);
|
|
423
|
+
this.cleanupObsoleteServices(existingAccessory, channel);
|
|
424
|
+
this.updateAccessoryNames(existingAccessory, channel);
|
|
425
|
+
this.api.updatePlatformAccessories([existingAccessory]);
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
this.unregisterMqttHandlers(existingAccessory.UUID);
|
|
429
|
+
this.restoreAccessoryContext(existingAccessory, previousContext);
|
|
430
|
+
this.restoreAccessoryNames(existingAccessory, previousNames);
|
|
431
|
+
retainPreviousChannel(channel, previousDevice);
|
|
432
|
+
if (previousDevice && this.isSupportedChannel(previousDevice)) {
|
|
433
|
+
try {
|
|
434
|
+
this.prepareAccessoryForReconfiguration(existingAccessory, previousDevice);
|
|
435
|
+
existingAccessory.context.deviceConfigured = this.setupAccessory(previousDevice, existingAccessory);
|
|
436
|
+
}
|
|
437
|
+
catch (restoreError) {
|
|
438
|
+
this.log.error(`Failed to restore previous handler for ${existingAccessory.displayName}: ` +
|
|
439
|
+
`${restoreError.message}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
this.log.error(`Failed to configure ${channel.channelCaption}; previous context retained: ${error.message}`);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
const previousDevice = existingAccessory.context.device;
|
|
448
|
+
const previousContext = { ...existingAccessory.context };
|
|
449
|
+
const previousNames = this.captureAccessoryNames(existingAccessory);
|
|
450
|
+
try {
|
|
451
|
+
const metadataChanged = !this.areChannelContextsEqual(previousDevice, channel);
|
|
452
|
+
const identityChanged = existingAccessory.context.suplaIdentityVersion !== 2
|
|
453
|
+
|| existingAccessory.context.suplaAccountNamespace !== this.accountNamespace;
|
|
454
|
+
existingAccessory.context.device = channel;
|
|
455
|
+
existingAccessory.context.deviceSignature = signature;
|
|
456
|
+
this.stampAccessoryIdentity(existingAccessory);
|
|
457
|
+
const namesChanged = this.updateAccessoryNames(existingAccessory, channel);
|
|
458
|
+
if (metadataChanged || identityChanged || namesChanged) {
|
|
459
|
+
this.api.updatePlatformAccessories([existingAccessory]);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
this.restoreAccessoryContext(existingAccessory, previousContext);
|
|
464
|
+
this.restoreAccessoryNames(existingAccessory, previousNames);
|
|
465
|
+
retainPreviousChannel(channel, previousDevice);
|
|
466
|
+
this.log.error(`Failed to commit metadata for ${channel.channelCaption}; previous name and context retained: ` +
|
|
467
|
+
`${error.message}`);
|
|
468
|
+
continue;
|
|
231
469
|
}
|
|
232
|
-
const configured = this.setupAccessory(channel, existingAccessory);
|
|
233
|
-
existingAccessory.context.deviceConfigured = configured;
|
|
234
470
|
}
|
|
235
|
-
this.api.updatePlatformAccessories([existingAccessory]);
|
|
236
471
|
continue;
|
|
237
472
|
}
|
|
238
473
|
this.log.info('Adding new accessory:', channel.channelCaption);
|
|
239
474
|
this.log.debug(`Registering channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
|
|
240
475
|
`function=${channel.channelFunction} type=${channel.channelType}`);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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.
|
|
602
|
+
this.log.warn(`Failed to load channel cache: ${error.message}`);
|
|
603
|
+
}
|
|
604
|
+
const legacyChannels = this.loadChannelsFromConfig();
|
|
605
|
+
const compatibleLegacyChannels = legacyChannels.filter(channel => this.isSafeLegacyCacheMigration(channel));
|
|
606
|
+
if (compatibleLegacyChannels.length > 0) {
|
|
607
|
+
this.log.info('Migrating legacy Supla channels cache from config to Homebridge storage.');
|
|
608
|
+
this.persistChannels(compatibleLegacyChannels);
|
|
609
|
+
}
|
|
610
|
+
if (compatibleLegacyChannels.length !== legacyChannels.length) {
|
|
611
|
+
this.log.warn('Skipped account-unscoped legacy channels from config cache; ' +
|
|
612
|
+
'they will be recreated from authenticated live discovery.');
|
|
304
613
|
}
|
|
614
|
+
return compatibleLegacyChannels;
|
|
615
|
+
}
|
|
616
|
+
isSafeLegacyCacheMigration(channel) {
|
|
617
|
+
const normalized = this.normalizeChannelContext(channel);
|
|
618
|
+
return this.isChannelAccountCompatible(normalized)
|
|
619
|
+
&& (!this.isUnscopedLegacyChannel(normalized) || this.shouldMigrateUnscopedLegacyAccessories());
|
|
620
|
+
}
|
|
621
|
+
persistChannels(channels) {
|
|
622
|
+
try {
|
|
623
|
+
const cachePath = this.getChannelCachePath();
|
|
624
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
625
|
+
const payload = {
|
|
626
|
+
version: 2,
|
|
627
|
+
accountNamespace: this.accountNamespace,
|
|
628
|
+
channels: channels.filter(channel => this.isChannelAccountCompatible(channel)),
|
|
629
|
+
};
|
|
630
|
+
const tempPath = `${cachePath}.tmp-${process.pid}`;
|
|
631
|
+
fs.writeFileSync(tempPath, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
632
|
+
fs.renameSync(tempPath, cachePath);
|
|
633
|
+
this.log.debug(`Saved ${channels.length} channels to Homebridge storage.`);
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
this.log.error(`Failed to save channel cache: ${error.message}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
getChannelCachePath() {
|
|
640
|
+
const configView = this.config;
|
|
641
|
+
const identity = [
|
|
642
|
+
settings_1.PLATFORM_NAME,
|
|
643
|
+
String(configView.name ?? ''),
|
|
644
|
+
String(configView.host ?? '').trim().toLowerCase(),
|
|
645
|
+
String(configView.port ?? '').trim(),
|
|
646
|
+
String(configView.protocol ?? '').trim().toLowerCase(),
|
|
647
|
+
String(configView.tls ?? '').trim().toLowerCase(),
|
|
648
|
+
String(configView.username ?? '').trim(),
|
|
649
|
+
this.accountNamespace,
|
|
650
|
+
].join('\u0000');
|
|
651
|
+
const instanceKey = (0, crypto_1.createHash)('sha256').update(identity).digest('hex').slice(0, 20);
|
|
652
|
+
return path.join(this.api.user.storagePath(), `supla-channels-${instanceKey}.json`);
|
|
653
|
+
}
|
|
654
|
+
getLegacyChannelCachePath() {
|
|
655
|
+
const configView = this.config;
|
|
656
|
+
const identity = [
|
|
657
|
+
settings_1.PLATFORM_NAME,
|
|
658
|
+
String(configView.name ?? ''),
|
|
659
|
+
String(configView.host ?? ''),
|
|
660
|
+
String(configView.username ?? ''),
|
|
661
|
+
].join('\u0000');
|
|
662
|
+
const instanceKey = (0, crypto_1.createHash)('sha256').update(identity).digest('hex').slice(0, 20);
|
|
663
|
+
return path.join(this.api.user.storagePath(), `supla-channels-${instanceKey}.json`);
|
|
664
|
+
}
|
|
665
|
+
computeAccountNamespace() {
|
|
666
|
+
const configView = this.config;
|
|
667
|
+
const identity = [
|
|
668
|
+
String(configView.host ?? '').trim().toLowerCase(),
|
|
669
|
+
String(configView.port ?? '').trim(),
|
|
670
|
+
String(configView.username ?? '').trim(),
|
|
671
|
+
].join('\u0000');
|
|
672
|
+
return (0, crypto_1.createHash)('sha256').update(identity).digest('hex');
|
|
673
|
+
}
|
|
674
|
+
stampAccessoryIdentity(accessory) {
|
|
675
|
+
accessory.context.suplaIdentityVersion = 2;
|
|
676
|
+
accessory.context.suplaAccountNamespace = this.accountNamespace;
|
|
677
|
+
// Retain this legacy field for consumers that used the first remediation schema.
|
|
678
|
+
accessory.context.schemaVersion = 2;
|
|
679
|
+
}
|
|
680
|
+
isAccessoryAccountCompatible(accessory, channel) {
|
|
681
|
+
const storedNamespace = accessory.context.suplaAccountNamespace;
|
|
682
|
+
if (typeof storedNamespace === 'string' && storedNamespace.length > 0) {
|
|
683
|
+
return storedNamespace === this.accountNamespace;
|
|
684
|
+
}
|
|
685
|
+
if (channel === undefined) {
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
if (this.isUnscopedLegacyChannel(channel)) {
|
|
689
|
+
return this.shouldMigrateUnscopedLegacyAccessories();
|
|
690
|
+
}
|
|
691
|
+
return this.isChannelAccountCompatible(channel);
|
|
692
|
+
}
|
|
693
|
+
isChannelAccountCompatible(channel) {
|
|
694
|
+
const topic = this.normalizeTopicBase(channel.topic ?? channel.rawTopic ?? '');
|
|
695
|
+
const match = topic.match(/^supla\/([^/]+)\/devices\//);
|
|
696
|
+
if (!match) {
|
|
697
|
+
// Legacy rollershutter topics contain no account identifier. They are
|
|
698
|
+
// recognized here, but migration requires the explicit opt-in checked by
|
|
699
|
+
// isSafeLegacyCacheMigration/isAccessoryAccountCompatible.
|
|
700
|
+
return topic.startsWith('supla/channels/status/rollershutter/');
|
|
701
|
+
}
|
|
702
|
+
const configuredUsername = String(this.config.username ?? '');
|
|
703
|
+
return match[1] === configuredUsername;
|
|
704
|
+
}
|
|
705
|
+
isUnscopedLegacyChannel(channel) {
|
|
706
|
+
const topic = this.normalizeTopicBase(channel.topic ?? channel.rawTopic ?? '');
|
|
707
|
+
return topic.startsWith('supla/channels/status/rollershutter/');
|
|
708
|
+
}
|
|
709
|
+
shouldMigrateUnscopedLegacyAccessories() {
|
|
710
|
+
const configured = this.config.migrateUnscopedLegacyAccessories;
|
|
711
|
+
return this.parseBooleanStrict(configured) === true;
|
|
305
712
|
}
|
|
306
713
|
normalizeChannelContext(channel) {
|
|
307
714
|
const rawTopic = (channel.rawTopic ?? channel.topic ?? '').toString();
|
|
308
715
|
const baseFromTopic = this.normalizeTopicBase(rawTopic || (channel.topic ?? '').toString());
|
|
309
|
-
let deviceId = channel.deviceId;
|
|
310
|
-
let channelId = channel.channelId;
|
|
716
|
+
let deviceId = channel.deviceId?.toString().trim();
|
|
717
|
+
let channelId = channel.channelId?.toString().trim();
|
|
311
718
|
if (!deviceId || !channelId) {
|
|
312
719
|
const match = baseFromTopic.match(/\/devices\/(\d+)\/channels\/(\d+)(?:\/|$)/);
|
|
313
720
|
if (match) {
|
|
@@ -321,23 +728,38 @@ class SuplaPlatform {
|
|
|
321
728
|
if (!channelId) {
|
|
322
729
|
channelId = 'unknown';
|
|
323
730
|
}
|
|
324
|
-
const
|
|
325
|
-
const
|
|
326
|
-
const
|
|
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
|
|
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
|
|
755
|
+
const stableTopic = this.normalizeTopicBase(channel.topic || channel.rawTopic || '');
|
|
756
|
+
const channelKey = hasIds
|
|
339
757
|
? `${deviceId}:${channelId}`
|
|
340
|
-
:
|
|
758
|
+
: stableTopic;
|
|
759
|
+
if (!channelKey) {
|
|
760
|
+
throw new Error('Cannot derive a stable Supla channel identity without IDs or a topic');
|
|
761
|
+
}
|
|
762
|
+
const key = `${this.accountNamespace}:${channelKey}`;
|
|
341
763
|
return this.api.hap.uuid.generate(key);
|
|
342
764
|
}
|
|
343
765
|
getChannelSignature(channel) {
|
|
@@ -393,18 +815,346 @@ class SuplaPlatform {
|
|
|
393
815
|
}
|
|
394
816
|
return Array.from(byKey.values());
|
|
395
817
|
}
|
|
818
|
+
mergeChannels(cachedChannels, liveChannels, preserveCachedOnly = true, snapshotAuthoritative = false) {
|
|
819
|
+
const cachedByKey = new Map();
|
|
820
|
+
for (const rawChannel of cachedChannels) {
|
|
821
|
+
const channel = this.normalizeChannelContext(rawChannel);
|
|
822
|
+
cachedByKey.set(this.getChannelRegistryKey(channel), channel);
|
|
823
|
+
}
|
|
824
|
+
const merged = preserveCachedOnly
|
|
825
|
+
? new Map(cachedByKey)
|
|
826
|
+
: new Map();
|
|
827
|
+
for (const rawChannel of liveChannels) {
|
|
828
|
+
const channel = this.normalizeChannelContext(rawChannel);
|
|
829
|
+
const key = this.getChannelRegistryKey(channel);
|
|
830
|
+
const cached = cachedByKey.get(key);
|
|
831
|
+
if (cached) {
|
|
832
|
+
const useLiveCaption = channel.channelCaptionAuthoritative === true
|
|
833
|
+
&& (snapshotAuthoritative
|
|
834
|
+
|| channel.channelCaptionSource === 'retained'
|
|
835
|
+
|| channel.channelCaptionSource === 'authoritative_snapshot');
|
|
836
|
+
merged.set(key, new SuplaChannelContext_1.SuplaChannelContext(channel.topic || cached.topic, this.isKnownMetadata(channel.channelType) ? channel.channelType : cached.channelType, this.isKnownMetadata(channel.channelFunction) ? channel.channelFunction : cached.channelFunction, useLiveCaption ? channel.channelCaption : cached.channelCaption, channel.deviceId || cached.deviceId, channel.channelId || cached.channelId, channel.rawTopic || cached.rawTopic, useLiveCaption
|
|
837
|
+
? true
|
|
838
|
+
: cached.channelCaptionAuthoritative, useLiveCaption
|
|
839
|
+
? snapshotAuthoritative
|
|
840
|
+
? 'authoritative_snapshot'
|
|
841
|
+
: channel.channelCaptionSource
|
|
842
|
+
: cached.channelCaptionSource));
|
|
843
|
+
}
|
|
844
|
+
else if (this.isKnownMetadata(channel.channelType)
|
|
845
|
+
&& this.isKnownMetadata(channel.channelFunction)) {
|
|
846
|
+
merged.set(key, channel);
|
|
847
|
+
}
|
|
848
|
+
else {
|
|
849
|
+
this.log.warn(`Deferring incomplete new channel ${channel.deviceId}/${channel.channelId} ` +
|
|
850
|
+
`(function=${channel.channelFunction}, type=${channel.channelType}).`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return Array.from(merged.values());
|
|
854
|
+
}
|
|
855
|
+
isKnownMetadata(value) {
|
|
856
|
+
return Boolean(value && value.trim() && value.toUpperCase() !== 'UNKNOWN');
|
|
857
|
+
}
|
|
396
858
|
getKnownChannels() {
|
|
397
859
|
return Array.from(this.channelRegistry.values());
|
|
398
860
|
}
|
|
399
|
-
|
|
400
|
-
|
|
861
|
+
isSupportedChannel(channel) {
|
|
862
|
+
if (this.isExplicitNoFunction(channel.channelFunction)) {
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
865
|
+
if (!this.isKnownMetadata(channel.channelFunction) || !this.isKnownMetadata(channel.channelType)) {
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
return this.getExpectedServiceUuids(channel).size > 0;
|
|
869
|
+
}
|
|
870
|
+
isExplicitlyUnsupportedChannel(channel) {
|
|
871
|
+
return this.isExplicitNoFunction(channel.channelFunction)
|
|
872
|
+
|| (this.isKnownMetadata(channel.channelFunction)
|
|
873
|
+
&& this.isKnownMetadata(channel.channelType)
|
|
874
|
+
&& this.getExpectedServiceUuids(channel).size === 0);
|
|
875
|
+
}
|
|
876
|
+
getExpectedServiceUuids(channel, accessory) {
|
|
877
|
+
if (this.isExplicitNoFunction(channel.channelFunction)) {
|
|
878
|
+
return new Set();
|
|
879
|
+
}
|
|
880
|
+
const byFunction = {
|
|
881
|
+
CONTROLLINGTHEGARAGEDOOR: [this.Service.GarageDoorOpener.UUID],
|
|
882
|
+
CONTROLLINGTHEGATE: [this.Service.GarageDoorOpener.UUID],
|
|
883
|
+
CONTROLLINGTHEGATEWAYLOCK: [this.Service.LockMechanism.UUID],
|
|
884
|
+
LIGHTSWITCH: [this.Service.Lightbulb.UUID],
|
|
885
|
+
POWERSWITCH: [this.Service.Switch.UUID],
|
|
886
|
+
RGBLIGHTING: [this.Service.Lightbulb.UUID],
|
|
887
|
+
DIMMER: [this.Service.Lightbulb.UUID],
|
|
888
|
+
DIMMERANDRGBLIGHTING: [this.Service.Lightbulb.UUID],
|
|
889
|
+
CONTROLLINGTHEROLLERSHUTTER: [this.Service.WindowCovering.UUID],
|
|
890
|
+
CONTROLLINGTHEFACADEBLIND: [this.Service.WindowCovering.UUID],
|
|
891
|
+
OPENINGSENSOR_GATE: [this.Service.ContactSensor.UUID],
|
|
892
|
+
OPENINGSENSOR_GATEWAY: [this.Service.ContactSensor.UUID],
|
|
893
|
+
OPENINGSENSOR_WINDOW: [this.Service.ContactSensor.UUID],
|
|
894
|
+
GENERAL_PURPOSE_MEASUREMENT: [this.Service.AirQualitySensor.UUID],
|
|
895
|
+
THERMOMETER: [this.Service.TemperatureSensor.UUID],
|
|
896
|
+
HUMIDITYANDTEMPERATURE: [this.Service.TemperatureSensor.UUID, this.Service.HumiditySensor.UUID],
|
|
897
|
+
PRESSURESENSOR: [this.Service.LightSensor.UUID],
|
|
898
|
+
VALVEOPENCLOSE: [this.Service.Valve.UUID],
|
|
899
|
+
HVAC_THERMOSTAT: [this.Service.Thermostat.UUID],
|
|
900
|
+
ELECTRICITYMETER: [this.Service.LightSensor.UUID],
|
|
901
|
+
ACTION_TRIGGER: [this.Service.StatelessProgrammableSwitch.UUID],
|
|
902
|
+
};
|
|
903
|
+
const byType = {
|
|
904
|
+
BINARYSENSOR: [
|
|
905
|
+
this.isLeakSensorChannel(channel, accessory)
|
|
906
|
+
? this.Service.LeakSensor.UUID
|
|
907
|
+
: this.Service.ContactSensor.UUID,
|
|
908
|
+
],
|
|
909
|
+
THERMOMETER: [this.Service.TemperatureSensor.UUID],
|
|
910
|
+
THERMOMETERDS18B20: [this.Service.TemperatureSensor.UUID],
|
|
911
|
+
HUMIDITYANDTEMPSENSOR: [this.Service.TemperatureSensor.UUID, this.Service.HumiditySensor.UUID],
|
|
912
|
+
GENERAL_PURPOSE_MEASUREMENT: [this.Service.AirQualitySensor.UUID],
|
|
913
|
+
PRESSURESENSOR: [this.Service.LightSensor.UUID],
|
|
914
|
+
ELECTRICITYMETER: [this.Service.LightSensor.UUID],
|
|
915
|
+
ACTION_TRIGGER: [this.Service.StatelessProgrammableSwitch.UUID],
|
|
916
|
+
};
|
|
917
|
+
return new Set(byFunction[channel.channelFunction] ?? byType[channel.channelType] ?? []);
|
|
918
|
+
}
|
|
919
|
+
isExplicitNoFunction(value) {
|
|
920
|
+
return value?.trim().toUpperCase() === 'NONE';
|
|
921
|
+
}
|
|
922
|
+
cleanupObsoleteServices(accessory, channel) {
|
|
923
|
+
const expected = this.getExpectedServiceUuids(channel, accessory);
|
|
924
|
+
const knownPluginServiceUuids = new Set([
|
|
925
|
+
this.Service.GarageDoorOpener.UUID,
|
|
926
|
+
this.Service.LockMechanism.UUID,
|
|
927
|
+
this.Service.Lightbulb.UUID,
|
|
928
|
+
this.Service.Switch.UUID,
|
|
929
|
+
this.Service.WindowCovering.UUID,
|
|
930
|
+
this.Service.Door.UUID,
|
|
931
|
+
this.Service.ContactSensor.UUID,
|
|
932
|
+
this.Service.LeakSensor.UUID,
|
|
933
|
+
this.Service.TemperatureSensor.UUID,
|
|
934
|
+
this.Service.HumiditySensor.UUID,
|
|
935
|
+
this.Service.AirQualitySensor.UUID,
|
|
936
|
+
this.Service.LightSensor.UUID,
|
|
937
|
+
this.Service.Valve.UUID,
|
|
938
|
+
this.Service.Thermostat.UUID,
|
|
939
|
+
this.Service.StatelessProgrammableSwitch.UUID,
|
|
940
|
+
]);
|
|
941
|
+
for (const service of [...accessory.services]) {
|
|
942
|
+
if (knownPluginServiceUuids.has(service.UUID) && !expected.has(service.UUID)) {
|
|
943
|
+
accessory.removeService(service);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
prepareAccessoryForReconfiguration(accessory, channel) {
|
|
948
|
+
this.unregisterMqttHandlers(accessory.UUID);
|
|
949
|
+
this.cleanupObsoleteServices(accessory, channel);
|
|
950
|
+
const expectedServices = this.getExpectedServiceUuids(channel, accessory);
|
|
401
951
|
for (const service of accessory.services) {
|
|
402
|
-
if (service.UUID
|
|
952
|
+
if (!expectedServices.has(service.UUID)) {
|
|
403
953
|
continue;
|
|
404
954
|
}
|
|
405
|
-
|
|
955
|
+
for (const characteristic of service.characteristics) {
|
|
956
|
+
characteristic.removeOnGet();
|
|
957
|
+
characteristic.removeOnSet();
|
|
958
|
+
}
|
|
959
|
+
this.removeObsoleteOptionalCharacteristics(service, channel);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
removeObsoleteOptionalCharacteristics(service, channel) {
|
|
963
|
+
const obsolete = new Set();
|
|
964
|
+
const servicesWithoutStatusFault = new Set([
|
|
965
|
+
this.Service.GarageDoorOpener.UUID,
|
|
966
|
+
this.Service.LockMechanism.UUID,
|
|
967
|
+
this.Service.Lightbulb.UUID,
|
|
968
|
+
this.Service.Switch.UUID,
|
|
969
|
+
this.Service.WindowCovering.UUID,
|
|
970
|
+
this.Service.Door.UUID,
|
|
971
|
+
this.Service.Thermostat.UUID,
|
|
972
|
+
this.Service.StatelessProgrammableSwitch.UUID,
|
|
973
|
+
]);
|
|
974
|
+
if (servicesWithoutStatusFault.has(service.UUID)) {
|
|
975
|
+
obsolete.add(this.Characteristic.StatusFault.UUID);
|
|
976
|
+
}
|
|
977
|
+
if (service.UUID === this.Service.Lightbulb.UUID) {
|
|
978
|
+
if (channel.channelFunction === 'LIGHTSWITCH') {
|
|
979
|
+
obsolete.add(this.Characteristic.Brightness.UUID);
|
|
980
|
+
obsolete.add(this.Characteristic.Hue.UUID);
|
|
981
|
+
obsolete.add(this.Characteristic.Saturation.UUID);
|
|
982
|
+
}
|
|
983
|
+
else if (channel.channelFunction === 'DIMMER') {
|
|
984
|
+
obsolete.add(this.Characteristic.Hue.UUID);
|
|
985
|
+
obsolete.add(this.Characteristic.Saturation.UUID);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
if (service.UUID === this.Service.WindowCovering.UUID
|
|
989
|
+
&& channel.channelFunction === 'CONTROLLINGTHEROLLERSHUTTER') {
|
|
990
|
+
obsolete.add(this.Characteristic.CurrentHorizontalTiltAngle.UUID);
|
|
991
|
+
obsolete.add(this.Characteristic.TargetHorizontalTiltAngle.UUID);
|
|
992
|
+
}
|
|
993
|
+
if (service.UUID === this.Service.WindowCovering.UUID) {
|
|
994
|
+
// StatusJammed is not part of the HAP WindowCovering service. Older
|
|
995
|
+
// versions added it dynamically; remove that invalid composition.
|
|
996
|
+
obsolete.add(this.Characteristic.StatusJammed.UUID);
|
|
997
|
+
}
|
|
998
|
+
for (const characteristic of [...service.characteristics]) {
|
|
999
|
+
if (obsolete.has(characteristic.UUID)) {
|
|
1000
|
+
service.removeCharacteristic(characteristic);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
revokeAccessoryHandlers(accessory) {
|
|
1005
|
+
this.unregisterMqttHandlers(accessory.UUID);
|
|
1006
|
+
const unavailable = new this.api.hap.HapStatusError(-70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
1007
|
+
for (const service of accessory.services) {
|
|
1008
|
+
if (service.UUID === this.Service.AccessoryInformation.UUID) {
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
for (const characteristic of service.characteristics) {
|
|
1012
|
+
characteristic.removeOnGet();
|
|
1013
|
+
characteristic.removeOnSet();
|
|
1014
|
+
if (characteristic.props.perms.includes("pr" /* this.api.hap.Perms.PAIRED_READ */)) {
|
|
1015
|
+
characteristic.updateValue(unavailable);
|
|
1016
|
+
}
|
|
1017
|
+
if (characteristic.props.perms.includes("pw" /* this.api.hap.Perms.PAIRED_WRITE */)) {
|
|
1018
|
+
characteristic.onSet(() => {
|
|
1019
|
+
throw new this.api.hap.HapStatusError(-70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
resolveHomeKitDisplayName(channel, currentName) {
|
|
1026
|
+
const captionName = channel.channelCaptionAuthoritative === true
|
|
1027
|
+
? (0, DisplayName_1.toHomeKitDisplayName)(channel.channelCaption)
|
|
1028
|
+
: undefined;
|
|
1029
|
+
const hasRenameEvidence = channel.channelCaptionSource === 'retained'
|
|
1030
|
+
|| channel.channelCaptionSource === 'authoritative_snapshot';
|
|
1031
|
+
if (captionName && hasRenameEvidence) {
|
|
1032
|
+
return captionName;
|
|
1033
|
+
}
|
|
1034
|
+
const safeCurrentName = (0, DisplayName_1.toHomeKitDisplayName)(currentName);
|
|
1035
|
+
if (safeCurrentName) {
|
|
1036
|
+
return safeCurrentName;
|
|
1037
|
+
}
|
|
1038
|
+
// A new accessory (or a damaged empty legacy display name) may use its
|
|
1039
|
+
// complete cached caption as an initial/recovery label. This is not allowed
|
|
1040
|
+
// to replace an already meaningful existing name.
|
|
1041
|
+
if (captionName) {
|
|
1042
|
+
return captionName;
|
|
1043
|
+
}
|
|
1044
|
+
return (0, DisplayName_1.toHomeKitDisplayName)((0, DisplayName_1.generatedChannelCaption)(channel.deviceId, channel.channelId))
|
|
1045
|
+
?? 'Supla Device';
|
|
1046
|
+
}
|
|
1047
|
+
captureAccessoryNames(accessory) {
|
|
1048
|
+
return {
|
|
1049
|
+
displayName: accessory.displayName,
|
|
1050
|
+
services: accessory.services.map((service) => {
|
|
1051
|
+
const characteristic = service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
|
|
1052
|
+
return {
|
|
1053
|
+
service,
|
|
1054
|
+
displayName: service.displayName,
|
|
1055
|
+
hadNameCharacteristic: characteristic !== undefined,
|
|
1056
|
+
nameValue: typeof characteristic?.value === 'string' ? characteristic.value : undefined,
|
|
1057
|
+
};
|
|
1058
|
+
}),
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
restoreAccessoryNames(accessory, snapshot) {
|
|
1062
|
+
try {
|
|
1063
|
+
accessory.updateDisplayName(snapshot.displayName);
|
|
1064
|
+
}
|
|
1065
|
+
catch (error) {
|
|
1066
|
+
this.log.error(`Failed to restore accessory display name ${snapshot.displayName}: ${error.message}`);
|
|
1067
|
+
}
|
|
1068
|
+
for (const entry of snapshot.services) {
|
|
1069
|
+
try {
|
|
1070
|
+
entry.service.displayName = entry.displayName;
|
|
1071
|
+
const current = entry.service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
|
|
1072
|
+
if (!entry.hadNameCharacteristic) {
|
|
1073
|
+
if (current) {
|
|
1074
|
+
entry.service.removeCharacteristic(current);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
else if (entry.nameValue !== undefined && current?.value !== entry.nameValue) {
|
|
1078
|
+
entry.service.setCharacteristic(this.Characteristic.Name, entry.nameValue);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
catch (error) {
|
|
1082
|
+
this.log.error(`Failed to restore service name for ${snapshot.displayName}: ${error.message}`);
|
|
1083
|
+
}
|
|
406
1084
|
}
|
|
407
1085
|
}
|
|
1086
|
+
restoreAccessoryContext(accessory, snapshot) {
|
|
1087
|
+
for (const key of Object.keys(accessory.context)) {
|
|
1088
|
+
delete accessory.context[key];
|
|
1089
|
+
}
|
|
1090
|
+
Object.assign(accessory.context, snapshot);
|
|
1091
|
+
}
|
|
1092
|
+
areChannelContextsEqual(current, incoming) {
|
|
1093
|
+
if (!current) {
|
|
1094
|
+
return false;
|
|
1095
|
+
}
|
|
1096
|
+
const fields = [
|
|
1097
|
+
'topic',
|
|
1098
|
+
'channelType',
|
|
1099
|
+
'channelFunction',
|
|
1100
|
+
'channelCaption',
|
|
1101
|
+
'deviceId',
|
|
1102
|
+
'channelId',
|
|
1103
|
+
'rawTopic',
|
|
1104
|
+
'channelCaptionAuthoritative',
|
|
1105
|
+
'channelCaptionSource',
|
|
1106
|
+
];
|
|
1107
|
+
return fields.every(field => current[field] === incoming[field]);
|
|
1108
|
+
}
|
|
1109
|
+
updateAccessoryNames(accessory, channel) {
|
|
1110
|
+
let changed = false;
|
|
1111
|
+
const caption = (0, DisplayName_1.normalizeVendorCaption)(channel.channelCaption);
|
|
1112
|
+
const homeKitName = this.resolveHomeKitDisplayName(channel, accessory.displayName);
|
|
1113
|
+
if (accessory.displayName !== homeKitName) {
|
|
1114
|
+
accessory.updateDisplayName(homeKitName);
|
|
1115
|
+
changed = true;
|
|
1116
|
+
}
|
|
1117
|
+
const informationService = accessory.getService(this.Service.AccessoryInformation);
|
|
1118
|
+
const informationName = informationService?.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
|
|
1119
|
+
if (informationService) {
|
|
1120
|
+
if (informationService.displayName !== homeKitName) {
|
|
1121
|
+
informationService.displayName = homeKitName;
|
|
1122
|
+
changed = true;
|
|
1123
|
+
}
|
|
1124
|
+
if (informationName?.value !== homeKitName) {
|
|
1125
|
+
informationService.setCharacteristic(this.Characteristic.Name, homeKitName);
|
|
1126
|
+
changed = true;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
const expected = this.getExpectedServiceUuids(channel, accessory);
|
|
1130
|
+
const electricityMetric = accessory.context.suplaElectricityMetric;
|
|
1131
|
+
for (const service of accessory.services) {
|
|
1132
|
+
if (expected.has(service.UUID)) {
|
|
1133
|
+
const serviceName = channel.channelFunction === 'ELECTRICITYMETER'
|
|
1134
|
+
&& typeof electricityMetric === 'string'
|
|
1135
|
+
? (0, DisplayName_1.homeKitElectricityServiceName)(homeKitName, electricityMetric)
|
|
1136
|
+
: homeKitName;
|
|
1137
|
+
const nameCharacteristic = service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
|
|
1138
|
+
if (service.displayName !== serviceName) {
|
|
1139
|
+
service.displayName = serviceName;
|
|
1140
|
+
changed = true;
|
|
1141
|
+
}
|
|
1142
|
+
if (nameCharacteristic?.value !== serviceName) {
|
|
1143
|
+
service.setCharacteristic(this.Characteristic.Name, serviceName);
|
|
1144
|
+
changed = true;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
if (accessory.context.suplaVendorCaption !== caption
|
|
1149
|
+
|| accessory.context.suplaVendorCaptionAuthoritative !== (channel.channelCaptionAuthoritative === true)
|
|
1150
|
+
|| accessory.context.suplaHomeKitDisplayName !== homeKitName) {
|
|
1151
|
+
changed = true;
|
|
1152
|
+
}
|
|
1153
|
+
accessory.context.suplaVendorCaption = caption;
|
|
1154
|
+
accessory.context.suplaVendorCaptionAuthoritative = channel.channelCaptionAuthoritative === true;
|
|
1155
|
+
accessory.context.suplaHomeKitDisplayName = homeKitName;
|
|
1156
|
+
return changed;
|
|
1157
|
+
}
|
|
408
1158
|
setupAccessory(channel, accessory) {
|
|
409
1159
|
this.unregisterMqttHandlers(accessory.UUID);
|
|
410
1160
|
this.log.debug(`Mapping channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
|
|
@@ -473,20 +1223,13 @@ class SuplaPlatform {
|
|
|
473
1223
|
break;
|
|
474
1224
|
}
|
|
475
1225
|
switch (channel.channelType) {
|
|
476
|
-
case 'DIMMERANDRGBLED':
|
|
477
|
-
new DimmerRgbLightAccessory_1.DimmerRgbLightAccessory(this, accessory, channel);
|
|
478
|
-
return true;
|
|
479
|
-
case 'DIMMER':
|
|
480
|
-
new DimmerAccessory_1.DimmerAccessory(this, accessory, channel);
|
|
481
|
-
return true;
|
|
482
|
-
case 'RELAY':
|
|
483
|
-
new SwitchAccessory_1.SwitchAccessory(this, accessory, channel);
|
|
484
|
-
return true;
|
|
485
1226
|
case 'BINARYSENSOR':
|
|
486
|
-
if (this.isLeakSensorChannel(channel)) {
|
|
1227
|
+
if (this.isLeakSensorChannel(channel, accessory)) {
|
|
1228
|
+
accessory.context.suplaBinarySensorKind = 'leak';
|
|
487
1229
|
new LeakSensorAccessory_1.LeakSensorAccessory(this, accessory, channel);
|
|
488
1230
|
}
|
|
489
1231
|
else {
|
|
1232
|
+
accessory.context.suplaBinarySensorKind = 'contact';
|
|
490
1233
|
new ContactSensorAccessory_1.ContactSensorAccessory(this, accessory, channel);
|
|
491
1234
|
}
|
|
492
1235
|
return true;
|
|
@@ -515,9 +1258,21 @@ class SuplaPlatform {
|
|
|
515
1258
|
this.log.warn(`Unsupported channel ${channel.channelCaption} (${channel.channelFunction}/${channel.channelType})`);
|
|
516
1259
|
return false;
|
|
517
1260
|
}
|
|
518
|
-
isLeakSensorChannel(channel) {
|
|
519
|
-
const
|
|
520
|
-
|
|
1261
|
+
isLeakSensorChannel(channel, accessory) {
|
|
1262
|
+
const functionName = (channel.channelFunction ?? '').trim().toUpperCase();
|
|
1263
|
+
if (functionName.includes('FLOOD')
|
|
1264
|
+
|| functionName.includes('WATERLEAK')
|
|
1265
|
+
|| functionName === 'LEAKSENSOR') {
|
|
1266
|
+
return true;
|
|
1267
|
+
}
|
|
1268
|
+
const persistedKind = accessory?.context?.suplaBinarySensorKind;
|
|
1269
|
+
if (persistedKind === 'leak' || persistedKind === 'contact') {
|
|
1270
|
+
return persistedKind === 'leak';
|
|
1271
|
+
}
|
|
1272
|
+
// Preserve the topology of accessories created by older releases, which
|
|
1273
|
+
// inferred this type from a caption. The migrated kind is persisted by
|
|
1274
|
+
// setupAccessory and subsequent captions no longer participate.
|
|
1275
|
+
return accessory?.services?.some(service => service.UUID === this.Service.LeakSensor.UUID) === true;
|
|
521
1276
|
}
|
|
522
1277
|
getCoveringControlMode() {
|
|
523
1278
|
return this.coveringControlMode;
|
|
@@ -561,6 +1316,97 @@ class SuplaPlatform {
|
|
|
561
1316
|
getGateLockSetOffPayload() {
|
|
562
1317
|
return this.gateLockSetOffPayload;
|
|
563
1318
|
}
|
|
1319
|
+
claimGateLockFailSafe(accessoryUuid) {
|
|
1320
|
+
const task = this.gateLockFailSafeTasks.get(accessoryUuid);
|
|
1321
|
+
if (task) {
|
|
1322
|
+
this.unregisterMqttHandlers(task.ownerId);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
transferGateLockFailSafe(accessory, stateTopic, commandTopic, offPayload) {
|
|
1326
|
+
if (this.shuttingDown || accessory.context.suplaGateLockPendingOff !== true) {
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
if (this.gateLockFailSafeTasks.has(accessory.UUID)) {
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
const task = {
|
|
1333
|
+
ownerId: `gate-lock-failsafe:${accessory.UUID}`,
|
|
1334
|
+
accessory,
|
|
1335
|
+
stateTopic,
|
|
1336
|
+
commandTopic,
|
|
1337
|
+
offPayload,
|
|
1338
|
+
attempted: false,
|
|
1339
|
+
};
|
|
1340
|
+
this.gateLockFailSafeTasks.set(accessory.UUID, task);
|
|
1341
|
+
this.registerOwnerCleanup(task.ownerId, () => {
|
|
1342
|
+
if (task.retryTimer) {
|
|
1343
|
+
clearTimeout(task.retryTimer);
|
|
1344
|
+
task.retryTimer = undefined;
|
|
1345
|
+
}
|
|
1346
|
+
if (this.gateLockFailSafeTasks.get(accessory.UUID) === task) {
|
|
1347
|
+
this.gateLockFailSafeTasks.delete(accessory.UUID);
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1350
|
+
this.registerMqttHandler(stateTopic, (message, _topic, packet) => {
|
|
1351
|
+
const relayOn = this.parseBooleanStrict(message.toString());
|
|
1352
|
+
if (relayOn !== false || packet?.retain === true || !task.attempted) {
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
accessory.context.suplaGateLockPendingOff = false;
|
|
1356
|
+
const accessoryStillRegistered = Array.isArray(this.accessories)
|
|
1357
|
+
&& this.accessories.includes(accessory);
|
|
1358
|
+
try {
|
|
1359
|
+
if (accessoryStillRegistered) {
|
|
1360
|
+
this.api.updatePlatformAccessories([accessory]);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
catch (error) {
|
|
1364
|
+
accessory.context.suplaGateLockPendingOff = true;
|
|
1365
|
+
this.log.error(`Failed to persist transferred gate-lock OFF confirmation for ${accessory.displayName}: ` +
|
|
1366
|
+
`${error.message}`);
|
|
1367
|
+
this.scheduleGateLockFailSafeRetry(task);
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
this.log.info(`Gate-lock fail-safe OFF confirmed for ${accessory.displayName}.`);
|
|
1371
|
+
this.unregisterMqttHandlers(task.ownerId);
|
|
1372
|
+
}, task.ownerId);
|
|
1373
|
+
this.registerMqttTransportHandler((connected) => {
|
|
1374
|
+
if (connected) {
|
|
1375
|
+
this.attemptGateLockFailSafe(task);
|
|
1376
|
+
}
|
|
1377
|
+
}, task.ownerId);
|
|
1378
|
+
}
|
|
1379
|
+
attemptGateLockFailSafe(task) {
|
|
1380
|
+
if (this.shuttingDown
|
|
1381
|
+
|| this.gateLockFailSafeTasks.get(task.accessory.UUID) !== task
|
|
1382
|
+
|| task.publishInFlight) {
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
task.attempted = true;
|
|
1386
|
+
this.log.warn(`Publishing transferred gate-lock fail-safe OFF for ${task.accessory.displayName}.`);
|
|
1387
|
+
const operation = this.publishActionAsync(task.commandTopic, task.offPayload);
|
|
1388
|
+
task.publishInFlight = operation;
|
|
1389
|
+
void operation.catch((error) => {
|
|
1390
|
+
this.log.warn(`Transferred gate-lock OFF remains unconfirmed for ${task.accessory.displayName}: ` +
|
|
1391
|
+
`${error.message}`);
|
|
1392
|
+
}).finally(() => {
|
|
1393
|
+
if (task.publishInFlight === operation) {
|
|
1394
|
+
task.publishInFlight = undefined;
|
|
1395
|
+
}
|
|
1396
|
+
this.scheduleGateLockFailSafeRetry(task);
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
scheduleGateLockFailSafeRetry(task) {
|
|
1400
|
+
if (this.shuttingDown
|
|
1401
|
+
|| task.retryTimer
|
|
1402
|
+
|| this.gateLockFailSafeTasks.get(task.accessory.UUID) !== task) {
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
task.retryTimer = setTimeout(() => {
|
|
1406
|
+
task.retryTimer = undefined;
|
|
1407
|
+
this.attemptGateLockFailSafe(task);
|
|
1408
|
+
}, 5000);
|
|
1409
|
+
}
|
|
564
1410
|
getFrontGateConfig() {
|
|
565
1411
|
return {
|
|
566
1412
|
fullTravelMs: this.frontGateFullTravelMs,
|
|
@@ -598,6 +1444,13 @@ class SuplaPlatform {
|
|
|
598
1444
|
}
|
|
599
1445
|
return 'execute_action';
|
|
600
1446
|
}
|
|
1447
|
+
normalizeDurationSeconds(value, fallback, min, max) {
|
|
1448
|
+
const parsed = Number(value);
|
|
1449
|
+
if (!Number.isFinite(parsed)) {
|
|
1450
|
+
return fallback;
|
|
1451
|
+
}
|
|
1452
|
+
return Math.min(max, Math.max(min, parsed));
|
|
1453
|
+
}
|
|
601
1454
|
normalizeFrontGateFullTravelMs(value) {
|
|
602
1455
|
const fallbackMs = 25000;
|
|
603
1456
|
const parsed = Number(value);
|
|
@@ -724,6 +1577,20 @@ class SuplaPlatform {
|
|
|
724
1577
|
}
|
|
725
1578
|
};
|
|
726
1579
|
}
|
|
1580
|
+
registerShutdownDrain(ownerId, drain) {
|
|
1581
|
+
const drains = this.shutdownDrains.get(ownerId) ?? new Set();
|
|
1582
|
+
drains.add(drain);
|
|
1583
|
+
this.shutdownDrains.set(ownerId, drains);
|
|
1584
|
+
const cleanup = () => {
|
|
1585
|
+
const active = this.shutdownDrains.get(ownerId);
|
|
1586
|
+
active?.delete(drain);
|
|
1587
|
+
if (active?.size === 0) {
|
|
1588
|
+
this.shutdownDrains.delete(ownerId);
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
this.registerOwnerCleanup(ownerId, cleanup);
|
|
1592
|
+
return cleanup;
|
|
1593
|
+
}
|
|
727
1594
|
unregisterMqttHandlers(ownerId) {
|
|
728
1595
|
this.runOwnerCleanup(ownerId);
|
|
729
1596
|
const ownerTopics = this.mqttHandlerOwners.get(ownerId);
|
|
@@ -755,14 +1622,70 @@ class SuplaPlatform {
|
|
|
755
1622
|
this.mqttDesiredSubscriptions.clear();
|
|
756
1623
|
this.mqttSubscriptions.clear();
|
|
757
1624
|
this.mqttPendingSubscriptions.clear();
|
|
1625
|
+
this.mqttPendingSubscriptionOperations.clear();
|
|
758
1626
|
this.clearAllSubscriptionRetries();
|
|
1627
|
+
this.shutdownDrains.clear();
|
|
759
1628
|
this.ownerCleanups.clear();
|
|
760
1629
|
}
|
|
761
1630
|
publishCommand(topic, payload, callback) {
|
|
762
|
-
this.publishMqtt(topic, payload, { qos: this.commandQos, retain: this.commandRetain }, callback);
|
|
1631
|
+
this.publishMqtt(topic, payload, { qos: this.commandQos, retain: this.commandRetain }, error => callback?.(error));
|
|
1632
|
+
}
|
|
1633
|
+
publishCommandAsync(topic, payload) {
|
|
1634
|
+
return this.publishMqttAsync(topic, payload, { qos: this.commandQos, retain: this.commandRetain });
|
|
763
1635
|
}
|
|
764
1636
|
publishGateAction(topic, payload, callback) {
|
|
765
|
-
this.publishMqtt(topic, payload, { qos: 0, retain: false },
|
|
1637
|
+
this.publishMqtt(topic, payload, { qos: 0, retain: false }, (error, failureOrigin) => {
|
|
1638
|
+
if (error && failureOrigin === 'callback') {
|
|
1639
|
+
error.mqttPublishFailureKind = 'completion_unknown';
|
|
1640
|
+
}
|
|
1641
|
+
callback?.(error);
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
publishActionAsync(topic, payload) {
|
|
1645
|
+
return this.publishMqttAsync(topic, payload, { qos: 0, retain: false }, true);
|
|
1646
|
+
}
|
|
1647
|
+
publishGateActionAsync(topic, payload) {
|
|
1648
|
+
return this.publishActionAsync(topic, payload);
|
|
1649
|
+
}
|
|
1650
|
+
isPublishCompletionUnknown(error) {
|
|
1651
|
+
return error instanceof Error
|
|
1652
|
+
&& error.mqttPublishFailureKind === 'completion_unknown';
|
|
1653
|
+
}
|
|
1654
|
+
publishMqttAsync(topic, payload, options, callbackFailureIsAmbiguous = false) {
|
|
1655
|
+
return new Promise((resolve, reject) => {
|
|
1656
|
+
let settled = false;
|
|
1657
|
+
const finish = (kind, error) => {
|
|
1658
|
+
if (settled) {
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
settled = true;
|
|
1662
|
+
clearTimeout(timer);
|
|
1663
|
+
if (!kind || !error) {
|
|
1664
|
+
resolve();
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
this.log.error(`Publish failed for ${topic}: ${error.message}`);
|
|
1668
|
+
const hapError = new this.api.hap.HapStatusError(kind === 'completion_unknown'
|
|
1669
|
+
? -70408 /* this.api.hap.HAPStatus.OPERATION_TIMED_OUT */
|
|
1670
|
+
: -70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
1671
|
+
hapError.mqttPublishFailureKind = kind;
|
|
1672
|
+
hapError.cause = error;
|
|
1673
|
+
reject(hapError);
|
|
1674
|
+
};
|
|
1675
|
+
const timer = setTimeout(() => {
|
|
1676
|
+
finish('completion_unknown', new Error(`MQTT publish completion timed out after ${this.publishTimeoutMs}ms`));
|
|
1677
|
+
}, this.publishTimeoutMs);
|
|
1678
|
+
this.publishMqtt(topic, payload, options, (error, failureOrigin) => {
|
|
1679
|
+
if (error) {
|
|
1680
|
+
finish(callbackFailureIsAmbiguous && failureOrigin === 'callback'
|
|
1681
|
+
? 'completion_unknown'
|
|
1682
|
+
: 'definite_failure', error);
|
|
1683
|
+
}
|
|
1684
|
+
else {
|
|
1685
|
+
finish();
|
|
1686
|
+
}
|
|
1687
|
+
});
|
|
1688
|
+
});
|
|
766
1689
|
}
|
|
767
1690
|
publishMqtt(topic, payload, options, callback) {
|
|
768
1691
|
const client = this.MqttClient?.client;
|
|
@@ -770,19 +1693,30 @@ class SuplaPlatform {
|
|
|
770
1693
|
const error = new Error('MQTT not connected');
|
|
771
1694
|
this.log.error(`MQTT not connected; cannot publish ${topic}`);
|
|
772
1695
|
if (callback) {
|
|
773
|
-
callback(error);
|
|
1696
|
+
callback(error, 'preflight');
|
|
774
1697
|
}
|
|
775
1698
|
return;
|
|
776
1699
|
}
|
|
777
|
-
|
|
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(
|
|
780
|
-
return;
|
|
1714
|
+
callback(publishError, 'preflight');
|
|
781
1715
|
}
|
|
782
|
-
|
|
783
|
-
this.log.error(`Publish failed for ${topic}: ${
|
|
1716
|
+
else {
|
|
1717
|
+
this.log.error(`Publish failed for ${topic}: ${publishError.message}`);
|
|
784
1718
|
}
|
|
785
|
-
}
|
|
1719
|
+
}
|
|
786
1720
|
}
|
|
787
1721
|
parseBoolean(value) {
|
|
788
1722
|
if (typeof value === 'boolean') {
|
|
@@ -825,6 +1759,21 @@ class SuplaPlatform {
|
|
|
825
1759
|
}
|
|
826
1760
|
return undefined;
|
|
827
1761
|
}
|
|
1762
|
+
parseFiniteNumberStrict(value) {
|
|
1763
|
+
if (typeof value === 'number') {
|
|
1764
|
+
return Number.isFinite(value) ? value : undefined;
|
|
1765
|
+
}
|
|
1766
|
+
if (typeof value !== 'string') {
|
|
1767
|
+
return undefined;
|
|
1768
|
+
}
|
|
1769
|
+
const normalized = value.trim();
|
|
1770
|
+
if (!normalized
|
|
1771
|
+
|| !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) {
|
|
1772
|
+
return undefined;
|
|
1773
|
+
}
|
|
1774
|
+
const parsed = Number(normalized);
|
|
1775
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
1776
|
+
}
|
|
828
1777
|
runOwnerCleanup(ownerId) {
|
|
829
1778
|
const cleanups = this.ownerCleanups.get(ownerId);
|
|
830
1779
|
if (!cleanups) {
|
|
@@ -856,12 +1805,17 @@ class SuplaPlatform {
|
|
|
856
1805
|
}
|
|
857
1806
|
}
|
|
858
1807
|
}
|
|
1808
|
+
advanceMqttTransportEpoch() {
|
|
1809
|
+
this.mqttTransportEpoch += 1;
|
|
1810
|
+
}
|
|
859
1811
|
clearActiveSubscriptions() {
|
|
1812
|
+
this.advanceMqttTransportEpoch();
|
|
860
1813
|
if (this.mqttSubscriptions.size > 0 || this.mqttPendingSubscriptions.size > 0) {
|
|
861
1814
|
this.log.debug('MQTT connection lost; clearing active subscriptions.');
|
|
862
1815
|
}
|
|
863
1816
|
this.mqttSubscriptions.clear();
|
|
864
1817
|
this.mqttPendingSubscriptions.clear();
|
|
1818
|
+
this.mqttPendingSubscriptionOperations.clear();
|
|
865
1819
|
this.clearAllSubscriptionRetries();
|
|
866
1820
|
}
|
|
867
1821
|
clearAllSubscriptionRetries() {
|
|
@@ -916,9 +1870,11 @@ class SuplaPlatform {
|
|
|
916
1870
|
}
|
|
917
1871
|
this.mqttRetryState.set(topic, state);
|
|
918
1872
|
this.log.debug(`Retrying MQTT subscribe for ${topic} in ${scheduledDelayMs}ms (${reason}).`);
|
|
1873
|
+
const scheduledEpoch = this.mqttTransportEpoch;
|
|
919
1874
|
const timer = setTimeout(() => {
|
|
920
1875
|
this.mqttRetryTimers.delete(topic);
|
|
921
|
-
if (
|
|
1876
|
+
if (scheduledEpoch !== this.mqttTransportEpoch
|
|
1877
|
+
|| !this.mqttDesiredSubscriptions.has(topic)) {
|
|
922
1878
|
return;
|
|
923
1879
|
}
|
|
924
1880
|
this.ensureSubscribed(topic, true);
|
|
@@ -927,7 +1883,7 @@ class SuplaPlatform {
|
|
|
927
1883
|
}
|
|
928
1884
|
isSubscriptionGranted(topic, granted) {
|
|
929
1885
|
if (!Array.isArray(granted) || granted.length === 0) {
|
|
930
|
-
return
|
|
1886
|
+
return false;
|
|
931
1887
|
}
|
|
932
1888
|
const entry = granted.find((item) => item.topic === topic);
|
|
933
1889
|
if (!entry) {
|
|
@@ -971,11 +1927,25 @@ class SuplaPlatform {
|
|
|
971
1927
|
return;
|
|
972
1928
|
}
|
|
973
1929
|
this.mqttPendingSubscriptions.add(topic);
|
|
1930
|
+
const client = this.MqttClient.client;
|
|
1931
|
+
const operation = {
|
|
1932
|
+
client,
|
|
1933
|
+
epoch: this.mqttTransportEpoch,
|
|
1934
|
+
id: ++this.nextMqttSubscriptionOperationId,
|
|
1935
|
+
};
|
|
1936
|
+
this.mqttPendingSubscriptionOperations.set(topic, operation);
|
|
974
1937
|
const subscribeOptions = {
|
|
975
1938
|
qos: 0,
|
|
976
1939
|
...(this.mqttNoLocalTopics.has(topic) && this.mqttProtocolVersion === 5 ? { nl: true } : {}),
|
|
977
1940
|
};
|
|
978
|
-
|
|
1941
|
+
client.subscribe(topic, subscribeOptions, (err, granted) => {
|
|
1942
|
+
if (this.mqttPendingSubscriptionOperations.get(topic) !== operation
|
|
1943
|
+
|| this.mqttTransportEpoch !== operation.epoch
|
|
1944
|
+
|| this.MqttClient?.client !== operation.client
|
|
1945
|
+
|| !operation.client.connected) {
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
this.mqttPendingSubscriptionOperations.delete(topic);
|
|
979
1949
|
this.mqttPendingSubscriptions.delete(topic);
|
|
980
1950
|
if (err) {
|
|
981
1951
|
this.logSubscriptionIssue(topic, `MQTT subscribe failed for ${topic}: ${err.message}`);
|
|
@@ -1014,6 +1984,7 @@ class SuplaPlatform {
|
|
|
1014
1984
|
}
|
|
1015
1985
|
if (force) {
|
|
1016
1986
|
this.mqttPendingSubscriptions.clear();
|
|
1987
|
+
this.mqttPendingSubscriptionOperations.clear();
|
|
1017
1988
|
}
|
|
1018
1989
|
for (const topic of this.mqttDesiredSubscriptions) {
|
|
1019
1990
|
this.ensureSubscribed(topic, force);
|
|
@@ -1023,6 +1994,7 @@ class SuplaPlatform {
|
|
|
1023
1994
|
this.mqttDesiredSubscriptions.delete(topic);
|
|
1024
1995
|
this.mqttNoLocalTopics.delete(topic);
|
|
1025
1996
|
this.mqttPendingSubscriptions.delete(topic);
|
|
1997
|
+
this.mqttPendingSubscriptionOperations.delete(topic);
|
|
1026
1998
|
this.clearSubscriptionRetry(topic);
|
|
1027
1999
|
if (!this.MqttClient) {
|
|
1028
2000
|
return;
|