@marcel2215/homebridge-supla-plugin 2.1.32 → 2.1.33
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 +1 -67
- package/config.json +3 -0
- package/config.schema.json +75 -92
- package/dist/Accesories/ActionTriggerAccessory.d.ts +1 -0
- package/dist/Accesories/ActionTriggerAccessory.d.ts.map +1 -1
- package/dist/Accesories/ActionTriggerAccessory.js +9 -15
- package/dist/Accesories/ActionTriggerAccessory.js.map +1 -1
- package/dist/Accesories/AirQualityAccessory.d.ts +1 -3
- package/dist/Accesories/AirQualityAccessory.d.ts.map +1 -1
- package/dist/Accesories/AirQualityAccessory.js +5 -47
- package/dist/Accesories/AirQualityAccessory.js.map +1 -1
- package/dist/Accesories/ContactSensorAccessory.d.ts +1 -3
- package/dist/Accesories/ContactSensorAccessory.d.ts.map +1 -1
- package/dist/Accesories/ContactSensorAccessory.js +4 -32
- package/dist/Accesories/ContactSensorAccessory.js.map +1 -1
- package/dist/Accesories/DimmerAccessory.d.ts +3 -7
- package/dist/Accesories/DimmerAccessory.d.ts.map +1 -1
- package/dist/Accesories/DimmerAccessory.js +16 -84
- package/dist/Accesories/DimmerAccessory.js.map +1 -1
- package/dist/Accesories/DimmerRgbLightAccessory.d.ts +3 -11
- package/dist/Accesories/DimmerRgbLightAccessory.d.ts.map +1 -1
- package/dist/Accesories/DimmerRgbLightAccessory.js +26 -148
- package/dist/Accesories/DimmerRgbLightAccessory.js.map +1 -1
- package/dist/Accesories/DoorAccessory.d.ts +1 -14
- package/dist/Accesories/DoorAccessory.d.ts.map +1 -1
- package/dist/Accesories/DoorAccessory.js +20 -146
- package/dist/Accesories/DoorAccessory.js.map +1 -1
- package/dist/Accesories/ElectricityMeterAccessory.d.ts +1 -13
- package/dist/Accesories/ElectricityMeterAccessory.d.ts.map +1 -1
- package/dist/Accesories/ElectricityMeterAccessory.js +12 -127
- package/dist/Accesories/ElectricityMeterAccessory.js.map +1 -1
- package/dist/Accesories/FacadeBlindAccessory.d.ts +0 -6
- package/dist/Accesories/FacadeBlindAccessory.d.ts.map +1 -1
- package/dist/Accesories/FacadeBlindAccessory.js +64 -205
- package/dist/Accesories/FacadeBlindAccessory.js.map +1 -1
- package/dist/Accesories/FrontGateFsm.d.ts +68 -74
- package/dist/Accesories/FrontGateFsm.d.ts.map +1 -1
- package/dist/Accesories/FrontGateFsm.js +403 -563
- package/dist/Accesories/FrontGateFsm.js.map +1 -1
- package/dist/Accesories/GarageDoorOpenerAccesory.d.ts +3 -12
- package/dist/Accesories/GarageDoorOpenerAccesory.d.ts.map +1 -1
- package/dist/Accesories/GarageDoorOpenerAccesory.js +21 -168
- package/dist/Accesories/GarageDoorOpenerAccesory.js.map +1 -1
- package/dist/Accesories/GateAccessory.d.ts +2 -12
- package/dist/Accesories/GateAccessory.d.ts.map +1 -1
- package/dist/Accesories/GateAccessory.js +89 -258
- package/dist/Accesories/GateAccessory.js.map +1 -1
- package/dist/Accesories/GateLockAccessory.d.ts +1 -17
- package/dist/Accesories/GateLockAccessory.d.ts.map +1 -1
- package/dist/Accesories/GateLockAccessory.js +50 -375
- package/dist/Accesories/GateLockAccessory.js.map +1 -1
- package/dist/Accesories/LeakSensorAccessory.d.ts +1 -3
- package/dist/Accesories/LeakSensorAccessory.d.ts.map +1 -1
- package/dist/Accesories/LeakSensorAccessory.js +4 -32
- package/dist/Accesories/LeakSensorAccessory.js.map +1 -1
- package/dist/Accesories/LightBulbAccesory.d.ts +3 -4
- package/dist/Accesories/LightBulbAccesory.d.ts.map +1 -1
- package/dist/Accesories/LightBulbAccesory.js +12 -40
- package/dist/Accesories/LightBulbAccesory.js.map +1 -1
- package/dist/Accesories/PressureAccessory.d.ts +1 -3
- package/dist/Accesories/PressureAccessory.d.ts.map +1 -1
- package/dist/Accesories/PressureAccessory.js +5 -28
- package/dist/Accesories/PressureAccessory.js.map +1 -1
- package/dist/Accesories/RGBLightBulbAccesory.d.ts +1 -10
- package/dist/Accesories/RGBLightBulbAccesory.d.ts.map +1 -1
- package/dist/Accesories/RGBLightBulbAccesory.js +17 -136
- package/dist/Accesories/RGBLightBulbAccesory.js.map +1 -1
- package/dist/Accesories/RollerShutterAccessory.d.ts +0 -6
- package/dist/Accesories/RollerShutterAccessory.d.ts.map +1 -1
- package/dist/Accesories/RollerShutterAccessory.js +75 -221
- package/dist/Accesories/RollerShutterAccessory.js.map +1 -1
- package/dist/Accesories/SwitchAccessory.d.ts +3 -4
- package/dist/Accesories/SwitchAccessory.d.ts.map +1 -1
- package/dist/Accesories/SwitchAccessory.js +12 -40
- package/dist/Accesories/SwitchAccessory.js.map +1 -1
- package/dist/Accesories/TemperatureAccessory.d.ts +1 -3
- package/dist/Accesories/TemperatureAccessory.d.ts.map +1 -1
- package/dist/Accesories/TemperatureAccessory.js +5 -28
- package/dist/Accesories/TemperatureAccessory.js.map +1 -1
- package/dist/Accesories/TemperatureHumidityAccessory.d.ts +1 -5
- package/dist/Accesories/TemperatureHumidityAccessory.d.ts.map +1 -1
- package/dist/Accesories/TemperatureHumidityAccessory.js +9 -40
- package/dist/Accesories/TemperatureHumidityAccessory.js.map +1 -1
- package/dist/Accesories/ThermostatAccessory.d.ts +1 -18
- package/dist/Accesories/ThermostatAccessory.d.ts.map +1 -1
- package/dist/Accesories/ThermostatAccessory.js +34 -193
- package/dist/Accesories/ThermostatAccessory.js.map +1 -1
- package/dist/Accesories/ValveAccessory.d.ts +2 -5
- package/dist/Accesories/ValveAccessory.d.ts.map +1 -1
- package/dist/Accesories/ValveAccessory.js +8 -47
- package/dist/Accesories/ValveAccessory.js.map +1 -1
- package/dist/Accesories/WicketAccesory.d.ts +3 -18
- package/dist/Accesories/WicketAccesory.d.ts.map +1 -1
- package/dist/Accesories/WicketAccesory.js +14 -154
- 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 +3 -7
- package/dist/Heplers/ColorConverters.js.map +1 -1
- package/dist/Heplers/SuplaChannelContext.d.ts +1 -3
- package/dist/Heplers/SuplaChannelContext.d.ts.map +1 -1
- package/dist/Heplers/SuplaChannelContext.js +1 -3
- package/dist/Heplers/SuplaChannelContext.js.map +1 -1
- package/dist/Heplers/SuplaMqttClient.d.ts +3 -11
- package/dist/Heplers/SuplaMqttClient.d.ts.map +1 -1
- package/dist/Heplers/SuplaMqttClient.js +112 -215
- package/dist/Heplers/SuplaMqttClient.js.map +1 -1
- package/dist/Heplers/SuplaMqttClientContext.d.ts +1 -2
- package/dist/Heplers/SuplaMqttClientContext.d.ts.map +1 -1
- package/dist/Heplers/SuplaMqttClientContext.js +1 -2
- package/dist/Heplers/SuplaMqttClientContext.js.map +1 -1
- package/dist/platform.d.ts +36 -93
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +236 -1285
- package/dist/platform.js.map +1 -1
- package/package.json +5 -10
- package/dist/Heplers/DisplayName.d.ts +0 -15
- package/dist/Heplers/DisplayName.d.ts.map +0 -1
- package/dist/Heplers/DisplayName.js +0 -174
- package/dist/Heplers/DisplayName.js.map +0 -1
package/dist/platform.js
CHANGED
|
@@ -38,8 +38,6 @@ 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");
|
|
43
41
|
const SuplaMqttClient_1 = require("./Heplers/SuplaMqttClient");
|
|
44
42
|
const RGBLightBulbAccesory_1 = require("./Accesories/RGBLightBulbAccesory");
|
|
45
43
|
const GateAccessory_1 = require("./Accesories/GateAccessory");
|
|
@@ -60,7 +58,6 @@ const ThermostatAccessory_1 = require("./Accesories/ThermostatAccessory");
|
|
|
60
58
|
const ElectricityMeterAccessory_1 = require("./Accesories/ElectricityMeterAccessory");
|
|
61
59
|
const DimmerRgbLightAccessory_1 = require("./Accesories/DimmerRgbLightAccessory");
|
|
62
60
|
const ActionTriggerAccessory_1 = require("./Accesories/ActionTriggerAccessory");
|
|
63
|
-
const DisplayName_1 = require("./Heplers/DisplayName");
|
|
64
61
|
/**
|
|
65
62
|
* HomebridgePlatform
|
|
66
63
|
* This class is the main constructor for your plugin, this is where you should
|
|
@@ -77,165 +74,80 @@ class SuplaPlatform {
|
|
|
77
74
|
this.mqttHandlers = new Map();
|
|
78
75
|
this.mqttHandlerOwners = new Map();
|
|
79
76
|
this.mqttWildcardHandlers = new Map();
|
|
80
|
-
this.mqttNoLocalTopics = new Set();
|
|
81
|
-
this.mqttTransportHandlers = new Map();
|
|
82
|
-
this.channelRegistry = new Map();
|
|
83
77
|
this.ownerCleanups = new Map();
|
|
84
|
-
this.shutdownDrains = new Map();
|
|
85
|
-
this.gateLockFailSafeTasks = new Map();
|
|
86
78
|
this.mqttDesiredSubscriptions = new Set();
|
|
87
79
|
this.mqttSubscriptions = new Set();
|
|
88
80
|
this.mqttPendingSubscriptions = new Set();
|
|
89
|
-
this.mqttPendingSubscriptionOperations = new Map();
|
|
90
81
|
this.mqttRetryTimers = new Map();
|
|
91
82
|
this.mqttRetryState = new Map();
|
|
92
83
|
this.mqttRouterAttached = false;
|
|
93
|
-
this.mqttTransportConnected = false;
|
|
94
|
-
this.mqttTransportEpoch = 0;
|
|
95
|
-
this.nextMqttSubscriptionOperationId = 0;
|
|
96
|
-
this.shuttingDown = false;
|
|
97
|
-
this.publishTimeoutMs = 5000;
|
|
98
84
|
this.log.debug('Finished initializing platform:', this.config.name);
|
|
99
|
-
this.accountNamespace = this.computeAccountNamespace();
|
|
100
85
|
const configView = this.config;
|
|
101
86
|
this.coveringControlMode = this.normalizeCoveringControlMode(configView.coveringControlMode);
|
|
102
87
|
this.coveringSetTopicSuffix = this.normalizeTopicSuffix(configView.coveringSetTopicSuffix || 'set/closing_percentage');
|
|
103
88
|
this.coveringTiltTopicSuffix = this.normalizeTopicSuffix(configView.coveringTiltTopicSuffix || 'set/tilt');
|
|
104
|
-
this.coveringExecuteActionOpen =
|
|
105
|
-
this.coveringExecuteActionClose =
|
|
106
|
-
this.coveringExecuteActionStop =
|
|
107
|
-
this.coveringTravelTimeSeconds =
|
|
108
|
-
this.
|
|
89
|
+
this.coveringExecuteActionOpen = (configView.coveringExecuteActionOpen || 'reveal').toString();
|
|
90
|
+
this.coveringExecuteActionClose = (configView.coveringExecuteActionClose || 'shut').toString();
|
|
91
|
+
this.coveringExecuteActionStop = (configView.coveringExecuteActionStop || 'stop').toString();
|
|
92
|
+
this.coveringTravelTimeSeconds = Number(configView.coveringTravelTimeSeconds) || 0;
|
|
93
|
+
this.gateControlMode = this.normalizeGateControlMode(configView.gateControlMode);
|
|
94
|
+
this.gateExecuteActionOpen = (configView.gateExecuteActionOpen || 'open_close').toString();
|
|
95
|
+
this.gateExecuteActionClose = (configView.gateExecuteActionClose || 'open_close').toString();
|
|
96
|
+
this.gateExecuteActionToggle = (configView.gateExecuteActionToggle || 'open_close').toString();
|
|
109
97
|
this.gateLockControlMode = this.normalizeGateLockControlMode(configView.gateLockControlMode);
|
|
110
|
-
this.gateLockExecuteAction =
|
|
98
|
+
this.gateLockExecuteAction = (configView.gateLockExecuteAction || 'open').toString();
|
|
111
99
|
this.gateLockSetTopicSuffix = this.normalizeTopicSuffix(configView.gateLockSetTopicSuffix || 'set/on');
|
|
112
|
-
this.gateLockPulseSeconds =
|
|
100
|
+
this.gateLockPulseSeconds = Number(configView.gateLockPulseSeconds) || 0;
|
|
113
101
|
this.gateLockSetOnPayload = (configView.gateLockSetOnPayload ?? 'true').toString();
|
|
114
102
|
this.gateLockSetOffPayload = (configView.gateLockSetOffPayload ?? 'false').toString();
|
|
115
|
-
this.
|
|
103
|
+
this.gatePartialHiMode = this.normalizeGatePartialHiMode(configView.gatePartialHiMode);
|
|
104
|
+
this.gateReverseFollowUpDelayMs = this.normalizeGateReverseFollowUpDelayMs(configView.gateReverseFollowUpDelayMs);
|
|
105
|
+
this.gateOpenAssumeDelayMs = this.normalizeGateOpenAssumeDelayMs(configView.gateOpenAssumeDelayMs);
|
|
106
|
+
this.gateCommandCooldownMs = this.normalizeGateCommandCooldownMs(configView.gateCommandCooldownMs);
|
|
107
|
+
this.gatePublishRetryDelayMs = this.normalizeGatePublishRetryDelayMs(configView.gatePublishRetryDelayMs);
|
|
108
|
+
this.gateStrictReverseDoublePulse = this.parseBoolean(configView.gateStrictReverseDoublePulse ?? true);
|
|
109
|
+
this.gateDebugTimeline = this.parseBoolean(configView.gateDebugTimeline ?? false);
|
|
110
|
+
this.frontGateFullTravelMs = this.normalizeFrontGateFullTravelMs(configView.frontGateFullTravelMs ?? configView.gateOpenAssumeDelayMs);
|
|
116
111
|
this.frontGateReversePauseMs = this.normalizeFrontGateReversePauseMs(configView.frontGateReversePauseMs);
|
|
117
|
-
this.
|
|
118
|
-
this.
|
|
119
|
-
this.
|
|
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);
|
|
112
|
+
this.frontGateWrongDirectionRunMs = this.normalizeFrontGateWrongDirectionRunMs(configView.frontGateWrongDirectionRunMs);
|
|
113
|
+
this.frontGateMinimumPulseGapMs = this.normalizeFrontGateMinimumPulseGapMs(configView.frontGateMinimumPulseGapMs ?? configView.gateCommandCooldownMs);
|
|
114
|
+
this.frontGateCloseRetryLimit = this.normalizeFrontGateCloseRetryLimit(configView.frontGateCloseRetryLimit);
|
|
124
115
|
this.commandQos = this.normalizeCommandQos(configView.commandQos);
|
|
125
116
|
this.commandRetain = this.parseBoolean(configView.commandRetain ?? false);
|
|
126
117
|
this.api.on('shutdown', () => {
|
|
127
|
-
|
|
118
|
+
this.unregisterAllMqttHandlers();
|
|
119
|
+
if (this.MqttClient) {
|
|
120
|
+
this.MqttClient.client.end(true);
|
|
121
|
+
}
|
|
128
122
|
});
|
|
129
123
|
this.api.on('didFinishLaunching', () => {
|
|
130
124
|
log.debug('Executed didFinishLaunching callback');
|
|
131
|
-
|
|
132
|
-
|
|
125
|
+
const mqttSettings = this.config;
|
|
126
|
+
this.MqttClient = new SuplaMqttClient_1.SuplaMqttClient(mqttSettings, this.log);
|
|
127
|
+
this.startMqttRouter();
|
|
128
|
+
this.MqttClient.client.on('connect', () => {
|
|
129
|
+
this.resubscribeAll(true);
|
|
130
|
+
});
|
|
131
|
+
this.MqttClient.client.on('close', () => {
|
|
132
|
+
this.clearActiveSubscriptions();
|
|
133
|
+
});
|
|
134
|
+
this.MqttClient.client.on('offline', () => {
|
|
135
|
+
this.clearActiveSubscriptions();
|
|
136
|
+
});
|
|
137
|
+
this.MqttClient.client.on('end', () => {
|
|
138
|
+
this.clearActiveSubscriptions();
|
|
139
|
+
});
|
|
140
|
+
if (this.MqttClient.client.connected) {
|
|
141
|
+
this.resubscribeAll(true);
|
|
142
|
+
}
|
|
143
|
+
this.discoverDevices();
|
|
144
|
+
this.MqttClient.discoverChannelsAsync((topic, handler) => (this.registerMqttHandler(topic, handler, 'discovery'))).then((channels) => {
|
|
145
|
+
this.persistChannels(channels);
|
|
146
|
+
this.discoverDevices(channels);
|
|
147
|
+
this.log.info('Channels discovered and saved to config file');
|
|
148
|
+
}).catch((error) => {
|
|
149
|
+
this.log.error(`Channel discovery failed: ${error.message}`);
|
|
133
150
|
});
|
|
134
|
-
});
|
|
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
151
|
});
|
|
240
152
|
}
|
|
241
153
|
/**
|
|
@@ -244,27 +156,9 @@ class SuplaPlatform {
|
|
|
244
156
|
*/
|
|
245
157
|
configureAccessory(accessory) {
|
|
246
158
|
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
|
-
}
|
|
266
159
|
// add the restored accessory to the accessories cache so we can track if it has already been registered
|
|
267
160
|
this.accessories.push(accessory);
|
|
161
|
+
const cachedDevice = accessory.context.device;
|
|
268
162
|
if (!cachedDevice || typeof cachedDevice !== 'object') {
|
|
269
163
|
this.log.warn(`Cached accessory ${accessory.displayName} missing device context; will configure after discovery.`);
|
|
270
164
|
accessory.context.deviceConfigured = false;
|
|
@@ -272,75 +166,27 @@ class SuplaPlatform {
|
|
|
272
166
|
}
|
|
273
167
|
const normalized = this.normalizeChannelContext(cachedDevice);
|
|
274
168
|
accessory.context.device = normalized;
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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;
|
|
169
|
+
accessory.context.deviceSignature = this.getChannelSignature(normalized);
|
|
170
|
+
const configured = this.setupAccessory(normalized, accessory);
|
|
171
|
+
accessory.context.deviceConfigured = configured;
|
|
290
172
|
}
|
|
291
173
|
/**
|
|
292
174
|
* This is an example method showing how to register discovered accessories.
|
|
293
175
|
* Accessories must only be registered once, previously created accessories
|
|
294
176
|
* must not be registered again to prevent "duplicate UUID" errors.
|
|
295
177
|
*/
|
|
296
|
-
discoverDevices(channelsOverride
|
|
297
|
-
const
|
|
298
|
-
|
|
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
|
-
};
|
|
178
|
+
discoverDevices(channelsOverride) {
|
|
179
|
+
const rawChannels = channelsOverride ?? this.loadChannelsFromConfig();
|
|
180
|
+
const channels = rawChannels.map(channel => this.normalizeChannelContext(channel));
|
|
333
181
|
this.log.info('Channels discovered:', channels.length);
|
|
334
182
|
this.log.debug(`Discovery mode: ${channelsOverride ? 'live' : 'cached'} channels`);
|
|
335
183
|
const channelUuids = new Set();
|
|
336
|
-
const
|
|
184
|
+
const shouldPrune = channelsOverride !== undefined && channels.length > 0;
|
|
185
|
+
const findExistingByIds = (candidate) => {
|
|
337
186
|
if (candidate.deviceId === 'unknown' || candidate.channelId === 'unknown') {
|
|
338
187
|
return undefined;
|
|
339
188
|
}
|
|
340
|
-
|
|
341
|
-
if (!this.isAccessoryAccountCompatible(accessory, accessory.context.device)) {
|
|
342
|
-
return false;
|
|
343
|
-
}
|
|
189
|
+
return this.accessories.find((accessory) => {
|
|
344
190
|
const existing = accessory.context.device;
|
|
345
191
|
if (!existing) {
|
|
346
192
|
return false;
|
|
@@ -350,32 +196,11 @@ class SuplaPlatform {
|
|
|
350
196
|
}
|
|
351
197
|
return existing.deviceId === candidate.deviceId && existing.channelId === candidate.channelId;
|
|
352
198
|
});
|
|
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;
|
|
373
199
|
};
|
|
374
200
|
// loop over the discovered devices and register each one if it has not already been registered
|
|
375
201
|
for (const channel of channels) {
|
|
376
|
-
const
|
|
377
|
-
const
|
|
378
|
-
const uuid = existingByIds?.UUID ?? scopedUuid;
|
|
202
|
+
const existingByIds = findExistingByIds(channel);
|
|
203
|
+
const uuid = existingByIds?.UUID ?? this.getChannelUuid(channel);
|
|
379
204
|
channelUuids.add(uuid);
|
|
380
205
|
const existingAccessory = existingByIds ?? this.accessories.find(accessory => accessory.UUID === uuid);
|
|
381
206
|
if (existingAccessory) {
|
|
@@ -383,163 +208,45 @@ class SuplaPlatform {
|
|
|
383
208
|
const signature = this.getChannelSignature(channel);
|
|
384
209
|
const previousSignature = existingAccessory.context.deviceSignature;
|
|
385
210
|
const wasConfigured = existingAccessory.context.deviceConfigured === true;
|
|
211
|
+
existingAccessory.context.device = channel;
|
|
212
|
+
existingAccessory.context.deviceSignature = signature;
|
|
386
213
|
this.log.debug(`Restoring channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
|
|
387
214
|
`function=${channel.channelFunction} type=${channel.channelType}`);
|
|
388
|
-
if (!this.isSupportedChannel(channel, existingAccessory)) {
|
|
389
|
-
if (this.isExplicitlyUnsupportedChannel(channel, existingAccessory)) {
|
|
390
|
-
this.log.warn(`Channel ${channel.channelCaption} is now explicitly unsupported ` +
|
|
391
|
-
`(${channel.channelFunction}/${channel.channelType}); revoking its handlers.`);
|
|
392
|
-
this.revokeAccessoryHandlers(existingAccessory);
|
|
393
|
-
existingAccessory.context.device = channel;
|
|
394
|
-
existingAccessory.context.deviceSignature = signature;
|
|
395
|
-
existingAccessory.context.deviceConfigured = false;
|
|
396
|
-
this.stampAccessoryIdentity(existingAccessory);
|
|
397
|
-
this.api.updatePlatformAccessories([existingAccessory]);
|
|
398
|
-
}
|
|
399
|
-
else {
|
|
400
|
-
this.log.warn(`Deferring incomplete channel ${channel.channelCaption} ` +
|
|
401
|
-
`(${channel.channelFunction}/${channel.channelType}); retaining last configured context.`);
|
|
402
|
-
}
|
|
403
|
-
continue;
|
|
404
|
-
}
|
|
405
215
|
if (previousSignature !== signature || !wasConfigured) {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const previousNames = this.captureAccessoryNames(existingAccessory);
|
|
409
|
-
try {
|
|
410
|
-
const safeDisplayName = this.resolveHomeKitDisplayName(channel, existingAccessory.displayName);
|
|
411
|
-
if (existingAccessory.displayName !== safeDisplayName) {
|
|
412
|
-
existingAccessory.updateDisplayName(safeDisplayName);
|
|
413
|
-
}
|
|
414
|
-
this.prepareAccessoryForReconfiguration(existingAccessory, channel);
|
|
415
|
-
const configured = this.setupAccessory(channel, existingAccessory);
|
|
416
|
-
if (!configured) {
|
|
417
|
-
throw new Error('No compatible accessory handler');
|
|
418
|
-
}
|
|
419
|
-
existingAccessory.context.device = channel;
|
|
420
|
-
existingAccessory.context.deviceSignature = signature;
|
|
421
|
-
existingAccessory.context.deviceConfigured = true;
|
|
422
|
-
this.stampAccessoryIdentity(existingAccessory);
|
|
423
|
-
this.cleanupObsoleteServices(existingAccessory, channel);
|
|
424
|
-
this.updateAccessoryNames(existingAccessory, channel);
|
|
425
|
-
this.api.updatePlatformAccessories([existingAccessory]);
|
|
426
|
-
}
|
|
427
|
-
catch (error) {
|
|
428
|
-
this.unregisterMqttHandlers(existingAccessory.UUID);
|
|
429
|
-
this.restoreAccessoryContext(existingAccessory, previousContext);
|
|
430
|
-
this.restoreAccessoryNames(existingAccessory, previousNames);
|
|
431
|
-
retainPreviousChannel(channel, previousDevice);
|
|
432
|
-
if (previousDevice && this.isSupportedChannel(previousDevice, existingAccessory)) {
|
|
433
|
-
try {
|
|
434
|
-
this.prepareAccessoryForReconfiguration(existingAccessory, previousDevice);
|
|
435
|
-
existingAccessory.context.deviceConfigured = this.setupAccessory(previousDevice, existingAccessory);
|
|
436
|
-
}
|
|
437
|
-
catch (restoreError) {
|
|
438
|
-
this.log.error(`Failed to restore previous handler for ${existingAccessory.displayName}: ` +
|
|
439
|
-
`${restoreError.message}`);
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
this.log.error(`Failed to configure ${channel.channelCaption}; previous context retained: ${error.message}`);
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
else {
|
|
447
|
-
const previousDevice = existingAccessory.context.device;
|
|
448
|
-
const previousContext = { ...existingAccessory.context };
|
|
449
|
-
const previousNames = this.captureAccessoryNames(existingAccessory);
|
|
450
|
-
try {
|
|
451
|
-
const metadataChanged = !this.areChannelContextsEqual(previousDevice, channel);
|
|
452
|
-
const identityChanged = existingAccessory.context.suplaIdentityVersion !== 2
|
|
453
|
-
|| existingAccessory.context.suplaAccountNamespace !== this.accountNamespace;
|
|
454
|
-
existingAccessory.context.device = channel;
|
|
455
|
-
existingAccessory.context.deviceSignature = signature;
|
|
456
|
-
this.stampAccessoryIdentity(existingAccessory);
|
|
457
|
-
const namesChanged = this.updateAccessoryNames(existingAccessory, channel);
|
|
458
|
-
if (metadataChanged || identityChanged || namesChanged) {
|
|
459
|
-
this.api.updatePlatformAccessories([existingAccessory]);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
catch (error) {
|
|
463
|
-
this.restoreAccessoryContext(existingAccessory, previousContext);
|
|
464
|
-
this.restoreAccessoryNames(existingAccessory, previousNames);
|
|
465
|
-
retainPreviousChannel(channel, previousDevice);
|
|
466
|
-
this.log.error(`Failed to commit metadata for ${channel.channelCaption}; previous name and context retained: ` +
|
|
467
|
-
`${error.message}`);
|
|
468
|
-
continue;
|
|
216
|
+
if (previousSignature && previousSignature !== signature) {
|
|
217
|
+
this.resetAccessoryServices(existingAccessory);
|
|
469
218
|
}
|
|
219
|
+
const configured = this.setupAccessory(channel, existingAccessory);
|
|
220
|
+
existingAccessory.context.deviceConfigured = configured;
|
|
470
221
|
}
|
|
222
|
+
this.api.updatePlatformAccessories([existingAccessory]);
|
|
471
223
|
continue;
|
|
472
224
|
}
|
|
473
225
|
this.log.info('Adding new accessory:', channel.channelCaption);
|
|
474
226
|
this.log.debug(`Registering channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
|
|
475
227
|
`function=${channel.channelFunction} type=${channel.channelType}`);
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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);
|
|
228
|
+
const accessory = new this.api.platformAccessory(channel.channelCaption, uuid);
|
|
229
|
+
accessory.context.device = channel;
|
|
230
|
+
accessory.context.deviceSignature = this.getChannelSignature(channel);
|
|
231
|
+
if (this.setupAccessory(channel, accessory)) {
|
|
489
232
|
accessory.context.deviceConfigured = true;
|
|
490
|
-
this.stampAccessoryIdentity(accessory);
|
|
491
|
-
this.updateAccessoryNames(accessory, channel);
|
|
492
233
|
this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
|
|
493
|
-
registered = true;
|
|
494
234
|
this.accessories.push(accessory);
|
|
495
235
|
this.api.updatePlatformAccessories([accessory]);
|
|
496
236
|
}
|
|
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
|
-
}
|
|
514
237
|
}
|
|
515
238
|
if (shouldPrune) {
|
|
516
239
|
const accessoriesToRemove = this.accessories.filter(accessory => !channelUuids.has(accessory.UUID));
|
|
517
240
|
for (const accessory of accessoriesToRemove) {
|
|
518
241
|
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
|
-
}
|
|
533
242
|
this.unregisterMqttHandlers(accessory.UUID);
|
|
243
|
+
this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
|
|
534
244
|
const index = this.accessories.indexOf(accessory);
|
|
535
245
|
if (index !== -1) {
|
|
536
246
|
this.accessories.splice(index, 1);
|
|
537
247
|
}
|
|
538
248
|
}
|
|
539
249
|
}
|
|
540
|
-
if (channelsOverride !== undefined) {
|
|
541
|
-
this.persistChannels(channelsToPersist);
|
|
542
|
-
}
|
|
543
250
|
}
|
|
544
251
|
loadChannelsFromConfig() {
|
|
545
252
|
const rawChannels = this.config.channels;
|
|
@@ -562,159 +269,31 @@ class SuplaPlatform {
|
|
|
562
269
|
this.log.warn('Cached channels format is invalid; expected string or array.');
|
|
563
270
|
return [];
|
|
564
271
|
}
|
|
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;
|
|
572
|
-
try {
|
|
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}.`);
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
catch (error) {
|
|
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
272
|
persistChannels(channels) {
|
|
622
273
|
try {
|
|
623
|
-
const
|
|
624
|
-
fs.
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
274
|
+
const configPath = this.api.user.configPath();
|
|
275
|
+
const config = JSON.parse(fs.readFileSync(configPath).toString());
|
|
276
|
+
const platformConfig = config.platforms?.find((platform) => platform.platform === settings_1.PLATFORM_NAME);
|
|
277
|
+
if (!platformConfig) {
|
|
278
|
+
this.log.warn(`Failed to save channels: ${settings_1.PLATFORM_NAME} not found in config.`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
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.`);
|
|
634
287
|
}
|
|
635
288
|
catch (error) {
|
|
636
|
-
this.log.error(`Failed to save
|
|
289
|
+
this.log.error(`Failed to save channels: ${error.message}`);
|
|
637
290
|
}
|
|
638
291
|
}
|
|
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
|
-
}
|
|
713
292
|
normalizeChannelContext(channel) {
|
|
714
293
|
const rawTopic = (channel.rawTopic ?? channel.topic ?? '').toString();
|
|
715
294
|
const baseFromTopic = this.normalizeTopicBase(rawTopic || (channel.topic ?? '').toString());
|
|
716
|
-
let deviceId = channel.deviceId
|
|
717
|
-
let channelId = channel.channelId
|
|
295
|
+
let deviceId = channel.deviceId;
|
|
296
|
+
let channelId = channel.channelId;
|
|
718
297
|
if (!deviceId || !channelId) {
|
|
719
298
|
const match = baseFromTopic.match(/\/devices\/(\d+)\/channels\/(\d+)(?:\/|$)/);
|
|
720
299
|
if (match) {
|
|
@@ -728,451 +307,43 @@ class SuplaPlatform {
|
|
|
728
307
|
if (!channelId) {
|
|
729
308
|
channelId = 'unknown';
|
|
730
309
|
}
|
|
731
|
-
const
|
|
732
|
-
const
|
|
733
|
-
const
|
|
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';
|
|
310
|
+
const caption = channel.channelCaption || `Device ${deviceId} Channel ${channelId}`;
|
|
311
|
+
const channelType = channel.channelType || 'UNKNOWN';
|
|
312
|
+
const channelFunction = channel.channelFunction || 'UNKNOWN';
|
|
741
313
|
const mqttContext = this.config;
|
|
742
|
-
const
|
|
743
|
-
const baseTopic = baseFromTopic || (hasStableIds
|
|
744
|
-
? `supla/${mqttContext.username}/devices/${deviceId}/channels/${channelId}`
|
|
745
|
-
: '');
|
|
314
|
+
const baseTopic = baseFromTopic || `supla/${mqttContext.username}/devices/${deviceId}/channels/${channelId}`;
|
|
746
315
|
if (deviceId === 'unknown' || channelId === 'unknown') {
|
|
747
316
|
this.log.warn(`Channel missing device/channel id for topic ${rawTopic}`);
|
|
748
317
|
}
|
|
749
|
-
return new SuplaChannelContext_1.SuplaChannelContext(baseTopic, channelType, channelFunction, caption, deviceId, channelId, rawTopic || undefined
|
|
318
|
+
return new SuplaChannelContext_1.SuplaChannelContext(baseTopic, channelType, channelFunction, caption, deviceId, channelId, rawTopic || undefined);
|
|
750
319
|
}
|
|
751
320
|
getChannelUuid(channel) {
|
|
752
321
|
const deviceId = channel.deviceId;
|
|
753
322
|
const channelId = channel.channelId;
|
|
754
323
|
const hasIds = deviceId && channelId && deviceId !== 'unknown' && channelId !== 'unknown';
|
|
755
|
-
const
|
|
756
|
-
const channelKey = hasIds
|
|
324
|
+
const key = hasIds
|
|
757
325
|
? `${deviceId}:${channelId}`
|
|
758
|
-
:
|
|
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}`;
|
|
326
|
+
: (channel.topic || channel.channelCaption || `${deviceId}:${channelId}`);
|
|
763
327
|
return this.api.hap.uuid.generate(key);
|
|
764
328
|
}
|
|
765
329
|
getChannelSignature(channel) {
|
|
766
|
-
|
|
330
|
+
return [
|
|
767
331
|
channel.topic ?? '',
|
|
768
332
|
channel.channelFunction ?? '',
|
|
769
333
|
channel.channelType ?? '',
|
|
770
334
|
channel.deviceId ?? '',
|
|
771
335
|
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),
|
|
800
336
|
].join('|');
|
|
801
337
|
}
|
|
802
|
-
|
|
803
|
-
this.
|
|
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, accessory) {
|
|
862
|
-
if (this.isExplicitNoFunction(channel.channelFunction)) {
|
|
863
|
-
return this.getLegacyNoFunctionServiceUuids(channel, accessory).size > 0;
|
|
864
|
-
}
|
|
865
|
-
if (!this.isKnownMetadata(channel.channelFunction) || !this.isKnownMetadata(channel.channelType)) {
|
|
866
|
-
return false;
|
|
867
|
-
}
|
|
868
|
-
return this.getExpectedServiceUuids(channel).size > 0;
|
|
869
|
-
}
|
|
870
|
-
isExplicitlyUnsupportedChannel(channel, accessory) {
|
|
871
|
-
if (this.isExplicitNoFunction(channel.channelFunction)) {
|
|
872
|
-
return this.getLegacyNoFunctionServiceUuids(channel, accessory).size === 0;
|
|
873
|
-
}
|
|
874
|
-
return this.isKnownMetadata(channel.channelFunction)
|
|
875
|
-
&& this.isKnownMetadata(channel.channelType)
|
|
876
|
-
&& this.getExpectedServiceUuids(channel).size === 0;
|
|
877
|
-
}
|
|
878
|
-
getExpectedServiceUuids(channel, accessory) {
|
|
879
|
-
if (this.isExplicitNoFunction(channel.channelFunction)) {
|
|
880
|
-
return this.getLegacyNoFunctionServiceUuids(channel, accessory);
|
|
881
|
-
}
|
|
882
|
-
const byFunction = {
|
|
883
|
-
CONTROLLINGTHEGARAGEDOOR: [this.Service.GarageDoorOpener.UUID],
|
|
884
|
-
CONTROLLINGTHEGATE: [this.Service.GarageDoorOpener.UUID],
|
|
885
|
-
CONTROLLINGTHEGATEWAYLOCK: [this.Service.LockMechanism.UUID],
|
|
886
|
-
LIGHTSWITCH: [this.Service.Lightbulb.UUID],
|
|
887
|
-
POWERSWITCH: [this.Service.Switch.UUID],
|
|
888
|
-
RGBLIGHTING: [this.Service.Lightbulb.UUID],
|
|
889
|
-
DIMMER: [this.Service.Lightbulb.UUID],
|
|
890
|
-
DIMMERANDRGBLIGHTING: [this.Service.Lightbulb.UUID],
|
|
891
|
-
CONTROLLINGTHEROLLERSHUTTER: [this.Service.WindowCovering.UUID],
|
|
892
|
-
CONTROLLINGTHEFACADEBLIND: [this.Service.WindowCovering.UUID],
|
|
893
|
-
OPENINGSENSOR_GATE: [this.Service.ContactSensor.UUID],
|
|
894
|
-
OPENINGSENSOR_GATEWAY: [this.Service.ContactSensor.UUID],
|
|
895
|
-
OPENINGSENSOR_WINDOW: [this.Service.ContactSensor.UUID],
|
|
896
|
-
GENERAL_PURPOSE_MEASUREMENT: [this.Service.AirQualitySensor.UUID],
|
|
897
|
-
THERMOMETER: [this.Service.TemperatureSensor.UUID],
|
|
898
|
-
HUMIDITYANDTEMPERATURE: [this.Service.TemperatureSensor.UUID, this.Service.HumiditySensor.UUID],
|
|
899
|
-
PRESSURESENSOR: [this.Service.LightSensor.UUID],
|
|
900
|
-
VALVEOPENCLOSE: [this.Service.Valve.UUID],
|
|
901
|
-
HVAC_THERMOSTAT: [this.Service.Thermostat.UUID],
|
|
902
|
-
ELECTRICITYMETER: [this.Service.LightSensor.UUID],
|
|
903
|
-
ACTION_TRIGGER: [this.Service.StatelessProgrammableSwitch.UUID],
|
|
904
|
-
};
|
|
905
|
-
const byType = {
|
|
906
|
-
BINARYSENSOR: [
|
|
907
|
-
this.isLeakSensorChannel(channel, accessory)
|
|
908
|
-
? this.Service.LeakSensor.UUID
|
|
909
|
-
: this.Service.ContactSensor.UUID,
|
|
910
|
-
],
|
|
911
|
-
THERMOMETER: [this.Service.TemperatureSensor.UUID],
|
|
912
|
-
THERMOMETERDS18B20: [this.Service.TemperatureSensor.UUID],
|
|
913
|
-
HUMIDITYANDTEMPSENSOR: [this.Service.TemperatureSensor.UUID, this.Service.HumiditySensor.UUID],
|
|
914
|
-
GENERAL_PURPOSE_MEASUREMENT: [this.Service.AirQualitySensor.UUID],
|
|
915
|
-
PRESSURESENSOR: [this.Service.LightSensor.UUID],
|
|
916
|
-
ELECTRICITYMETER: [this.Service.LightSensor.UUID],
|
|
917
|
-
ACTION_TRIGGER: [this.Service.StatelessProgrammableSwitch.UUID],
|
|
918
|
-
};
|
|
919
|
-
return new Set(byFunction[channel.channelFunction] ?? byType[channel.channelType] ?? []);
|
|
920
|
-
}
|
|
921
|
-
getLegacyNoFunctionServiceUuids(channel, accessory) {
|
|
922
|
-
if (!accessory) {
|
|
923
|
-
return new Set();
|
|
924
|
-
}
|
|
925
|
-
const compatibleByType = {
|
|
926
|
-
RELAY: [this.Service.Switch.UUID, this.Service.Lightbulb.UUID],
|
|
927
|
-
DIMMER: [this.Service.Lightbulb.UUID],
|
|
928
|
-
DIMMERANDRGBLED: [this.Service.Lightbulb.UUID],
|
|
929
|
-
BINARYSENSOR: [this.Service.LeakSensor.UUID, this.Service.ContactSensor.UUID],
|
|
930
|
-
THERMOMETER: [this.Service.TemperatureSensor.UUID],
|
|
931
|
-
THERMOMETERDS18B20: [this.Service.TemperatureSensor.UUID],
|
|
932
|
-
HUMIDITYANDTEMPSENSOR: [this.Service.TemperatureSensor.UUID, this.Service.HumiditySensor.UUID],
|
|
933
|
-
};
|
|
934
|
-
const compatible = compatibleByType[channel.channelType] ?? [];
|
|
935
|
-
return new Set(compatible.filter(uuid => accessory.services.some(service => service.UUID === uuid)));
|
|
936
|
-
}
|
|
937
|
-
isExplicitNoFunction(value) {
|
|
938
|
-
return value?.trim().toUpperCase() === 'NONE';
|
|
939
|
-
}
|
|
940
|
-
cleanupObsoleteServices(accessory, channel) {
|
|
941
|
-
const expected = this.getExpectedServiceUuids(channel, accessory);
|
|
942
|
-
const knownPluginServiceUuids = new Set([
|
|
943
|
-
this.Service.GarageDoorOpener.UUID,
|
|
944
|
-
this.Service.LockMechanism.UUID,
|
|
945
|
-
this.Service.Lightbulb.UUID,
|
|
946
|
-
this.Service.Switch.UUID,
|
|
947
|
-
this.Service.WindowCovering.UUID,
|
|
948
|
-
this.Service.Door.UUID,
|
|
949
|
-
this.Service.ContactSensor.UUID,
|
|
950
|
-
this.Service.LeakSensor.UUID,
|
|
951
|
-
this.Service.TemperatureSensor.UUID,
|
|
952
|
-
this.Service.HumiditySensor.UUID,
|
|
953
|
-
this.Service.AirQualitySensor.UUID,
|
|
954
|
-
this.Service.LightSensor.UUID,
|
|
955
|
-
this.Service.Valve.UUID,
|
|
956
|
-
this.Service.Thermostat.UUID,
|
|
957
|
-
this.Service.StatelessProgrammableSwitch.UUID,
|
|
958
|
-
]);
|
|
959
|
-
for (const service of [...accessory.services]) {
|
|
960
|
-
if (knownPluginServiceUuids.has(service.UUID) && !expected.has(service.UUID)) {
|
|
961
|
-
accessory.removeService(service);
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
prepareAccessoryForReconfiguration(accessory, channel) {
|
|
966
|
-
this.unregisterMqttHandlers(accessory.UUID);
|
|
967
|
-
this.cleanupObsoleteServices(accessory, channel);
|
|
968
|
-
const expectedServices = this.getExpectedServiceUuids(channel, accessory);
|
|
338
|
+
resetAccessoryServices(accessory) {
|
|
339
|
+
const keepUuid = this.Service.AccessoryInformation.UUID;
|
|
969
340
|
for (const service of accessory.services) {
|
|
970
|
-
if (
|
|
341
|
+
if (service.UUID === keepUuid) {
|
|
971
342
|
continue;
|
|
972
343
|
}
|
|
973
|
-
|
|
974
|
-
characteristic.removeOnGet();
|
|
975
|
-
characteristic.removeOnSet();
|
|
976
|
-
}
|
|
977
|
-
this.removeObsoleteOptionalCharacteristics(service, channel);
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
removeObsoleteOptionalCharacteristics(service, channel) {
|
|
981
|
-
const obsolete = new Set();
|
|
982
|
-
const servicesWithoutStatusFault = new Set([
|
|
983
|
-
this.Service.GarageDoorOpener.UUID,
|
|
984
|
-
this.Service.LockMechanism.UUID,
|
|
985
|
-
this.Service.Lightbulb.UUID,
|
|
986
|
-
this.Service.Switch.UUID,
|
|
987
|
-
this.Service.WindowCovering.UUID,
|
|
988
|
-
this.Service.Door.UUID,
|
|
989
|
-
this.Service.Thermostat.UUID,
|
|
990
|
-
this.Service.StatelessProgrammableSwitch.UUID,
|
|
991
|
-
]);
|
|
992
|
-
if (servicesWithoutStatusFault.has(service.UUID)) {
|
|
993
|
-
obsolete.add(this.Characteristic.StatusFault.UUID);
|
|
994
|
-
}
|
|
995
|
-
if (service.UUID === this.Service.Lightbulb.UUID) {
|
|
996
|
-
if (channel.channelFunction === 'LIGHTSWITCH') {
|
|
997
|
-
obsolete.add(this.Characteristic.Brightness.UUID);
|
|
998
|
-
obsolete.add(this.Characteristic.Hue.UUID);
|
|
999
|
-
obsolete.add(this.Characteristic.Saturation.UUID);
|
|
1000
|
-
}
|
|
1001
|
-
else if (channel.channelFunction === 'DIMMER') {
|
|
1002
|
-
obsolete.add(this.Characteristic.Hue.UUID);
|
|
1003
|
-
obsolete.add(this.Characteristic.Saturation.UUID);
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
if (service.UUID === this.Service.WindowCovering.UUID
|
|
1007
|
-
&& channel.channelFunction === 'CONTROLLINGTHEROLLERSHUTTER') {
|
|
1008
|
-
obsolete.add(this.Characteristic.CurrentHorizontalTiltAngle.UUID);
|
|
1009
|
-
obsolete.add(this.Characteristic.TargetHorizontalTiltAngle.UUID);
|
|
1010
|
-
}
|
|
1011
|
-
if (service.UUID === this.Service.WindowCovering.UUID) {
|
|
1012
|
-
// StatusJammed is not part of the HAP WindowCovering service. Older
|
|
1013
|
-
// versions added it dynamically; remove that invalid composition.
|
|
1014
|
-
obsolete.add(this.Characteristic.StatusJammed.UUID);
|
|
1015
|
-
}
|
|
1016
|
-
for (const characteristic of [...service.characteristics]) {
|
|
1017
|
-
if (obsolete.has(characteristic.UUID)) {
|
|
1018
|
-
service.removeCharacteristic(characteristic);
|
|
1019
|
-
}
|
|
344
|
+
accessory.removeService(service);
|
|
1020
345
|
}
|
|
1021
346
|
}
|
|
1022
|
-
revokeAccessoryHandlers(accessory) {
|
|
1023
|
-
this.unregisterMqttHandlers(accessory.UUID);
|
|
1024
|
-
const unavailable = new this.api.hap.HapStatusError(-70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
1025
|
-
for (const service of accessory.services) {
|
|
1026
|
-
if (service.UUID === this.Service.AccessoryInformation.UUID) {
|
|
1027
|
-
continue;
|
|
1028
|
-
}
|
|
1029
|
-
for (const characteristic of service.characteristics) {
|
|
1030
|
-
characteristic.removeOnGet();
|
|
1031
|
-
characteristic.removeOnSet();
|
|
1032
|
-
if (characteristic.props.perms.includes("pr" /* this.api.hap.Perms.PAIRED_READ */)) {
|
|
1033
|
-
characteristic.updateValue(unavailable);
|
|
1034
|
-
}
|
|
1035
|
-
if (characteristic.props.perms.includes("pw" /* this.api.hap.Perms.PAIRED_WRITE */)) {
|
|
1036
|
-
characteristic.onSet(() => {
|
|
1037
|
-
throw new this.api.hap.HapStatusError(-70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
1038
|
-
});
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
resolveHomeKitDisplayName(channel, currentName) {
|
|
1044
|
-
const captionName = channel.channelCaptionAuthoritative === true
|
|
1045
|
-
? (0, DisplayName_1.toHomeKitDisplayName)(channel.channelCaption)
|
|
1046
|
-
: undefined;
|
|
1047
|
-
const hasRenameEvidence = channel.channelCaptionSource === 'retained'
|
|
1048
|
-
|| channel.channelCaptionSource === 'authoritative_snapshot';
|
|
1049
|
-
if (captionName && hasRenameEvidence) {
|
|
1050
|
-
return captionName;
|
|
1051
|
-
}
|
|
1052
|
-
const safeCurrentName = (0, DisplayName_1.toHomeKitDisplayName)(currentName);
|
|
1053
|
-
if (safeCurrentName) {
|
|
1054
|
-
return safeCurrentName;
|
|
1055
|
-
}
|
|
1056
|
-
// A new accessory (or a damaged empty legacy display name) may use its
|
|
1057
|
-
// complete cached caption as an initial/recovery label. This is not allowed
|
|
1058
|
-
// to replace an already meaningful existing name.
|
|
1059
|
-
if (captionName) {
|
|
1060
|
-
return captionName;
|
|
1061
|
-
}
|
|
1062
|
-
return (0, DisplayName_1.toHomeKitDisplayName)((0, DisplayName_1.generatedChannelCaption)(channel.deviceId, channel.channelId))
|
|
1063
|
-
?? 'Supla Device';
|
|
1064
|
-
}
|
|
1065
|
-
captureAccessoryNames(accessory) {
|
|
1066
|
-
return {
|
|
1067
|
-
displayName: accessory.displayName,
|
|
1068
|
-
services: accessory.services.map((service) => {
|
|
1069
|
-
const characteristic = service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
|
|
1070
|
-
return {
|
|
1071
|
-
service,
|
|
1072
|
-
displayName: service.displayName,
|
|
1073
|
-
hadNameCharacteristic: characteristic !== undefined,
|
|
1074
|
-
nameValue: typeof characteristic?.value === 'string' ? characteristic.value : undefined,
|
|
1075
|
-
};
|
|
1076
|
-
}),
|
|
1077
|
-
};
|
|
1078
|
-
}
|
|
1079
|
-
restoreAccessoryNames(accessory, snapshot) {
|
|
1080
|
-
try {
|
|
1081
|
-
accessory.updateDisplayName(snapshot.displayName);
|
|
1082
|
-
}
|
|
1083
|
-
catch (error) {
|
|
1084
|
-
this.log.error(`Failed to restore accessory display name ${snapshot.displayName}: ${error.message}`);
|
|
1085
|
-
}
|
|
1086
|
-
for (const entry of snapshot.services) {
|
|
1087
|
-
try {
|
|
1088
|
-
entry.service.displayName = entry.displayName;
|
|
1089
|
-
const current = entry.service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
|
|
1090
|
-
if (!entry.hadNameCharacteristic) {
|
|
1091
|
-
if (current) {
|
|
1092
|
-
entry.service.removeCharacteristic(current);
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
else if (entry.nameValue !== undefined && current?.value !== entry.nameValue) {
|
|
1096
|
-
entry.service.setCharacteristic(this.Characteristic.Name, entry.nameValue);
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
catch (error) {
|
|
1100
|
-
this.log.error(`Failed to restore service name for ${snapshot.displayName}: ${error.message}`);
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
restoreAccessoryContext(accessory, snapshot) {
|
|
1105
|
-
for (const key of Object.keys(accessory.context)) {
|
|
1106
|
-
delete accessory.context[key];
|
|
1107
|
-
}
|
|
1108
|
-
Object.assign(accessory.context, snapshot);
|
|
1109
|
-
}
|
|
1110
|
-
areChannelContextsEqual(current, incoming) {
|
|
1111
|
-
if (!current) {
|
|
1112
|
-
return false;
|
|
1113
|
-
}
|
|
1114
|
-
const fields = [
|
|
1115
|
-
'topic',
|
|
1116
|
-
'channelType',
|
|
1117
|
-
'channelFunction',
|
|
1118
|
-
'channelCaption',
|
|
1119
|
-
'deviceId',
|
|
1120
|
-
'channelId',
|
|
1121
|
-
'rawTopic',
|
|
1122
|
-
'channelCaptionAuthoritative',
|
|
1123
|
-
'channelCaptionSource',
|
|
1124
|
-
];
|
|
1125
|
-
return fields.every(field => current[field] === incoming[field]);
|
|
1126
|
-
}
|
|
1127
|
-
updateAccessoryNames(accessory, channel) {
|
|
1128
|
-
let changed = false;
|
|
1129
|
-
const caption = (0, DisplayName_1.normalizeVendorCaption)(channel.channelCaption);
|
|
1130
|
-
const homeKitName = this.resolveHomeKitDisplayName(channel, accessory.displayName);
|
|
1131
|
-
if (accessory.displayName !== homeKitName) {
|
|
1132
|
-
accessory.updateDisplayName(homeKitName);
|
|
1133
|
-
changed = true;
|
|
1134
|
-
}
|
|
1135
|
-
const informationService = accessory.getService(this.Service.AccessoryInformation);
|
|
1136
|
-
const informationName = informationService?.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
|
|
1137
|
-
if (informationService) {
|
|
1138
|
-
if (informationService.displayName !== homeKitName) {
|
|
1139
|
-
informationService.displayName = homeKitName;
|
|
1140
|
-
changed = true;
|
|
1141
|
-
}
|
|
1142
|
-
if (informationName?.value !== homeKitName) {
|
|
1143
|
-
informationService.setCharacteristic(this.Characteristic.Name, homeKitName);
|
|
1144
|
-
changed = true;
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
const expected = this.getExpectedServiceUuids(channel, accessory);
|
|
1148
|
-
const electricityMetric = accessory.context.suplaElectricityMetric;
|
|
1149
|
-
for (const service of accessory.services) {
|
|
1150
|
-
if (expected.has(service.UUID)) {
|
|
1151
|
-
const serviceName = channel.channelFunction === 'ELECTRICITYMETER'
|
|
1152
|
-
&& typeof electricityMetric === 'string'
|
|
1153
|
-
? (0, DisplayName_1.homeKitElectricityServiceName)(homeKitName, electricityMetric)
|
|
1154
|
-
: homeKitName;
|
|
1155
|
-
const nameCharacteristic = service.characteristics.find(candidate => candidate.UUID === this.Characteristic.Name.UUID);
|
|
1156
|
-
if (service.displayName !== serviceName) {
|
|
1157
|
-
service.displayName = serviceName;
|
|
1158
|
-
changed = true;
|
|
1159
|
-
}
|
|
1160
|
-
if (nameCharacteristic?.value !== serviceName) {
|
|
1161
|
-
service.setCharacteristic(this.Characteristic.Name, serviceName);
|
|
1162
|
-
changed = true;
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
if (accessory.context.suplaVendorCaption !== caption
|
|
1167
|
-
|| accessory.context.suplaVendorCaptionAuthoritative !== (channel.channelCaptionAuthoritative === true)
|
|
1168
|
-
|| accessory.context.suplaHomeKitDisplayName !== homeKitName) {
|
|
1169
|
-
changed = true;
|
|
1170
|
-
}
|
|
1171
|
-
accessory.context.suplaVendorCaption = caption;
|
|
1172
|
-
accessory.context.suplaVendorCaptionAuthoritative = channel.channelCaptionAuthoritative === true;
|
|
1173
|
-
accessory.context.suplaHomeKitDisplayName = homeKitName;
|
|
1174
|
-
return changed;
|
|
1175
|
-
}
|
|
1176
347
|
setupAccessory(channel, accessory) {
|
|
1177
348
|
this.unregisterMqttHandlers(accessory.UUID);
|
|
1178
349
|
this.log.debug(`Mapping channel ${channel.channelCaption} (${channel.deviceId}/${channel.channelId}) ` +
|
|
@@ -1248,21 +419,13 @@ class SuplaPlatform {
|
|
|
1248
419
|
new DimmerAccessory_1.DimmerAccessory(this, accessory, channel);
|
|
1249
420
|
return true;
|
|
1250
421
|
case 'RELAY':
|
|
1251
|
-
|
|
1252
|
-
&& accessory.getService(this.Service.Lightbulb)) {
|
|
1253
|
-
new LightBulbAccesory_1.LightAccesory(this, accessory, channel);
|
|
1254
|
-
}
|
|
1255
|
-
else {
|
|
1256
|
-
new SwitchAccessory_1.SwitchAccessory(this, accessory, channel);
|
|
1257
|
-
}
|
|
422
|
+
new SwitchAccessory_1.SwitchAccessory(this, accessory, channel);
|
|
1258
423
|
return true;
|
|
1259
424
|
case 'BINARYSENSOR':
|
|
1260
|
-
if (this.isLeakSensorChannel(channel
|
|
1261
|
-
accessory.context.suplaBinarySensorKind = 'leak';
|
|
425
|
+
if (this.isLeakSensorChannel(channel)) {
|
|
1262
426
|
new LeakSensorAccessory_1.LeakSensorAccessory(this, accessory, channel);
|
|
1263
427
|
}
|
|
1264
428
|
else {
|
|
1265
|
-
accessory.context.suplaBinarySensorKind = 'contact';
|
|
1266
429
|
new ContactSensorAccessory_1.ContactSensorAccessory(this, accessory, channel);
|
|
1267
430
|
}
|
|
1268
431
|
return true;
|
|
@@ -1291,21 +454,9 @@ class SuplaPlatform {
|
|
|
1291
454
|
this.log.warn(`Unsupported channel ${channel.channelCaption} (${channel.channelFunction}/${channel.channelType})`);
|
|
1292
455
|
return false;
|
|
1293
456
|
}
|
|
1294
|
-
isLeakSensorChannel(channel
|
|
1295
|
-
const
|
|
1296
|
-
|
|
1297
|
-
|| functionName.includes('WATERLEAK')
|
|
1298
|
-
|| functionName === 'LEAKSENSOR') {
|
|
1299
|
-
return true;
|
|
1300
|
-
}
|
|
1301
|
-
const persistedKind = accessory?.context?.suplaBinarySensorKind;
|
|
1302
|
-
if (persistedKind === 'leak' || persistedKind === 'contact') {
|
|
1303
|
-
return persistedKind === 'leak';
|
|
1304
|
-
}
|
|
1305
|
-
// Preserve the topology of accessories created by older releases, which
|
|
1306
|
-
// inferred this type from a caption. The migrated kind is persisted by
|
|
1307
|
-
// setupAccessory and subsequent captions no longer participate.
|
|
1308
|
-
return accessory?.services?.some(service => service.UUID === this.Service.LeakSensor.UUID) === true;
|
|
457
|
+
isLeakSensorChannel(channel) {
|
|
458
|
+
const caption = (channel.channelCaption ?? '').toLowerCase();
|
|
459
|
+
return ['leak', 'flood', 'water', 'zalania'].some(term => caption.includes(term));
|
|
1309
460
|
}
|
|
1310
461
|
getCoveringControlMode() {
|
|
1311
462
|
return this.coveringControlMode;
|
|
@@ -1328,6 +479,15 @@ class SuplaPlatform {
|
|
|
1328
479
|
getCoveringTravelTimeSeconds() {
|
|
1329
480
|
return this.coveringTravelTimeSeconds;
|
|
1330
481
|
}
|
|
482
|
+
getGateControlMode() {
|
|
483
|
+
return this.gateControlMode;
|
|
484
|
+
}
|
|
485
|
+
getGateExecuteActionOpen() {
|
|
486
|
+
return this.gateExecuteActionOpen;
|
|
487
|
+
}
|
|
488
|
+
getGateExecuteActionClose() {
|
|
489
|
+
return this.gateExecuteActionClose;
|
|
490
|
+
}
|
|
1331
491
|
getGateExecuteActionToggle() {
|
|
1332
492
|
return this.gateExecuteActionToggle;
|
|
1333
493
|
}
|
|
@@ -1349,116 +509,50 @@ class SuplaPlatform {
|
|
|
1349
509
|
getGateLockSetOffPayload() {
|
|
1350
510
|
return this.gateLockSetOffPayload;
|
|
1351
511
|
}
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
if (task) {
|
|
1355
|
-
this.unregisterMqttHandlers(task.ownerId);
|
|
1356
|
-
}
|
|
512
|
+
getGatePartialHiMode() {
|
|
513
|
+
return this.gatePartialHiMode;
|
|
1357
514
|
}
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
return;
|
|
1361
|
-
}
|
|
1362
|
-
if (this.gateLockFailSafeTasks.has(accessory.UUID)) {
|
|
1363
|
-
return;
|
|
1364
|
-
}
|
|
1365
|
-
const task = {
|
|
1366
|
-
ownerId: `gate-lock-failsafe:${accessory.UUID}`,
|
|
1367
|
-
accessory,
|
|
1368
|
-
stateTopic,
|
|
1369
|
-
commandTopic,
|
|
1370
|
-
offPayload,
|
|
1371
|
-
attempted: false,
|
|
1372
|
-
};
|
|
1373
|
-
this.gateLockFailSafeTasks.set(accessory.UUID, task);
|
|
1374
|
-
this.registerOwnerCleanup(task.ownerId, () => {
|
|
1375
|
-
if (task.retryTimer) {
|
|
1376
|
-
clearTimeout(task.retryTimer);
|
|
1377
|
-
task.retryTimer = undefined;
|
|
1378
|
-
}
|
|
1379
|
-
if (this.gateLockFailSafeTasks.get(accessory.UUID) === task) {
|
|
1380
|
-
this.gateLockFailSafeTasks.delete(accessory.UUID);
|
|
1381
|
-
}
|
|
1382
|
-
});
|
|
1383
|
-
this.registerMqttHandler(stateTopic, (message, _topic, packet) => {
|
|
1384
|
-
const relayOn = this.parseBooleanStrict(message.toString());
|
|
1385
|
-
if (relayOn !== false || packet?.retain === true || !task.attempted) {
|
|
1386
|
-
return;
|
|
1387
|
-
}
|
|
1388
|
-
accessory.context.suplaGateLockPendingOff = false;
|
|
1389
|
-
const accessoryStillRegistered = Array.isArray(this.accessories)
|
|
1390
|
-
&& this.accessories.includes(accessory);
|
|
1391
|
-
try {
|
|
1392
|
-
if (accessoryStillRegistered) {
|
|
1393
|
-
this.api.updatePlatformAccessories([accessory]);
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
catch (error) {
|
|
1397
|
-
accessory.context.suplaGateLockPendingOff = true;
|
|
1398
|
-
this.log.error(`Failed to persist transferred gate-lock OFF confirmation for ${accessory.displayName}: ` +
|
|
1399
|
-
`${error.message}`);
|
|
1400
|
-
this.scheduleGateLockFailSafeRetry(task);
|
|
1401
|
-
return;
|
|
1402
|
-
}
|
|
1403
|
-
this.log.info(`Gate-lock fail-safe OFF confirmed for ${accessory.displayName}.`);
|
|
1404
|
-
this.unregisterMqttHandlers(task.ownerId);
|
|
1405
|
-
}, task.ownerId);
|
|
1406
|
-
this.registerMqttTransportHandler((connected) => {
|
|
1407
|
-
if (connected) {
|
|
1408
|
-
this.attemptGateLockFailSafe(task);
|
|
1409
|
-
}
|
|
1410
|
-
}, task.ownerId);
|
|
515
|
+
getGateReverseFollowUpDelayMs() {
|
|
516
|
+
return this.gateReverseFollowUpDelayMs;
|
|
1411
517
|
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|| this.gateLockFailSafeTasks.get(task.accessory.UUID) !== task
|
|
1415
|
-
|| task.publishInFlight) {
|
|
1416
|
-
return;
|
|
1417
|
-
}
|
|
1418
|
-
task.attempted = true;
|
|
1419
|
-
this.log.warn(`Publishing transferred gate-lock fail-safe OFF for ${task.accessory.displayName}.`);
|
|
1420
|
-
const operation = this.publishActionAsync(task.commandTopic, task.offPayload);
|
|
1421
|
-
task.publishInFlight = operation;
|
|
1422
|
-
void operation.catch((error) => {
|
|
1423
|
-
this.log.warn(`Transferred gate-lock OFF remains unconfirmed for ${task.accessory.displayName}: ` +
|
|
1424
|
-
`${error.message}`);
|
|
1425
|
-
}).finally(() => {
|
|
1426
|
-
if (task.publishInFlight === operation) {
|
|
1427
|
-
task.publishInFlight = undefined;
|
|
1428
|
-
}
|
|
1429
|
-
this.scheduleGateLockFailSafeRetry(task);
|
|
1430
|
-
});
|
|
518
|
+
getGateOpenAssumeDelayMs() {
|
|
519
|
+
return this.gateOpenAssumeDelayMs;
|
|
1431
520
|
}
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
this.attemptGateLockFailSafe(task);
|
|
1441
|
-
}, 5000);
|
|
521
|
+
getGateCommandCooldownMs() {
|
|
522
|
+
return this.gateCommandCooldownMs;
|
|
523
|
+
}
|
|
524
|
+
getGatePublishRetryDelayMs() {
|
|
525
|
+
return this.gatePublishRetryDelayMs;
|
|
526
|
+
}
|
|
527
|
+
getGateStrictReverseDoublePulse() {
|
|
528
|
+
return this.gateStrictReverseDoublePulse;
|
|
1442
529
|
}
|
|
1443
|
-
|
|
530
|
+
getGateDebugTimeline() {
|
|
531
|
+
return this.gateDebugTimeline;
|
|
532
|
+
}
|
|
533
|
+
getFrontGateTimings() {
|
|
1444
534
|
return {
|
|
1445
535
|
fullTravelMs: this.frontGateFullTravelMs,
|
|
1446
536
|
reversePauseMs: this.frontGateReversePauseMs,
|
|
537
|
+
wrongDirectionRunMs: this.frontGateWrongDirectionRunMs,
|
|
1447
538
|
minimumPulseGapMs: this.frontGateMinimumPulseGapMs,
|
|
1448
|
-
|
|
1449
|
-
unknownClosePolicy: this.frontGateUnknownClosePolicy,
|
|
1450
|
-
seekClosedMaxPulses: this.frontGateSeekClosedMaxPulses,
|
|
1451
|
-
assumeOpenAfterTravel: this.frontGateAssumeOpenAfterTravel,
|
|
539
|
+
closeRetryLimit: this.frontGateCloseRetryLimit,
|
|
1452
540
|
};
|
|
1453
541
|
}
|
|
1454
|
-
shouldFallbackFrontGateSensorToControlChannel() {
|
|
1455
|
-
return this.frontGateSensorFallbackToControlChannel;
|
|
1456
|
-
}
|
|
1457
|
-
isMqttNoLocalAvailable() {
|
|
1458
|
-
return this.mqttProtocolVersion === 5;
|
|
1459
|
-
}
|
|
1460
542
|
getFrontGatePulseAction() {
|
|
1461
|
-
|
|
543
|
+
const toggleAction = this.gateExecuteActionToggle.trim();
|
|
544
|
+
if (toggleAction) {
|
|
545
|
+
return toggleAction;
|
|
546
|
+
}
|
|
547
|
+
const openAction = this.gateExecuteActionOpen.trim();
|
|
548
|
+
const closeAction = this.gateExecuteActionClose.trim();
|
|
549
|
+
if (openAction && openAction === closeAction) {
|
|
550
|
+
return openAction;
|
|
551
|
+
}
|
|
552
|
+
if (openAction) {
|
|
553
|
+
return openAction;
|
|
554
|
+
}
|
|
555
|
+
return closeAction;
|
|
1462
556
|
}
|
|
1463
557
|
normalizeCoveringControlMode(value) {
|
|
1464
558
|
const normalized = (value ?? 'set').toString().toLowerCase();
|
|
@@ -1470,6 +564,13 @@ class SuplaPlatform {
|
|
|
1470
564
|
}
|
|
1471
565
|
return 'set';
|
|
1472
566
|
}
|
|
567
|
+
normalizeGateControlMode(value) {
|
|
568
|
+
const normalized = (value ?? 'execute_action').toString().toLowerCase();
|
|
569
|
+
if (normalized === 'toggle') {
|
|
570
|
+
return 'toggle';
|
|
571
|
+
}
|
|
572
|
+
return 'execute_action';
|
|
573
|
+
}
|
|
1473
574
|
normalizeGateLockControlMode(value) {
|
|
1474
575
|
const normalized = (value ?? 'execute_action').toString().toLowerCase();
|
|
1475
576
|
if (normalized === 'set_on_pulse') {
|
|
@@ -1477,12 +578,54 @@ class SuplaPlatform {
|
|
|
1477
578
|
}
|
|
1478
579
|
return 'execute_action';
|
|
1479
580
|
}
|
|
1480
|
-
|
|
581
|
+
normalizeGatePartialHiMode(value) {
|
|
582
|
+
const normalized = (value ?? 'moving').toString().toLowerCase();
|
|
583
|
+
if (normalized === 'open_endstop' || normalized === 'open') {
|
|
584
|
+
return 'open_endstop';
|
|
585
|
+
}
|
|
586
|
+
if (normalized === 'pedestrian_endstop' || normalized === 'pedestrian') {
|
|
587
|
+
return 'pedestrian_endstop';
|
|
588
|
+
}
|
|
589
|
+
if (normalized === 'ignore' || normalized === 'absent') {
|
|
590
|
+
return 'ignore';
|
|
591
|
+
}
|
|
592
|
+
return 'moving';
|
|
593
|
+
}
|
|
594
|
+
normalizeGateReverseFollowUpDelayMs(value) {
|
|
595
|
+
const fallbackMs = 3000;
|
|
1481
596
|
const parsed = Number(value);
|
|
1482
597
|
if (!Number.isFinite(parsed)) {
|
|
1483
|
-
return
|
|
598
|
+
return fallbackMs;
|
|
599
|
+
}
|
|
600
|
+
const rounded = Math.round(parsed);
|
|
601
|
+
return Math.min(10000, Math.max(250, rounded));
|
|
602
|
+
}
|
|
603
|
+
normalizeGateOpenAssumeDelayMs(value) {
|
|
604
|
+
const fallbackMs = 22000;
|
|
605
|
+
const parsed = Number(value);
|
|
606
|
+
if (!Number.isFinite(parsed)) {
|
|
607
|
+
return fallbackMs;
|
|
1484
608
|
}
|
|
1485
|
-
|
|
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));
|
|
1486
629
|
}
|
|
1487
630
|
normalizeFrontGateFullTravelMs(value) {
|
|
1488
631
|
const fallbackMs = 25000;
|
|
@@ -1502,35 +645,32 @@ class SuplaPlatform {
|
|
|
1502
645
|
const rounded = Math.round(parsed);
|
|
1503
646
|
return Math.min(15000, Math.max(3000, rounded));
|
|
1504
647
|
}
|
|
1505
|
-
|
|
1506
|
-
const fallbackMs =
|
|
648
|
+
normalizeFrontGateWrongDirectionRunMs(value) {
|
|
649
|
+
const fallbackMs = 700;
|
|
1507
650
|
const parsed = Number(value);
|
|
1508
651
|
if (!Number.isFinite(parsed)) {
|
|
1509
652
|
return fallbackMs;
|
|
1510
653
|
}
|
|
1511
654
|
const rounded = Math.round(parsed);
|
|
1512
|
-
return Math.min(
|
|
655
|
+
return Math.min(5000, Math.max(100, rounded));
|
|
1513
656
|
}
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
return value;
|
|
657
|
+
normalizeFrontGateMinimumPulseGapMs(value) {
|
|
658
|
+
const fallbackMs = 3000;
|
|
659
|
+
const parsed = Number(value);
|
|
660
|
+
if (!Number.isFinite(parsed)) {
|
|
661
|
+
return fallbackMs;
|
|
1520
662
|
}
|
|
1521
|
-
|
|
663
|
+
const rounded = Math.round(parsed);
|
|
664
|
+
return Math.min(10000, Math.max(0, rounded));
|
|
1522
665
|
}
|
|
1523
|
-
|
|
1524
|
-
const fallbackValue =
|
|
666
|
+
normalizeFrontGateCloseRetryLimit(value) {
|
|
667
|
+
const fallbackValue = 0;
|
|
1525
668
|
const parsed = Number(value);
|
|
1526
669
|
if (!Number.isFinite(parsed)) {
|
|
1527
670
|
return fallbackValue;
|
|
1528
671
|
}
|
|
1529
672
|
const rounded = Math.round(parsed);
|
|
1530
|
-
return Math.min(3, Math.max(
|
|
1531
|
-
}
|
|
1532
|
-
normalizeMqttProtocolVersion(value) {
|
|
1533
|
-
return Number(value) === 5 ? 5 : 4;
|
|
673
|
+
return Math.min(3, Math.max(0, rounded));
|
|
1534
674
|
}
|
|
1535
675
|
normalizeTopicSuffix(value) {
|
|
1536
676
|
return value.toString().replace(/^\/+/, '');
|
|
@@ -1546,7 +686,7 @@ class SuplaPlatform {
|
|
|
1546
686
|
base = base.replace(/\/set\/.+$/, '');
|
|
1547
687
|
return base.replace(/\/+$/, '');
|
|
1548
688
|
}
|
|
1549
|
-
registerMqttHandler(topic, handler, ownerId
|
|
689
|
+
registerMqttHandler(topic, handler, ownerId) {
|
|
1550
690
|
if (!topic) {
|
|
1551
691
|
return () => undefined;
|
|
1552
692
|
}
|
|
@@ -1561,9 +701,6 @@ class SuplaPlatform {
|
|
|
1561
701
|
ownerTopics.set(topic, ownerHandlers);
|
|
1562
702
|
this.mqttHandlerOwners.set(ownerId, ownerTopics);
|
|
1563
703
|
this.mqttDesiredSubscriptions.add(topic);
|
|
1564
|
-
if (options.noLocal && this.mqttProtocolVersion === 5) {
|
|
1565
|
-
this.mqttNoLocalTopics.add(topic);
|
|
1566
|
-
}
|
|
1567
704
|
this.ensureSubscribed(topic, false);
|
|
1568
705
|
return () => {
|
|
1569
706
|
this.removeHandler(topic, handler);
|
|
@@ -1580,21 +717,6 @@ class SuplaPlatform {
|
|
|
1580
717
|
}
|
|
1581
718
|
};
|
|
1582
719
|
}
|
|
1583
|
-
registerMqttTransportHandler(handler, ownerId) {
|
|
1584
|
-
const handlers = this.mqttTransportHandlers.get(ownerId) ?? new Set();
|
|
1585
|
-
handlers.add(handler);
|
|
1586
|
-
this.mqttTransportHandlers.set(ownerId, handlers);
|
|
1587
|
-
handler(this.mqttTransportConnected);
|
|
1588
|
-
const cleanup = () => {
|
|
1589
|
-
const active = this.mqttTransportHandlers.get(ownerId);
|
|
1590
|
-
active?.delete(handler);
|
|
1591
|
-
if (active?.size === 0) {
|
|
1592
|
-
this.mqttTransportHandlers.delete(ownerId);
|
|
1593
|
-
}
|
|
1594
|
-
};
|
|
1595
|
-
this.registerOwnerCleanup(ownerId, cleanup);
|
|
1596
|
-
return cleanup;
|
|
1597
|
-
}
|
|
1598
720
|
registerOwnerCleanup(ownerId, cleanup) {
|
|
1599
721
|
const cleanups = this.ownerCleanups.get(ownerId) ?? new Set();
|
|
1600
722
|
cleanups.add(cleanup);
|
|
@@ -1610,20 +732,6 @@ class SuplaPlatform {
|
|
|
1610
732
|
}
|
|
1611
733
|
};
|
|
1612
734
|
}
|
|
1613
|
-
registerShutdownDrain(ownerId, drain) {
|
|
1614
|
-
const drains = this.shutdownDrains.get(ownerId) ?? new Set();
|
|
1615
|
-
drains.add(drain);
|
|
1616
|
-
this.shutdownDrains.set(ownerId, drains);
|
|
1617
|
-
const cleanup = () => {
|
|
1618
|
-
const active = this.shutdownDrains.get(ownerId);
|
|
1619
|
-
active?.delete(drain);
|
|
1620
|
-
if (active?.size === 0) {
|
|
1621
|
-
this.shutdownDrains.delete(ownerId);
|
|
1622
|
-
}
|
|
1623
|
-
};
|
|
1624
|
-
this.registerOwnerCleanup(ownerId, cleanup);
|
|
1625
|
-
return cleanup;
|
|
1626
|
-
}
|
|
1627
735
|
unregisterMqttHandlers(ownerId) {
|
|
1628
736
|
this.runOwnerCleanup(ownerId);
|
|
1629
737
|
const ownerTopics = this.mqttHandlerOwners.get(ownerId);
|
|
@@ -1650,106 +758,31 @@ class SuplaPlatform {
|
|
|
1650
758
|
}
|
|
1651
759
|
this.mqttHandlers.clear();
|
|
1652
760
|
this.mqttWildcardHandlers.clear();
|
|
1653
|
-
this.mqttNoLocalTopics.clear();
|
|
1654
|
-
this.mqttTransportHandlers.clear();
|
|
1655
761
|
this.mqttDesiredSubscriptions.clear();
|
|
1656
762
|
this.mqttSubscriptions.clear();
|
|
1657
763
|
this.mqttPendingSubscriptions.clear();
|
|
1658
|
-
this.mqttPendingSubscriptionOperations.clear();
|
|
1659
764
|
this.clearAllSubscriptionRetries();
|
|
1660
|
-
this.shutdownDrains.clear();
|
|
1661
765
|
this.ownerCleanups.clear();
|
|
1662
766
|
}
|
|
1663
767
|
publishCommand(topic, payload, callback) {
|
|
1664
|
-
this.publishMqtt(topic, payload, { qos: this.commandQos, retain: this.commandRetain }, error => callback?.(error));
|
|
1665
|
-
}
|
|
1666
|
-
publishCommandAsync(topic, payload) {
|
|
1667
|
-
return this.publishMqttAsync(topic, payload, { qos: this.commandQos, retain: this.commandRetain });
|
|
1668
|
-
}
|
|
1669
|
-
publishGateAction(topic, payload, callback) {
|
|
1670
|
-
this.publishMqtt(topic, payload, { qos: 0, retain: false }, (error, failureOrigin) => {
|
|
1671
|
-
if (error && failureOrigin === 'callback') {
|
|
1672
|
-
error.mqttPublishFailureKind = 'completion_unknown';
|
|
1673
|
-
}
|
|
1674
|
-
callback?.(error);
|
|
1675
|
-
});
|
|
1676
|
-
}
|
|
1677
|
-
publishActionAsync(topic, payload) {
|
|
1678
|
-
return this.publishMqttAsync(topic, payload, { qos: 0, retain: false }, true);
|
|
1679
|
-
}
|
|
1680
|
-
publishGateActionAsync(topic, payload) {
|
|
1681
|
-
return this.publishActionAsync(topic, payload);
|
|
1682
|
-
}
|
|
1683
|
-
isPublishCompletionUnknown(error) {
|
|
1684
|
-
return error instanceof Error
|
|
1685
|
-
&& error.mqttPublishFailureKind === 'completion_unknown';
|
|
1686
|
-
}
|
|
1687
|
-
publishMqttAsync(topic, payload, options, callbackFailureIsAmbiguous = false) {
|
|
1688
|
-
return new Promise((resolve, reject) => {
|
|
1689
|
-
let settled = false;
|
|
1690
|
-
const finish = (kind, error) => {
|
|
1691
|
-
if (settled) {
|
|
1692
|
-
return;
|
|
1693
|
-
}
|
|
1694
|
-
settled = true;
|
|
1695
|
-
clearTimeout(timer);
|
|
1696
|
-
if (!kind || !error) {
|
|
1697
|
-
resolve();
|
|
1698
|
-
return;
|
|
1699
|
-
}
|
|
1700
|
-
this.log.error(`Publish failed for ${topic}: ${error.message}`);
|
|
1701
|
-
const hapError = new this.api.hap.HapStatusError(kind === 'completion_unknown'
|
|
1702
|
-
? -70408 /* this.api.hap.HAPStatus.OPERATION_TIMED_OUT */
|
|
1703
|
-
: -70402 /* this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
1704
|
-
hapError.mqttPublishFailureKind = kind;
|
|
1705
|
-
hapError.cause = error;
|
|
1706
|
-
reject(hapError);
|
|
1707
|
-
};
|
|
1708
|
-
const timer = setTimeout(() => {
|
|
1709
|
-
finish('completion_unknown', new Error(`MQTT publish completion timed out after ${this.publishTimeoutMs}ms`));
|
|
1710
|
-
}, this.publishTimeoutMs);
|
|
1711
|
-
this.publishMqtt(topic, payload, options, (error, failureOrigin) => {
|
|
1712
|
-
if (error) {
|
|
1713
|
-
finish(callbackFailureIsAmbiguous && failureOrigin === 'callback'
|
|
1714
|
-
? 'completion_unknown'
|
|
1715
|
-
: 'definite_failure', error);
|
|
1716
|
-
}
|
|
1717
|
-
else {
|
|
1718
|
-
finish();
|
|
1719
|
-
}
|
|
1720
|
-
});
|
|
1721
|
-
});
|
|
1722
|
-
}
|
|
1723
|
-
publishMqtt(topic, payload, options, callback) {
|
|
1724
768
|
const client = this.MqttClient?.client;
|
|
1725
769
|
if (!client || !client.connected) {
|
|
1726
770
|
const error = new Error('MQTT not connected');
|
|
1727
771
|
this.log.error(`MQTT not connected; cannot publish ${topic}`);
|
|
1728
772
|
if (callback) {
|
|
1729
|
-
callback(error
|
|
773
|
+
callback(error);
|
|
1730
774
|
}
|
|
1731
775
|
return;
|
|
1732
776
|
}
|
|
1733
|
-
|
|
1734
|
-
client.publish(topic, payload, options, (error) => {
|
|
1735
|
-
if (callback) {
|
|
1736
|
-
callback(error, error ? 'callback' : undefined);
|
|
1737
|
-
return;
|
|
1738
|
-
}
|
|
1739
|
-
if (error) {
|
|
1740
|
-
this.log.error(`Publish failed for ${topic}: ${error.message}`);
|
|
1741
|
-
}
|
|
1742
|
-
});
|
|
1743
|
-
}
|
|
1744
|
-
catch (error) {
|
|
1745
|
-
const publishError = error instanceof Error ? error : new Error(String(error));
|
|
777
|
+
client.publish(topic, payload, { qos: this.commandQos, retain: this.commandRetain }, (error) => {
|
|
1746
778
|
if (callback) {
|
|
1747
|
-
callback(
|
|
779
|
+
callback(error);
|
|
780
|
+
return;
|
|
1748
781
|
}
|
|
1749
|
-
|
|
1750
|
-
this.log.error(`Publish failed for ${topic}: ${
|
|
782
|
+
if (error) {
|
|
783
|
+
this.log.error(`Publish failed for ${topic}: ${error.message}`);
|
|
1751
784
|
}
|
|
1752
|
-
}
|
|
785
|
+
});
|
|
1753
786
|
}
|
|
1754
787
|
parseBoolean(value) {
|
|
1755
788
|
if (typeof value === 'boolean') {
|
|
@@ -1767,46 +800,6 @@ class SuplaPlatform {
|
|
|
1767
800
|
}
|
|
1768
801
|
return false;
|
|
1769
802
|
}
|
|
1770
|
-
parseBooleanStrict(value) {
|
|
1771
|
-
if (typeof value === 'boolean') {
|
|
1772
|
-
return value;
|
|
1773
|
-
}
|
|
1774
|
-
if (typeof value === 'number') {
|
|
1775
|
-
if (value === 1) {
|
|
1776
|
-
return true;
|
|
1777
|
-
}
|
|
1778
|
-
if (value === 0) {
|
|
1779
|
-
return false;
|
|
1780
|
-
}
|
|
1781
|
-
return undefined;
|
|
1782
|
-
}
|
|
1783
|
-
if (typeof value !== 'string') {
|
|
1784
|
-
return undefined;
|
|
1785
|
-
}
|
|
1786
|
-
const normalized = value.trim().toLowerCase();
|
|
1787
|
-
if (['1', 'true', 'on', 'yes'].includes(normalized)) {
|
|
1788
|
-
return true;
|
|
1789
|
-
}
|
|
1790
|
-
if (['0', 'false', 'off', 'no'].includes(normalized)) {
|
|
1791
|
-
return false;
|
|
1792
|
-
}
|
|
1793
|
-
return undefined;
|
|
1794
|
-
}
|
|
1795
|
-
parseFiniteNumberStrict(value) {
|
|
1796
|
-
if (typeof value === 'number') {
|
|
1797
|
-
return Number.isFinite(value) ? value : undefined;
|
|
1798
|
-
}
|
|
1799
|
-
if (typeof value !== 'string') {
|
|
1800
|
-
return undefined;
|
|
1801
|
-
}
|
|
1802
|
-
const normalized = value.trim();
|
|
1803
|
-
if (!normalized
|
|
1804
|
-
|| !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) {
|
|
1805
|
-
return undefined;
|
|
1806
|
-
}
|
|
1807
|
-
const parsed = Number(normalized);
|
|
1808
|
-
return Number.isFinite(parsed) ? parsed : undefined;
|
|
1809
|
-
}
|
|
1810
803
|
runOwnerCleanup(ownerId) {
|
|
1811
804
|
const cleanups = this.ownerCleanups.get(ownerId);
|
|
1812
805
|
if (!cleanups) {
|
|
@@ -1822,33 +815,12 @@ class SuplaPlatform {
|
|
|
1822
815
|
}
|
|
1823
816
|
this.ownerCleanups.delete(ownerId);
|
|
1824
817
|
}
|
|
1825
|
-
updateMqttTransportState(connected) {
|
|
1826
|
-
if (this.mqttTransportConnected === connected) {
|
|
1827
|
-
return;
|
|
1828
|
-
}
|
|
1829
|
-
this.mqttTransportConnected = connected;
|
|
1830
|
-
for (const handlers of this.mqttTransportHandlers.values()) {
|
|
1831
|
-
for (const handler of handlers) {
|
|
1832
|
-
try {
|
|
1833
|
-
handler(connected);
|
|
1834
|
-
}
|
|
1835
|
-
catch (error) {
|
|
1836
|
-
this.log.error(`MQTT transport handler failed: ${error.message}`);
|
|
1837
|
-
}
|
|
1838
|
-
}
|
|
1839
|
-
}
|
|
1840
|
-
}
|
|
1841
|
-
advanceMqttTransportEpoch() {
|
|
1842
|
-
this.mqttTransportEpoch += 1;
|
|
1843
|
-
}
|
|
1844
818
|
clearActiveSubscriptions() {
|
|
1845
|
-
this.advanceMqttTransportEpoch();
|
|
1846
819
|
if (this.mqttSubscriptions.size > 0 || this.mqttPendingSubscriptions.size > 0) {
|
|
1847
820
|
this.log.debug('MQTT connection lost; clearing active subscriptions.');
|
|
1848
821
|
}
|
|
1849
822
|
this.mqttSubscriptions.clear();
|
|
1850
823
|
this.mqttPendingSubscriptions.clear();
|
|
1851
|
-
this.mqttPendingSubscriptionOperations.clear();
|
|
1852
824
|
this.clearAllSubscriptionRetries();
|
|
1853
825
|
}
|
|
1854
826
|
clearAllSubscriptionRetries() {
|
|
@@ -1903,11 +875,9 @@ class SuplaPlatform {
|
|
|
1903
875
|
}
|
|
1904
876
|
this.mqttRetryState.set(topic, state);
|
|
1905
877
|
this.log.debug(`Retrying MQTT subscribe for ${topic} in ${scheduledDelayMs}ms (${reason}).`);
|
|
1906
|
-
const scheduledEpoch = this.mqttTransportEpoch;
|
|
1907
878
|
const timer = setTimeout(() => {
|
|
1908
879
|
this.mqttRetryTimers.delete(topic);
|
|
1909
|
-
if (
|
|
1910
|
-
|| !this.mqttDesiredSubscriptions.has(topic)) {
|
|
880
|
+
if (!this.mqttDesiredSubscriptions.has(topic)) {
|
|
1911
881
|
return;
|
|
1912
882
|
}
|
|
1913
883
|
this.ensureSubscribed(topic, true);
|
|
@@ -1916,7 +886,7 @@ class SuplaPlatform {
|
|
|
1916
886
|
}
|
|
1917
887
|
isSubscriptionGranted(topic, granted) {
|
|
1918
888
|
if (!Array.isArray(granted) || granted.length === 0) {
|
|
1919
|
-
return
|
|
889
|
+
return true;
|
|
1920
890
|
}
|
|
1921
891
|
const entry = granted.find((item) => item.topic === topic);
|
|
1922
892
|
if (!entry) {
|
|
@@ -1960,25 +930,7 @@ class SuplaPlatform {
|
|
|
1960
930
|
return;
|
|
1961
931
|
}
|
|
1962
932
|
this.mqttPendingSubscriptions.add(topic);
|
|
1963
|
-
|
|
1964
|
-
const operation = {
|
|
1965
|
-
client,
|
|
1966
|
-
epoch: this.mqttTransportEpoch,
|
|
1967
|
-
id: ++this.nextMqttSubscriptionOperationId,
|
|
1968
|
-
};
|
|
1969
|
-
this.mqttPendingSubscriptionOperations.set(topic, operation);
|
|
1970
|
-
const subscribeOptions = {
|
|
1971
|
-
qos: 0,
|
|
1972
|
-
...(this.mqttNoLocalTopics.has(topic) && this.mqttProtocolVersion === 5 ? { nl: true } : {}),
|
|
1973
|
-
};
|
|
1974
|
-
client.subscribe(topic, subscribeOptions, (err, granted) => {
|
|
1975
|
-
if (this.mqttPendingSubscriptionOperations.get(topic) !== operation
|
|
1976
|
-
|| this.mqttTransportEpoch !== operation.epoch
|
|
1977
|
-
|| this.MqttClient?.client !== operation.client
|
|
1978
|
-
|| !operation.client.connected) {
|
|
1979
|
-
return;
|
|
1980
|
-
}
|
|
1981
|
-
this.mqttPendingSubscriptionOperations.delete(topic);
|
|
933
|
+
this.MqttClient.client.subscribe(topic, (err, granted) => {
|
|
1982
934
|
this.mqttPendingSubscriptions.delete(topic);
|
|
1983
935
|
if (err) {
|
|
1984
936
|
this.logSubscriptionIssue(topic, `MQTT subscribe failed for ${topic}: ${err.message}`);
|
|
@@ -2017,7 +969,6 @@ class SuplaPlatform {
|
|
|
2017
969
|
}
|
|
2018
970
|
if (force) {
|
|
2019
971
|
this.mqttPendingSubscriptions.clear();
|
|
2020
|
-
this.mqttPendingSubscriptionOperations.clear();
|
|
2021
972
|
}
|
|
2022
973
|
for (const topic of this.mqttDesiredSubscriptions) {
|
|
2023
974
|
this.ensureSubscribed(topic, force);
|
|
@@ -2025,19 +976,19 @@ class SuplaPlatform {
|
|
|
2025
976
|
}
|
|
2026
977
|
removeSubscription(topic) {
|
|
2027
978
|
this.mqttDesiredSubscriptions.delete(topic);
|
|
2028
|
-
this.mqttNoLocalTopics.delete(topic);
|
|
2029
979
|
this.mqttPendingSubscriptions.delete(topic);
|
|
2030
|
-
this.mqttPendingSubscriptionOperations.delete(topic);
|
|
2031
980
|
this.clearSubscriptionRetry(topic);
|
|
2032
|
-
|
|
2033
|
-
if (!wasSubscribed || !this.MqttClient || this.shuttingDown) {
|
|
981
|
+
if (!this.MqttClient) {
|
|
2034
982
|
return;
|
|
2035
983
|
}
|
|
2036
|
-
this.
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
984
|
+
if (this.mqttSubscriptions.has(topic)) {
|
|
985
|
+
this.MqttClient.client.unsubscribe(topic, (err) => {
|
|
986
|
+
if (err) {
|
|
987
|
+
this.log.error(`MQTT unsubscribe failed for ${topic}: ${err.message}`);
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
this.mqttSubscriptions.delete(topic);
|
|
991
|
+
}
|
|
2041
992
|
}
|
|
2042
993
|
removeHandler(topic, handler) {
|
|
2043
994
|
const handlerMap = this.isWildcardTopic(topic) ? this.mqttWildcardHandlers : this.mqttHandlers;
|
|
@@ -2090,7 +1041,7 @@ class SuplaPlatform {
|
|
|
2090
1041
|
}
|
|
2091
1042
|
this.mqttRouterAttached = true;
|
|
2092
1043
|
this.resubscribeAll(false);
|
|
2093
|
-
this.MqttClient.client.on('message', (topic, message
|
|
1044
|
+
this.MqttClient.client.on('message', (topic, message) => {
|
|
2094
1045
|
const dispatched = new Set();
|
|
2095
1046
|
const dispatch = (handler, label) => {
|
|
2096
1047
|
if (dispatched.has(handler)) {
|
|
@@ -2098,7 +1049,7 @@ class SuplaPlatform {
|
|
|
2098
1049
|
}
|
|
2099
1050
|
dispatched.add(handler);
|
|
2100
1051
|
try {
|
|
2101
|
-
handler(message, topic
|
|
1052
|
+
handler(message, topic);
|
|
2102
1053
|
}
|
|
2103
1054
|
catch (error) {
|
|
2104
1055
|
this.log.error(`MQTT handler error for ${label}: ${error.message}`);
|