@marcel2215/homebridge-supla-plugin 2.1.28 → 2.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -1
- package/config.schema.json +92 -75
- 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 +74 -68
- package/dist/Accesories/FrontGateFsm.d.ts.map +1 -1
- package/dist/Accesories/FrontGateFsm.js +563 -403
- 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 +12 -2
- package/dist/Accesories/GateAccessory.d.ts.map +1 -1
- package/dist/Accesories/GateAccessory.js +245 -90
- 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 +221 -75
- 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 +11 -3
- package/dist/Heplers/SuplaMqttClient.d.ts.map +1 -1
- package/dist/Heplers/SuplaMqttClient.js +215 -112
- package/dist/Heplers/SuplaMqttClient.js.map +1 -1
- package/dist/Heplers/SuplaMqttClientContext.d.ts +2 -1
- package/dist/Heplers/SuplaMqttClientContext.d.ts.map +1 -1
- package/dist/Heplers/SuplaMqttClientContext.js +2 -1
- package/dist/Heplers/SuplaMqttClientContext.js.map +1 -1
- package/dist/platform.d.ts +92 -36
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +1253 -235
- package/dist/platform.js.map +1 -1
- package/package.json +10 -5
- package/config.json +0 -3
package/dist/platform.js
CHANGED
|
@@ -38,6 +38,8 @@ const settings_1 = require("./settings");
|
|
|
38
38
|
const GarageDoorOpenerAccesory_1 = require("./Accesories/GarageDoorOpenerAccesory");
|
|
39
39
|
const LightBulbAccesory_1 = require("./Accesories/LightBulbAccesory");
|
|
40
40
|
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const crypto_1 = require("crypto");
|
|
41
43
|
const SuplaMqttClient_1 = require("./Heplers/SuplaMqttClient");
|
|
42
44
|
const RGBLightBulbAccesory_1 = require("./Accesories/RGBLightBulbAccesory");
|
|
43
45
|
const GateAccessory_1 = require("./Accesories/GateAccessory");
|
|
@@ -58,6 +60,7 @@ const ThermostatAccessory_1 = require("./Accesories/ThermostatAccessory");
|
|
|
58
60
|
const ElectricityMeterAccessory_1 = require("./Accesories/ElectricityMeterAccessory");
|
|
59
61
|
const DimmerRgbLightAccessory_1 = require("./Accesories/DimmerRgbLightAccessory");
|
|
60
62
|
const ActionTriggerAccessory_1 = require("./Accesories/ActionTriggerAccessory");
|
|
63
|
+
const DisplayName_1 = require("./Heplers/DisplayName");
|
|
61
64
|
/**
|
|
62
65
|
* HomebridgePlatform
|
|
63
66
|
* This class is the main constructor for your plugin, this is where you should
|
|
@@ -74,91 +77,194 @@ class SuplaPlatform {
|
|
|
74
77
|
this.mqttHandlers = new Map();
|
|
75
78
|
this.mqttHandlerOwners = new Map();
|
|
76
79
|
this.mqttWildcardHandlers = new Map();
|
|
80
|
+
this.mqttNoLocalTopics = new Set();
|
|
81
|
+
this.mqttTransportHandlers = new Map();
|
|
82
|
+
this.channelRegistry = new Map();
|
|
77
83
|
this.ownerCleanups = new Map();
|
|
84
|
+
this.shutdownDrains = new Map();
|
|
85
|
+
this.gateLockFailSafeTasks = new Map();
|
|
78
86
|
this.mqttDesiredSubscriptions = new Set();
|
|
79
87
|
this.mqttSubscriptions = new Set();
|
|
80
88
|
this.mqttPendingSubscriptions = new Set();
|
|
89
|
+
this.mqttPendingSubscriptionOperations = new Map();
|
|
81
90
|
this.mqttRetryTimers = new Map();
|
|
82
91
|
this.mqttRetryState = new Map();
|
|
83
92
|
this.mqttRouterAttached = false;
|
|
93
|
+
this.mqttTransportConnected = false;
|
|
94
|
+
this.mqttTransportEpoch = 0;
|
|
95
|
+
this.nextMqttSubscriptionOperationId = 0;
|
|
96
|
+
this.shuttingDown = false;
|
|
97
|
+
this.publishTimeoutMs = 5000;
|
|
84
98
|
this.log.debug('Finished initializing platform:', this.config.name);
|
|
99
|
+
this.accountNamespace = this.computeAccountNamespace();
|
|
85
100
|
const configView = this.config;
|
|
86
101
|
this.coveringControlMode = this.normalizeCoveringControlMode(configView.coveringControlMode);
|
|
87
102
|
this.coveringSetTopicSuffix = this.normalizeTopicSuffix(configView.coveringSetTopicSuffix || 'set/closing_percentage');
|
|
88
103
|
this.coveringTiltTopicSuffix = this.normalizeTopicSuffix(configView.coveringTiltTopicSuffix || 'set/tilt');
|
|
89
|
-
this.coveringExecuteActionOpen = (configView.coveringExecuteActionOpen
|
|
90
|
-
this.coveringExecuteActionClose = (configView.coveringExecuteActionClose
|
|
91
|
-
this.coveringExecuteActionStop = (configView.coveringExecuteActionStop
|
|
92
|
-
this.coveringTravelTimeSeconds =
|
|
93
|
-
this.
|
|
94
|
-
this.gateExecuteActionOpen = (configView.gateExecuteActionOpen || 'open_close').toString();
|
|
95
|
-
this.gateExecuteActionClose = (configView.gateExecuteActionClose || 'open_close').toString();
|
|
96
|
-
this.gateExecuteActionToggle = (configView.gateExecuteActionToggle || 'open_close').toString();
|
|
104
|
+
this.coveringExecuteActionOpen = String(configView.coveringExecuteActionOpen ?? '').trim() || 'reveal';
|
|
105
|
+
this.coveringExecuteActionClose = String(configView.coveringExecuteActionClose ?? '').trim() || 'shut';
|
|
106
|
+
this.coveringExecuteActionStop = String(configView.coveringExecuteActionStop ?? '').trim() || 'stop';
|
|
107
|
+
this.coveringTravelTimeSeconds = this.normalizeDurationSeconds(configView.coveringTravelTimeSeconds, 0, 0, 600);
|
|
108
|
+
this.gateExecuteActionToggle = String(configView.gateExecuteActionToggle ?? '').trim() || 'open_close';
|
|
97
109
|
this.gateLockControlMode = this.normalizeGateLockControlMode(configView.gateLockControlMode);
|
|
98
|
-
this.gateLockExecuteAction = (configView.gateLockExecuteAction
|
|
110
|
+
this.gateLockExecuteAction = String(configView.gateLockExecuteAction ?? '').trim() || 'open';
|
|
99
111
|
this.gateLockSetTopicSuffix = this.normalizeTopicSuffix(configView.gateLockSetTopicSuffix || 'set/on');
|
|
100
|
-
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);
|
|
101
113
|
this.gateLockSetOnPayload = (configView.gateLockSetOnPayload ?? 'true').toString();
|
|
102
114
|
this.gateLockSetOffPayload = (configView.gateLockSetOffPayload ?? 'false').toString();
|
|
103
|
-
this.
|
|
104
|
-
this.gateReverseFollowUpDelayMs = this.normalizeGateReverseFollowUpDelayMs(configView.gateReverseFollowUpDelayMs);
|
|
105
|
-
this.gateOpenAssumeDelayMs = this.normalizeGateOpenAssumeDelayMs(configView.gateOpenAssumeDelayMs);
|
|
106
|
-
this.gateCommandCooldownMs = this.normalizeGateCommandCooldownMs(configView.gateCommandCooldownMs);
|
|
107
|
-
this.gatePublishRetryDelayMs = this.normalizeGatePublishRetryDelayMs(configView.gatePublishRetryDelayMs);
|
|
108
|
-
this.gateStrictReverseDoublePulse = this.parseBoolean(configView.gateStrictReverseDoublePulse ?? true);
|
|
109
|
-
this.gateDebugTimeline = this.parseBoolean(configView.gateDebugTimeline ?? false);
|
|
110
|
-
this.frontGateFullTravelMs = this.normalizeFrontGateFullTravelMs(configView.frontGateFullTravelMs ?? configView.gateOpenAssumeDelayMs);
|
|
115
|
+
this.frontGateFullTravelMs = this.normalizeFrontGateFullTravelMs(configView.frontGateFullTravelMs);
|
|
111
116
|
this.frontGateReversePauseMs = this.normalizeFrontGateReversePauseMs(configView.frontGateReversePauseMs);
|
|
112
|
-
this.
|
|
113
|
-
this.
|
|
114
|
-
this.
|
|
117
|
+
this.frontGateMinimumPulseGapMs = this.normalizeFrontGateMinimumPulseGapMs(configView.frontGateMinimumPulseGapMs);
|
|
118
|
+
this.frontGateUnknownOpenPolicy = this.normalizeFrontGateUnknownOpenPolicy(configView.frontGateUnknownOpenPolicy);
|
|
119
|
+
this.frontGateUnknownClosePolicy = this.normalizeFrontGateUnknownClosePolicy(configView.frontGateUnknownClosePolicy);
|
|
120
|
+
this.frontGateSeekClosedMaxPulses = this.normalizeFrontGateSeekClosedMaxPulses(configView.frontGateSeekClosedMaxPulses);
|
|
121
|
+
this.frontGateAssumeOpenAfterTravel = this.parseBoolean(configView.frontGateAssumeOpenAfterTravel ?? false);
|
|
122
|
+
this.frontGateSensorFallbackToControlChannel = this.parseBoolean(configView.frontGateSensorFallbackToControlChannel ?? false);
|
|
123
|
+
this.mqttProtocolVersion = this.normalizeMqttProtocolVersion(configView.mqttProtocolVersion);
|
|
115
124
|
this.commandQos = this.normalizeCommandQos(configView.commandQos);
|
|
116
125
|
this.commandRetain = this.parseBoolean(configView.commandRetain ?? false);
|
|
117
126
|
this.api.on('shutdown', () => {
|
|
118
|
-
this.
|
|
119
|
-
if (this.MqttClient) {
|
|
120
|
-
this.MqttClient.client.end(true);
|
|
121
|
-
}
|
|
127
|
+
void this.shutdownGracefully();
|
|
122
128
|
});
|
|
123
129
|
this.api.on('didFinishLaunching', () => {
|
|
124
130
|
log.debug('Executed didFinishLaunching callback');
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
this.startMqttRouter();
|
|
128
|
-
this.MqttClient.client.on('connect', () => {
|
|
129
|
-
this.resubscribeAll(true);
|
|
130
|
-
});
|
|
131
|
-
this.MqttClient.client.on('close', () => {
|
|
132
|
-
this.clearActiveSubscriptions();
|
|
133
|
-
});
|
|
134
|
-
this.MqttClient.client.on('offline', () => {
|
|
135
|
-
this.clearActiveSubscriptions();
|
|
136
|
-
});
|
|
137
|
-
this.MqttClient.client.on('end', () => {
|
|
138
|
-
this.clearActiveSubscriptions();
|
|
139
|
-
});
|
|
140
|
-
if (this.MqttClient.client.connected) {
|
|
141
|
-
this.resubscribeAll(true);
|
|
142
|
-
}
|
|
143
|
-
this.discoverDevices();
|
|
144
|
-
this.MqttClient.discoverChannelsAsync((topic, handler) => (this.registerMqttHandler(topic, handler, 'discovery'))).then((channels) => {
|
|
145
|
-
this.persistChannels(channels);
|
|
146
|
-
this.discoverDevices(channels);
|
|
147
|
-
this.log.info('Channels discovered and saved to config file');
|
|
148
|
-
}).catch((error) => {
|
|
149
|
-
this.log.error(`Channel discovery failed: ${error.message}`);
|
|
131
|
+
void this.start().catch((error) => {
|
|
132
|
+
this.log.error(`Supla startup failed: ${error.message}`);
|
|
150
133
|
});
|
|
151
134
|
});
|
|
152
135
|
}
|
|
136
|
+
async start() {
|
|
137
|
+
const mqttSettings = this.config;
|
|
138
|
+
this.MqttClient = new SuplaMqttClient_1.SuplaMqttClient(mqttSettings, this.log);
|
|
139
|
+
this.startMqttRouter();
|
|
140
|
+
this.MqttClient.client.on('connect', () => {
|
|
141
|
+
if (this.shuttingDown) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.advanceMqttTransportEpoch();
|
|
145
|
+
this.updateMqttTransportState(true);
|
|
146
|
+
this.resubscribeAll(true);
|
|
147
|
+
});
|
|
148
|
+
this.MqttClient.client.on('close', () => {
|
|
149
|
+
this.updateMqttTransportState(false);
|
|
150
|
+
this.clearActiveSubscriptions();
|
|
151
|
+
});
|
|
152
|
+
this.MqttClient.client.on('offline', () => {
|
|
153
|
+
this.updateMqttTransportState(false);
|
|
154
|
+
this.clearActiveSubscriptions();
|
|
155
|
+
});
|
|
156
|
+
this.MqttClient.client.on('end', () => {
|
|
157
|
+
this.updateMqttTransportState(false);
|
|
158
|
+
this.clearActiveSubscriptions();
|
|
159
|
+
});
|
|
160
|
+
if (this.MqttClient.client.connected) {
|
|
161
|
+
this.advanceMqttTransportEpoch();
|
|
162
|
+
this.updateMqttTransportState(true);
|
|
163
|
+
this.resubscribeAll(true);
|
|
164
|
+
}
|
|
165
|
+
this.discoverDevices();
|
|
166
|
+
const snapshot = await this.MqttClient.discoverChannelsSnapshotAsync();
|
|
167
|
+
if (this.shuttingDown) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
this.discoverDevices(snapshot.items, snapshot);
|
|
171
|
+
this.log.info(`MQTT discovery merged ${snapshot.items.length} channel(s); ` +
|
|
172
|
+
`authoritative=${snapshot.authoritative} complete=${snapshot.complete}.`);
|
|
173
|
+
}
|
|
174
|
+
shutdownGracefully() {
|
|
175
|
+
if (this.shutdownPromise) {
|
|
176
|
+
return this.shutdownPromise;
|
|
177
|
+
}
|
|
178
|
+
this.shutdownPromise = this.performGracefulShutdown();
|
|
179
|
+
return this.shutdownPromise;
|
|
180
|
+
}
|
|
181
|
+
async performGracefulShutdown() {
|
|
182
|
+
const drains = Array.from(this.shutdownDrains.values())
|
|
183
|
+
.flatMap(ownerDrains => Array.from(ownerDrains));
|
|
184
|
+
if (drains.length > 0) {
|
|
185
|
+
const drainTimeoutMs = 1500;
|
|
186
|
+
let timeout;
|
|
187
|
+
try {
|
|
188
|
+
await Promise.race([
|
|
189
|
+
Promise.allSettled(drains.map(drain => Promise.resolve().then(drain))),
|
|
190
|
+
new Promise((resolve) => {
|
|
191
|
+
timeout = setTimeout(resolve, drainTimeoutMs);
|
|
192
|
+
}),
|
|
193
|
+
]);
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
if (timeout) {
|
|
197
|
+
clearTimeout(timeout);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
this.shuttingDown = true;
|
|
202
|
+
this.unregisterAllMqttHandlers();
|
|
203
|
+
const client = this.MqttClient?.client;
|
|
204
|
+
if (!client) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
await new Promise((resolve) => {
|
|
208
|
+
let settled = false;
|
|
209
|
+
const finish = () => {
|
|
210
|
+
if (settled) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
settled = true;
|
|
214
|
+
clearTimeout(forceTimer);
|
|
215
|
+
resolve();
|
|
216
|
+
};
|
|
217
|
+
const forceTimer = setTimeout(() => {
|
|
218
|
+
try {
|
|
219
|
+
client.end(true);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
this.log.debug(`Forced MQTT shutdown failed: ${error.message}`);
|
|
223
|
+
}
|
|
224
|
+
finish();
|
|
225
|
+
}, 1000);
|
|
226
|
+
try {
|
|
227
|
+
client.end(false, {}, finish);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
this.log.debug(`Graceful MQTT shutdown failed: ${error.message}`);
|
|
231
|
+
try {
|
|
232
|
+
client.end(true);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// The client is already closing; the bounded timer/finish path owns completion.
|
|
236
|
+
}
|
|
237
|
+
finish();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
153
241
|
/**
|
|
154
242
|
* This function is invoked when homebridge restores cached accessories from disk at startup.
|
|
155
243
|
* It should be used to setup event handlers for characteristics and update respective values.
|
|
156
244
|
*/
|
|
157
245
|
configureAccessory(accessory) {
|
|
158
246
|
this.log.info('Loading accessory from cache:', accessory.displayName);
|
|
247
|
+
const cachedDevice = accessory.context.device;
|
|
248
|
+
const storedNamespace = accessory.context.suplaAccountNamespace;
|
|
249
|
+
let mismatchRetirementFailed = false;
|
|
250
|
+
if (typeof storedNamespace === 'string'
|
|
251
|
+
&& storedNamespace.length > 0
|
|
252
|
+
&& storedNamespace !== this.accountNamespace) {
|
|
253
|
+
try {
|
|
254
|
+
this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
|
|
255
|
+
this.log.info(`Retired cached accessory ${accessory.displayName} because its stored Supla account identity differs.`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
this.log.error(`Failed to retire account-incompatible accessory ${accessory.displayName}: ${error.message}`);
|
|
260
|
+
// Keep tracking the accessory so it can be made unavailable safely.
|
|
261
|
+
mismatchRetirementFailed = true;
|
|
262
|
+
accessory.context.deviceConfigured = false;
|
|
263
|
+
this.revokeAccessoryHandlers(accessory);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
159
266
|
// add the restored accessory to the accessories cache so we can track if it has already been registered
|
|
160
267
|
this.accessories.push(accessory);
|
|
161
|
-
const cachedDevice = accessory.context.device;
|
|
162
268
|
if (!cachedDevice || typeof cachedDevice !== 'object') {
|
|
163
269
|
this.log.warn(`Cached accessory ${accessory.displayName} missing device context; will configure after discovery.`);
|
|
164
270
|
accessory.context.deviceConfigured = false;
|
|
@@ -166,27 +272,75 @@ class SuplaPlatform {
|
|
|
166
272
|
}
|
|
167
273
|
const normalized = this.normalizeChannelContext(cachedDevice);
|
|
168
274
|
accessory.context.device = normalized;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
275
|
+
if (!this.isAccessoryAccountCompatible(accessory, normalized)) {
|
|
276
|
+
this.log.warn(`Cached accessory ${accessory.displayName} belongs to a different Supla account; ` +
|
|
277
|
+
'leaving it disabled until an authoritative discovery can retire it.');
|
|
278
|
+
accessory.context.deviceConfigured = false;
|
|
279
|
+
if (!mismatchRetirementFailed) {
|
|
280
|
+
this.revokeAccessoryHandlers(accessory);
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
this.stampAccessoryIdentity(accessory);
|
|
285
|
+
this.addChannelToRegistry(normalized);
|
|
286
|
+
// Accessory construction is intentionally deferred until didFinishLaunching.
|
|
287
|
+
// Homebridge restores cached accessories one at a time, so configuring a gate
|
|
288
|
+
// here could pair it before its separate contact-sensor accessory is known.
|
|
289
|
+
accessory.context.deviceConfigured = false;
|
|
172
290
|
}
|
|
173
291
|
/**
|
|
174
292
|
* This is an example method showing how to register discovered accessories.
|
|
175
293
|
* Accessories must only be registered once, previously created accessories
|
|
176
294
|
* must not be registered again to prevent "duplicate UUID" errors.
|
|
177
295
|
*/
|
|
178
|
-
discoverDevices(channelsOverride) {
|
|
179
|
-
const
|
|
180
|
-
|
|
296
|
+
discoverDevices(channelsOverride, snapshot) {
|
|
297
|
+
const cachedChannels = this.deduplicateChannels([
|
|
298
|
+
...this.loadPersistedChannels(),
|
|
299
|
+
...this.accessories
|
|
300
|
+
.filter(accessory => this.isAccessoryAccountCompatible(accessory, accessory.context.device))
|
|
301
|
+
.map(accessory => accessory.context.device)
|
|
302
|
+
.filter((channel) => Boolean(channel)),
|
|
303
|
+
].map(channel => this.normalizeChannelContext(channel)));
|
|
304
|
+
const shouldPrune = snapshot?.authoritative === true && snapshot.complete === true;
|
|
305
|
+
const rawChannels = channelsOverride === undefined
|
|
306
|
+
? cachedChannels
|
|
307
|
+
: this.mergeChannels(cachedChannels, channelsOverride, !shouldPrune, shouldPrune);
|
|
308
|
+
let channels = rawChannels
|
|
309
|
+
.map(channel => this.normalizeChannelContext(channel))
|
|
310
|
+
.filter(channel => this.isChannelAccountCompatible(channel));
|
|
311
|
+
if (channels.length === 0 && channelsOverride === undefined) {
|
|
312
|
+
channels = this.accessories
|
|
313
|
+
.filter(accessory => this.isAccessoryAccountCompatible(accessory, accessory.context.device))
|
|
314
|
+
.map(accessory => accessory.context.device)
|
|
315
|
+
.filter((channel) => Boolean(channel))
|
|
316
|
+
.map(channel => this.normalizeChannelContext(channel));
|
|
317
|
+
}
|
|
318
|
+
channels = this.deduplicateChannels(channels);
|
|
319
|
+
this.replaceChannelRegistry(channels);
|
|
320
|
+
const channelsToPersist = [...channels];
|
|
321
|
+
const retainPreviousChannel = (incoming, previous) => {
|
|
322
|
+
if (!previous) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const normalizedPrevious = this.normalizeChannelContext(previous);
|
|
326
|
+
const key = this.getChannelRegistryKey(incoming);
|
|
327
|
+
const index = channelsToPersist.findIndex(candidate => this.getChannelRegistryKey(candidate) === key);
|
|
328
|
+
if (index !== -1) {
|
|
329
|
+
channelsToPersist[index] = normalizedPrevious;
|
|
330
|
+
}
|
|
331
|
+
this.addChannelToRegistry(normalizedPrevious);
|
|
332
|
+
};
|
|
181
333
|
this.log.info('Channels discovered:', channels.length);
|
|
182
334
|
this.log.debug(`Discovery mode: ${channelsOverride ? 'live' : 'cached'} channels`);
|
|
183
335
|
const channelUuids = new Set();
|
|
184
|
-
const
|
|
185
|
-
const findExistingByIds = (candidate) => {
|
|
336
|
+
const findExistingByIds = (candidate, scopedUuid) => {
|
|
186
337
|
if (candidate.deviceId === 'unknown' || candidate.channelId === 'unknown') {
|
|
187
338
|
return undefined;
|
|
188
339
|
}
|
|
189
|
-
|
|
340
|
+
const matches = this.accessories.filter((accessory) => {
|
|
341
|
+
if (!this.isAccessoryAccountCompatible(accessory, accessory.context.device)) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
190
344
|
const existing = accessory.context.device;
|
|
191
345
|
if (!existing) {
|
|
192
346
|
return false;
|
|
@@ -196,11 +350,32 @@ class SuplaPlatform {
|
|
|
196
350
|
}
|
|
197
351
|
return existing.deviceId === candidate.deviceId && existing.channelId === candidate.channelId;
|
|
198
352
|
});
|
|
353
|
+
if (matches.length <= 1) {
|
|
354
|
+
return matches[0];
|
|
355
|
+
}
|
|
356
|
+
const scopedMatch = matches.find(accessory => accessory.UUID === scopedUuid);
|
|
357
|
+
if (scopedMatch) {
|
|
358
|
+
this.log.warn(`Found both scoped and legacy accessories for channel ${candidate.deviceId}/${candidate.channelId}; ` +
|
|
359
|
+
'using the scoped identity and leaving the duplicate disabled.');
|
|
360
|
+
for (const duplicate of matches) {
|
|
361
|
+
if (duplicate !== scopedMatch) {
|
|
362
|
+
this.revokeAccessoryHandlers(duplicate);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return scopedMatch;
|
|
366
|
+
}
|
|
367
|
+
this.log.error(`Ambiguous cached identities for channel ${candidate.deviceId}/${candidate.channelId}; ` +
|
|
368
|
+
'refusing to bind either accessory.');
|
|
369
|
+
for (const match of matches) {
|
|
370
|
+
this.revokeAccessoryHandlers(match);
|
|
371
|
+
}
|
|
372
|
+
return undefined;
|
|
199
373
|
};
|
|
200
374
|
// loop over the discovered devices and register each one if it has not already been registered
|
|
201
375
|
for (const channel of channels) {
|
|
202
|
-
const
|
|
203
|
-
const
|
|
376
|
+
const scopedUuid = this.getChannelUuid(channel);
|
|
377
|
+
const existingByIds = findExistingByIds(channel, scopedUuid);
|
|
378
|
+
const uuid = existingByIds?.UUID ?? scopedUuid;
|
|
204
379
|
channelUuids.add(uuid);
|
|
205
380
|
const existingAccessory = existingByIds ?? this.accessories.find(accessory => accessory.UUID === uuid);
|
|
206
381
|
if (existingAccessory) {
|
|
@@ -208,45 +383,163 @@ class SuplaPlatform {
|
|
|
208
383
|
const signature = this.getChannelSignature(channel);
|
|
209
384
|
const previousSignature = existingAccessory.context.deviceSignature;
|
|
210
385
|
const wasConfigured = existingAccessory.context.deviceConfigured === true;
|
|
211
|
-
existingAccessory.context.device = channel;
|
|
212
|
-
existingAccessory.context.deviceSignature = signature;
|
|
213
386
|
this.log.debug(`Restoring channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
|
|
214
387
|
`function=${channel.channelFunction} type=${channel.channelType}`);
|
|
388
|
+
if (!this.isSupportedChannel(channel)) {
|
|
389
|
+
if (this.isExplicitlyUnsupportedChannel(channel)) {
|
|
390
|
+
this.log.warn(`Channel ${channel.channelCaption} is now explicitly unsupported ` +
|
|
391
|
+
`(${channel.channelFunction}/${channel.channelType}); revoking its handlers.`);
|
|
392
|
+
this.revokeAccessoryHandlers(existingAccessory);
|
|
393
|
+
existingAccessory.context.device = channel;
|
|
394
|
+
existingAccessory.context.deviceSignature = signature;
|
|
395
|
+
existingAccessory.context.deviceConfigured = false;
|
|
396
|
+
this.stampAccessoryIdentity(existingAccessory);
|
|
397
|
+
this.api.updatePlatformAccessories([existingAccessory]);
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
this.log.warn(`Deferring incomplete channel ${channel.channelCaption} ` +
|
|
401
|
+
`(${channel.channelFunction}/${channel.channelType}); retaining last configured context.`);
|
|
402
|
+
}
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
215
405
|
if (previousSignature !== signature || !wasConfigured) {
|
|
216
|
-
|
|
217
|
-
|
|
406
|
+
const previousDevice = existingAccessory.context.device;
|
|
407
|
+
const previousContext = { ...existingAccessory.context };
|
|
408
|
+
const previousNames = this.captureAccessoryNames(existingAccessory);
|
|
409
|
+
try {
|
|
410
|
+
const safeDisplayName = this.resolveHomeKitDisplayName(channel, existingAccessory.displayName);
|
|
411
|
+
if (existingAccessory.displayName !== safeDisplayName) {
|
|
412
|
+
existingAccessory.updateDisplayName(safeDisplayName);
|
|
413
|
+
}
|
|
414
|
+
this.prepareAccessoryForReconfiguration(existingAccessory, channel);
|
|
415
|
+
const configured = this.setupAccessory(channel, existingAccessory);
|
|
416
|
+
if (!configured) {
|
|
417
|
+
throw new Error('No compatible accessory handler');
|
|
418
|
+
}
|
|
419
|
+
existingAccessory.context.device = channel;
|
|
420
|
+
existingAccessory.context.deviceSignature = signature;
|
|
421
|
+
existingAccessory.context.deviceConfigured = true;
|
|
422
|
+
this.stampAccessoryIdentity(existingAccessory);
|
|
423
|
+
this.cleanupObsoleteServices(existingAccessory, channel);
|
|
424
|
+
this.updateAccessoryNames(existingAccessory, channel);
|
|
425
|
+
this.api.updatePlatformAccessories([existingAccessory]);
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
this.unregisterMqttHandlers(existingAccessory.UUID);
|
|
429
|
+
this.restoreAccessoryContext(existingAccessory, previousContext);
|
|
430
|
+
this.restoreAccessoryNames(existingAccessory, previousNames);
|
|
431
|
+
retainPreviousChannel(channel, previousDevice);
|
|
432
|
+
if (previousDevice && this.isSupportedChannel(previousDevice)) {
|
|
433
|
+
try {
|
|
434
|
+
this.prepareAccessoryForReconfiguration(existingAccessory, previousDevice);
|
|
435
|
+
existingAccessory.context.deviceConfigured = this.setupAccessory(previousDevice, existingAccessory);
|
|
436
|
+
}
|
|
437
|
+
catch (restoreError) {
|
|
438
|
+
this.log.error(`Failed to restore previous handler for ${existingAccessory.displayName}: ` +
|
|
439
|
+
`${restoreError.message}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
this.log.error(`Failed to configure ${channel.channelCaption}; previous context retained: ${error.message}`);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
const previousDevice = existingAccessory.context.device;
|
|
448
|
+
const previousContext = { ...existingAccessory.context };
|
|
449
|
+
const previousNames = this.captureAccessoryNames(existingAccessory);
|
|
450
|
+
try {
|
|
451
|
+
const metadataChanged = !this.areChannelContextsEqual(previousDevice, channel);
|
|
452
|
+
const identityChanged = existingAccessory.context.suplaIdentityVersion !== 2
|
|
453
|
+
|| existingAccessory.context.suplaAccountNamespace !== this.accountNamespace;
|
|
454
|
+
existingAccessory.context.device = channel;
|
|
455
|
+
existingAccessory.context.deviceSignature = signature;
|
|
456
|
+
this.stampAccessoryIdentity(existingAccessory);
|
|
457
|
+
const namesChanged = this.updateAccessoryNames(existingAccessory, channel);
|
|
458
|
+
if (metadataChanged || identityChanged || namesChanged) {
|
|
459
|
+
this.api.updatePlatformAccessories([existingAccessory]);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
this.restoreAccessoryContext(existingAccessory, previousContext);
|
|
464
|
+
this.restoreAccessoryNames(existingAccessory, previousNames);
|
|
465
|
+
retainPreviousChannel(channel, previousDevice);
|
|
466
|
+
this.log.error(`Failed to commit metadata for ${channel.channelCaption}; previous name and context retained: ` +
|
|
467
|
+
`${error.message}`);
|
|
468
|
+
continue;
|
|
218
469
|
}
|
|
219
|
-
const configured = this.setupAccessory(channel, existingAccessory);
|
|
220
|
-
existingAccessory.context.deviceConfigured = configured;
|
|
221
470
|
}
|
|
222
|
-
this.api.updatePlatformAccessories([existingAccessory]);
|
|
223
471
|
continue;
|
|
224
472
|
}
|
|
225
473
|
this.log.info('Adding new accessory:', channel.channelCaption);
|
|
226
474
|
this.log.debug(`Registering channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
|
|
227
475
|
`function=${channel.channelFunction} type=${channel.channelType}`);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
476
|
+
if (!this.isSupportedChannel(channel)) {
|
|
477
|
+
this.log.warn(`Skipping unsupported/incomplete channel ${channel.channelCaption} ` +
|
|
478
|
+
`(${channel.channelFunction}/${channel.channelType}).`);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
const accessory = new this.api.platformAccessory(this.resolveHomeKitDisplayName(channel), uuid);
|
|
482
|
+
let registered = false;
|
|
483
|
+
try {
|
|
484
|
+
if (!this.setupAccessory(channel, accessory)) {
|
|
485
|
+
throw new Error('No compatible accessory handler');
|
|
486
|
+
}
|
|
487
|
+
accessory.context.device = channel;
|
|
488
|
+
accessory.context.deviceSignature = this.getChannelSignature(channel);
|
|
232
489
|
accessory.context.deviceConfigured = true;
|
|
490
|
+
this.stampAccessoryIdentity(accessory);
|
|
491
|
+
this.updateAccessoryNames(accessory, channel);
|
|
233
492
|
this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
|
|
493
|
+
registered = true;
|
|
234
494
|
this.accessories.push(accessory);
|
|
235
495
|
this.api.updatePlatformAccessories([accessory]);
|
|
236
496
|
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
this.unregisterMqttHandlers(accessory.UUID);
|
|
499
|
+
const index = this.accessories.indexOf(accessory);
|
|
500
|
+
if (index !== -1) {
|
|
501
|
+
this.accessories.splice(index, 1);
|
|
502
|
+
}
|
|
503
|
+
if (registered) {
|
|
504
|
+
try {
|
|
505
|
+
this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
|
|
506
|
+
}
|
|
507
|
+
catch (rollbackError) {
|
|
508
|
+
this.log.error(`Failed to roll back registration for ${channel.channelCaption}: ` +
|
|
509
|
+
`${rollbackError.message}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
this.log.error(`Failed to register ${channel.channelCaption}: ${error.message}`);
|
|
513
|
+
}
|
|
237
514
|
}
|
|
238
515
|
if (shouldPrune) {
|
|
239
516
|
const accessoriesToRemove = this.accessories.filter(accessory => !channelUuids.has(accessory.UUID));
|
|
240
517
|
for (const accessory of accessoriesToRemove) {
|
|
241
518
|
this.log.info('Removing existing accessory from cache:', accessory.displayName);
|
|
519
|
+
try {
|
|
520
|
+
// Homebridge owns the durable registration. Do not tear down the live
|
|
521
|
+
// handler until that external mutation has committed successfully.
|
|
522
|
+
this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
this.log.error(`Failed to remove ${accessory.displayName}; retaining its active handler: ` +
|
|
526
|
+
`${error.message}`);
|
|
527
|
+
const retainedDevice = accessory.context.device;
|
|
528
|
+
if (retainedDevice && typeof retainedDevice === 'object') {
|
|
529
|
+
this.addChannelToRegistry(this.normalizeChannelContext(retainedDevice));
|
|
530
|
+
}
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
242
533
|
this.unregisterMqttHandlers(accessory.UUID);
|
|
243
|
-
this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
|
|
244
534
|
const index = this.accessories.indexOf(accessory);
|
|
245
535
|
if (index !== -1) {
|
|
246
536
|
this.accessories.splice(index, 1);
|
|
247
537
|
}
|
|
248
538
|
}
|
|
249
539
|
}
|
|
540
|
+
if (channelsOverride !== undefined) {
|
|
541
|
+
this.persistChannels(channelsToPersist);
|
|
542
|
+
}
|
|
250
543
|
}
|
|
251
544
|
loadChannelsFromConfig() {
|
|
252
545
|
const rawChannels = this.config.channels;
|
|
@@ -269,31 +562,159 @@ class SuplaPlatform {
|
|
|
269
562
|
this.log.warn('Cached channels format is invalid; expected string or array.');
|
|
270
563
|
return [];
|
|
271
564
|
}
|
|
272
|
-
|
|
565
|
+
loadPersistedChannels() {
|
|
566
|
+
const currentStoragePath = this.getChannelCachePath();
|
|
567
|
+
const legacyStoragePath = this.getLegacyChannelCachePath();
|
|
568
|
+
const shouldMigrateLegacyPath = currentStoragePath !== legacyStoragePath
|
|
569
|
+
&& !fs.existsSync(currentStoragePath)
|
|
570
|
+
&& fs.existsSync(legacyStoragePath);
|
|
571
|
+
const storagePath = shouldMigrateLegacyPath ? legacyStoragePath : currentStoragePath;
|
|
273
572
|
try {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
573
|
+
if (fs.existsSync(storagePath)) {
|
|
574
|
+
const parsed = JSON.parse(fs.readFileSync(storagePath, 'utf8'));
|
|
575
|
+
if (parsed.version === 2
|
|
576
|
+
&& parsed.accountNamespace === this.accountNamespace
|
|
577
|
+
&& Array.isArray(parsed.channels)) {
|
|
578
|
+
if (shouldMigrateLegacyPath) {
|
|
579
|
+
this.log.info('Migrating Supla channel cache to the port/protocol-scoped filename.');
|
|
580
|
+
this.persistChannels(parsed.channels);
|
|
581
|
+
}
|
|
582
|
+
return parsed.channels;
|
|
583
|
+
}
|
|
584
|
+
if (parsed.version === 1 && Array.isArray(parsed.channels)) {
|
|
585
|
+
const compatible = parsed.channels.filter(channel => this.isSafeLegacyCacheMigration(channel));
|
|
586
|
+
if (compatible.length === parsed.channels.length) {
|
|
587
|
+
this.log.info('Migrating version 1 Supla channel cache to account-scoped format.');
|
|
588
|
+
this.persistChannels(compatible);
|
|
589
|
+
return compatible;
|
|
590
|
+
}
|
|
591
|
+
this.log.warn('Version 1 Supla cache contains account-unscoped legacy channels; ' +
|
|
592
|
+
'live discovery or explicit migrateUnscopedLegacyAccessories=true is required.');
|
|
593
|
+
if (compatible.length > 0) {
|
|
594
|
+
this.persistChannels(compatible);
|
|
595
|
+
return compatible;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
this.log.warn(`Ignoring unsupported Supla channel cache format at ${storagePath}.`);
|
|
280
599
|
}
|
|
281
|
-
platformConfig.channels = JSON.stringify(channels);
|
|
282
|
-
const payload = JSON.stringify(config, null, 2);
|
|
283
|
-
const tempPath = `${configPath}.tmp`;
|
|
284
|
-
fs.writeFileSync(tempPath, payload);
|
|
285
|
-
fs.renameSync(tempPath, configPath);
|
|
286
|
-
this.log.debug(`Saved ${channels.length} channels to config.`);
|
|
287
600
|
}
|
|
288
601
|
catch (error) {
|
|
289
|
-
this.log.
|
|
602
|
+
this.log.warn(`Failed to load channel cache: ${error.message}`);
|
|
603
|
+
}
|
|
604
|
+
const legacyChannels = this.loadChannelsFromConfig();
|
|
605
|
+
const compatibleLegacyChannels = legacyChannels.filter(channel => this.isSafeLegacyCacheMigration(channel));
|
|
606
|
+
if (compatibleLegacyChannels.length > 0) {
|
|
607
|
+
this.log.info('Migrating legacy Supla channels cache from config to Homebridge storage.');
|
|
608
|
+
this.persistChannels(compatibleLegacyChannels);
|
|
609
|
+
}
|
|
610
|
+
if (compatibleLegacyChannels.length !== legacyChannels.length) {
|
|
611
|
+
this.log.warn('Skipped account-unscoped legacy channels from config cache; ' +
|
|
612
|
+
'they will be recreated from authenticated live discovery.');
|
|
613
|
+
}
|
|
614
|
+
return compatibleLegacyChannels;
|
|
615
|
+
}
|
|
616
|
+
isSafeLegacyCacheMigration(channel) {
|
|
617
|
+
const normalized = this.normalizeChannelContext(channel);
|
|
618
|
+
return this.isChannelAccountCompatible(normalized)
|
|
619
|
+
&& (!this.isUnscopedLegacyChannel(normalized) || this.shouldMigrateUnscopedLegacyAccessories());
|
|
620
|
+
}
|
|
621
|
+
persistChannels(channels) {
|
|
622
|
+
try {
|
|
623
|
+
const cachePath = this.getChannelCachePath();
|
|
624
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
625
|
+
const payload = {
|
|
626
|
+
version: 2,
|
|
627
|
+
accountNamespace: this.accountNamespace,
|
|
628
|
+
channels: channels.filter(channel => this.isChannelAccountCompatible(channel)),
|
|
629
|
+
};
|
|
630
|
+
const tempPath = `${cachePath}.tmp-${process.pid}`;
|
|
631
|
+
fs.writeFileSync(tempPath, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
632
|
+
fs.renameSync(tempPath, cachePath);
|
|
633
|
+
this.log.debug(`Saved ${channels.length} channels to Homebridge storage.`);
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
this.log.error(`Failed to save channel cache: ${error.message}`);
|
|
290
637
|
}
|
|
291
638
|
}
|
|
639
|
+
getChannelCachePath() {
|
|
640
|
+
const configView = this.config;
|
|
641
|
+
const identity = [
|
|
642
|
+
settings_1.PLATFORM_NAME,
|
|
643
|
+
String(configView.name ?? ''),
|
|
644
|
+
String(configView.host ?? '').trim().toLowerCase(),
|
|
645
|
+
String(configView.port ?? '').trim(),
|
|
646
|
+
String(configView.protocol ?? '').trim().toLowerCase(),
|
|
647
|
+
String(configView.tls ?? '').trim().toLowerCase(),
|
|
648
|
+
String(configView.username ?? '').trim(),
|
|
649
|
+
this.accountNamespace,
|
|
650
|
+
].join('\u0000');
|
|
651
|
+
const instanceKey = (0, crypto_1.createHash)('sha256').update(identity).digest('hex').slice(0, 20);
|
|
652
|
+
return path.join(this.api.user.storagePath(), `supla-channels-${instanceKey}.json`);
|
|
653
|
+
}
|
|
654
|
+
getLegacyChannelCachePath() {
|
|
655
|
+
const configView = this.config;
|
|
656
|
+
const identity = [
|
|
657
|
+
settings_1.PLATFORM_NAME,
|
|
658
|
+
String(configView.name ?? ''),
|
|
659
|
+
String(configView.host ?? ''),
|
|
660
|
+
String(configView.username ?? ''),
|
|
661
|
+
].join('\u0000');
|
|
662
|
+
const instanceKey = (0, crypto_1.createHash)('sha256').update(identity).digest('hex').slice(0, 20);
|
|
663
|
+
return path.join(this.api.user.storagePath(), `supla-channels-${instanceKey}.json`);
|
|
664
|
+
}
|
|
665
|
+
computeAccountNamespace() {
|
|
666
|
+
const configView = this.config;
|
|
667
|
+
const identity = [
|
|
668
|
+
String(configView.host ?? '').trim().toLowerCase(),
|
|
669
|
+
String(configView.port ?? '').trim(),
|
|
670
|
+
String(configView.username ?? '').trim(),
|
|
671
|
+
].join('\u0000');
|
|
672
|
+
return (0, crypto_1.createHash)('sha256').update(identity).digest('hex');
|
|
673
|
+
}
|
|
674
|
+
stampAccessoryIdentity(accessory) {
|
|
675
|
+
accessory.context.suplaIdentityVersion = 2;
|
|
676
|
+
accessory.context.suplaAccountNamespace = this.accountNamespace;
|
|
677
|
+
// Retain this legacy field for consumers that used the first remediation schema.
|
|
678
|
+
accessory.context.schemaVersion = 2;
|
|
679
|
+
}
|
|
680
|
+
isAccessoryAccountCompatible(accessory, channel) {
|
|
681
|
+
const storedNamespace = accessory.context.suplaAccountNamespace;
|
|
682
|
+
if (typeof storedNamespace === 'string' && storedNamespace.length > 0) {
|
|
683
|
+
return storedNamespace === this.accountNamespace;
|
|
684
|
+
}
|
|
685
|
+
if (channel === undefined) {
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
if (this.isUnscopedLegacyChannel(channel)) {
|
|
689
|
+
return this.shouldMigrateUnscopedLegacyAccessories();
|
|
690
|
+
}
|
|
691
|
+
return this.isChannelAccountCompatible(channel);
|
|
692
|
+
}
|
|
693
|
+
isChannelAccountCompatible(channel) {
|
|
694
|
+
const topic = this.normalizeTopicBase(channel.topic ?? channel.rawTopic ?? '');
|
|
695
|
+
const match = topic.match(/^supla\/([^/]+)\/devices\//);
|
|
696
|
+
if (!match) {
|
|
697
|
+
// Legacy rollershutter topics contain no account identifier. They are
|
|
698
|
+
// recognized here, but migration requires the explicit opt-in checked by
|
|
699
|
+
// isSafeLegacyCacheMigration/isAccessoryAccountCompatible.
|
|
700
|
+
return topic.startsWith('supla/channels/status/rollershutter/');
|
|
701
|
+
}
|
|
702
|
+
const configuredUsername = String(this.config.username ?? '');
|
|
703
|
+
return match[1] === configuredUsername;
|
|
704
|
+
}
|
|
705
|
+
isUnscopedLegacyChannel(channel) {
|
|
706
|
+
const topic = this.normalizeTopicBase(channel.topic ?? channel.rawTopic ?? '');
|
|
707
|
+
return topic.startsWith('supla/channels/status/rollershutter/');
|
|
708
|
+
}
|
|
709
|
+
shouldMigrateUnscopedLegacyAccessories() {
|
|
710
|
+
const configured = this.config.migrateUnscopedLegacyAccessories;
|
|
711
|
+
return this.parseBooleanStrict(configured) === true;
|
|
712
|
+
}
|
|
292
713
|
normalizeChannelContext(channel) {
|
|
293
714
|
const rawTopic = (channel.rawTopic ?? channel.topic ?? '').toString();
|
|
294
715
|
const baseFromTopic = this.normalizeTopicBase(rawTopic || (channel.topic ?? '').toString());
|
|
295
|
-
let deviceId = channel.deviceId;
|
|
296
|
-
let channelId = channel.channelId;
|
|
716
|
+
let deviceId = channel.deviceId?.toString().trim();
|
|
717
|
+
let channelId = channel.channelId?.toString().trim();
|
|
297
718
|
if (!deviceId || !channelId) {
|
|
298
719
|
const match = baseFromTopic.match(/\/devices\/(\d+)\/channels\/(\d+)(?:\/|$)/);
|
|
299
720
|
if (match) {
|
|
@@ -307,43 +728,433 @@ class SuplaPlatform {
|
|
|
307
728
|
if (!channelId) {
|
|
308
729
|
channelId = 'unknown';
|
|
309
730
|
}
|
|
310
|
-
const
|
|
311
|
-
const
|
|
312
|
-
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';
|
|
313
741
|
const mqttContext = this.config;
|
|
314
|
-
const
|
|
742
|
+
const hasStableIds = deviceId !== 'unknown' && channelId !== 'unknown';
|
|
743
|
+
const baseTopic = baseFromTopic || (hasStableIds
|
|
744
|
+
? `supla/${mqttContext.username}/devices/${deviceId}/channels/${channelId}`
|
|
745
|
+
: '');
|
|
315
746
|
if (deviceId === 'unknown' || channelId === 'unknown') {
|
|
316
747
|
this.log.warn(`Channel missing device/channel id for topic ${rawTopic}`);
|
|
317
748
|
}
|
|
318
|
-
return new SuplaChannelContext_1.SuplaChannelContext(baseTopic, channelType, channelFunction, caption, deviceId, channelId, rawTopic || undefined);
|
|
749
|
+
return new SuplaChannelContext_1.SuplaChannelContext(baseTopic, channelType, channelFunction, caption, deviceId, channelId, rawTopic || undefined, captionAuthoritative, captionSource);
|
|
319
750
|
}
|
|
320
751
|
getChannelUuid(channel) {
|
|
321
752
|
const deviceId = channel.deviceId;
|
|
322
753
|
const channelId = channel.channelId;
|
|
323
754
|
const hasIds = deviceId && channelId && deviceId !== 'unknown' && channelId !== 'unknown';
|
|
324
|
-
const
|
|
755
|
+
const stableTopic = this.normalizeTopicBase(channel.topic || channel.rawTopic || '');
|
|
756
|
+
const channelKey = hasIds
|
|
325
757
|
? `${deviceId}:${channelId}`
|
|
326
|
-
:
|
|
758
|
+
: stableTopic;
|
|
759
|
+
if (!channelKey) {
|
|
760
|
+
throw new Error('Cannot derive a stable Supla channel identity without IDs or a topic');
|
|
761
|
+
}
|
|
762
|
+
const key = `${this.accountNamespace}:${channelKey}`;
|
|
327
763
|
return this.api.hap.uuid.generate(key);
|
|
328
764
|
}
|
|
329
765
|
getChannelSignature(channel) {
|
|
330
|
-
|
|
766
|
+
const signature = [
|
|
331
767
|
channel.topic ?? '',
|
|
332
768
|
channel.channelFunction ?? '',
|
|
333
769
|
channel.channelType ?? '',
|
|
334
770
|
channel.deviceId ?? '',
|
|
335
771
|
channel.channelId ?? '',
|
|
772
|
+
];
|
|
773
|
+
if (channel.channelFunction === 'CONTROLLINGTHEGATE') {
|
|
774
|
+
const configView = this.config;
|
|
775
|
+
const sensorRegistrySignature = Array.from(this.channelRegistry.values())
|
|
776
|
+
.filter(candidate => {
|
|
777
|
+
const functionName = (candidate.channelFunction ?? '').toUpperCase();
|
|
778
|
+
const typeName = (candidate.channelType ?? '').toUpperCase();
|
|
779
|
+
return functionName.startsWith('OPENINGSENSOR_') || typeName === 'BINARYSENSOR';
|
|
780
|
+
})
|
|
781
|
+
.map(candidate => [
|
|
782
|
+
candidate.topic,
|
|
783
|
+
candidate.channelFunction,
|
|
784
|
+
candidate.channelType,
|
|
785
|
+
candidate.deviceId,
|
|
786
|
+
candidate.channelId,
|
|
787
|
+
candidate.channelCaption,
|
|
788
|
+
].join(':'))
|
|
789
|
+
.sort()
|
|
790
|
+
.join(';');
|
|
791
|
+
signature.push(configView.frontGateSensorTopic ?? '', String(configView.frontGateSensorDeviceId ?? ''), String(configView.frontGateSensorChannelId ?? ''), String(this.frontGateSensorFallbackToControlChannel), sensorRegistrySignature);
|
|
792
|
+
}
|
|
793
|
+
return signature.join('|');
|
|
794
|
+
}
|
|
795
|
+
getChannelRegistryKey(channel) {
|
|
796
|
+
return [
|
|
797
|
+
channel.deviceId ?? '',
|
|
798
|
+
channel.channelId ?? '',
|
|
799
|
+
this.normalizeTopicBase(channel.topic),
|
|
336
800
|
].join('|');
|
|
337
801
|
}
|
|
338
|
-
|
|
339
|
-
|
|
802
|
+
addChannelToRegistry(channel) {
|
|
803
|
+
this.channelRegistry.set(this.getChannelRegistryKey(channel), channel);
|
|
804
|
+
}
|
|
805
|
+
replaceChannelRegistry(channels) {
|
|
806
|
+
this.channelRegistry.clear();
|
|
807
|
+
for (const channel of channels) {
|
|
808
|
+
this.addChannelToRegistry(channel);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
deduplicateChannels(channels) {
|
|
812
|
+
const byKey = new Map();
|
|
813
|
+
for (const channel of channels) {
|
|
814
|
+
byKey.set(this.getChannelRegistryKey(channel), channel);
|
|
815
|
+
}
|
|
816
|
+
return Array.from(byKey.values());
|
|
817
|
+
}
|
|
818
|
+
mergeChannels(cachedChannels, liveChannels, preserveCachedOnly = true, snapshotAuthoritative = false) {
|
|
819
|
+
const cachedByKey = new Map();
|
|
820
|
+
for (const rawChannel of cachedChannels) {
|
|
821
|
+
const channel = this.normalizeChannelContext(rawChannel);
|
|
822
|
+
cachedByKey.set(this.getChannelRegistryKey(channel), channel);
|
|
823
|
+
}
|
|
824
|
+
const merged = preserveCachedOnly
|
|
825
|
+
? new Map(cachedByKey)
|
|
826
|
+
: new Map();
|
|
827
|
+
for (const rawChannel of liveChannels) {
|
|
828
|
+
const channel = this.normalizeChannelContext(rawChannel);
|
|
829
|
+
const key = this.getChannelRegistryKey(channel);
|
|
830
|
+
const cached = cachedByKey.get(key);
|
|
831
|
+
if (cached) {
|
|
832
|
+
const useLiveCaption = channel.channelCaptionAuthoritative === true
|
|
833
|
+
&& (snapshotAuthoritative
|
|
834
|
+
|| channel.channelCaptionSource === 'retained'
|
|
835
|
+
|| channel.channelCaptionSource === 'authoritative_snapshot');
|
|
836
|
+
merged.set(key, new SuplaChannelContext_1.SuplaChannelContext(channel.topic || cached.topic, this.isKnownMetadata(channel.channelType) ? channel.channelType : cached.channelType, this.isKnownMetadata(channel.channelFunction) ? channel.channelFunction : cached.channelFunction, useLiveCaption ? channel.channelCaption : cached.channelCaption, channel.deviceId || cached.deviceId, channel.channelId || cached.channelId, channel.rawTopic || cached.rawTopic, useLiveCaption
|
|
837
|
+
? true
|
|
838
|
+
: cached.channelCaptionAuthoritative, useLiveCaption
|
|
839
|
+
? snapshotAuthoritative
|
|
840
|
+
? 'authoritative_snapshot'
|
|
841
|
+
: channel.channelCaptionSource
|
|
842
|
+
: cached.channelCaptionSource));
|
|
843
|
+
}
|
|
844
|
+
else if (this.isKnownMetadata(channel.channelType)
|
|
845
|
+
&& this.isKnownMetadata(channel.channelFunction)) {
|
|
846
|
+
merged.set(key, channel);
|
|
847
|
+
}
|
|
848
|
+
else {
|
|
849
|
+
this.log.warn(`Deferring incomplete new channel ${channel.deviceId}/${channel.channelId} ` +
|
|
850
|
+
`(function=${channel.channelFunction}, type=${channel.channelType}).`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return Array.from(merged.values());
|
|
854
|
+
}
|
|
855
|
+
isKnownMetadata(value) {
|
|
856
|
+
return Boolean(value && value.trim() && value.toUpperCase() !== 'UNKNOWN');
|
|
857
|
+
}
|
|
858
|
+
getKnownChannels() {
|
|
859
|
+
return Array.from(this.channelRegistry.values());
|
|
860
|
+
}
|
|
861
|
+
isSupportedChannel(channel) {
|
|
862
|
+
if (this.isExplicitNoFunction(channel.channelFunction)) {
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
865
|
+
if (!this.isKnownMetadata(channel.channelFunction) || !this.isKnownMetadata(channel.channelType)) {
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
return this.getExpectedServiceUuids(channel).size > 0;
|
|
869
|
+
}
|
|
870
|
+
isExplicitlyUnsupportedChannel(channel) {
|
|
871
|
+
return this.isExplicitNoFunction(channel.channelFunction)
|
|
872
|
+
|| (this.isKnownMetadata(channel.channelFunction)
|
|
873
|
+
&& this.isKnownMetadata(channel.channelType)
|
|
874
|
+
&& this.getExpectedServiceUuids(channel).size === 0);
|
|
875
|
+
}
|
|
876
|
+
getExpectedServiceUuids(channel, accessory) {
|
|
877
|
+
if (this.isExplicitNoFunction(channel.channelFunction)) {
|
|
878
|
+
return new Set();
|
|
879
|
+
}
|
|
880
|
+
const byFunction = {
|
|
881
|
+
CONTROLLINGTHEGARAGEDOOR: [this.Service.GarageDoorOpener.UUID],
|
|
882
|
+
CONTROLLINGTHEGATE: [this.Service.GarageDoorOpener.UUID],
|
|
883
|
+
CONTROLLINGTHEGATEWAYLOCK: [this.Service.LockMechanism.UUID],
|
|
884
|
+
LIGHTSWITCH: [this.Service.Lightbulb.UUID],
|
|
885
|
+
POWERSWITCH: [this.Service.Switch.UUID],
|
|
886
|
+
RGBLIGHTING: [this.Service.Lightbulb.UUID],
|
|
887
|
+
DIMMER: [this.Service.Lightbulb.UUID],
|
|
888
|
+
DIMMERANDRGBLIGHTING: [this.Service.Lightbulb.UUID],
|
|
889
|
+
CONTROLLINGTHEROLLERSHUTTER: [this.Service.WindowCovering.UUID],
|
|
890
|
+
CONTROLLINGTHEFACADEBLIND: [this.Service.WindowCovering.UUID],
|
|
891
|
+
OPENINGSENSOR_GATE: [this.Service.ContactSensor.UUID],
|
|
892
|
+
OPENINGSENSOR_GATEWAY: [this.Service.ContactSensor.UUID],
|
|
893
|
+
OPENINGSENSOR_WINDOW: [this.Service.ContactSensor.UUID],
|
|
894
|
+
GENERAL_PURPOSE_MEASUREMENT: [this.Service.AirQualitySensor.UUID],
|
|
895
|
+
THERMOMETER: [this.Service.TemperatureSensor.UUID],
|
|
896
|
+
HUMIDITYANDTEMPERATURE: [this.Service.TemperatureSensor.UUID, this.Service.HumiditySensor.UUID],
|
|
897
|
+
PRESSURESENSOR: [this.Service.LightSensor.UUID],
|
|
898
|
+
VALVEOPENCLOSE: [this.Service.Valve.UUID],
|
|
899
|
+
HVAC_THERMOSTAT: [this.Service.Thermostat.UUID],
|
|
900
|
+
ELECTRICITYMETER: [this.Service.LightSensor.UUID],
|
|
901
|
+
ACTION_TRIGGER: [this.Service.StatelessProgrammableSwitch.UUID],
|
|
902
|
+
};
|
|
903
|
+
const byType = {
|
|
904
|
+
BINARYSENSOR: [
|
|
905
|
+
this.isLeakSensorChannel(channel, accessory)
|
|
906
|
+
? this.Service.LeakSensor.UUID
|
|
907
|
+
: this.Service.ContactSensor.UUID,
|
|
908
|
+
],
|
|
909
|
+
THERMOMETER: [this.Service.TemperatureSensor.UUID],
|
|
910
|
+
THERMOMETERDS18B20: [this.Service.TemperatureSensor.UUID],
|
|
911
|
+
HUMIDITYANDTEMPSENSOR: [this.Service.TemperatureSensor.UUID, this.Service.HumiditySensor.UUID],
|
|
912
|
+
GENERAL_PURPOSE_MEASUREMENT: [this.Service.AirQualitySensor.UUID],
|
|
913
|
+
PRESSURESENSOR: [this.Service.LightSensor.UUID],
|
|
914
|
+
ELECTRICITYMETER: [this.Service.LightSensor.UUID],
|
|
915
|
+
ACTION_TRIGGER: [this.Service.StatelessProgrammableSwitch.UUID],
|
|
916
|
+
};
|
|
917
|
+
return new Set(byFunction[channel.channelFunction] ?? byType[channel.channelType] ?? []);
|
|
918
|
+
}
|
|
919
|
+
isExplicitNoFunction(value) {
|
|
920
|
+
return value?.trim().toUpperCase() === 'NONE';
|
|
921
|
+
}
|
|
922
|
+
cleanupObsoleteServices(accessory, channel) {
|
|
923
|
+
const expected = this.getExpectedServiceUuids(channel, accessory);
|
|
924
|
+
const knownPluginServiceUuids = new Set([
|
|
925
|
+
this.Service.GarageDoorOpener.UUID,
|
|
926
|
+
this.Service.LockMechanism.UUID,
|
|
927
|
+
this.Service.Lightbulb.UUID,
|
|
928
|
+
this.Service.Switch.UUID,
|
|
929
|
+
this.Service.WindowCovering.UUID,
|
|
930
|
+
this.Service.Door.UUID,
|
|
931
|
+
this.Service.ContactSensor.UUID,
|
|
932
|
+
this.Service.LeakSensor.UUID,
|
|
933
|
+
this.Service.TemperatureSensor.UUID,
|
|
934
|
+
this.Service.HumiditySensor.UUID,
|
|
935
|
+
this.Service.AirQualitySensor.UUID,
|
|
936
|
+
this.Service.LightSensor.UUID,
|
|
937
|
+
this.Service.Valve.UUID,
|
|
938
|
+
this.Service.Thermostat.UUID,
|
|
939
|
+
this.Service.StatelessProgrammableSwitch.UUID,
|
|
940
|
+
]);
|
|
941
|
+
for (const service of [...accessory.services]) {
|
|
942
|
+
if (knownPluginServiceUuids.has(service.UUID) && !expected.has(service.UUID)) {
|
|
943
|
+
accessory.removeService(service);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
prepareAccessoryForReconfiguration(accessory, channel) {
|
|
948
|
+
this.unregisterMqttHandlers(accessory.UUID);
|
|
949
|
+
this.cleanupObsoleteServices(accessory, channel);
|
|
950
|
+
const expectedServices = this.getExpectedServiceUuids(channel, accessory);
|
|
951
|
+
for (const service of accessory.services) {
|
|
952
|
+
if (!expectedServices.has(service.UUID)) {
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
for (const characteristic of service.characteristics) {
|
|
956
|
+
characteristic.removeOnGet();
|
|
957
|
+
characteristic.removeOnSet();
|
|
958
|
+
}
|
|
959
|
+
this.removeObsoleteOptionalCharacteristics(service, channel);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
removeObsoleteOptionalCharacteristics(service, channel) {
|
|
963
|
+
const obsolete = new Set();
|
|
964
|
+
const servicesWithoutStatusFault = new Set([
|
|
965
|
+
this.Service.GarageDoorOpener.UUID,
|
|
966
|
+
this.Service.LockMechanism.UUID,
|
|
967
|
+
this.Service.Lightbulb.UUID,
|
|
968
|
+
this.Service.Switch.UUID,
|
|
969
|
+
this.Service.WindowCovering.UUID,
|
|
970
|
+
this.Service.Door.UUID,
|
|
971
|
+
this.Service.Thermostat.UUID,
|
|
972
|
+
this.Service.StatelessProgrammableSwitch.UUID,
|
|
973
|
+
]);
|
|
974
|
+
if (servicesWithoutStatusFault.has(service.UUID)) {
|
|
975
|
+
obsolete.add(this.Characteristic.StatusFault.UUID);
|
|
976
|
+
}
|
|
977
|
+
if (service.UUID === this.Service.Lightbulb.UUID) {
|
|
978
|
+
if (channel.channelFunction === 'LIGHTSWITCH') {
|
|
979
|
+
obsolete.add(this.Characteristic.Brightness.UUID);
|
|
980
|
+
obsolete.add(this.Characteristic.Hue.UUID);
|
|
981
|
+
obsolete.add(this.Characteristic.Saturation.UUID);
|
|
982
|
+
}
|
|
983
|
+
else if (channel.channelFunction === 'DIMMER') {
|
|
984
|
+
obsolete.add(this.Characteristic.Hue.UUID);
|
|
985
|
+
obsolete.add(this.Characteristic.Saturation.UUID);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
if (service.UUID === this.Service.WindowCovering.UUID
|
|
989
|
+
&& channel.channelFunction === 'CONTROLLINGTHEROLLERSHUTTER') {
|
|
990
|
+
obsolete.add(this.Characteristic.CurrentHorizontalTiltAngle.UUID);
|
|
991
|
+
obsolete.add(this.Characteristic.TargetHorizontalTiltAngle.UUID);
|
|
992
|
+
}
|
|
993
|
+
if (service.UUID === this.Service.WindowCovering.UUID) {
|
|
994
|
+
// StatusJammed is not part of the HAP WindowCovering service. Older
|
|
995
|
+
// versions added it dynamically; remove that invalid composition.
|
|
996
|
+
obsolete.add(this.Characteristic.StatusJammed.UUID);
|
|
997
|
+
}
|
|
998
|
+
for (const characteristic of [...service.characteristics]) {
|
|
999
|
+
if (obsolete.has(characteristic.UUID)) {
|
|
1000
|
+
service.removeCharacteristic(characteristic);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
revokeAccessoryHandlers(accessory) {
|
|
1005
|
+
this.unregisterMqttHandlers(accessory.UUID);
|
|
1006
|
+
const unavailable = new this.api.hap.HapStatusError(-70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
340
1007
|
for (const service of accessory.services) {
|
|
341
|
-
if (service.UUID ===
|
|
1008
|
+
if (service.UUID === this.Service.AccessoryInformation.UUID) {
|
|
342
1009
|
continue;
|
|
343
1010
|
}
|
|
344
|
-
|
|
1011
|
+
for (const characteristic of service.characteristics) {
|
|
1012
|
+
characteristic.removeOnGet();
|
|
1013
|
+
characteristic.removeOnSet();
|
|
1014
|
+
if (characteristic.props.perms.includes("pr" /* this.api.hap.Perms.PAIRED_READ */)) {
|
|
1015
|
+
characteristic.updateValue(unavailable);
|
|
1016
|
+
}
|
|
1017
|
+
if (characteristic.props.perms.includes("pw" /* this.api.hap.Perms.PAIRED_WRITE */)) {
|
|
1018
|
+
characteristic.onSet(() => {
|
|
1019
|
+
throw new this.api.hap.HapStatusError(-70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
345
1023
|
}
|
|
346
1024
|
}
|
|
1025
|
+
resolveHomeKitDisplayName(channel, currentName) {
|
|
1026
|
+
const captionName = channel.channelCaptionAuthoritative === true
|
|
1027
|
+
? (0, DisplayName_1.toHomeKitDisplayName)(channel.channelCaption)
|
|
1028
|
+
: undefined;
|
|
1029
|
+
const hasRenameEvidence = channel.channelCaptionSource === 'retained'
|
|
1030
|
+
|| channel.channelCaptionSource === 'authoritative_snapshot';
|
|
1031
|
+
if (captionName && hasRenameEvidence) {
|
|
1032
|
+
return captionName;
|
|
1033
|
+
}
|
|
1034
|
+
const safeCurrentName = (0, DisplayName_1.toHomeKitDisplayName)(currentName);
|
|
1035
|
+
if (safeCurrentName) {
|
|
1036
|
+
return safeCurrentName;
|
|
1037
|
+
}
|
|
1038
|
+
// A new accessory (or a damaged empty legacy display name) may use its
|
|
1039
|
+
// complete cached caption as an initial/recovery label. This is not allowed
|
|
1040
|
+
// to replace an already meaningful existing name.
|
|
1041
|
+
if (captionName) {
|
|
1042
|
+
return captionName;
|
|
1043
|
+
}
|
|
1044
|
+
return (0, DisplayName_1.toHomeKitDisplayName)((0, DisplayName_1.generatedChannelCaption)(channel.deviceId, channel.channelId))
|
|
1045
|
+
?? 'Supla Device';
|
|
1046
|
+
}
|
|
1047
|
+
captureAccessoryNames(accessory) {
|
|
1048
|
+
return {
|
|
1049
|
+
displayName: accessory.displayName,
|
|
1050
|
+
services: accessory.services.map((service) => {
|
|
1051
|
+
const characteristic = service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
|
|
1052
|
+
return {
|
|
1053
|
+
service,
|
|
1054
|
+
displayName: service.displayName,
|
|
1055
|
+
hadNameCharacteristic: characteristic !== undefined,
|
|
1056
|
+
nameValue: typeof characteristic?.value === 'string' ? characteristic.value : undefined,
|
|
1057
|
+
};
|
|
1058
|
+
}),
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
restoreAccessoryNames(accessory, snapshot) {
|
|
1062
|
+
try {
|
|
1063
|
+
accessory.updateDisplayName(snapshot.displayName);
|
|
1064
|
+
}
|
|
1065
|
+
catch (error) {
|
|
1066
|
+
this.log.error(`Failed to restore accessory display name ${snapshot.displayName}: ${error.message}`);
|
|
1067
|
+
}
|
|
1068
|
+
for (const entry of snapshot.services) {
|
|
1069
|
+
try {
|
|
1070
|
+
entry.service.displayName = entry.displayName;
|
|
1071
|
+
const current = entry.service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
|
|
1072
|
+
if (!entry.hadNameCharacteristic) {
|
|
1073
|
+
if (current) {
|
|
1074
|
+
entry.service.removeCharacteristic(current);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
else if (entry.nameValue !== undefined && current?.value !== entry.nameValue) {
|
|
1078
|
+
entry.service.setCharacteristic(this.Characteristic.Name, entry.nameValue);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
catch (error) {
|
|
1082
|
+
this.log.error(`Failed to restore service name for ${snapshot.displayName}: ${error.message}`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
restoreAccessoryContext(accessory, snapshot) {
|
|
1087
|
+
for (const key of Object.keys(accessory.context)) {
|
|
1088
|
+
delete accessory.context[key];
|
|
1089
|
+
}
|
|
1090
|
+
Object.assign(accessory.context, snapshot);
|
|
1091
|
+
}
|
|
1092
|
+
areChannelContextsEqual(current, incoming) {
|
|
1093
|
+
if (!current) {
|
|
1094
|
+
return false;
|
|
1095
|
+
}
|
|
1096
|
+
const fields = [
|
|
1097
|
+
'topic',
|
|
1098
|
+
'channelType',
|
|
1099
|
+
'channelFunction',
|
|
1100
|
+
'channelCaption',
|
|
1101
|
+
'deviceId',
|
|
1102
|
+
'channelId',
|
|
1103
|
+
'rawTopic',
|
|
1104
|
+
'channelCaptionAuthoritative',
|
|
1105
|
+
'channelCaptionSource',
|
|
1106
|
+
];
|
|
1107
|
+
return fields.every(field => current[field] === incoming[field]);
|
|
1108
|
+
}
|
|
1109
|
+
updateAccessoryNames(accessory, channel) {
|
|
1110
|
+
let changed = false;
|
|
1111
|
+
const caption = (0, DisplayName_1.normalizeVendorCaption)(channel.channelCaption);
|
|
1112
|
+
const homeKitName = this.resolveHomeKitDisplayName(channel, accessory.displayName);
|
|
1113
|
+
if (accessory.displayName !== homeKitName) {
|
|
1114
|
+
accessory.updateDisplayName(homeKitName);
|
|
1115
|
+
changed = true;
|
|
1116
|
+
}
|
|
1117
|
+
const informationService = accessory.getService(this.Service.AccessoryInformation);
|
|
1118
|
+
const informationName = informationService?.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
|
|
1119
|
+
if (informationService) {
|
|
1120
|
+
if (informationService.displayName !== homeKitName) {
|
|
1121
|
+
informationService.displayName = homeKitName;
|
|
1122
|
+
changed = true;
|
|
1123
|
+
}
|
|
1124
|
+
if (informationName?.value !== homeKitName) {
|
|
1125
|
+
informationService.setCharacteristic(this.Characteristic.Name, homeKitName);
|
|
1126
|
+
changed = true;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
const expected = this.getExpectedServiceUuids(channel, accessory);
|
|
1130
|
+
const electricityMetric = accessory.context.suplaElectricityMetric;
|
|
1131
|
+
for (const service of accessory.services) {
|
|
1132
|
+
if (expected.has(service.UUID)) {
|
|
1133
|
+
const serviceName = channel.channelFunction === 'ELECTRICITYMETER'
|
|
1134
|
+
&& typeof electricityMetric === 'string'
|
|
1135
|
+
? (0, DisplayName_1.homeKitElectricityServiceName)(homeKitName, electricityMetric)
|
|
1136
|
+
: homeKitName;
|
|
1137
|
+
const nameCharacteristic = service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
|
|
1138
|
+
if (service.displayName !== serviceName) {
|
|
1139
|
+
service.displayName = serviceName;
|
|
1140
|
+
changed = true;
|
|
1141
|
+
}
|
|
1142
|
+
if (nameCharacteristic?.value !== serviceName) {
|
|
1143
|
+
service.setCharacteristic(this.Characteristic.Name, serviceName);
|
|
1144
|
+
changed = true;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
if (accessory.context.suplaVendorCaption !== caption
|
|
1149
|
+
|| accessory.context.suplaVendorCaptionAuthoritative !== (channel.channelCaptionAuthoritative === true)
|
|
1150
|
+
|| accessory.context.suplaHomeKitDisplayName !== homeKitName) {
|
|
1151
|
+
changed = true;
|
|
1152
|
+
}
|
|
1153
|
+
accessory.context.suplaVendorCaption = caption;
|
|
1154
|
+
accessory.context.suplaVendorCaptionAuthoritative = channel.channelCaptionAuthoritative === true;
|
|
1155
|
+
accessory.context.suplaHomeKitDisplayName = homeKitName;
|
|
1156
|
+
return changed;
|
|
1157
|
+
}
|
|
347
1158
|
setupAccessory(channel, accessory) {
|
|
348
1159
|
this.unregisterMqttHandlers(accessory.UUID);
|
|
349
1160
|
this.log.debug(`Mapping channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
|
|
@@ -412,20 +1223,13 @@ class SuplaPlatform {
|
|
|
412
1223
|
break;
|
|
413
1224
|
}
|
|
414
1225
|
switch (channel.channelType) {
|
|
415
|
-
case 'DIMMERANDRGBLED':
|
|
416
|
-
new DimmerRgbLightAccessory_1.DimmerRgbLightAccessory(this, accessory, channel);
|
|
417
|
-
return true;
|
|
418
|
-
case 'DIMMER':
|
|
419
|
-
new DimmerAccessory_1.DimmerAccessory(this, accessory, channel);
|
|
420
|
-
return true;
|
|
421
|
-
case 'RELAY':
|
|
422
|
-
new SwitchAccessory_1.SwitchAccessory(this, accessory, channel);
|
|
423
|
-
return true;
|
|
424
1226
|
case 'BINARYSENSOR':
|
|
425
|
-
if (this.isLeakSensorChannel(channel)) {
|
|
1227
|
+
if (this.isLeakSensorChannel(channel, accessory)) {
|
|
1228
|
+
accessory.context.suplaBinarySensorKind = 'leak';
|
|
426
1229
|
new LeakSensorAccessory_1.LeakSensorAccessory(this, accessory, channel);
|
|
427
1230
|
}
|
|
428
1231
|
else {
|
|
1232
|
+
accessory.context.suplaBinarySensorKind = 'contact';
|
|
429
1233
|
new ContactSensorAccessory_1.ContactSensorAccessory(this, accessory, channel);
|
|
430
1234
|
}
|
|
431
1235
|
return true;
|
|
@@ -454,9 +1258,21 @@ class SuplaPlatform {
|
|
|
454
1258
|
this.log.warn(`Unsupported channel ${channel.channelCaption} (${channel.channelFunction}/${channel.channelType})`);
|
|
455
1259
|
return false;
|
|
456
1260
|
}
|
|
457
|
-
isLeakSensorChannel(channel) {
|
|
458
|
-
const
|
|
459
|
-
|
|
1261
|
+
isLeakSensorChannel(channel, accessory) {
|
|
1262
|
+
const functionName = (channel.channelFunction ?? '').trim().toUpperCase();
|
|
1263
|
+
if (functionName.includes('FLOOD')
|
|
1264
|
+
|| functionName.includes('WATERLEAK')
|
|
1265
|
+
|| functionName === 'LEAKSENSOR') {
|
|
1266
|
+
return true;
|
|
1267
|
+
}
|
|
1268
|
+
const persistedKind = accessory?.context?.suplaBinarySensorKind;
|
|
1269
|
+
if (persistedKind === 'leak' || persistedKind === 'contact') {
|
|
1270
|
+
return persistedKind === 'leak';
|
|
1271
|
+
}
|
|
1272
|
+
// Preserve the topology of accessories created by older releases, which
|
|
1273
|
+
// inferred this type from a caption. The migrated kind is persisted by
|
|
1274
|
+
// setupAccessory and subsequent captions no longer participate.
|
|
1275
|
+
return accessory?.services?.some(service => service.UUID === this.Service.LeakSensor.UUID) === true;
|
|
460
1276
|
}
|
|
461
1277
|
getCoveringControlMode() {
|
|
462
1278
|
return this.coveringControlMode;
|
|
@@ -479,15 +1295,6 @@ class SuplaPlatform {
|
|
|
479
1295
|
getCoveringTravelTimeSeconds() {
|
|
480
1296
|
return this.coveringTravelTimeSeconds;
|
|
481
1297
|
}
|
|
482
|
-
getGateControlMode() {
|
|
483
|
-
return this.gateControlMode;
|
|
484
|
-
}
|
|
485
|
-
getGateExecuteActionOpen() {
|
|
486
|
-
return this.gateExecuteActionOpen;
|
|
487
|
-
}
|
|
488
|
-
getGateExecuteActionClose() {
|
|
489
|
-
return this.gateExecuteActionClose;
|
|
490
|
-
}
|
|
491
1298
|
getGateExecuteActionToggle() {
|
|
492
1299
|
return this.gateExecuteActionToggle;
|
|
493
1300
|
}
|
|
@@ -509,50 +1316,116 @@ class SuplaPlatform {
|
|
|
509
1316
|
getGateLockSetOffPayload() {
|
|
510
1317
|
return this.gateLockSetOffPayload;
|
|
511
1318
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
}
|
|
518
|
-
getGateOpenAssumeDelayMs() {
|
|
519
|
-
return this.gateOpenAssumeDelayMs;
|
|
520
|
-
}
|
|
521
|
-
getGateCommandCooldownMs() {
|
|
522
|
-
return this.gateCommandCooldownMs;
|
|
1319
|
+
claimGateLockFailSafe(accessoryUuid) {
|
|
1320
|
+
const task = this.gateLockFailSafeTasks.get(accessoryUuid);
|
|
1321
|
+
if (task) {
|
|
1322
|
+
this.unregisterMqttHandlers(task.ownerId);
|
|
1323
|
+
}
|
|
523
1324
|
}
|
|
524
|
-
|
|
525
|
-
|
|
1325
|
+
transferGateLockFailSafe(accessory, stateTopic, commandTopic, offPayload) {
|
|
1326
|
+
if (this.shuttingDown || accessory.context.suplaGateLockPendingOff !== true) {
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
if (this.gateLockFailSafeTasks.has(accessory.UUID)) {
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
const task = {
|
|
1333
|
+
ownerId: `gate-lock-failsafe:${accessory.UUID}`,
|
|
1334
|
+
accessory,
|
|
1335
|
+
stateTopic,
|
|
1336
|
+
commandTopic,
|
|
1337
|
+
offPayload,
|
|
1338
|
+
attempted: false,
|
|
1339
|
+
};
|
|
1340
|
+
this.gateLockFailSafeTasks.set(accessory.UUID, task);
|
|
1341
|
+
this.registerOwnerCleanup(task.ownerId, () => {
|
|
1342
|
+
if (task.retryTimer) {
|
|
1343
|
+
clearTimeout(task.retryTimer);
|
|
1344
|
+
task.retryTimer = undefined;
|
|
1345
|
+
}
|
|
1346
|
+
if (this.gateLockFailSafeTasks.get(accessory.UUID) === task) {
|
|
1347
|
+
this.gateLockFailSafeTasks.delete(accessory.UUID);
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1350
|
+
this.registerMqttHandler(stateTopic, (message, _topic, packet) => {
|
|
1351
|
+
const relayOn = this.parseBooleanStrict(message.toString());
|
|
1352
|
+
if (relayOn !== false || packet?.retain === true || !task.attempted) {
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
accessory.context.suplaGateLockPendingOff = false;
|
|
1356
|
+
const accessoryStillRegistered = Array.isArray(this.accessories)
|
|
1357
|
+
&& this.accessories.includes(accessory);
|
|
1358
|
+
try {
|
|
1359
|
+
if (accessoryStillRegistered) {
|
|
1360
|
+
this.api.updatePlatformAccessories([accessory]);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
catch (error) {
|
|
1364
|
+
accessory.context.suplaGateLockPendingOff = true;
|
|
1365
|
+
this.log.error(`Failed to persist transferred gate-lock OFF confirmation for ${accessory.displayName}: ` +
|
|
1366
|
+
`${error.message}`);
|
|
1367
|
+
this.scheduleGateLockFailSafeRetry(task);
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
this.log.info(`Gate-lock fail-safe OFF confirmed for ${accessory.displayName}.`);
|
|
1371
|
+
this.unregisterMqttHandlers(task.ownerId);
|
|
1372
|
+
}, task.ownerId);
|
|
1373
|
+
this.registerMqttTransportHandler((connected) => {
|
|
1374
|
+
if (connected) {
|
|
1375
|
+
this.attemptGateLockFailSafe(task);
|
|
1376
|
+
}
|
|
1377
|
+
}, task.ownerId);
|
|
526
1378
|
}
|
|
527
|
-
|
|
528
|
-
|
|
1379
|
+
attemptGateLockFailSafe(task) {
|
|
1380
|
+
if (this.shuttingDown
|
|
1381
|
+
|| this.gateLockFailSafeTasks.get(task.accessory.UUID) !== task
|
|
1382
|
+
|| task.publishInFlight) {
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
task.attempted = true;
|
|
1386
|
+
this.log.warn(`Publishing transferred gate-lock fail-safe OFF for ${task.accessory.displayName}.`);
|
|
1387
|
+
const operation = this.publishActionAsync(task.commandTopic, task.offPayload);
|
|
1388
|
+
task.publishInFlight = operation;
|
|
1389
|
+
void operation.catch((error) => {
|
|
1390
|
+
this.log.warn(`Transferred gate-lock OFF remains unconfirmed for ${task.accessory.displayName}: ` +
|
|
1391
|
+
`${error.message}`);
|
|
1392
|
+
}).finally(() => {
|
|
1393
|
+
if (task.publishInFlight === operation) {
|
|
1394
|
+
task.publishInFlight = undefined;
|
|
1395
|
+
}
|
|
1396
|
+
this.scheduleGateLockFailSafeRetry(task);
|
|
1397
|
+
});
|
|
529
1398
|
}
|
|
530
|
-
|
|
531
|
-
|
|
1399
|
+
scheduleGateLockFailSafeRetry(task) {
|
|
1400
|
+
if (this.shuttingDown
|
|
1401
|
+
|| task.retryTimer
|
|
1402
|
+
|| this.gateLockFailSafeTasks.get(task.accessory.UUID) !== task) {
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
task.retryTimer = setTimeout(() => {
|
|
1406
|
+
task.retryTimer = undefined;
|
|
1407
|
+
this.attemptGateLockFailSafe(task);
|
|
1408
|
+
}, 5000);
|
|
532
1409
|
}
|
|
533
|
-
|
|
1410
|
+
getFrontGateConfig() {
|
|
534
1411
|
return {
|
|
535
1412
|
fullTravelMs: this.frontGateFullTravelMs,
|
|
536
1413
|
reversePauseMs: this.frontGateReversePauseMs,
|
|
537
|
-
wrongDirectionRunMs: this.frontGateWrongDirectionRunMs,
|
|
538
1414
|
minimumPulseGapMs: this.frontGateMinimumPulseGapMs,
|
|
539
|
-
|
|
1415
|
+
unknownOpenPolicy: this.frontGateUnknownOpenPolicy,
|
|
1416
|
+
unknownClosePolicy: this.frontGateUnknownClosePolicy,
|
|
1417
|
+
seekClosedMaxPulses: this.frontGateSeekClosedMaxPulses,
|
|
1418
|
+
assumeOpenAfterTravel: this.frontGateAssumeOpenAfterTravel,
|
|
540
1419
|
};
|
|
541
1420
|
}
|
|
1421
|
+
shouldFallbackFrontGateSensorToControlChannel() {
|
|
1422
|
+
return this.frontGateSensorFallbackToControlChannel;
|
|
1423
|
+
}
|
|
1424
|
+
isMqttNoLocalAvailable() {
|
|
1425
|
+
return this.mqttProtocolVersion === 5;
|
|
1426
|
+
}
|
|
542
1427
|
getFrontGatePulseAction() {
|
|
543
|
-
|
|
544
|
-
if (toggleAction) {
|
|
545
|
-
return toggleAction;
|
|
546
|
-
}
|
|
547
|
-
const openAction = this.gateExecuteActionOpen.trim();
|
|
548
|
-
const closeAction = this.gateExecuteActionClose.trim();
|
|
549
|
-
if (openAction && openAction === closeAction) {
|
|
550
|
-
return openAction;
|
|
551
|
-
}
|
|
552
|
-
if (openAction) {
|
|
553
|
-
return openAction;
|
|
554
|
-
}
|
|
555
|
-
return closeAction;
|
|
1428
|
+
return this.gateExecuteActionToggle.trim();
|
|
556
1429
|
}
|
|
557
1430
|
normalizeCoveringControlMode(value) {
|
|
558
1431
|
const normalized = (value ?? 'set').toString().toLowerCase();
|
|
@@ -564,13 +1437,6 @@ class SuplaPlatform {
|
|
|
564
1437
|
}
|
|
565
1438
|
return 'set';
|
|
566
1439
|
}
|
|
567
|
-
normalizeGateControlMode(value) {
|
|
568
|
-
const normalized = (value ?? 'execute_action').toString().toLowerCase();
|
|
569
|
-
if (normalized === 'toggle') {
|
|
570
|
-
return 'toggle';
|
|
571
|
-
}
|
|
572
|
-
return 'execute_action';
|
|
573
|
-
}
|
|
574
1440
|
normalizeGateLockControlMode(value) {
|
|
575
1441
|
const normalized = (value ?? 'execute_action').toString().toLowerCase();
|
|
576
1442
|
if (normalized === 'set_on_pulse') {
|
|
@@ -578,54 +1444,12 @@ class SuplaPlatform {
|
|
|
578
1444
|
}
|
|
579
1445
|
return 'execute_action';
|
|
580
1446
|
}
|
|
581
|
-
|
|
582
|
-
const normalized = (value ?? 'moving').toString().toLowerCase();
|
|
583
|
-
if (normalized === 'open_endstop' || normalized === 'open') {
|
|
584
|
-
return 'open_endstop';
|
|
585
|
-
}
|
|
586
|
-
if (normalized === 'pedestrian_endstop' || normalized === 'pedestrian') {
|
|
587
|
-
return 'pedestrian_endstop';
|
|
588
|
-
}
|
|
589
|
-
if (normalized === 'ignore' || normalized === 'absent') {
|
|
590
|
-
return 'ignore';
|
|
591
|
-
}
|
|
592
|
-
return 'moving';
|
|
593
|
-
}
|
|
594
|
-
normalizeGateReverseFollowUpDelayMs(value) {
|
|
595
|
-
const fallbackMs = 3000;
|
|
1447
|
+
normalizeDurationSeconds(value, fallback, min, max) {
|
|
596
1448
|
const parsed = Number(value);
|
|
597
1449
|
if (!Number.isFinite(parsed)) {
|
|
598
|
-
return
|
|
1450
|
+
return fallback;
|
|
599
1451
|
}
|
|
600
|
-
|
|
601
|
-
return Math.min(10000, Math.max(250, rounded));
|
|
602
|
-
}
|
|
603
|
-
normalizeGateOpenAssumeDelayMs(value) {
|
|
604
|
-
const fallbackMs = 22000;
|
|
605
|
-
const parsed = Number(value);
|
|
606
|
-
if (!Number.isFinite(parsed)) {
|
|
607
|
-
return fallbackMs;
|
|
608
|
-
}
|
|
609
|
-
const rounded = Math.round(parsed);
|
|
610
|
-
return Math.min(120000, Math.max(1000, rounded));
|
|
611
|
-
}
|
|
612
|
-
normalizeGateCommandCooldownMs(value) {
|
|
613
|
-
const fallbackMs = 700;
|
|
614
|
-
const parsed = Number(value);
|
|
615
|
-
if (!Number.isFinite(parsed)) {
|
|
616
|
-
return fallbackMs;
|
|
617
|
-
}
|
|
618
|
-
const rounded = Math.round(parsed);
|
|
619
|
-
return Math.min(15000, Math.max(3000, rounded));
|
|
620
|
-
}
|
|
621
|
-
normalizeGatePublishRetryDelayMs(value) {
|
|
622
|
-
const fallbackMs = 300;
|
|
623
|
-
const parsed = Number(value);
|
|
624
|
-
if (!Number.isFinite(parsed)) {
|
|
625
|
-
return fallbackMs;
|
|
626
|
-
}
|
|
627
|
-
const rounded = Math.round(parsed);
|
|
628
|
-
return Math.min(5000, Math.max(0, rounded));
|
|
1452
|
+
return Math.min(max, Math.max(min, parsed));
|
|
629
1453
|
}
|
|
630
1454
|
normalizeFrontGateFullTravelMs(value) {
|
|
631
1455
|
const fallbackMs = 25000;
|
|
@@ -645,15 +1469,6 @@ class SuplaPlatform {
|
|
|
645
1469
|
const rounded = Math.round(parsed);
|
|
646
1470
|
return Math.min(15000, Math.max(3000, rounded));
|
|
647
1471
|
}
|
|
648
|
-
normalizeFrontGateWrongDirectionRunMs(value) {
|
|
649
|
-
const fallbackMs = 700;
|
|
650
|
-
const parsed = Number(value);
|
|
651
|
-
if (!Number.isFinite(parsed)) {
|
|
652
|
-
return fallbackMs;
|
|
653
|
-
}
|
|
654
|
-
const rounded = Math.round(parsed);
|
|
655
|
-
return Math.min(5000, Math.max(100, rounded));
|
|
656
|
-
}
|
|
657
1472
|
normalizeFrontGateMinimumPulseGapMs(value) {
|
|
658
1473
|
const fallbackMs = 3000;
|
|
659
1474
|
const parsed = Number(value);
|
|
@@ -661,16 +1476,28 @@ class SuplaPlatform {
|
|
|
661
1476
|
return fallbackMs;
|
|
662
1477
|
}
|
|
663
1478
|
const rounded = Math.round(parsed);
|
|
664
|
-
return Math.min(
|
|
1479
|
+
return Math.min(15000, Math.max(0, rounded));
|
|
1480
|
+
}
|
|
1481
|
+
normalizeFrontGateUnknownOpenPolicy(value) {
|
|
1482
|
+
return value === 'accept_non_closed' ? 'accept_non_closed' : 'reject';
|
|
665
1483
|
}
|
|
666
|
-
|
|
667
|
-
|
|
1484
|
+
normalizeFrontGateUnknownClosePolicy(value) {
|
|
1485
|
+
if (value === 'single_pulse_best_effort' || value === 'seek_closed') {
|
|
1486
|
+
return value;
|
|
1487
|
+
}
|
|
1488
|
+
return 'reject';
|
|
1489
|
+
}
|
|
1490
|
+
normalizeFrontGateSeekClosedMaxPulses(value) {
|
|
1491
|
+
const fallbackValue = 3;
|
|
668
1492
|
const parsed = Number(value);
|
|
669
1493
|
if (!Number.isFinite(parsed)) {
|
|
670
1494
|
return fallbackValue;
|
|
671
1495
|
}
|
|
672
1496
|
const rounded = Math.round(parsed);
|
|
673
|
-
return Math.min(3, Math.max(
|
|
1497
|
+
return Math.min(3, Math.max(1, rounded));
|
|
1498
|
+
}
|
|
1499
|
+
normalizeMqttProtocolVersion(value) {
|
|
1500
|
+
return Number(value) === 5 ? 5 : 4;
|
|
674
1501
|
}
|
|
675
1502
|
normalizeTopicSuffix(value) {
|
|
676
1503
|
return value.toString().replace(/^\/+/, '');
|
|
@@ -686,7 +1513,7 @@ class SuplaPlatform {
|
|
|
686
1513
|
base = base.replace(/\/set\/.+$/, '');
|
|
687
1514
|
return base.replace(/\/+$/, '');
|
|
688
1515
|
}
|
|
689
|
-
registerMqttHandler(topic, handler, ownerId) {
|
|
1516
|
+
registerMqttHandler(topic, handler, ownerId, options = {}) {
|
|
690
1517
|
if (!topic) {
|
|
691
1518
|
return () => undefined;
|
|
692
1519
|
}
|
|
@@ -701,6 +1528,9 @@ class SuplaPlatform {
|
|
|
701
1528
|
ownerTopics.set(topic, ownerHandlers);
|
|
702
1529
|
this.mqttHandlerOwners.set(ownerId, ownerTopics);
|
|
703
1530
|
this.mqttDesiredSubscriptions.add(topic);
|
|
1531
|
+
if (options.noLocal && this.mqttProtocolVersion === 5) {
|
|
1532
|
+
this.mqttNoLocalTopics.add(topic);
|
|
1533
|
+
}
|
|
704
1534
|
this.ensureSubscribed(topic, false);
|
|
705
1535
|
return () => {
|
|
706
1536
|
this.removeHandler(topic, handler);
|
|
@@ -717,6 +1547,21 @@ class SuplaPlatform {
|
|
|
717
1547
|
}
|
|
718
1548
|
};
|
|
719
1549
|
}
|
|
1550
|
+
registerMqttTransportHandler(handler, ownerId) {
|
|
1551
|
+
const handlers = this.mqttTransportHandlers.get(ownerId) ?? new Set();
|
|
1552
|
+
handlers.add(handler);
|
|
1553
|
+
this.mqttTransportHandlers.set(ownerId, handlers);
|
|
1554
|
+
handler(this.mqttTransportConnected);
|
|
1555
|
+
const cleanup = () => {
|
|
1556
|
+
const active = this.mqttTransportHandlers.get(ownerId);
|
|
1557
|
+
active?.delete(handler);
|
|
1558
|
+
if (active?.size === 0) {
|
|
1559
|
+
this.mqttTransportHandlers.delete(ownerId);
|
|
1560
|
+
}
|
|
1561
|
+
};
|
|
1562
|
+
this.registerOwnerCleanup(ownerId, cleanup);
|
|
1563
|
+
return cleanup;
|
|
1564
|
+
}
|
|
720
1565
|
registerOwnerCleanup(ownerId, cleanup) {
|
|
721
1566
|
const cleanups = this.ownerCleanups.get(ownerId) ?? new Set();
|
|
722
1567
|
cleanups.add(cleanup);
|
|
@@ -732,6 +1577,20 @@ class SuplaPlatform {
|
|
|
732
1577
|
}
|
|
733
1578
|
};
|
|
734
1579
|
}
|
|
1580
|
+
registerShutdownDrain(ownerId, drain) {
|
|
1581
|
+
const drains = this.shutdownDrains.get(ownerId) ?? new Set();
|
|
1582
|
+
drains.add(drain);
|
|
1583
|
+
this.shutdownDrains.set(ownerId, drains);
|
|
1584
|
+
const cleanup = () => {
|
|
1585
|
+
const active = this.shutdownDrains.get(ownerId);
|
|
1586
|
+
active?.delete(drain);
|
|
1587
|
+
if (active?.size === 0) {
|
|
1588
|
+
this.shutdownDrains.delete(ownerId);
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
this.registerOwnerCleanup(ownerId, cleanup);
|
|
1592
|
+
return cleanup;
|
|
1593
|
+
}
|
|
735
1594
|
unregisterMqttHandlers(ownerId) {
|
|
736
1595
|
this.runOwnerCleanup(ownerId);
|
|
737
1596
|
const ownerTopics = this.mqttHandlerOwners.get(ownerId);
|
|
@@ -758,31 +1617,106 @@ class SuplaPlatform {
|
|
|
758
1617
|
}
|
|
759
1618
|
this.mqttHandlers.clear();
|
|
760
1619
|
this.mqttWildcardHandlers.clear();
|
|
1620
|
+
this.mqttNoLocalTopics.clear();
|
|
1621
|
+
this.mqttTransportHandlers.clear();
|
|
761
1622
|
this.mqttDesiredSubscriptions.clear();
|
|
762
1623
|
this.mqttSubscriptions.clear();
|
|
763
1624
|
this.mqttPendingSubscriptions.clear();
|
|
1625
|
+
this.mqttPendingSubscriptionOperations.clear();
|
|
764
1626
|
this.clearAllSubscriptionRetries();
|
|
1627
|
+
this.shutdownDrains.clear();
|
|
765
1628
|
this.ownerCleanups.clear();
|
|
766
1629
|
}
|
|
767
1630
|
publishCommand(topic, payload, callback) {
|
|
1631
|
+
this.publishMqtt(topic, payload, { qos: this.commandQos, retain: this.commandRetain }, error => callback?.(error));
|
|
1632
|
+
}
|
|
1633
|
+
publishCommandAsync(topic, payload) {
|
|
1634
|
+
return this.publishMqttAsync(topic, payload, { qos: this.commandQos, retain: this.commandRetain });
|
|
1635
|
+
}
|
|
1636
|
+
publishGateAction(topic, payload, callback) {
|
|
1637
|
+
this.publishMqtt(topic, payload, { qos: 0, retain: false }, (error, failureOrigin) => {
|
|
1638
|
+
if (error && failureOrigin === 'callback') {
|
|
1639
|
+
error.mqttPublishFailureKind = 'completion_unknown';
|
|
1640
|
+
}
|
|
1641
|
+
callback?.(error);
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
publishActionAsync(topic, payload) {
|
|
1645
|
+
return this.publishMqttAsync(topic, payload, { qos: 0, retain: false }, true);
|
|
1646
|
+
}
|
|
1647
|
+
publishGateActionAsync(topic, payload) {
|
|
1648
|
+
return this.publishActionAsync(topic, payload);
|
|
1649
|
+
}
|
|
1650
|
+
isPublishCompletionUnknown(error) {
|
|
1651
|
+
return error instanceof Error
|
|
1652
|
+
&& error.mqttPublishFailureKind === 'completion_unknown';
|
|
1653
|
+
}
|
|
1654
|
+
publishMqttAsync(topic, payload, options, callbackFailureIsAmbiguous = false) {
|
|
1655
|
+
return new Promise((resolve, reject) => {
|
|
1656
|
+
let settled = false;
|
|
1657
|
+
const finish = (kind, error) => {
|
|
1658
|
+
if (settled) {
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
settled = true;
|
|
1662
|
+
clearTimeout(timer);
|
|
1663
|
+
if (!kind || !error) {
|
|
1664
|
+
resolve();
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
this.log.error(`Publish failed for ${topic}: ${error.message}`);
|
|
1668
|
+
const hapError = new this.api.hap.HapStatusError(kind === 'completion_unknown'
|
|
1669
|
+
? -70408 /* this.api.hap.HAPStatus.OPERATION_TIMED_OUT */
|
|
1670
|
+
: -70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
1671
|
+
hapError.mqttPublishFailureKind = kind;
|
|
1672
|
+
hapError.cause = error;
|
|
1673
|
+
reject(hapError);
|
|
1674
|
+
};
|
|
1675
|
+
const timer = setTimeout(() => {
|
|
1676
|
+
finish('completion_unknown', new Error(`MQTT publish completion timed out after ${this.publishTimeoutMs}ms`));
|
|
1677
|
+
}, this.publishTimeoutMs);
|
|
1678
|
+
this.publishMqtt(topic, payload, options, (error, failureOrigin) => {
|
|
1679
|
+
if (error) {
|
|
1680
|
+
finish(callbackFailureIsAmbiguous && failureOrigin === 'callback'
|
|
1681
|
+
? 'completion_unknown'
|
|
1682
|
+
: 'definite_failure', error);
|
|
1683
|
+
}
|
|
1684
|
+
else {
|
|
1685
|
+
finish();
|
|
1686
|
+
}
|
|
1687
|
+
});
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
publishMqtt(topic, payload, options, callback) {
|
|
768
1691
|
const client = this.MqttClient?.client;
|
|
769
1692
|
if (!client || !client.connected) {
|
|
770
1693
|
const error = new Error('MQTT not connected');
|
|
771
1694
|
this.log.error(`MQTT not connected; cannot publish ${topic}`);
|
|
772
1695
|
if (callback) {
|
|
773
|
-
callback(error);
|
|
1696
|
+
callback(error, 'preflight');
|
|
774
1697
|
}
|
|
775
1698
|
return;
|
|
776
1699
|
}
|
|
777
|
-
|
|
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') {
|
|
@@ -800,6 +1734,46 @@ class SuplaPlatform {
|
|
|
800
1734
|
}
|
|
801
1735
|
return false;
|
|
802
1736
|
}
|
|
1737
|
+
parseBooleanStrict(value) {
|
|
1738
|
+
if (typeof value === 'boolean') {
|
|
1739
|
+
return value;
|
|
1740
|
+
}
|
|
1741
|
+
if (typeof value === 'number') {
|
|
1742
|
+
if (value === 1) {
|
|
1743
|
+
return true;
|
|
1744
|
+
}
|
|
1745
|
+
if (value === 0) {
|
|
1746
|
+
return false;
|
|
1747
|
+
}
|
|
1748
|
+
return undefined;
|
|
1749
|
+
}
|
|
1750
|
+
if (typeof value !== 'string') {
|
|
1751
|
+
return undefined;
|
|
1752
|
+
}
|
|
1753
|
+
const normalized = value.trim().toLowerCase();
|
|
1754
|
+
if (['1', 'true', 'on', 'yes'].includes(normalized)) {
|
|
1755
|
+
return true;
|
|
1756
|
+
}
|
|
1757
|
+
if (['0', 'false', 'off', 'no'].includes(normalized)) {
|
|
1758
|
+
return false;
|
|
1759
|
+
}
|
|
1760
|
+
return undefined;
|
|
1761
|
+
}
|
|
1762
|
+
parseFiniteNumberStrict(value) {
|
|
1763
|
+
if (typeof value === 'number') {
|
|
1764
|
+
return Number.isFinite(value) ? value : undefined;
|
|
1765
|
+
}
|
|
1766
|
+
if (typeof value !== 'string') {
|
|
1767
|
+
return undefined;
|
|
1768
|
+
}
|
|
1769
|
+
const normalized = value.trim();
|
|
1770
|
+
if (!normalized
|
|
1771
|
+
|| !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) {
|
|
1772
|
+
return undefined;
|
|
1773
|
+
}
|
|
1774
|
+
const parsed = Number(normalized);
|
|
1775
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
1776
|
+
}
|
|
803
1777
|
runOwnerCleanup(ownerId) {
|
|
804
1778
|
const cleanups = this.ownerCleanups.get(ownerId);
|
|
805
1779
|
if (!cleanups) {
|
|
@@ -815,12 +1789,33 @@ class SuplaPlatform {
|
|
|
815
1789
|
}
|
|
816
1790
|
this.ownerCleanups.delete(ownerId);
|
|
817
1791
|
}
|
|
1792
|
+
updateMqttTransportState(connected) {
|
|
1793
|
+
if (this.mqttTransportConnected === connected) {
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
this.mqttTransportConnected = connected;
|
|
1797
|
+
for (const handlers of this.mqttTransportHandlers.values()) {
|
|
1798
|
+
for (const handler of handlers) {
|
|
1799
|
+
try {
|
|
1800
|
+
handler(connected);
|
|
1801
|
+
}
|
|
1802
|
+
catch (error) {
|
|
1803
|
+
this.log.error(`MQTT transport handler failed: ${error.message}`);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
advanceMqttTransportEpoch() {
|
|
1809
|
+
this.mqttTransportEpoch += 1;
|
|
1810
|
+
}
|
|
818
1811
|
clearActiveSubscriptions() {
|
|
1812
|
+
this.advanceMqttTransportEpoch();
|
|
819
1813
|
if (this.mqttSubscriptions.size > 0 || this.mqttPendingSubscriptions.size > 0) {
|
|
820
1814
|
this.log.debug('MQTT connection lost; clearing active subscriptions.');
|
|
821
1815
|
}
|
|
822
1816
|
this.mqttSubscriptions.clear();
|
|
823
1817
|
this.mqttPendingSubscriptions.clear();
|
|
1818
|
+
this.mqttPendingSubscriptionOperations.clear();
|
|
824
1819
|
this.clearAllSubscriptionRetries();
|
|
825
1820
|
}
|
|
826
1821
|
clearAllSubscriptionRetries() {
|
|
@@ -875,9 +1870,11 @@ class SuplaPlatform {
|
|
|
875
1870
|
}
|
|
876
1871
|
this.mqttRetryState.set(topic, state);
|
|
877
1872
|
this.log.debug(`Retrying MQTT subscribe for ${topic} in ${scheduledDelayMs}ms (${reason}).`);
|
|
1873
|
+
const scheduledEpoch = this.mqttTransportEpoch;
|
|
878
1874
|
const timer = setTimeout(() => {
|
|
879
1875
|
this.mqttRetryTimers.delete(topic);
|
|
880
|
-
if (
|
|
1876
|
+
if (scheduledEpoch !== this.mqttTransportEpoch
|
|
1877
|
+
|| !this.mqttDesiredSubscriptions.has(topic)) {
|
|
881
1878
|
return;
|
|
882
1879
|
}
|
|
883
1880
|
this.ensureSubscribed(topic, true);
|
|
@@ -886,7 +1883,7 @@ class SuplaPlatform {
|
|
|
886
1883
|
}
|
|
887
1884
|
isSubscriptionGranted(topic, granted) {
|
|
888
1885
|
if (!Array.isArray(granted) || granted.length === 0) {
|
|
889
|
-
return
|
|
1886
|
+
return false;
|
|
890
1887
|
}
|
|
891
1888
|
const entry = granted.find((item) => item.topic === topic);
|
|
892
1889
|
if (!entry) {
|
|
@@ -930,7 +1927,25 @@ class SuplaPlatform {
|
|
|
930
1927
|
return;
|
|
931
1928
|
}
|
|
932
1929
|
this.mqttPendingSubscriptions.add(topic);
|
|
933
|
-
this.MqttClient.client
|
|
1930
|
+
const client = this.MqttClient.client;
|
|
1931
|
+
const operation = {
|
|
1932
|
+
client,
|
|
1933
|
+
epoch: this.mqttTransportEpoch,
|
|
1934
|
+
id: ++this.nextMqttSubscriptionOperationId,
|
|
1935
|
+
};
|
|
1936
|
+
this.mqttPendingSubscriptionOperations.set(topic, operation);
|
|
1937
|
+
const subscribeOptions = {
|
|
1938
|
+
qos: 0,
|
|
1939
|
+
...(this.mqttNoLocalTopics.has(topic) && this.mqttProtocolVersion === 5 ? { nl: true } : {}),
|
|
1940
|
+
};
|
|
1941
|
+
client.subscribe(topic, subscribeOptions, (err, granted) => {
|
|
1942
|
+
if (this.mqttPendingSubscriptionOperations.get(topic) !== operation
|
|
1943
|
+
|| this.mqttTransportEpoch !== operation.epoch
|
|
1944
|
+
|| this.MqttClient?.client !== operation.client
|
|
1945
|
+
|| !operation.client.connected) {
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
this.mqttPendingSubscriptionOperations.delete(topic);
|
|
934
1949
|
this.mqttPendingSubscriptions.delete(topic);
|
|
935
1950
|
if (err) {
|
|
936
1951
|
this.logSubscriptionIssue(topic, `MQTT subscribe failed for ${topic}: ${err.message}`);
|
|
@@ -969,6 +1984,7 @@ class SuplaPlatform {
|
|
|
969
1984
|
}
|
|
970
1985
|
if (force) {
|
|
971
1986
|
this.mqttPendingSubscriptions.clear();
|
|
1987
|
+
this.mqttPendingSubscriptionOperations.clear();
|
|
972
1988
|
}
|
|
973
1989
|
for (const topic of this.mqttDesiredSubscriptions) {
|
|
974
1990
|
this.ensureSubscribed(topic, force);
|
|
@@ -976,7 +1992,9 @@ class SuplaPlatform {
|
|
|
976
1992
|
}
|
|
977
1993
|
removeSubscription(topic) {
|
|
978
1994
|
this.mqttDesiredSubscriptions.delete(topic);
|
|
1995
|
+
this.mqttNoLocalTopics.delete(topic);
|
|
979
1996
|
this.mqttPendingSubscriptions.delete(topic);
|
|
1997
|
+
this.mqttPendingSubscriptionOperations.delete(topic);
|
|
980
1998
|
this.clearSubscriptionRetry(topic);
|
|
981
1999
|
if (!this.MqttClient) {
|
|
982
2000
|
return;
|
|
@@ -1041,7 +2059,7 @@ class SuplaPlatform {
|
|
|
1041
2059
|
}
|
|
1042
2060
|
this.mqttRouterAttached = true;
|
|
1043
2061
|
this.resubscribeAll(false);
|
|
1044
|
-
this.MqttClient.client.on('message', (topic, message) => {
|
|
2062
|
+
this.MqttClient.client.on('message', (topic, message, packet) => {
|
|
1045
2063
|
const dispatched = new Set();
|
|
1046
2064
|
const dispatch = (handler, label) => {
|
|
1047
2065
|
if (dispatched.has(handler)) {
|
|
@@ -1049,7 +2067,7 @@ class SuplaPlatform {
|
|
|
1049
2067
|
}
|
|
1050
2068
|
dispatched.add(handler);
|
|
1051
2069
|
try {
|
|
1052
|
-
handler(message, topic);
|
|
2070
|
+
handler(message, topic, packet);
|
|
1053
2071
|
}
|
|
1054
2072
|
catch (error) {
|
|
1055
2073
|
this.log.error(`MQTT handler error for ${label}: ${error.message}`);
|