@marcel2215/homebridge-supla-plugin 2.1.29 → 2.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +5 -3
- package/dist/Accesories/DimmerAccessory.d.ts.map +1 -1
- package/dist/Accesories/DimmerAccessory.js +50 -14
- package/dist/Accesories/DimmerAccessory.js.map +1 -1
- package/dist/Accesories/DimmerRgbLightAccessory.d.ts +11 -3
- package/dist/Accesories/DimmerRgbLightAccessory.d.ts.map +1 -1
- package/dist/Accesories/DimmerRgbLightAccessory.js +148 -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 +140 -18
- 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 +4 -3
- package/dist/Accesories/LightBulbAccesory.d.ts.map +1 -1
- package/dist/Accesories/LightBulbAccesory.js +40 -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 +10 -1
- package/dist/Accesories/RGBLightBulbAccesory.d.ts.map +1 -1
- package/dist/Accesories/RGBLightBulbAccesory.js +136 -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 +4 -3
- package/dist/Accesories/SwitchAccessory.d.ts.map +1 -1
- package/dist/Accesories/SwitchAccessory.js +40 -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 +59 -3
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +1119 -116
- 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, existingAccessory)) {
|
|
389
|
+
if (this.isExplicitlyUnsupportedChannel(channel, existingAccessory)) {
|
|
390
|
+
this.log.warn(`Channel ${channel.channelCaption} is now explicitly unsupported ` +
|
|
391
|
+
`(${channel.channelFunction}/${channel.channelType}); revoking its handlers.`);
|
|
392
|
+
this.revokeAccessoryHandlers(existingAccessory);
|
|
393
|
+
existingAccessory.context.device = channel;
|
|
394
|
+
existingAccessory.context.deviceSignature = signature;
|
|
395
|
+
existingAccessory.context.deviceConfigured = false;
|
|
396
|
+
this.stampAccessoryIdentity(existingAccessory);
|
|
397
|
+
this.api.updatePlatformAccessories([existingAccessory]);
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
this.log.warn(`Deferring incomplete channel ${channel.channelCaption} ` +
|
|
401
|
+
`(${channel.channelFunction}/${channel.channelType}); retaining last configured context.`);
|
|
402
|
+
}
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
228
405
|
if (previousSignature !== signature || !wasConfigured) {
|
|
229
|
-
|
|
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, existingAccessory)) {
|
|
433
|
+
try {
|
|
434
|
+
this.prepareAccessoryForReconfiguration(existingAccessory, previousDevice);
|
|
435
|
+
existingAccessory.context.deviceConfigured = this.setupAccessory(previousDevice, existingAccessory);
|
|
436
|
+
}
|
|
437
|
+
catch (restoreError) {
|
|
438
|
+
this.log.error(`Failed to restore previous handler for ${existingAccessory.displayName}: ` +
|
|
439
|
+
`${restoreError.message}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
this.log.error(`Failed to configure ${channel.channelCaption}; previous context retained: ${error.message}`);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
const previousDevice = existingAccessory.context.device;
|
|
448
|
+
const previousContext = { ...existingAccessory.context };
|
|
449
|
+
const previousNames = this.captureAccessoryNames(existingAccessory);
|
|
450
|
+
try {
|
|
451
|
+
const metadataChanged = !this.areChannelContextsEqual(previousDevice, channel);
|
|
452
|
+
const identityChanged = existingAccessory.context.suplaIdentityVersion !== 2
|
|
453
|
+
|| existingAccessory.context.suplaAccountNamespace !== this.accountNamespace;
|
|
454
|
+
existingAccessory.context.device = channel;
|
|
455
|
+
existingAccessory.context.deviceSignature = signature;
|
|
456
|
+
this.stampAccessoryIdentity(existingAccessory);
|
|
457
|
+
const namesChanged = this.updateAccessoryNames(existingAccessory, channel);
|
|
458
|
+
if (metadataChanged || identityChanged || namesChanged) {
|
|
459
|
+
this.api.updatePlatformAccessories([existingAccessory]);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
this.restoreAccessoryContext(existingAccessory, previousContext);
|
|
464
|
+
this.restoreAccessoryNames(existingAccessory, previousNames);
|
|
465
|
+
retainPreviousChannel(channel, previousDevice);
|
|
466
|
+
this.log.error(`Failed to commit metadata for ${channel.channelCaption}; previous name and context retained: ` +
|
|
467
|
+
`${error.message}`);
|
|
468
|
+
continue;
|
|
231
469
|
}
|
|
232
|
-
const configured = this.setupAccessory(channel, existingAccessory);
|
|
233
|
-
existingAccessory.context.deviceConfigured = configured;
|
|
234
470
|
}
|
|
235
|
-
this.api.updatePlatformAccessories([existingAccessory]);
|
|
236
471
|
continue;
|
|
237
472
|
}
|
|
238
473
|
this.log.info('Adding new accessory:', channel.channelCaption);
|
|
239
474
|
this.log.debug(`Registering channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
|
|
240
475
|
`function=${channel.channelFunction} type=${channel.channelType}`);
|
|
241
|
-
|
|
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.');
|
|
613
|
+
}
|
|
614
|
+
return compatibleLegacyChannels;
|
|
615
|
+
}
|
|
616
|
+
isSafeLegacyCacheMigration(channel) {
|
|
617
|
+
const normalized = this.normalizeChannelContext(channel);
|
|
618
|
+
return this.isChannelAccountCompatible(normalized)
|
|
619
|
+
&& (!this.isUnscopedLegacyChannel(normalized) || this.shouldMigrateUnscopedLegacyAccessories());
|
|
620
|
+
}
|
|
621
|
+
persistChannels(channels) {
|
|
622
|
+
try {
|
|
623
|
+
const cachePath = this.getChannelCachePath();
|
|
624
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
625
|
+
const payload = {
|
|
626
|
+
version: 2,
|
|
627
|
+
accountNamespace: this.accountNamespace,
|
|
628
|
+
channels: channels.filter(channel => this.isChannelAccountCompatible(channel)),
|
|
629
|
+
};
|
|
630
|
+
const tempPath = `${cachePath}.tmp-${process.pid}`;
|
|
631
|
+
fs.writeFileSync(tempPath, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
632
|
+
fs.renameSync(tempPath, cachePath);
|
|
633
|
+
this.log.debug(`Saved ${channels.length} channels to Homebridge storage.`);
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
this.log.error(`Failed to save channel cache: ${error.message}`);
|
|
304
637
|
}
|
|
305
638
|
}
|
|
639
|
+
getChannelCachePath() {
|
|
640
|
+
const configView = this.config;
|
|
641
|
+
const identity = [
|
|
642
|
+
settings_1.PLATFORM_NAME,
|
|
643
|
+
String(configView.name ?? ''),
|
|
644
|
+
String(configView.host ?? '').trim().toLowerCase(),
|
|
645
|
+
String(configView.port ?? '').trim(),
|
|
646
|
+
String(configView.protocol ?? '').trim().toLowerCase(),
|
|
647
|
+
String(configView.tls ?? '').trim().toLowerCase(),
|
|
648
|
+
String(configView.username ?? '').trim(),
|
|
649
|
+
this.accountNamespace,
|
|
650
|
+
].join('\u0000');
|
|
651
|
+
const instanceKey = (0, crypto_1.createHash)('sha256').update(identity).digest('hex').slice(0, 20);
|
|
652
|
+
return path.join(this.api.user.storagePath(), `supla-channels-${instanceKey}.json`);
|
|
653
|
+
}
|
|
654
|
+
getLegacyChannelCachePath() {
|
|
655
|
+
const configView = this.config;
|
|
656
|
+
const identity = [
|
|
657
|
+
settings_1.PLATFORM_NAME,
|
|
658
|
+
String(configView.name ?? ''),
|
|
659
|
+
String(configView.host ?? ''),
|
|
660
|
+
String(configView.username ?? ''),
|
|
661
|
+
].join('\u0000');
|
|
662
|
+
const instanceKey = (0, crypto_1.createHash)('sha256').update(identity).digest('hex').slice(0, 20);
|
|
663
|
+
return path.join(this.api.user.storagePath(), `supla-channels-${instanceKey}.json`);
|
|
664
|
+
}
|
|
665
|
+
computeAccountNamespace() {
|
|
666
|
+
const configView = this.config;
|
|
667
|
+
const identity = [
|
|
668
|
+
String(configView.host ?? '').trim().toLowerCase(),
|
|
669
|
+
String(configView.port ?? '').trim(),
|
|
670
|
+
String(configView.username ?? '').trim(),
|
|
671
|
+
].join('\u0000');
|
|
672
|
+
return (0, crypto_1.createHash)('sha256').update(identity).digest('hex');
|
|
673
|
+
}
|
|
674
|
+
stampAccessoryIdentity(accessory) {
|
|
675
|
+
accessory.context.suplaIdentityVersion = 2;
|
|
676
|
+
accessory.context.suplaAccountNamespace = this.accountNamespace;
|
|
677
|
+
// Retain this legacy field for consumers that used the first remediation schema.
|
|
678
|
+
accessory.context.schemaVersion = 2;
|
|
679
|
+
}
|
|
680
|
+
isAccessoryAccountCompatible(accessory, channel) {
|
|
681
|
+
const storedNamespace = accessory.context.suplaAccountNamespace;
|
|
682
|
+
if (typeof storedNamespace === 'string' && storedNamespace.length > 0) {
|
|
683
|
+
return storedNamespace === this.accountNamespace;
|
|
684
|
+
}
|
|
685
|
+
if (channel === undefined) {
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
if (this.isUnscopedLegacyChannel(channel)) {
|
|
689
|
+
return this.shouldMigrateUnscopedLegacyAccessories();
|
|
690
|
+
}
|
|
691
|
+
return this.isChannelAccountCompatible(channel);
|
|
692
|
+
}
|
|
693
|
+
isChannelAccountCompatible(channel) {
|
|
694
|
+
const topic = this.normalizeTopicBase(channel.topic ?? channel.rawTopic ?? '');
|
|
695
|
+
const match = topic.match(/^supla\/([^/]+)\/devices\//);
|
|
696
|
+
if (!match) {
|
|
697
|
+
// Legacy rollershutter topics contain no account identifier. They are
|
|
698
|
+
// recognized here, but migration requires the explicit opt-in checked by
|
|
699
|
+
// isSafeLegacyCacheMigration/isAccessoryAccountCompatible.
|
|
700
|
+
return topic.startsWith('supla/channels/status/rollershutter/');
|
|
701
|
+
}
|
|
702
|
+
const configuredUsername = String(this.config.username ?? '');
|
|
703
|
+
return match[1] === configuredUsername;
|
|
704
|
+
}
|
|
705
|
+
isUnscopedLegacyChannel(channel) {
|
|
706
|
+
const topic = this.normalizeTopicBase(channel.topic ?? channel.rawTopic ?? '');
|
|
707
|
+
return topic.startsWith('supla/channels/status/rollershutter/');
|
|
708
|
+
}
|
|
709
|
+
shouldMigrateUnscopedLegacyAccessories() {
|
|
710
|
+
const configured = this.config.migrateUnscopedLegacyAccessories;
|
|
711
|
+
return this.parseBooleanStrict(configured) === true;
|
|
712
|
+
}
|
|
306
713
|
normalizeChannelContext(channel) {
|
|
307
714
|
const rawTopic = (channel.rawTopic ?? channel.topic ?? '').toString();
|
|
308
715
|
const baseFromTopic = this.normalizeTopicBase(rawTopic || (channel.topic ?? '').toString());
|
|
309
|
-
let deviceId = channel.deviceId;
|
|
310
|
-
let channelId = channel.channelId;
|
|
716
|
+
let deviceId = channel.deviceId?.toString().trim();
|
|
717
|
+
let channelId = channel.channelId?.toString().trim();
|
|
311
718
|
if (!deviceId || !channelId) {
|
|
312
719
|
const match = baseFromTopic.match(/\/devices\/(\d+)\/channels\/(\d+)(?:\/|$)/);
|
|
313
720
|
if (match) {
|
|
@@ -321,23 +728,38 @@ class SuplaPlatform {
|
|
|
321
728
|
if (!channelId) {
|
|
322
729
|
channelId = 'unknown';
|
|
323
730
|
}
|
|
324
|
-
const
|
|
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,364 @@ class SuplaPlatform {
|
|
|
393
815
|
}
|
|
394
816
|
return Array.from(byKey.values());
|
|
395
817
|
}
|
|
818
|
+
mergeChannels(cachedChannels, liveChannels, preserveCachedOnly = true, snapshotAuthoritative = false) {
|
|
819
|
+
const cachedByKey = new Map();
|
|
820
|
+
for (const rawChannel of cachedChannels) {
|
|
821
|
+
const channel = this.normalizeChannelContext(rawChannel);
|
|
822
|
+
cachedByKey.set(this.getChannelRegistryKey(channel), channel);
|
|
823
|
+
}
|
|
824
|
+
const merged = preserveCachedOnly
|
|
825
|
+
? new Map(cachedByKey)
|
|
826
|
+
: new Map();
|
|
827
|
+
for (const rawChannel of liveChannels) {
|
|
828
|
+
const channel = this.normalizeChannelContext(rawChannel);
|
|
829
|
+
const key = this.getChannelRegistryKey(channel);
|
|
830
|
+
const cached = cachedByKey.get(key);
|
|
831
|
+
if (cached) {
|
|
832
|
+
const useLiveCaption = channel.channelCaptionAuthoritative === true
|
|
833
|
+
&& (snapshotAuthoritative
|
|
834
|
+
|| channel.channelCaptionSource === 'retained'
|
|
835
|
+
|| channel.channelCaptionSource === 'authoritative_snapshot');
|
|
836
|
+
merged.set(key, new SuplaChannelContext_1.SuplaChannelContext(channel.topic || cached.topic, this.isKnownMetadata(channel.channelType) ? channel.channelType : cached.channelType, this.isKnownMetadata(channel.channelFunction) ? channel.channelFunction : cached.channelFunction, useLiveCaption ? channel.channelCaption : cached.channelCaption, channel.deviceId || cached.deviceId, channel.channelId || cached.channelId, channel.rawTopic || cached.rawTopic, useLiveCaption
|
|
837
|
+
? true
|
|
838
|
+
: cached.channelCaptionAuthoritative, useLiveCaption
|
|
839
|
+
? snapshotAuthoritative
|
|
840
|
+
? 'authoritative_snapshot'
|
|
841
|
+
: channel.channelCaptionSource
|
|
842
|
+
: cached.channelCaptionSource));
|
|
843
|
+
}
|
|
844
|
+
else if (this.isKnownMetadata(channel.channelType)
|
|
845
|
+
&& this.isKnownMetadata(channel.channelFunction)) {
|
|
846
|
+
merged.set(key, channel);
|
|
847
|
+
}
|
|
848
|
+
else {
|
|
849
|
+
this.log.warn(`Deferring incomplete new channel ${channel.deviceId}/${channel.channelId} ` +
|
|
850
|
+
`(function=${channel.channelFunction}, type=${channel.channelType}).`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return Array.from(merged.values());
|
|
854
|
+
}
|
|
855
|
+
isKnownMetadata(value) {
|
|
856
|
+
return Boolean(value && value.trim() && value.toUpperCase() !== 'UNKNOWN');
|
|
857
|
+
}
|
|
396
858
|
getKnownChannels() {
|
|
397
859
|
return Array.from(this.channelRegistry.values());
|
|
398
860
|
}
|
|
399
|
-
|
|
400
|
-
|
|
861
|
+
isSupportedChannel(channel, accessory) {
|
|
862
|
+
if (this.isExplicitNoFunction(channel.channelFunction)) {
|
|
863
|
+
return this.getLegacyNoFunctionServiceUuids(channel, accessory).size > 0;
|
|
864
|
+
}
|
|
865
|
+
if (!this.isKnownMetadata(channel.channelFunction) || !this.isKnownMetadata(channel.channelType)) {
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
return this.getExpectedServiceUuids(channel).size > 0;
|
|
869
|
+
}
|
|
870
|
+
isExplicitlyUnsupportedChannel(channel, accessory) {
|
|
871
|
+
if (this.isExplicitNoFunction(channel.channelFunction)) {
|
|
872
|
+
return this.getLegacyNoFunctionServiceUuids(channel, accessory).size === 0;
|
|
873
|
+
}
|
|
874
|
+
return this.isKnownMetadata(channel.channelFunction)
|
|
875
|
+
&& this.isKnownMetadata(channel.channelType)
|
|
876
|
+
&& this.getExpectedServiceUuids(channel).size === 0;
|
|
877
|
+
}
|
|
878
|
+
getExpectedServiceUuids(channel, accessory) {
|
|
879
|
+
if (this.isExplicitNoFunction(channel.channelFunction)) {
|
|
880
|
+
return this.getLegacyNoFunctionServiceUuids(channel, accessory);
|
|
881
|
+
}
|
|
882
|
+
const byFunction = {
|
|
883
|
+
CONTROLLINGTHEGARAGEDOOR: [this.Service.GarageDoorOpener.UUID],
|
|
884
|
+
CONTROLLINGTHEGATE: [this.Service.GarageDoorOpener.UUID],
|
|
885
|
+
CONTROLLINGTHEGATEWAYLOCK: [this.Service.LockMechanism.UUID],
|
|
886
|
+
LIGHTSWITCH: [this.Service.Lightbulb.UUID],
|
|
887
|
+
POWERSWITCH: [this.Service.Switch.UUID],
|
|
888
|
+
RGBLIGHTING: [this.Service.Lightbulb.UUID],
|
|
889
|
+
DIMMER: [this.Service.Lightbulb.UUID],
|
|
890
|
+
DIMMERANDRGBLIGHTING: [this.Service.Lightbulb.UUID],
|
|
891
|
+
CONTROLLINGTHEROLLERSHUTTER: [this.Service.WindowCovering.UUID],
|
|
892
|
+
CONTROLLINGTHEFACADEBLIND: [this.Service.WindowCovering.UUID],
|
|
893
|
+
OPENINGSENSOR_GATE: [this.Service.ContactSensor.UUID],
|
|
894
|
+
OPENINGSENSOR_GATEWAY: [this.Service.ContactSensor.UUID],
|
|
895
|
+
OPENINGSENSOR_WINDOW: [this.Service.ContactSensor.UUID],
|
|
896
|
+
GENERAL_PURPOSE_MEASUREMENT: [this.Service.AirQualitySensor.UUID],
|
|
897
|
+
THERMOMETER: [this.Service.TemperatureSensor.UUID],
|
|
898
|
+
HUMIDITYANDTEMPERATURE: [this.Service.TemperatureSensor.UUID, this.Service.HumiditySensor.UUID],
|
|
899
|
+
PRESSURESENSOR: [this.Service.LightSensor.UUID],
|
|
900
|
+
VALVEOPENCLOSE: [this.Service.Valve.UUID],
|
|
901
|
+
HVAC_THERMOSTAT: [this.Service.Thermostat.UUID],
|
|
902
|
+
ELECTRICITYMETER: [this.Service.LightSensor.UUID],
|
|
903
|
+
ACTION_TRIGGER: [this.Service.StatelessProgrammableSwitch.UUID],
|
|
904
|
+
};
|
|
905
|
+
const byType = {
|
|
906
|
+
BINARYSENSOR: [
|
|
907
|
+
this.isLeakSensorChannel(channel, accessory)
|
|
908
|
+
? this.Service.LeakSensor.UUID
|
|
909
|
+
: this.Service.ContactSensor.UUID,
|
|
910
|
+
],
|
|
911
|
+
THERMOMETER: [this.Service.TemperatureSensor.UUID],
|
|
912
|
+
THERMOMETERDS18B20: [this.Service.TemperatureSensor.UUID],
|
|
913
|
+
HUMIDITYANDTEMPSENSOR: [this.Service.TemperatureSensor.UUID, this.Service.HumiditySensor.UUID],
|
|
914
|
+
GENERAL_PURPOSE_MEASUREMENT: [this.Service.AirQualitySensor.UUID],
|
|
915
|
+
PRESSURESENSOR: [this.Service.LightSensor.UUID],
|
|
916
|
+
ELECTRICITYMETER: [this.Service.LightSensor.UUID],
|
|
917
|
+
ACTION_TRIGGER: [this.Service.StatelessProgrammableSwitch.UUID],
|
|
918
|
+
};
|
|
919
|
+
return new Set(byFunction[channel.channelFunction] ?? byType[channel.channelType] ?? []);
|
|
920
|
+
}
|
|
921
|
+
getLegacyNoFunctionServiceUuids(channel, accessory) {
|
|
922
|
+
if (!accessory) {
|
|
923
|
+
return new Set();
|
|
924
|
+
}
|
|
925
|
+
const compatibleByType = {
|
|
926
|
+
RELAY: [this.Service.Switch.UUID, this.Service.Lightbulb.UUID],
|
|
927
|
+
DIMMER: [this.Service.Lightbulb.UUID],
|
|
928
|
+
DIMMERANDRGBLED: [this.Service.Lightbulb.UUID],
|
|
929
|
+
BINARYSENSOR: [this.Service.LeakSensor.UUID, this.Service.ContactSensor.UUID],
|
|
930
|
+
THERMOMETER: [this.Service.TemperatureSensor.UUID],
|
|
931
|
+
THERMOMETERDS18B20: [this.Service.TemperatureSensor.UUID],
|
|
932
|
+
HUMIDITYANDTEMPSENSOR: [this.Service.TemperatureSensor.UUID, this.Service.HumiditySensor.UUID],
|
|
933
|
+
};
|
|
934
|
+
const compatible = compatibleByType[channel.channelType] ?? [];
|
|
935
|
+
return new Set(compatible.filter(uuid => accessory.services.some(service => service.UUID === uuid)));
|
|
936
|
+
}
|
|
937
|
+
isExplicitNoFunction(value) {
|
|
938
|
+
return value?.trim().toUpperCase() === 'NONE';
|
|
939
|
+
}
|
|
940
|
+
cleanupObsoleteServices(accessory, channel) {
|
|
941
|
+
const expected = this.getExpectedServiceUuids(channel, accessory);
|
|
942
|
+
const knownPluginServiceUuids = new Set([
|
|
943
|
+
this.Service.GarageDoorOpener.UUID,
|
|
944
|
+
this.Service.LockMechanism.UUID,
|
|
945
|
+
this.Service.Lightbulb.UUID,
|
|
946
|
+
this.Service.Switch.UUID,
|
|
947
|
+
this.Service.WindowCovering.UUID,
|
|
948
|
+
this.Service.Door.UUID,
|
|
949
|
+
this.Service.ContactSensor.UUID,
|
|
950
|
+
this.Service.LeakSensor.UUID,
|
|
951
|
+
this.Service.TemperatureSensor.UUID,
|
|
952
|
+
this.Service.HumiditySensor.UUID,
|
|
953
|
+
this.Service.AirQualitySensor.UUID,
|
|
954
|
+
this.Service.LightSensor.UUID,
|
|
955
|
+
this.Service.Valve.UUID,
|
|
956
|
+
this.Service.Thermostat.UUID,
|
|
957
|
+
this.Service.StatelessProgrammableSwitch.UUID,
|
|
958
|
+
]);
|
|
959
|
+
for (const service of [...accessory.services]) {
|
|
960
|
+
if (knownPluginServiceUuids.has(service.UUID) && !expected.has(service.UUID)) {
|
|
961
|
+
accessory.removeService(service);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
prepareAccessoryForReconfiguration(accessory, channel) {
|
|
966
|
+
this.unregisterMqttHandlers(accessory.UUID);
|
|
967
|
+
this.cleanupObsoleteServices(accessory, channel);
|
|
968
|
+
const expectedServices = this.getExpectedServiceUuids(channel, accessory);
|
|
969
|
+
for (const service of accessory.services) {
|
|
970
|
+
if (!expectedServices.has(service.UUID)) {
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
for (const characteristic of service.characteristics) {
|
|
974
|
+
characteristic.removeOnGet();
|
|
975
|
+
characteristic.removeOnSet();
|
|
976
|
+
}
|
|
977
|
+
this.removeObsoleteOptionalCharacteristics(service, channel);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
removeObsoleteOptionalCharacteristics(service, channel) {
|
|
981
|
+
const obsolete = new Set();
|
|
982
|
+
const servicesWithoutStatusFault = new Set([
|
|
983
|
+
this.Service.GarageDoorOpener.UUID,
|
|
984
|
+
this.Service.LockMechanism.UUID,
|
|
985
|
+
this.Service.Lightbulb.UUID,
|
|
986
|
+
this.Service.Switch.UUID,
|
|
987
|
+
this.Service.WindowCovering.UUID,
|
|
988
|
+
this.Service.Door.UUID,
|
|
989
|
+
this.Service.Thermostat.UUID,
|
|
990
|
+
this.Service.StatelessProgrammableSwitch.UUID,
|
|
991
|
+
]);
|
|
992
|
+
if (servicesWithoutStatusFault.has(service.UUID)) {
|
|
993
|
+
obsolete.add(this.Characteristic.StatusFault.UUID);
|
|
994
|
+
}
|
|
995
|
+
if (service.UUID === this.Service.Lightbulb.UUID) {
|
|
996
|
+
if (channel.channelFunction === 'LIGHTSWITCH') {
|
|
997
|
+
obsolete.add(this.Characteristic.Brightness.UUID);
|
|
998
|
+
obsolete.add(this.Characteristic.Hue.UUID);
|
|
999
|
+
obsolete.add(this.Characteristic.Saturation.UUID);
|
|
1000
|
+
}
|
|
1001
|
+
else if (channel.channelFunction === 'DIMMER') {
|
|
1002
|
+
obsolete.add(this.Characteristic.Hue.UUID);
|
|
1003
|
+
obsolete.add(this.Characteristic.Saturation.UUID);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
if (service.UUID === this.Service.WindowCovering.UUID
|
|
1007
|
+
&& channel.channelFunction === 'CONTROLLINGTHEROLLERSHUTTER') {
|
|
1008
|
+
obsolete.add(this.Characteristic.CurrentHorizontalTiltAngle.UUID);
|
|
1009
|
+
obsolete.add(this.Characteristic.TargetHorizontalTiltAngle.UUID);
|
|
1010
|
+
}
|
|
1011
|
+
if (service.UUID === this.Service.WindowCovering.UUID) {
|
|
1012
|
+
// StatusJammed is not part of the HAP WindowCovering service. Older
|
|
1013
|
+
// versions added it dynamically; remove that invalid composition.
|
|
1014
|
+
obsolete.add(this.Characteristic.StatusJammed.UUID);
|
|
1015
|
+
}
|
|
1016
|
+
for (const characteristic of [...service.characteristics]) {
|
|
1017
|
+
if (obsolete.has(characteristic.UUID)) {
|
|
1018
|
+
service.removeCharacteristic(characteristic);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
revokeAccessoryHandlers(accessory) {
|
|
1023
|
+
this.unregisterMqttHandlers(accessory.UUID);
|
|
1024
|
+
const unavailable = new this.api.hap.HapStatusError(-70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
401
1025
|
for (const service of accessory.services) {
|
|
402
|
-
if (service.UUID ===
|
|
1026
|
+
if (service.UUID === this.Service.AccessoryInformation.UUID) {
|
|
403
1027
|
continue;
|
|
404
1028
|
}
|
|
405
|
-
|
|
1029
|
+
for (const characteristic of service.characteristics) {
|
|
1030
|
+
characteristic.removeOnGet();
|
|
1031
|
+
characteristic.removeOnSet();
|
|
1032
|
+
if (characteristic.props.perms.includes("pr" /* this.api.hap.Perms.PAIRED_READ */)) {
|
|
1033
|
+
characteristic.updateValue(unavailable);
|
|
1034
|
+
}
|
|
1035
|
+
if (characteristic.props.perms.includes("pw" /* this.api.hap.Perms.PAIRED_WRITE */)) {
|
|
1036
|
+
characteristic.onSet(() => {
|
|
1037
|
+
throw new this.api.hap.HapStatusError(-70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
resolveHomeKitDisplayName(channel, currentName) {
|
|
1044
|
+
const captionName = channel.channelCaptionAuthoritative === true
|
|
1045
|
+
? (0, DisplayName_1.toHomeKitDisplayName)(channel.channelCaption)
|
|
1046
|
+
: undefined;
|
|
1047
|
+
const hasRenameEvidence = channel.channelCaptionSource === 'retained'
|
|
1048
|
+
|| channel.channelCaptionSource === 'authoritative_snapshot';
|
|
1049
|
+
if (captionName && hasRenameEvidence) {
|
|
1050
|
+
return captionName;
|
|
1051
|
+
}
|
|
1052
|
+
const safeCurrentName = (0, DisplayName_1.toHomeKitDisplayName)(currentName);
|
|
1053
|
+
if (safeCurrentName) {
|
|
1054
|
+
return safeCurrentName;
|
|
1055
|
+
}
|
|
1056
|
+
// A new accessory (or a damaged empty legacy display name) may use its
|
|
1057
|
+
// complete cached caption as an initial/recovery label. This is not allowed
|
|
1058
|
+
// to replace an already meaningful existing name.
|
|
1059
|
+
if (captionName) {
|
|
1060
|
+
return captionName;
|
|
1061
|
+
}
|
|
1062
|
+
return (0, DisplayName_1.toHomeKitDisplayName)((0, DisplayName_1.generatedChannelCaption)(channel.deviceId, channel.channelId))
|
|
1063
|
+
?? 'Supla Device';
|
|
1064
|
+
}
|
|
1065
|
+
captureAccessoryNames(accessory) {
|
|
1066
|
+
return {
|
|
1067
|
+
displayName: accessory.displayName,
|
|
1068
|
+
services: accessory.services.map((service) => {
|
|
1069
|
+
const characteristic = service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
|
|
1070
|
+
return {
|
|
1071
|
+
service,
|
|
1072
|
+
displayName: service.displayName,
|
|
1073
|
+
hadNameCharacteristic: characteristic !== undefined,
|
|
1074
|
+
nameValue: typeof characteristic?.value === 'string' ? characteristic.value : undefined,
|
|
1075
|
+
};
|
|
1076
|
+
}),
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
restoreAccessoryNames(accessory, snapshot) {
|
|
1080
|
+
try {
|
|
1081
|
+
accessory.updateDisplayName(snapshot.displayName);
|
|
1082
|
+
}
|
|
1083
|
+
catch (error) {
|
|
1084
|
+
this.log.error(`Failed to restore accessory display name ${snapshot.displayName}: ${error.message}`);
|
|
1085
|
+
}
|
|
1086
|
+
for (const entry of snapshot.services) {
|
|
1087
|
+
try {
|
|
1088
|
+
entry.service.displayName = entry.displayName;
|
|
1089
|
+
const current = entry.service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
|
|
1090
|
+
if (!entry.hadNameCharacteristic) {
|
|
1091
|
+
if (current) {
|
|
1092
|
+
entry.service.removeCharacteristic(current);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
else if (entry.nameValue !== undefined && current?.value !== entry.nameValue) {
|
|
1096
|
+
entry.service.setCharacteristic(this.Characteristic.Name, entry.nameValue);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
catch (error) {
|
|
1100
|
+
this.log.error(`Failed to restore service name for ${snapshot.displayName}: ${error.message}`);
|
|
1101
|
+
}
|
|
406
1102
|
}
|
|
407
1103
|
}
|
|
1104
|
+
restoreAccessoryContext(accessory, snapshot) {
|
|
1105
|
+
for (const key of Object.keys(accessory.context)) {
|
|
1106
|
+
delete accessory.context[key];
|
|
1107
|
+
}
|
|
1108
|
+
Object.assign(accessory.context, snapshot);
|
|
1109
|
+
}
|
|
1110
|
+
areChannelContextsEqual(current, incoming) {
|
|
1111
|
+
if (!current) {
|
|
1112
|
+
return false;
|
|
1113
|
+
}
|
|
1114
|
+
const fields = [
|
|
1115
|
+
'topic',
|
|
1116
|
+
'channelType',
|
|
1117
|
+
'channelFunction',
|
|
1118
|
+
'channelCaption',
|
|
1119
|
+
'deviceId',
|
|
1120
|
+
'channelId',
|
|
1121
|
+
'rawTopic',
|
|
1122
|
+
'channelCaptionAuthoritative',
|
|
1123
|
+
'channelCaptionSource',
|
|
1124
|
+
];
|
|
1125
|
+
return fields.every(field => current[field] === incoming[field]);
|
|
1126
|
+
}
|
|
1127
|
+
updateAccessoryNames(accessory, channel) {
|
|
1128
|
+
let changed = false;
|
|
1129
|
+
const caption = (0, DisplayName_1.normalizeVendorCaption)(channel.channelCaption);
|
|
1130
|
+
const homeKitName = this.resolveHomeKitDisplayName(channel, accessory.displayName);
|
|
1131
|
+
if (accessory.displayName !== homeKitName) {
|
|
1132
|
+
accessory.updateDisplayName(homeKitName);
|
|
1133
|
+
changed = true;
|
|
1134
|
+
}
|
|
1135
|
+
const informationService = accessory.getService(this.Service.AccessoryInformation);
|
|
1136
|
+
const informationName = informationService?.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
|
|
1137
|
+
if (informationService) {
|
|
1138
|
+
if (informationService.displayName !== homeKitName) {
|
|
1139
|
+
informationService.displayName = homeKitName;
|
|
1140
|
+
changed = true;
|
|
1141
|
+
}
|
|
1142
|
+
if (informationName?.value !== homeKitName) {
|
|
1143
|
+
informationService.setCharacteristic(this.Characteristic.Name, homeKitName);
|
|
1144
|
+
changed = true;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
const expected = this.getExpectedServiceUuids(channel, accessory);
|
|
1148
|
+
const electricityMetric = accessory.context.suplaElectricityMetric;
|
|
1149
|
+
for (const service of accessory.services) {
|
|
1150
|
+
if (expected.has(service.UUID)) {
|
|
1151
|
+
const serviceName = channel.channelFunction === 'ELECTRICITYMETER'
|
|
1152
|
+
&& typeof electricityMetric === 'string'
|
|
1153
|
+
? (0, DisplayName_1.homeKitElectricityServiceName)(homeKitName, electricityMetric)
|
|
1154
|
+
: homeKitName;
|
|
1155
|
+
const nameCharacteristic = service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
|
|
1156
|
+
if (service.displayName !== serviceName) {
|
|
1157
|
+
service.displayName = serviceName;
|
|
1158
|
+
changed = true;
|
|
1159
|
+
}
|
|
1160
|
+
if (nameCharacteristic?.value !== serviceName) {
|
|
1161
|
+
service.setCharacteristic(this.Characteristic.Name, serviceName);
|
|
1162
|
+
changed = true;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
if (accessory.context.suplaVendorCaption !== caption
|
|
1167
|
+
|| accessory.context.suplaVendorCaptionAuthoritative !== (channel.channelCaptionAuthoritative === true)
|
|
1168
|
+
|| accessory.context.suplaHomeKitDisplayName !== homeKitName) {
|
|
1169
|
+
changed = true;
|
|
1170
|
+
}
|
|
1171
|
+
accessory.context.suplaVendorCaption = caption;
|
|
1172
|
+
accessory.context.suplaVendorCaptionAuthoritative = channel.channelCaptionAuthoritative === true;
|
|
1173
|
+
accessory.context.suplaHomeKitDisplayName = homeKitName;
|
|
1174
|
+
return changed;
|
|
1175
|
+
}
|
|
408
1176
|
setupAccessory(channel, accessory) {
|
|
409
1177
|
this.unregisterMqttHandlers(accessory.UUID);
|
|
410
1178
|
this.log.debug(`Mapping channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
|
|
@@ -480,13 +1248,21 @@ class SuplaPlatform {
|
|
|
480
1248
|
new DimmerAccessory_1.DimmerAccessory(this, accessory, channel);
|
|
481
1249
|
return true;
|
|
482
1250
|
case 'RELAY':
|
|
483
|
-
|
|
1251
|
+
if (!accessory.getService(this.Service.Switch)
|
|
1252
|
+
&& accessory.getService(this.Service.Lightbulb)) {
|
|
1253
|
+
new LightBulbAccesory_1.LightAccesory(this, accessory, channel);
|
|
1254
|
+
}
|
|
1255
|
+
else {
|
|
1256
|
+
new SwitchAccessory_1.SwitchAccessory(this, accessory, channel);
|
|
1257
|
+
}
|
|
484
1258
|
return true;
|
|
485
1259
|
case 'BINARYSENSOR':
|
|
486
|
-
if (this.isLeakSensorChannel(channel)) {
|
|
1260
|
+
if (this.isLeakSensorChannel(channel, accessory)) {
|
|
1261
|
+
accessory.context.suplaBinarySensorKind = 'leak';
|
|
487
1262
|
new LeakSensorAccessory_1.LeakSensorAccessory(this, accessory, channel);
|
|
488
1263
|
}
|
|
489
1264
|
else {
|
|
1265
|
+
accessory.context.suplaBinarySensorKind = 'contact';
|
|
490
1266
|
new ContactSensorAccessory_1.ContactSensorAccessory(this, accessory, channel);
|
|
491
1267
|
}
|
|
492
1268
|
return true;
|
|
@@ -515,9 +1291,21 @@ class SuplaPlatform {
|
|
|
515
1291
|
this.log.warn(`Unsupported channel ${channel.channelCaption} (${channel.channelFunction}/${channel.channelType})`);
|
|
516
1292
|
return false;
|
|
517
1293
|
}
|
|
518
|
-
isLeakSensorChannel(channel) {
|
|
519
|
-
const
|
|
520
|
-
|
|
1294
|
+
isLeakSensorChannel(channel, accessory) {
|
|
1295
|
+
const functionName = (channel.channelFunction ?? '').trim().toUpperCase();
|
|
1296
|
+
if (functionName.includes('FLOOD')
|
|
1297
|
+
|| functionName.includes('WATERLEAK')
|
|
1298
|
+
|| functionName === 'LEAKSENSOR') {
|
|
1299
|
+
return true;
|
|
1300
|
+
}
|
|
1301
|
+
const persistedKind = accessory?.context?.suplaBinarySensorKind;
|
|
1302
|
+
if (persistedKind === 'leak' || persistedKind === 'contact') {
|
|
1303
|
+
return persistedKind === 'leak';
|
|
1304
|
+
}
|
|
1305
|
+
// Preserve the topology of accessories created by older releases, which
|
|
1306
|
+
// inferred this type from a caption. The migrated kind is persisted by
|
|
1307
|
+
// setupAccessory and subsequent captions no longer participate.
|
|
1308
|
+
return accessory?.services?.some(service => service.UUID === this.Service.LeakSensor.UUID) === true;
|
|
521
1309
|
}
|
|
522
1310
|
getCoveringControlMode() {
|
|
523
1311
|
return this.coveringControlMode;
|
|
@@ -561,6 +1349,97 @@ class SuplaPlatform {
|
|
|
561
1349
|
getGateLockSetOffPayload() {
|
|
562
1350
|
return this.gateLockSetOffPayload;
|
|
563
1351
|
}
|
|
1352
|
+
claimGateLockFailSafe(accessoryUuid) {
|
|
1353
|
+
const task = this.gateLockFailSafeTasks.get(accessoryUuid);
|
|
1354
|
+
if (task) {
|
|
1355
|
+
this.unregisterMqttHandlers(task.ownerId);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
transferGateLockFailSafe(accessory, stateTopic, commandTopic, offPayload) {
|
|
1359
|
+
if (this.shuttingDown || accessory.context.suplaGateLockPendingOff !== true) {
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
if (this.gateLockFailSafeTasks.has(accessory.UUID)) {
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
const task = {
|
|
1366
|
+
ownerId: `gate-lock-failsafe:${accessory.UUID}`,
|
|
1367
|
+
accessory,
|
|
1368
|
+
stateTopic,
|
|
1369
|
+
commandTopic,
|
|
1370
|
+
offPayload,
|
|
1371
|
+
attempted: false,
|
|
1372
|
+
};
|
|
1373
|
+
this.gateLockFailSafeTasks.set(accessory.UUID, task);
|
|
1374
|
+
this.registerOwnerCleanup(task.ownerId, () => {
|
|
1375
|
+
if (task.retryTimer) {
|
|
1376
|
+
clearTimeout(task.retryTimer);
|
|
1377
|
+
task.retryTimer = undefined;
|
|
1378
|
+
}
|
|
1379
|
+
if (this.gateLockFailSafeTasks.get(accessory.UUID) === task) {
|
|
1380
|
+
this.gateLockFailSafeTasks.delete(accessory.UUID);
|
|
1381
|
+
}
|
|
1382
|
+
});
|
|
1383
|
+
this.registerMqttHandler(stateTopic, (message, _topic, packet) => {
|
|
1384
|
+
const relayOn = this.parseBooleanStrict(message.toString());
|
|
1385
|
+
if (relayOn !== false || packet?.retain === true || !task.attempted) {
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
accessory.context.suplaGateLockPendingOff = false;
|
|
1389
|
+
const accessoryStillRegistered = Array.isArray(this.accessories)
|
|
1390
|
+
&& this.accessories.includes(accessory);
|
|
1391
|
+
try {
|
|
1392
|
+
if (accessoryStillRegistered) {
|
|
1393
|
+
this.api.updatePlatformAccessories([accessory]);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
catch (error) {
|
|
1397
|
+
accessory.context.suplaGateLockPendingOff = true;
|
|
1398
|
+
this.log.error(`Failed to persist transferred gate-lock OFF confirmation for ${accessory.displayName}: ` +
|
|
1399
|
+
`${error.message}`);
|
|
1400
|
+
this.scheduleGateLockFailSafeRetry(task);
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
this.log.info(`Gate-lock fail-safe OFF confirmed for ${accessory.displayName}.`);
|
|
1404
|
+
this.unregisterMqttHandlers(task.ownerId);
|
|
1405
|
+
}, task.ownerId);
|
|
1406
|
+
this.registerMqttTransportHandler((connected) => {
|
|
1407
|
+
if (connected) {
|
|
1408
|
+
this.attemptGateLockFailSafe(task);
|
|
1409
|
+
}
|
|
1410
|
+
}, task.ownerId);
|
|
1411
|
+
}
|
|
1412
|
+
attemptGateLockFailSafe(task) {
|
|
1413
|
+
if (this.shuttingDown
|
|
1414
|
+
|| this.gateLockFailSafeTasks.get(task.accessory.UUID) !== task
|
|
1415
|
+
|| task.publishInFlight) {
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
task.attempted = true;
|
|
1419
|
+
this.log.warn(`Publishing transferred gate-lock fail-safe OFF for ${task.accessory.displayName}.`);
|
|
1420
|
+
const operation = this.publishActionAsync(task.commandTopic, task.offPayload);
|
|
1421
|
+
task.publishInFlight = operation;
|
|
1422
|
+
void operation.catch((error) => {
|
|
1423
|
+
this.log.warn(`Transferred gate-lock OFF remains unconfirmed for ${task.accessory.displayName}: ` +
|
|
1424
|
+
`${error.message}`);
|
|
1425
|
+
}).finally(() => {
|
|
1426
|
+
if (task.publishInFlight === operation) {
|
|
1427
|
+
task.publishInFlight = undefined;
|
|
1428
|
+
}
|
|
1429
|
+
this.scheduleGateLockFailSafeRetry(task);
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
scheduleGateLockFailSafeRetry(task) {
|
|
1433
|
+
if (this.shuttingDown
|
|
1434
|
+
|| task.retryTimer
|
|
1435
|
+
|| this.gateLockFailSafeTasks.get(task.accessory.UUID) !== task) {
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
task.retryTimer = setTimeout(() => {
|
|
1439
|
+
task.retryTimer = undefined;
|
|
1440
|
+
this.attemptGateLockFailSafe(task);
|
|
1441
|
+
}, 5000);
|
|
1442
|
+
}
|
|
564
1443
|
getFrontGateConfig() {
|
|
565
1444
|
return {
|
|
566
1445
|
fullTravelMs: this.frontGateFullTravelMs,
|
|
@@ -598,6 +1477,13 @@ class SuplaPlatform {
|
|
|
598
1477
|
}
|
|
599
1478
|
return 'execute_action';
|
|
600
1479
|
}
|
|
1480
|
+
normalizeDurationSeconds(value, fallback, min, max) {
|
|
1481
|
+
const parsed = Number(value);
|
|
1482
|
+
if (!Number.isFinite(parsed)) {
|
|
1483
|
+
return fallback;
|
|
1484
|
+
}
|
|
1485
|
+
return Math.min(max, Math.max(min, parsed));
|
|
1486
|
+
}
|
|
601
1487
|
normalizeFrontGateFullTravelMs(value) {
|
|
602
1488
|
const fallbackMs = 25000;
|
|
603
1489
|
const parsed = Number(value);
|
|
@@ -724,6 +1610,20 @@ class SuplaPlatform {
|
|
|
724
1610
|
}
|
|
725
1611
|
};
|
|
726
1612
|
}
|
|
1613
|
+
registerShutdownDrain(ownerId, drain) {
|
|
1614
|
+
const drains = this.shutdownDrains.get(ownerId) ?? new Set();
|
|
1615
|
+
drains.add(drain);
|
|
1616
|
+
this.shutdownDrains.set(ownerId, drains);
|
|
1617
|
+
const cleanup = () => {
|
|
1618
|
+
const active = this.shutdownDrains.get(ownerId);
|
|
1619
|
+
active?.delete(drain);
|
|
1620
|
+
if (active?.size === 0) {
|
|
1621
|
+
this.shutdownDrains.delete(ownerId);
|
|
1622
|
+
}
|
|
1623
|
+
};
|
|
1624
|
+
this.registerOwnerCleanup(ownerId, cleanup);
|
|
1625
|
+
return cleanup;
|
|
1626
|
+
}
|
|
727
1627
|
unregisterMqttHandlers(ownerId) {
|
|
728
1628
|
this.runOwnerCleanup(ownerId);
|
|
729
1629
|
const ownerTopics = this.mqttHandlerOwners.get(ownerId);
|
|
@@ -755,14 +1655,70 @@ class SuplaPlatform {
|
|
|
755
1655
|
this.mqttDesiredSubscriptions.clear();
|
|
756
1656
|
this.mqttSubscriptions.clear();
|
|
757
1657
|
this.mqttPendingSubscriptions.clear();
|
|
1658
|
+
this.mqttPendingSubscriptionOperations.clear();
|
|
758
1659
|
this.clearAllSubscriptionRetries();
|
|
1660
|
+
this.shutdownDrains.clear();
|
|
759
1661
|
this.ownerCleanups.clear();
|
|
760
1662
|
}
|
|
761
1663
|
publishCommand(topic, payload, callback) {
|
|
762
|
-
this.publishMqtt(topic, payload, { qos: this.commandQos, retain: this.commandRetain }, callback);
|
|
1664
|
+
this.publishMqtt(topic, payload, { qos: this.commandQos, retain: this.commandRetain }, error => callback?.(error));
|
|
1665
|
+
}
|
|
1666
|
+
publishCommandAsync(topic, payload) {
|
|
1667
|
+
return this.publishMqttAsync(topic, payload, { qos: this.commandQos, retain: this.commandRetain });
|
|
763
1668
|
}
|
|
764
1669
|
publishGateAction(topic, payload, callback) {
|
|
765
|
-
this.publishMqtt(topic, payload, { qos: 0, retain: false },
|
|
1670
|
+
this.publishMqtt(topic, payload, { qos: 0, retain: false }, (error, failureOrigin) => {
|
|
1671
|
+
if (error && failureOrigin === 'callback') {
|
|
1672
|
+
error.mqttPublishFailureKind = 'completion_unknown';
|
|
1673
|
+
}
|
|
1674
|
+
callback?.(error);
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
publishActionAsync(topic, payload) {
|
|
1678
|
+
return this.publishMqttAsync(topic, payload, { qos: 0, retain: false }, true);
|
|
1679
|
+
}
|
|
1680
|
+
publishGateActionAsync(topic, payload) {
|
|
1681
|
+
return this.publishActionAsync(topic, payload);
|
|
1682
|
+
}
|
|
1683
|
+
isPublishCompletionUnknown(error) {
|
|
1684
|
+
return error instanceof Error
|
|
1685
|
+
&& error.mqttPublishFailureKind === 'completion_unknown';
|
|
1686
|
+
}
|
|
1687
|
+
publishMqttAsync(topic, payload, options, callbackFailureIsAmbiguous = false) {
|
|
1688
|
+
return new Promise((resolve, reject) => {
|
|
1689
|
+
let settled = false;
|
|
1690
|
+
const finish = (kind, error) => {
|
|
1691
|
+
if (settled) {
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
settled = true;
|
|
1695
|
+
clearTimeout(timer);
|
|
1696
|
+
if (!kind || !error) {
|
|
1697
|
+
resolve();
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
this.log.error(`Publish failed for ${topic}: ${error.message}`);
|
|
1701
|
+
const hapError = new this.api.hap.HapStatusError(kind === 'completion_unknown'
|
|
1702
|
+
? -70408 /* this.api.hap.HAPStatus.OPERATION_TIMED_OUT */
|
|
1703
|
+
: -70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
1704
|
+
hapError.mqttPublishFailureKind = kind;
|
|
1705
|
+
hapError.cause = error;
|
|
1706
|
+
reject(hapError);
|
|
1707
|
+
};
|
|
1708
|
+
const timer = setTimeout(() => {
|
|
1709
|
+
finish('completion_unknown', new Error(`MQTT publish completion timed out after ${this.publishTimeoutMs}ms`));
|
|
1710
|
+
}, this.publishTimeoutMs);
|
|
1711
|
+
this.publishMqtt(topic, payload, options, (error, failureOrigin) => {
|
|
1712
|
+
if (error) {
|
|
1713
|
+
finish(callbackFailureIsAmbiguous && failureOrigin === 'callback'
|
|
1714
|
+
? 'completion_unknown'
|
|
1715
|
+
: 'definite_failure', error);
|
|
1716
|
+
}
|
|
1717
|
+
else {
|
|
1718
|
+
finish();
|
|
1719
|
+
}
|
|
1720
|
+
});
|
|
1721
|
+
});
|
|
766
1722
|
}
|
|
767
1723
|
publishMqtt(topic, payload, options, callback) {
|
|
768
1724
|
const client = this.MqttClient?.client;
|
|
@@ -770,19 +1726,30 @@ class SuplaPlatform {
|
|
|
770
1726
|
const error = new Error('MQTT not connected');
|
|
771
1727
|
this.log.error(`MQTT not connected; cannot publish ${topic}`);
|
|
772
1728
|
if (callback) {
|
|
773
|
-
callback(error);
|
|
1729
|
+
callback(error, 'preflight');
|
|
774
1730
|
}
|
|
775
1731
|
return;
|
|
776
1732
|
}
|
|
777
|
-
|
|
1733
|
+
try {
|
|
1734
|
+
client.publish(topic, payload, options, (error) => {
|
|
1735
|
+
if (callback) {
|
|
1736
|
+
callback(error, error ? 'callback' : undefined);
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
if (error) {
|
|
1740
|
+
this.log.error(`Publish failed for ${topic}: ${error.message}`);
|
|
1741
|
+
}
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
catch (error) {
|
|
1745
|
+
const publishError = error instanceof Error ? error : new Error(String(error));
|
|
778
1746
|
if (callback) {
|
|
779
|
-
callback(
|
|
780
|
-
return;
|
|
1747
|
+
callback(publishError, 'preflight');
|
|
781
1748
|
}
|
|
782
|
-
|
|
783
|
-
this.log.error(`Publish failed for ${topic}: ${
|
|
1749
|
+
else {
|
|
1750
|
+
this.log.error(`Publish failed for ${topic}: ${publishError.message}`);
|
|
784
1751
|
}
|
|
785
|
-
}
|
|
1752
|
+
}
|
|
786
1753
|
}
|
|
787
1754
|
parseBoolean(value) {
|
|
788
1755
|
if (typeof value === 'boolean') {
|
|
@@ -825,6 +1792,21 @@ class SuplaPlatform {
|
|
|
825
1792
|
}
|
|
826
1793
|
return undefined;
|
|
827
1794
|
}
|
|
1795
|
+
parseFiniteNumberStrict(value) {
|
|
1796
|
+
if (typeof value === 'number') {
|
|
1797
|
+
return Number.isFinite(value) ? value : undefined;
|
|
1798
|
+
}
|
|
1799
|
+
if (typeof value !== 'string') {
|
|
1800
|
+
return undefined;
|
|
1801
|
+
}
|
|
1802
|
+
const normalized = value.trim();
|
|
1803
|
+
if (!normalized
|
|
1804
|
+
|| !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) {
|
|
1805
|
+
return undefined;
|
|
1806
|
+
}
|
|
1807
|
+
const parsed = Number(normalized);
|
|
1808
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
1809
|
+
}
|
|
828
1810
|
runOwnerCleanup(ownerId) {
|
|
829
1811
|
const cleanups = this.ownerCleanups.get(ownerId);
|
|
830
1812
|
if (!cleanups) {
|
|
@@ -856,12 +1838,17 @@ class SuplaPlatform {
|
|
|
856
1838
|
}
|
|
857
1839
|
}
|
|
858
1840
|
}
|
|
1841
|
+
advanceMqttTransportEpoch() {
|
|
1842
|
+
this.mqttTransportEpoch += 1;
|
|
1843
|
+
}
|
|
859
1844
|
clearActiveSubscriptions() {
|
|
1845
|
+
this.advanceMqttTransportEpoch();
|
|
860
1846
|
if (this.mqttSubscriptions.size > 0 || this.mqttPendingSubscriptions.size > 0) {
|
|
861
1847
|
this.log.debug('MQTT connection lost; clearing active subscriptions.');
|
|
862
1848
|
}
|
|
863
1849
|
this.mqttSubscriptions.clear();
|
|
864
1850
|
this.mqttPendingSubscriptions.clear();
|
|
1851
|
+
this.mqttPendingSubscriptionOperations.clear();
|
|
865
1852
|
this.clearAllSubscriptionRetries();
|
|
866
1853
|
}
|
|
867
1854
|
clearAllSubscriptionRetries() {
|
|
@@ -916,9 +1903,11 @@ class SuplaPlatform {
|
|
|
916
1903
|
}
|
|
917
1904
|
this.mqttRetryState.set(topic, state);
|
|
918
1905
|
this.log.debug(`Retrying MQTT subscribe for ${topic} in ${scheduledDelayMs}ms (${reason}).`);
|
|
1906
|
+
const scheduledEpoch = this.mqttTransportEpoch;
|
|
919
1907
|
const timer = setTimeout(() => {
|
|
920
1908
|
this.mqttRetryTimers.delete(topic);
|
|
921
|
-
if (
|
|
1909
|
+
if (scheduledEpoch !== this.mqttTransportEpoch
|
|
1910
|
+
|| !this.mqttDesiredSubscriptions.has(topic)) {
|
|
922
1911
|
return;
|
|
923
1912
|
}
|
|
924
1913
|
this.ensureSubscribed(topic, true);
|
|
@@ -927,7 +1916,7 @@ class SuplaPlatform {
|
|
|
927
1916
|
}
|
|
928
1917
|
isSubscriptionGranted(topic, granted) {
|
|
929
1918
|
if (!Array.isArray(granted) || granted.length === 0) {
|
|
930
|
-
return
|
|
1919
|
+
return false;
|
|
931
1920
|
}
|
|
932
1921
|
const entry = granted.find((item) => item.topic === topic);
|
|
933
1922
|
if (!entry) {
|
|
@@ -971,11 +1960,25 @@ class SuplaPlatform {
|
|
|
971
1960
|
return;
|
|
972
1961
|
}
|
|
973
1962
|
this.mqttPendingSubscriptions.add(topic);
|
|
1963
|
+
const client = this.MqttClient.client;
|
|
1964
|
+
const operation = {
|
|
1965
|
+
client,
|
|
1966
|
+
epoch: this.mqttTransportEpoch,
|
|
1967
|
+
id: ++this.nextMqttSubscriptionOperationId,
|
|
1968
|
+
};
|
|
1969
|
+
this.mqttPendingSubscriptionOperations.set(topic, operation);
|
|
974
1970
|
const subscribeOptions = {
|
|
975
1971
|
qos: 0,
|
|
976
1972
|
...(this.mqttNoLocalTopics.has(topic) && this.mqttProtocolVersion === 5 ? { nl: true } : {}),
|
|
977
1973
|
};
|
|
978
|
-
|
|
1974
|
+
client.subscribe(topic, subscribeOptions, (err, granted) => {
|
|
1975
|
+
if (this.mqttPendingSubscriptionOperations.get(topic) !== operation
|
|
1976
|
+
|| this.mqttTransportEpoch !== operation.epoch
|
|
1977
|
+
|| this.MqttClient?.client !== operation.client
|
|
1978
|
+
|| !operation.client.connected) {
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
this.mqttPendingSubscriptionOperations.delete(topic);
|
|
979
1982
|
this.mqttPendingSubscriptions.delete(topic);
|
|
980
1983
|
if (err) {
|
|
981
1984
|
this.logSubscriptionIssue(topic, `MQTT subscribe failed for ${topic}: ${err.message}`);
|
|
@@ -1014,6 +2017,7 @@ class SuplaPlatform {
|
|
|
1014
2017
|
}
|
|
1015
2018
|
if (force) {
|
|
1016
2019
|
this.mqttPendingSubscriptions.clear();
|
|
2020
|
+
this.mqttPendingSubscriptionOperations.clear();
|
|
1017
2021
|
}
|
|
1018
2022
|
for (const topic of this.mqttDesiredSubscriptions) {
|
|
1019
2023
|
this.ensureSubscribed(topic, force);
|
|
@@ -1023,18 +2027,17 @@ class SuplaPlatform {
|
|
|
1023
2027
|
this.mqttDesiredSubscriptions.delete(topic);
|
|
1024
2028
|
this.mqttNoLocalTopics.delete(topic);
|
|
1025
2029
|
this.mqttPendingSubscriptions.delete(topic);
|
|
2030
|
+
this.mqttPendingSubscriptionOperations.delete(topic);
|
|
1026
2031
|
this.clearSubscriptionRetry(topic);
|
|
1027
|
-
|
|
2032
|
+
const wasSubscribed = this.mqttSubscriptions.delete(topic);
|
|
2033
|
+
if (!wasSubscribed || !this.MqttClient || this.shuttingDown) {
|
|
1028
2034
|
return;
|
|
1029
2035
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
});
|
|
1036
|
-
this.mqttSubscriptions.delete(topic);
|
|
1037
|
-
}
|
|
2036
|
+
this.MqttClient.client.unsubscribe(topic, (err) => {
|
|
2037
|
+
if (err) {
|
|
2038
|
+
this.log.error(`MQTT unsubscribe failed for ${topic}: ${err.message}`);
|
|
2039
|
+
}
|
|
2040
|
+
});
|
|
1038
2041
|
}
|
|
1039
2042
|
removeHandler(topic, handler) {
|
|
1040
2043
|
const handlerMap = this.isWildcardTopic(topic) ? this.mqttWildcardHandlers : this.mqttHandlers;
|