@smarthome.exchange/integrations 2.0.0 → 4.0.0
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/changelog.md +46 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/core/classes.nativeintegrationowner.d.ts +2 -0
- package/dist_ts/core/classes.nativeintegrationowner.js +5 -1
- package/dist_ts/core/classes.runtimemanager.d.ts +2 -0
- package/dist_ts/core/classes.runtimemanager.js +11 -3
- package/dist_ts/core/types.d.ts +2 -0
- package/dist_ts/integrations/deconz/deconz.classes.client.d.ts +37 -29
- package/dist_ts/integrations/deconz/deconz.classes.client.js +521 -294
- package/dist_ts/integrations/deconz/deconz.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/deconz/deconz.classes.configflow.js +63 -33
- package/dist_ts/integrations/deconz/deconz.classes.integration.d.ts +4 -3
- package/dist_ts/integrations/deconz/deconz.classes.integration.js +85 -40
- package/dist_ts/integrations/deconz/deconz.mapper.d.ts +1 -0
- package/dist_ts/integrations/deconz/deconz.mapper.js +24 -6
- package/dist_ts/integrations/deconz/deconz.types.d.ts +43 -6
- package/dist_ts/integrations/deconz/deconz.validation.d.ts +7 -1
- package/dist_ts/integrations/deconz/deconz.validation.js +215 -4
- package/dist_ts/integrations/esphome/esphome.classes.client.d.ts +19 -7
- package/dist_ts/integrations/esphome/esphome.classes.client.js +183 -89
- package/dist_ts/integrations/esphome/esphome.classes.configflow.d.ts +5 -2
- package/dist_ts/integrations/esphome/esphome.classes.configflow.js +43 -33
- package/dist_ts/integrations/esphome/esphome.classes.integration.d.ts +8 -6
- package/dist_ts/integrations/esphome/esphome.classes.integration.js +71 -43
- package/dist_ts/integrations/esphome/esphome.discovery.js +4 -12
- package/dist_ts/integrations/esphome/esphome.mapper.d.ts +2 -2
- package/dist_ts/integrations/esphome/esphome.mapper.js +6 -4
- package/dist_ts/integrations/esphome/esphome.types.d.ts +31 -6
- package/dist_ts/integrations/esphome/esphome.types.js +1 -1
- package/dist_ts/integrations/esphome/esphome.validation.d.ts +7 -0
- package/dist_ts/integrations/esphome/esphome.validation.js +66 -0
- package/dist_ts/integrations/homeassistant/homeassistant.classes.client.d.ts +32 -4
- package/dist_ts/integrations/homeassistant/homeassistant.classes.client.js +345 -116
- package/dist_ts/integrations/homeassistant/homeassistant.classes.integration.d.ts +8 -5
- package/dist_ts/integrations/homeassistant/homeassistant.classes.integration.js +34 -8
- package/dist_ts/integrations/homeassistant/homeassistant.classes.runtime.d.ts +7 -4
- package/dist_ts/integrations/homeassistant/homeassistant.classes.runtime.js +44 -56
- package/dist_ts/integrations/homeassistant/homeassistant.types.d.ts +12 -1
- package/dist_ts/integrations/hue/hue.classes.client.d.ts +32 -4
- package/dist_ts/integrations/hue/hue.classes.client.js +331 -57
- package/dist_ts/integrations/hue/hue.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/hue/hue.classes.configflow.js +79 -27
- package/dist_ts/integrations/hue/hue.classes.integration.d.ts +3 -1
- package/dist_ts/integrations/hue/hue.classes.integration.js +83 -62
- package/dist_ts/integrations/hue/hue.mapper.d.ts +2 -0
- package/dist_ts/integrations/hue/hue.mapper.js +8 -6
- package/dist_ts/integrations/hue/hue.types.d.ts +32 -1
- package/dist_ts/integrations/hue/hue.validation.d.ts +9 -3
- package/dist_ts/integrations/hue/hue.validation.js +112 -11
- package/dist_ts/integrations/mqtt/mqtt.classes.client.d.ts +15 -2
- package/dist_ts/integrations/mqtt/mqtt.classes.client.js +173 -23
- package/dist_ts/integrations/mqtt/mqtt.classes.configflow.d.ts +5 -1
- package/dist_ts/integrations/mqtt/mqtt.classes.configflow.js +53 -33
- package/dist_ts/integrations/mqtt/mqtt.classes.integration.d.ts +4 -1
- package/dist_ts/integrations/mqtt/mqtt.classes.integration.js +127 -56
- package/dist_ts/integrations/mqtt/mqtt.classes.session.d.ts +13 -0
- package/dist_ts/integrations/mqtt/mqtt.classes.session.js +130 -0
- package/dist_ts/integrations/mqtt/mqtt.mapper.d.ts +3 -0
- package/dist_ts/integrations/mqtt/mqtt.mapper.js +44 -1
- package/dist_ts/integrations/mqtt/mqtt.types.d.ts +46 -0
- package/dist_ts/integrations/mqtt/mqtt.validation.d.ts +7 -0
- package/dist_ts/integrations/mqtt/mqtt.validation.js +180 -0
- package/dist_ts/integrations/samsungtv/samsungtv.classes.client.d.ts +4 -2
- package/dist_ts/integrations/samsungtv/samsungtv.classes.client.js +41 -16
- package/dist_ts/integrations/samsungtv/samsungtv.classes.configflow.d.ts +5 -0
- package/dist_ts/integrations/samsungtv/samsungtv.classes.configflow.js +20 -7
- package/dist_ts/integrations/samsungtv/samsungtv.classes.integration.d.ts +1 -0
- package/dist_ts/integrations/samsungtv/samsungtv.classes.integration.js +53 -50
- package/dist_ts/integrations/samsungtv/samsungtv.types.d.ts +4 -1
- package/dist_ts/integrations/shelly/shelly.classes.client.d.ts +26 -30
- package/dist_ts/integrations/shelly/shelly.classes.client.js +504 -276
- package/dist_ts/integrations/shelly/shelly.classes.configflow.d.ts +8 -3
- package/dist_ts/integrations/shelly/shelly.classes.configflow.js +43 -38
- package/dist_ts/integrations/shelly/shelly.classes.integration.d.ts +6 -6
- package/dist_ts/integrations/shelly/shelly.classes.integration.js +103 -101
- package/dist_ts/integrations/shelly/shelly.classes.subscription.d.ts +4 -0
- package/dist_ts/integrations/shelly/shelly.classes.subscription.js +24 -2
- package/dist_ts/integrations/shelly/shelly.mapper.d.ts +1 -2
- package/dist_ts/integrations/shelly/shelly.mapper.js +21 -20
- package/dist_ts/integrations/shelly/shelly.rpc.js +4 -1
- package/dist_ts/integrations/shelly/shelly.types.d.ts +42 -2
- package/dist_ts/integrations/shelly/shelly.types.js +11 -2
- package/dist_ts/integrations/shelly/shelly.validation.d.ts +7 -0
- package/dist_ts/integrations/shelly/shelly.validation.js +79 -0
- package/dist_ts/integrations/sonos/sonos.classes.client.d.ts +79 -22
- package/dist_ts/integrations/sonos/sonos.classes.client.js +427 -139
- package/dist_ts/integrations/sonos/sonos.classes.configflow.d.ts +9 -2
- package/dist_ts/integrations/sonos/sonos.classes.configflow.js +26 -28
- package/dist_ts/integrations/sonos/sonos.classes.integration.d.ts +7 -6
- package/dist_ts/integrations/sonos/sonos.classes.integration.js +289 -224
- package/dist_ts/integrations/sonos/sonos.config.d.ts +6 -1
- package/dist_ts/integrations/sonos/sonos.config.js +23 -2
- package/dist_ts/integrations/sonos/sonos.mapper.d.ts +1 -1
- package/dist_ts/integrations/sonos/sonos.mapper.js +14 -9
- package/dist_ts/integrations/sonos/sonos.matchers.js +3 -3
- package/dist_ts/integrations/sonos/sonos.types.d.ts +26 -1
- package/dist_ts/integrations/tasmota/tasmota.classes.integration.d.ts +5 -0
- package/dist_ts/integrations/tasmota/tasmota.classes.integration.js +23 -7
- package/dist_ts/integrations/tasmota/tasmota.classes.runtime.d.ts +19 -5
- package/dist_ts/integrations/tasmota/tasmota.classes.runtime.js +115 -41
- package/dist_ts/integrations/tasmota/tasmota.config.js +23 -1
- package/dist_ts/integrations/tasmota/tasmota.credentials.d.ts +11 -0
- package/dist_ts/integrations/tasmota/tasmota.credentials.js +97 -0
- package/dist_ts/integrations/tasmota/tasmota.discovery.d.ts +7 -1
- package/dist_ts/integrations/tasmota/tasmota.discovery.js +108 -30
- package/dist_ts/integrations/tasmota/tasmota.mapper.d.ts +2 -2
- package/dist_ts/integrations/tasmota/tasmota.mapper.js +7 -7
- package/dist_ts/integrations/tasmota/tasmota.types.d.ts +41 -2
- package/dist_ts/integrations/tuya/tuya.classes.integration.d.ts +4 -1
- package/dist_ts/integrations/tuya/tuya.classes.integration.js +79 -110
- package/dist_ts/integrations/tuya/tuya.classes.runtime.d.ts +5 -2
- package/dist_ts/integrations/tuya/tuya.classes.runtime.js +73 -26
- package/dist_ts/integrations/tuya/tuya.mapper.js +2 -2
- package/dist_ts/integrations/tuya/tuya.types.d.ts +3 -0
- package/dist_ts/integrations/tuya/tuya.validation.d.ts +2 -2
- package/dist_ts/integrations/tuya/tuya.validation.js +6 -11
- package/dist_ts/integrations/webostv/webostv.classes.client.d.ts +6 -5
- package/dist_ts/integrations/webostv/webostv.classes.client.js +147 -76
- package/dist_ts/integrations/webostv/webostv.classes.configflow.d.ts +7 -2
- package/dist_ts/integrations/webostv/webostv.classes.configflow.js +36 -20
- package/dist_ts/integrations/webostv/webostv.classes.integration.d.ts +4 -4
- package/dist_ts/integrations/webostv/webostv.classes.integration.js +46 -54
- package/dist_ts/integrations/webostv/webostv.mapper.js +5 -5
- package/dist_ts/integrations/webostv/webostv.types.d.ts +16 -3
- package/dist_ts/integrations/webostv/webostv.types.js +1 -1
- package/dist_ts/integrations/webostv/webostv.validation.d.ts +3 -1
- package/dist_ts/integrations/webostv/webostv.validation.js +23 -1
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.js +18 -8
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.d.ts +4 -1
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.js +54 -6
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.d.ts +12 -6
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.js +73 -59
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.d.ts +3 -2
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.js +17 -12
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.types.d.ts +31 -2
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.d.ts +9 -1
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.js +57 -7
- package/dist_ts/integrations/zwave_js/zwave_js.classes.client.d.ts +10 -4
- package/dist_ts/integrations/zwave_js/zwave_js.classes.client.js +167 -31
- package/dist_ts/integrations/zwave_js/zwave_js.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/zwave_js/zwave_js.classes.configflow.js +57 -35
- package/dist_ts/integrations/zwave_js/zwave_js.classes.integration.d.ts +3 -1
- package/dist_ts/integrations/zwave_js/zwave_js.classes.integration.js +59 -37
- package/dist_ts/integrations/zwave_js/zwave_js.discovery.js +3 -3
- package/dist_ts/integrations/zwave_js/zwave_js.mapper.js +41 -24
- package/dist_ts/integrations/zwave_js/zwave_js.types.d.ts +25 -3
- package/dist_ts/integrations/zwave_js/zwave_js.validation.d.ts +6 -2
- package/dist_ts/integrations/zwave_js/zwave_js.validation.js +52 -17
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/package.json +10 -10
- package/readme.md +25 -6
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/core/classes.nativeintegrationowner.ts +5 -0
- package/ts/core/classes.runtimemanager.ts +11 -2
- package/ts/core/types.ts +2 -0
- package/ts/integrations/deconz/deconz.classes.client.ts +348 -252
- package/ts/integrations/deconz/deconz.classes.configflow.ts +57 -29
- package/ts/integrations/deconz/deconz.classes.integration.ts +69 -37
- package/ts/integrations/deconz/deconz.mapper.ts +22 -5
- package/ts/integrations/deconz/deconz.types.ts +49 -6
- package/ts/integrations/deconz/deconz.validation.ts +177 -4
- package/ts/integrations/deconz/readme.md +15 -1
- package/ts/integrations/esphome/esphome.classes.client.ts +179 -94
- package/ts/integrations/esphome/esphome.classes.configflow.ts +40 -33
- package/ts/integrations/esphome/esphome.classes.integration.ts +63 -36
- package/ts/integrations/esphome/esphome.discovery.ts +3 -12
- package/ts/integrations/esphome/esphome.mapper.ts +9 -7
- package/ts/integrations/esphome/esphome.types.ts +35 -6
- package/ts/integrations/esphome/esphome.validation.ts +66 -0
- package/ts/integrations/esphome/readme.md +20 -6
- package/ts/integrations/homeassistant/homeassistant.classes.client.ts +353 -125
- package/ts/integrations/homeassistant/homeassistant.classes.integration.ts +35 -18
- package/ts/integrations/homeassistant/homeassistant.classes.runtime.ts +50 -63
- package/ts/integrations/homeassistant/homeassistant.types.ts +17 -1
- package/ts/integrations/homeassistant/readme.md +22 -6
- package/ts/integrations/hue/hue.classes.client.ts +288 -55
- package/ts/integrations/hue/hue.classes.configflow.ts +68 -25
- package/ts/integrations/hue/hue.classes.integration.ts +75 -54
- package/ts/integrations/hue/hue.mapper.ts +8 -5
- package/ts/integrations/hue/hue.types.ts +37 -1
- package/ts/integrations/hue/hue.validation.ts +102 -13
- package/ts/integrations/hue/readme.md +49 -12
- package/ts/integrations/mqtt/mqtt.classes.client.ts +136 -19
- package/ts/integrations/mqtt/mqtt.classes.configflow.ts +45 -32
- package/ts/integrations/mqtt/mqtt.classes.integration.ts +99 -38
- package/ts/integrations/mqtt/mqtt.classes.session.ts +118 -0
- package/ts/integrations/mqtt/mqtt.mapper.ts +43 -1
- package/ts/integrations/mqtt/mqtt.types.ts +52 -0
- package/ts/integrations/mqtt/mqtt.validation.ts +157 -0
- package/ts/integrations/mqtt/readme.md +12 -3
- package/ts/integrations/samsungtv/readme.md +11 -5
- package/ts/integrations/samsungtv/samsungtv.classes.client.ts +40 -15
- package/ts/integrations/samsungtv/samsungtv.classes.configflow.ts +21 -6
- package/ts/integrations/samsungtv/samsungtv.classes.integration.ts +53 -38
- package/ts/integrations/samsungtv/samsungtv.types.ts +4 -1
- package/ts/integrations/shelly/readme.md +22 -7
- package/ts/integrations/shelly/shelly.classes.client.ts +386 -269
- package/ts/integrations/shelly/shelly.classes.configflow.ts +43 -38
- package/ts/integrations/shelly/shelly.classes.integration.ts +92 -111
- package/ts/integrations/shelly/shelly.classes.subscription.ts +24 -1
- package/ts/integrations/shelly/shelly.mapper.ts +19 -20
- package/ts/integrations/shelly/shelly.rpc.ts +2 -0
- package/ts/integrations/shelly/shelly.types.ts +50 -2
- package/ts/integrations/shelly/shelly.validation.ts +76 -0
- package/ts/integrations/sonos/readme.md +117 -38
- package/ts/integrations/sonos/sonos.classes.client.ts +392 -123
- package/ts/integrations/sonos/sonos.classes.configflow.ts +25 -28
- package/ts/integrations/sonos/sonos.classes.integration.ts +211 -186
- package/ts/integrations/sonos/sonos.config.ts +24 -2
- package/ts/integrations/sonos/sonos.mapper.ts +13 -8
- package/ts/integrations/sonos/sonos.matchers.ts +2 -2
- package/ts/integrations/sonos/sonos.types.ts +30 -1
- package/ts/integrations/tasmota/readme.md +16 -5
- package/ts/integrations/tasmota/tasmota.classes.integration.ts +24 -6
- package/ts/integrations/tasmota/tasmota.classes.runtime.ts +112 -43
- package/ts/integrations/tasmota/tasmota.config.ts +17 -0
- package/ts/integrations/tasmota/tasmota.credentials.ts +92 -0
- package/ts/integrations/tasmota/tasmota.discovery.ts +87 -24
- package/ts/integrations/tasmota/tasmota.mapper.ts +6 -6
- package/ts/integrations/tasmota/tasmota.types.ts +43 -2
- package/ts/integrations/tuya/readme.md +52 -10
- package/ts/integrations/tuya/tuya.classes.integration.ts +69 -98
- package/ts/integrations/tuya/tuya.classes.runtime.ts +70 -24
- package/ts/integrations/tuya/tuya.mapper.ts +1 -1
- package/ts/integrations/tuya/tuya.types.ts +4 -0
- package/ts/integrations/tuya/tuya.validation.ts +5 -8
- package/ts/integrations/webostv/readme.md +115 -22
- package/ts/integrations/webostv/webostv.classes.client.ts +123 -50
- package/ts/integrations/webostv/webostv.classes.configflow.ts +35 -15
- package/ts/integrations/webostv/webostv.classes.integration.ts +44 -37
- package/ts/integrations/webostv/webostv.mapper.ts +4 -4
- package/ts/integrations/webostv/webostv.types.ts +18 -3
- package/ts/integrations/webostv/webostv.validation.ts +22 -1
- package/ts/integrations/zigbee2mqtt/readme.md +22 -27
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.ts +16 -7
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.ts +53 -6
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.ts +62 -49
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.ts +17 -11
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.types.ts +35 -2
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.ts +53 -7
- package/ts/integrations/zwave_js/readme.md +42 -9
- package/ts/integrations/zwave_js/zwave_js.classes.client.ts +155 -31
- package/ts/integrations/zwave_js/zwave_js.classes.configflow.ts +55 -31
- package/ts/integrations/zwave_js/zwave_js.classes.integration.ts +57 -35
- package/ts/integrations/zwave_js/zwave_js.discovery.ts +2 -2
- package/ts/integrations/zwave_js/zwave_js.mapper.ts +37 -23
- package/ts/integrations/zwave_js/zwave_js.types.ts +26 -3
- package/ts/integrations/zwave_js/zwave_js.validation.ts +52 -18
- package/ts/plugins.ts +2 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
2
|
import { MqttMapper } from './mqtt.mapper.js';
|
|
3
|
-
import type { IMqttConfig, IMqttConnectionState, IMqttMessage, IMqttPublishOptions, IMqttSnapshot } from './mqtt.types.js';
|
|
3
|
+
import type { IMqttConfig, IMqttConnectionState, IMqttMessage, IMqttPublishOptions, IMqttSnapshot, IMqttTransportCredentials } from './mqtt.types.js';
|
|
4
4
|
|
|
5
5
|
type TMqttMessageHandler = (messageArg: IMqttMessage) => void;
|
|
6
6
|
type TMqttStateHandler = (stateArg: IMqttConnectionState) => void;
|
|
@@ -10,6 +10,11 @@ interface IMqttSubscription {
|
|
|
10
10
|
handler?: TMqttMessageHandler;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
interface IMqttTransportCleanup {
|
|
14
|
+
promise?: Promise<void>;
|
|
15
|
+
failed: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
export const mqttTopicMatches = (filterArg: string, topicArg: string): boolean => {
|
|
14
19
|
if (topicArg.startsWith('$') && !filterArg.startsWith('$')) return false;
|
|
15
20
|
const filter = filterArg.split('/');
|
|
@@ -37,17 +42,23 @@ export class MqttClient {
|
|
|
37
42
|
private startPromise?: Promise<void>;
|
|
38
43
|
private destroyPromise?: Promise<void>;
|
|
39
44
|
private cleanupPromise = Promise.resolve();
|
|
45
|
+
private retryPromise?: Promise<void>;
|
|
46
|
+
private readonly transportCleanups = new Map<plugins.mqtt.MqttClient, IMqttTransportCleanup>();
|
|
40
47
|
private destroyed = false;
|
|
41
48
|
private ready = false;
|
|
42
49
|
private hasStarted = false;
|
|
43
50
|
private generation = 0;
|
|
51
|
+
private currentGeneration?: number;
|
|
52
|
+
private currentStream?: plugins.mqtt.MqttClient['stream'];
|
|
44
53
|
private readonly subscriptions = new Set<IMqttSubscription>();
|
|
45
54
|
private readonly messageHandlers = new Set<TMqttMessageHandler>();
|
|
46
55
|
private readonly stateHandlers = new Set<TMqttStateHandler>();
|
|
47
56
|
private readonly messages = new Map<string, IMqttMessage>();
|
|
57
|
+
private readonly currentMessageTopics = new Set<string>();
|
|
48
58
|
private readonly persistentTopics = new Set<string>();
|
|
49
59
|
private readonly brokerTopics = new Set<string>();
|
|
50
60
|
private readonly pendingOperations = new Set<(errorArg: Error) => void>();
|
|
61
|
+
private readonly publications = new Map<plugins.mqtt.MqttClient, number>();
|
|
51
62
|
private topicSync?: Promise<void>;
|
|
52
63
|
private topicSyncRequested = false;
|
|
53
64
|
private bufferedBytes = 0;
|
|
@@ -57,8 +68,9 @@ export class MqttClient {
|
|
|
57
68
|
private readonly maxPayloadBytes: number;
|
|
58
69
|
private readonly maxBufferedBytes: number;
|
|
59
70
|
|
|
60
|
-
constructor(private readonly config: IMqttConfig) {
|
|
71
|
+
constructor(private readonly config: IMqttConfig, private readonly credentials: IMqttTransportCredentials = {}) {
|
|
61
72
|
this.config = structuredClone(config);
|
|
73
|
+
this.credentials = structuredClone(credentials);
|
|
62
74
|
this.connectTimeoutMs = this.integerOption(config.connectTimeoutMs, 7000, 1, 2147483647);
|
|
63
75
|
this.operationTimeoutMs = this.integerOption(config.operationTimeoutMs, 7000, 1, 2147483647);
|
|
64
76
|
this.maxMessageTopics = this.integerOption(config.maxMessageTopics, 4096, 1, 100000);
|
|
@@ -73,6 +85,13 @@ export class MqttClient {
|
|
|
73
85
|
validateMqttTopic(topic, true);
|
|
74
86
|
this.persistentTopics.add(topic);
|
|
75
87
|
}
|
|
88
|
+
if (config.mode === 'selected-topics') {
|
|
89
|
+
for (const mapping of config.mappings ?? []) {
|
|
90
|
+
for (const topic of [mapping.stateTopic, ...(mapping.availabilityTopic ? [mapping.availabilityTopic] : [])]) {
|
|
91
|
+
validateMqttTopic(topic); this.persistentTopics.add(topic);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
76
95
|
if (config.discovery !== false) {
|
|
77
96
|
const prefix = config.discoveryPrefix || 'homeassistant';
|
|
78
97
|
validateMqttTopic(prefix);
|
|
@@ -106,6 +125,14 @@ export class MqttClient {
|
|
|
106
125
|
return MqttMapper.toSnapshot({ ...this.config, retainedMessages: [], discoveryMessages: [] }, this.connected, [...this.messages.values()]);
|
|
107
126
|
}
|
|
108
127
|
|
|
128
|
+
/** Live broker observations only, including arrivals before readiness; never starts a transport. */
|
|
129
|
+
public getCurrentSnapshot(): IMqttMessage[] {
|
|
130
|
+
if (this.destroyed || this.currentGeneration !== this.generation || !this.client?.connected ||
|
|
131
|
+
this.client.stream !== this.currentStream || this.currentStream?.destroyed) return [];
|
|
132
|
+
return [...this.messages.values()].filter((messageArg) => this.currentMessageTopics.has(messageArg.topic))
|
|
133
|
+
.map((messageArg) => structuredClone(messageArg));
|
|
134
|
+
}
|
|
135
|
+
|
|
109
136
|
public async start(abortSignalArg?: AbortSignal): Promise<void> {
|
|
110
137
|
this.throwIfAborted(abortSignalArg);
|
|
111
138
|
if (this.destroyed) throw new Error('MQTT client has been destroyed.');
|
|
@@ -144,13 +171,18 @@ export class MqttClient {
|
|
|
144
171
|
if (packetArg.cmd === 'publish' && packetArg.payload === payload) packetId = packetArg.messageId;
|
|
145
172
|
};
|
|
146
173
|
client.on('packetsend', observePacket);
|
|
174
|
+
this.publications.set(client, (this.publications.get(client) ?? 0) + 1);
|
|
147
175
|
try {
|
|
148
176
|
await this.bounded(client.publishAsync(optionsArg.topic, payload, {
|
|
149
177
|
qos: optionsArg.qos ?? 0, retain: optionsArg.retain ?? false,
|
|
150
178
|
}), this.operationTimeoutMs, abortSignalArg, () => {
|
|
151
179
|
if (packetId !== undefined && optionsArg.qos) client.removeOutgoingMessage(packetId);
|
|
152
180
|
});
|
|
153
|
-
} finally {
|
|
181
|
+
} finally {
|
|
182
|
+
client.removeListener('packetsend', observePacket);
|
|
183
|
+
const remaining = (this.publications.get(client) ?? 1) - 1;
|
|
184
|
+
if (remaining) this.publications.set(client, remaining); else this.publications.delete(client);
|
|
185
|
+
}
|
|
154
186
|
}
|
|
155
187
|
|
|
156
188
|
public async subscribe(topicArg: string, handlerArg?: TMqttMessageHandler, abortSignalArg?: AbortSignal): Promise<() => Promise<void>> {
|
|
@@ -175,10 +207,35 @@ export class MqttClient {
|
|
|
175
207
|
}
|
|
176
208
|
|
|
177
209
|
public destroy(): Promise<void> {
|
|
178
|
-
if (!this.destroyPromise)
|
|
210
|
+
if (!this.destroyPromise) {
|
|
211
|
+
this.destroyed = true;
|
|
212
|
+
this.clearCurrentGeneration();
|
|
213
|
+
this.destroyPromise = Promise.resolve().then(() => this.destroyConnection());
|
|
214
|
+
}
|
|
179
215
|
return this.destroyPromise;
|
|
180
216
|
}
|
|
181
217
|
|
|
218
|
+
/** Retry retained close handles only; this never admits a connection or replays application effects. */
|
|
219
|
+
public retryCleanup(): Promise<void> {
|
|
220
|
+
if (!this.destroyPromise) return this.destroy();
|
|
221
|
+
if (!this.retryPromise) {
|
|
222
|
+
const retry = Promise.resolve().then(async () => {
|
|
223
|
+
await this.destroyPromise?.then(() => undefined, () => undefined);
|
|
224
|
+
const cleanup = [...this.transportCleanups].filter(([, recordArg]) => recordArg.failed).map(([clientArg, recordArg]) => {
|
|
225
|
+
recordArg.promise = undefined;
|
|
226
|
+
recordArg.failed = false;
|
|
227
|
+
return this.closeClient(clientArg);
|
|
228
|
+
});
|
|
229
|
+
const results = await Promise.allSettled(cleanup);
|
|
230
|
+
const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason as unknown] : []);
|
|
231
|
+
if (failures.length) throw new AggregateError(failures, 'MQTT retained transport cleanup failed.');
|
|
232
|
+
});
|
|
233
|
+
this.retryPromise = retry;
|
|
234
|
+
void retry.then(() => { this.retryPromise = undefined; }, () => { this.retryPromise = undefined; });
|
|
235
|
+
}
|
|
236
|
+
return this.retryPromise;
|
|
237
|
+
}
|
|
238
|
+
|
|
182
239
|
private async startConnection(): Promise<void> {
|
|
183
240
|
let client = this.client;
|
|
184
241
|
if (!client) {
|
|
@@ -191,6 +248,7 @@ export class MqttClient {
|
|
|
191
248
|
protocolVersion: 4,
|
|
192
249
|
clientId: this.config.clientId || 'shx-' + plugins.crypto.randomBytes(8).toString('hex'),
|
|
193
250
|
username: this.config.username, password: this.config.password,
|
|
251
|
+
...this.credentials,
|
|
194
252
|
clean: this.config.clean ?? true,
|
|
195
253
|
keepalive: this.config.keepalive ?? 60,
|
|
196
254
|
rejectUnauthorized: this.config.rejectUnauthorized !== false,
|
|
@@ -202,9 +260,21 @@ export class MqttClient {
|
|
|
202
260
|
});
|
|
203
261
|
this.client = client;
|
|
204
262
|
const ownedClient = client;
|
|
263
|
+
const receivedGenerations = new WeakMap<plugins.mqtt.IPublishPacket, number>();
|
|
264
|
+
client.on('packetreceive', (packetArg) => {
|
|
265
|
+
if (packetArg.cmd !== 'publish' || this.client !== ownedClient || this.destroyed ||
|
|
266
|
+
this.currentGeneration !== this.generation || !ownedClient.connected ||
|
|
267
|
+
ownedClient.stream !== this.currentStream || this.currentStream?.destroyed) return;
|
|
268
|
+
// MQTT.js delivers the same packet through message, including its default QoS 2 store.
|
|
269
|
+
if (!receivedGenerations.has(packetArg)) receivedGenerations.set(packetArg, this.generation);
|
|
270
|
+
});
|
|
205
271
|
client.on('connect', () => {
|
|
272
|
+
if (this.client !== ownedClient || this.destroyed || !ownedClient.connected || ownedClient.stream.destroyed) return;
|
|
273
|
+
this.clearCurrentGeneration();
|
|
206
274
|
this.brokerTopics.clear();
|
|
207
275
|
const generation = ++this.generation;
|
|
276
|
+
this.currentGeneration = generation;
|
|
277
|
+
this.currentStream = ownedClient.stream;
|
|
208
278
|
void this.bounded(this.initializeConnection(ownedClient), this.operationTimeoutMs).then(() => {
|
|
209
279
|
if (this.client !== ownedClient || generation !== this.generation || !ownedClient.connected) return;
|
|
210
280
|
this.ready = true;
|
|
@@ -214,7 +284,9 @@ export class MqttClient {
|
|
|
214
284
|
});
|
|
215
285
|
});
|
|
216
286
|
client.on('message', (topicArg, payloadArg, packetArg) => {
|
|
217
|
-
if (this.client !== ownedClient
|
|
287
|
+
if (this.client !== ownedClient || this.destroyed || this.currentGeneration !== this.generation ||
|
|
288
|
+
receivedGenerations.get(packetArg) !== this.currentGeneration || !ownedClient.connected ||
|
|
289
|
+
ownedClient.stream !== this.currentStream || this.currentStream?.destroyed) return;
|
|
218
290
|
try {
|
|
219
291
|
this.handleMessage({ topic: topicArg, payload: payloadArg.toString('utf8'), qos: packetArg.qos, retain: packetArg.retain, timestamp: Date.now() });
|
|
220
292
|
} catch (error) { void this.failConnection(this.asError(error), ownedClient); }
|
|
@@ -222,15 +294,22 @@ export class MqttClient {
|
|
|
222
294
|
client.on('error', (errorArg) => {
|
|
223
295
|
if (this.client === ownedClient) {
|
|
224
296
|
this.ready = false;
|
|
225
|
-
this.
|
|
297
|
+
this.generation++;
|
|
298
|
+
this.clearCurrentGeneration();
|
|
299
|
+
try { this.emitState({ connected: false, error: errorArg }); }
|
|
300
|
+
catch (error) { void this.failConnection(this.asError(error), ownedClient); }
|
|
226
301
|
}
|
|
227
302
|
});
|
|
228
303
|
client.on('close', () => {
|
|
229
304
|
if (this.client !== ownedClient) return;
|
|
230
305
|
this.ready = false;
|
|
231
306
|
this.generation++;
|
|
307
|
+
this.clearCurrentGeneration();
|
|
232
308
|
this.brokerTopics.clear();
|
|
233
|
-
this.emitState({ connected: false });
|
|
309
|
+
try { this.emitState({ connected: false }); }
|
|
310
|
+
catch (error) { void this.failConnection(this.asError(error), ownedClient); }
|
|
311
|
+
// A new connection must never replay an unacknowledged application effect.
|
|
312
|
+
if (this.publications.has(ownedClient)) void this.failConnection(new Error('MQTT publication lost its connection before acknowledgement.'), ownedClient);
|
|
234
313
|
});
|
|
235
314
|
}
|
|
236
315
|
if (this.connected) return;
|
|
@@ -313,14 +392,20 @@ export class MqttClient {
|
|
|
313
392
|
}
|
|
314
393
|
|
|
315
394
|
private handleMessage(messageArg: IMqttMessage): void {
|
|
395
|
+
if (this.config.mode === 'selected-topics' && !this.persistentTopics.has(messageArg.topic)) return;
|
|
316
396
|
this.storeMessage(messageArg);
|
|
397
|
+
this.currentMessageTopics.add(messageArg.topic);
|
|
317
398
|
if (this.config.discovery !== false && messageArg.topic.endsWith('/config') && mqttTopicMatches((this.config.discoveryPrefix || 'homeassistant') + '/#', messageArg.topic)) {
|
|
318
399
|
void this.synchronizeTopics();
|
|
319
400
|
}
|
|
401
|
+
const failures: unknown[] = [];
|
|
320
402
|
for (const subscription of this.subscriptions) {
|
|
321
|
-
if (subscription.handler && mqttTopicMatches(subscription.topic, messageArg.topic))
|
|
403
|
+
if (subscription.handler && mqttTopicMatches(subscription.topic, messageArg.topic)) {
|
|
404
|
+
try { subscription.handler(messageArg); } catch (error) { failures.push(error); }
|
|
405
|
+
}
|
|
322
406
|
}
|
|
323
|
-
for (const handler of this.messageHandlers) handler(messageArg);
|
|
407
|
+
for (const handler of this.messageHandlers) { try { handler(messageArg); } catch (error) { failures.push(error); } }
|
|
408
|
+
if (failures.length) throw new AggregateError(failures, 'MQTT message callbacks failed.');
|
|
324
409
|
}
|
|
325
410
|
|
|
326
411
|
private storeMessage(messageArg: IMqttMessage): void {
|
|
@@ -337,6 +422,7 @@ export class MqttClient {
|
|
|
337
422
|
}
|
|
338
423
|
|
|
339
424
|
private removeMessage(topicArg: string): void {
|
|
425
|
+
this.currentMessageTopics.delete(topicArg);
|
|
340
426
|
const message = this.messages.get(topicArg);
|
|
341
427
|
if (message) {
|
|
342
428
|
this.bufferedBytes -= this.payloadBuffer(message.payload).length + Buffer.byteLength(topicArg);
|
|
@@ -344,31 +430,47 @@ export class MqttClient {
|
|
|
344
430
|
}
|
|
345
431
|
}
|
|
346
432
|
|
|
433
|
+
private clearCurrentGeneration(): void {
|
|
434
|
+
this.currentGeneration = undefined;
|
|
435
|
+
this.currentStream = undefined;
|
|
436
|
+
this.currentMessageTopics.clear();
|
|
437
|
+
}
|
|
438
|
+
|
|
347
439
|
private async destroyConnection(): Promise<void> {
|
|
348
440
|
this.destroyed = true;
|
|
349
441
|
this.ready = false;
|
|
442
|
+
this.clearCurrentGeneration();
|
|
350
443
|
const client = this.client;
|
|
351
444
|
this.client = undefined;
|
|
352
445
|
this.rejectOperations(new Error('MQTT client has been destroyed.'));
|
|
353
|
-
|
|
446
|
+
const stateHandlers = [...this.stateHandlers];
|
|
447
|
+
const notify = Promise.resolve().then(() => this.emitState({ connected: false }, stateHandlers));
|
|
354
448
|
this.subscriptions.clear();
|
|
355
449
|
this.messageHandlers.clear();
|
|
356
450
|
this.stateHandlers.clear();
|
|
357
451
|
this.messages.clear();
|
|
358
452
|
this.bufferedBytes = 0;
|
|
359
453
|
this.brokerTopics.clear();
|
|
360
|
-
|
|
361
|
-
|
|
454
|
+
const results = await Promise.allSettled([notify, ...(client ? [this.closeClient(client)] : []), this.cleanupPromise,
|
|
455
|
+
...(this.startPromise ? [this.startPromise.catch(() => undefined)] : []), ...(this.topicSync ? [this.topicSync.catch(() => undefined)] : [])]);
|
|
456
|
+
const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason as unknown] : []);
|
|
457
|
+
if (failures.length) throw new AggregateError(failures, 'MQTT client cleanup failed.');
|
|
362
458
|
}
|
|
363
459
|
|
|
364
460
|
private failConnection(errorArg: Error, clientArg: plugins.mqtt.MqttClient): Promise<void> {
|
|
365
461
|
if (this.client !== clientArg) return this.cleanupPromise;
|
|
366
462
|
this.ready = false;
|
|
367
463
|
this.generation++;
|
|
464
|
+
this.clearCurrentGeneration();
|
|
368
465
|
this.client = undefined;
|
|
369
466
|
this.brokerTopics.clear();
|
|
370
|
-
|
|
371
|
-
|
|
467
|
+
const cleanup = Promise.resolve().then(async () => {
|
|
468
|
+
const results = await Promise.allSettled([
|
|
469
|
+
Promise.resolve().then(() => this.emitState({ connected: false, error: errorArg })), this.closeClient(clientArg),
|
|
470
|
+
]);
|
|
471
|
+
const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason as unknown] : []);
|
|
472
|
+
if (failures.length) throw new AggregateError(failures, 'MQTT cancellation cleanup failed.');
|
|
473
|
+
});
|
|
372
474
|
this.cleanupPromise = cleanup;
|
|
373
475
|
void cleanup.then(() => this.rejectOperations(errorArg), (error: unknown) => {
|
|
374
476
|
this.rejectOperations(new AggregateError([errorArg, error], 'MQTT cancellation cleanup failed.'));
|
|
@@ -376,10 +478,23 @@ export class MqttClient {
|
|
|
376
478
|
return cleanup;
|
|
377
479
|
}
|
|
378
480
|
|
|
379
|
-
private
|
|
481
|
+
private closeClient(clientArg: plugins.mqtt.MqttClient): Promise<void> {
|
|
482
|
+
let record = this.transportCleanups.get(clientArg);
|
|
483
|
+
if (!record) { record = { failed: false }; this.transportCleanups.set(clientArg, record); }
|
|
484
|
+
if (!record.promise) {
|
|
485
|
+
const captured = record;
|
|
486
|
+
record.promise = Promise.resolve().then(() => this.performClientClose(clientArg)).then(() => {
|
|
487
|
+
this.transportCleanups.delete(clientArg);
|
|
488
|
+
}, (error: unknown) => { captured.failed = true; throw error; });
|
|
489
|
+
}
|
|
490
|
+
return record.promise;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private async performClientClose(clientArg: plugins.mqtt.MqttClient): Promise<void> {
|
|
380
494
|
const closed = clientArg.stream.closed ? Promise.resolve() : new Promise<void>((resolve) => clientArg.once('close', resolve));
|
|
381
|
-
await clientArg.endAsync(true);
|
|
382
|
-
|
|
495
|
+
const results = await Promise.allSettled([clientArg.endAsync(true), closed]);
|
|
496
|
+
const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason as unknown] : []);
|
|
497
|
+
if (failures.length) throw new AggregateError(failures, 'MQTT transport cleanup failed.');
|
|
383
498
|
}
|
|
384
499
|
|
|
385
500
|
private bounded<TValue>(promiseArg: Promise<TValue>, timeoutMsArg: number, abortSignalArg?: AbortSignal, cancelArg?: () => void): Promise<TValue> {
|
|
@@ -419,8 +534,10 @@ export class MqttClient {
|
|
|
419
534
|
this.pendingOperations.clear();
|
|
420
535
|
}
|
|
421
536
|
|
|
422
|
-
private emitState(stateArg: IMqttConnectionState): void {
|
|
423
|
-
|
|
537
|
+
private emitState(stateArg: IMqttConnectionState, handlersArg: Iterable<TMqttStateHandler> = this.stateHandlers): void {
|
|
538
|
+
const failures: unknown[] = [];
|
|
539
|
+
for (const handler of handlersArg) { try { handler(stateArg); } catch (error) { failures.push(error); } }
|
|
540
|
+
if (failures.length) throw new AggregateError(failures, 'MQTT connection callbacks failed.');
|
|
424
541
|
}
|
|
425
542
|
|
|
426
543
|
private requireClient(): plugins.mqtt.MqttClient {
|
|
@@ -1,48 +1,61 @@
|
|
|
1
|
+
import { NativeIntegrationOwner, type NativeIntegrationScope } from '../../core/classes.nativeintegrationowner.js';
|
|
2
|
+
import type { INativeAdmissionContext } from '../../core/classes.nativehomeadmission.js';
|
|
1
3
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
|
2
4
|
import type { IMqttConfig } from './mqtt.types.js';
|
|
5
|
+
import { mqttRecord, normalizeMqttIntegrationConfig } from './mqtt.validation.js';
|
|
3
6
|
|
|
4
7
|
export class MqttConfigFlow implements IConfigFlow<IMqttConfig> {
|
|
8
|
+
readonly #scope: NativeIntegrationScope;
|
|
9
|
+
|
|
10
|
+
constructor(ownerArg: NativeIntegrationOwner = new NativeIntegrationOwner('mqtt')) {
|
|
11
|
+
this.#scope = ownerArg.createFlowScope();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public destroy(): Promise<void> { return this.#scope.close(); }
|
|
15
|
+
public retryCleanup(): Promise<void> { return this.#scope.retryCleanup(); }
|
|
16
|
+
|
|
5
17
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IMqttConfig>> {
|
|
6
|
-
|
|
18
|
+
if (candidateArg.integrationDomain !== undefined && candidateArg.integrationDomain !== 'mqtt') return { kind: 'error', error: 'MQTT requires an MQTT candidate.' };
|
|
19
|
+
let context: INativeAdmissionContext;
|
|
20
|
+
try { context = this.#scope.admit(contextArg); }
|
|
21
|
+
catch { return { kind: 'error', error: 'MQTT configuration requires the explicit matching host home and an open flow.' }; }
|
|
22
|
+
const host = typeof candidateArg.host === 'string' ? candidateArg.host : undefined;
|
|
7
23
|
return {
|
|
8
|
-
kind: 'form',
|
|
9
|
-
|
|
10
|
-
description: 'Configure a local MQTT 3.1.1 broker and Home Assistant-style MQTT discovery.',
|
|
24
|
+
kind: 'form', title: 'Connect selected MQTT broker',
|
|
25
|
+
description: 'Use a caller-owned credential reference. Selected topic observations are read-only; discovery mode uses the configured MQTT discovery schema.',
|
|
11
26
|
fields: [
|
|
12
|
-
{ name: 'host', label: '
|
|
27
|
+
{ name: 'host', label: 'Selected broker host', type: 'text', required: true },
|
|
13
28
|
{ name: 'port', label: 'Broker port', type: 'number' },
|
|
14
|
-
{ name: 'protocol', label: 'Protocol', type: 'select', options: [{ label: '
|
|
15
|
-
{ name: '
|
|
16
|
-
{ name: '
|
|
29
|
+
{ name: 'protocol', label: 'Protocol', type: 'select', options: [{ label: 'MQTTS', value: 'mqtts' }, { label: 'MQTT', value: 'mqtt' }] },
|
|
30
|
+
{ name: 'mode', label: 'Mapping mode', type: 'select', options: [{ label: 'Selected topics', value: 'selected-topics' }, { label: 'MQTT discovery', value: 'discovery' }] },
|
|
31
|
+
{ name: 'mappings', label: 'Explicit observation mappings', type: 'text' },
|
|
32
|
+
{ name: 'credentialKey', label: 'Caller credential reference', type: 'text' },
|
|
33
|
+
{ name: 'allowUnencryptedLan', label: 'Consent to unencrypted local MQTT', type: 'boolean' },
|
|
17
34
|
{ name: 'clientId', label: 'Client ID', type: 'text' },
|
|
18
35
|
{ name: 'discoveryPrefix', label: 'Discovery prefix', type: 'text' },
|
|
19
36
|
],
|
|
20
|
-
submit:
|
|
21
|
-
const host = this.stringValue(valuesArg.host) || candidateArg.host;
|
|
22
|
-
const protocol = valuesArg.protocol === 'mqtts' ? 'mqtts' : 'mqtt';
|
|
23
|
-
const port = valuesArg.port ?? candidateArg.port ?? (protocol === 'mqtts' ? 8883 : 1883);
|
|
24
|
-
if (!host || typeof port !== 'number' || !Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
25
|
-
return { kind: 'error', error: 'A broker host and integer port from 1 through 65535 are required.' };
|
|
26
|
-
}
|
|
27
|
-
return {
|
|
28
|
-
kind: 'done',
|
|
29
|
-
title: 'MQTT configured',
|
|
30
|
-
config: {
|
|
31
|
-
host,
|
|
32
|
-
port,
|
|
33
|
-
protocol,
|
|
34
|
-
username: this.stringValue(valuesArg.username),
|
|
35
|
-
password: typeof valuesArg.password === 'string' ? valuesArg.password : undefined,
|
|
36
|
-
clientId: this.stringValue(valuesArg.clientId),
|
|
37
|
-
discovery: true,
|
|
38
|
-
discoveryPrefix: this.stringValue(valuesArg.discoveryPrefix) || 'homeassistant',
|
|
39
|
-
},
|
|
40
|
-
};
|
|
41
|
-
},
|
|
37
|
+
submit: (valuesArg) => this.#submit(valuesArg, context, host),
|
|
42
38
|
};
|
|
43
39
|
}
|
|
44
40
|
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
async #submit(valuesArg: Record<string, unknown>, contextArg: INativeAdmissionContext, candidateHostArg?: string): Promise<IConfigFlowStep<IMqttConfig>> {
|
|
42
|
+
let config: IMqttConfig & { homeId: string };
|
|
43
|
+
try {
|
|
44
|
+
const values = mqttRecord(valuesArg, ['homeId', 'host', 'port', 'protocol', 'mode', 'mappings', 'credentialKey',
|
|
45
|
+
'allowUnencryptedLan', 'clientId', 'discoveryPrefix', 'connectTimeoutMs', 'operationTimeoutMs']);
|
|
46
|
+
if (values.homeId !== undefined && values.homeId !== contextArg.homeId) throw new TypeError('MQTT form home conflicts with the host home.');
|
|
47
|
+
const mappings = typeof values.mappings === 'string' ? JSON.parse(values.mappings) as unknown : values.mappings;
|
|
48
|
+
config = normalizeMqttIntegrationConfig({ ...values, mappings, host: values.host ?? candidateHostArg,
|
|
49
|
+
protocol: values.protocol ?? 'mqtts', mode: values.mode ?? 'discovery', homeId: contextArg.homeId });
|
|
50
|
+
} catch { return { kind: 'error', error: 'MQTT values, exact topic mappings or host home are invalid. Supply credential references rather than secrets.' }; }
|
|
51
|
+
try {
|
|
52
|
+
return await this.#scope.run(contextArg, config.homeId, async (leaseArg): Promise<IConfigFlowStep<IMqttConfig>> => {
|
|
53
|
+
leaseArg.assertOpen();
|
|
54
|
+
return { kind: 'done', title: 'MQTT reference configuration prepared', config };
|
|
55
|
+
});
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error instanceof AggregateError) throw error;
|
|
58
|
+
return { kind: 'error', error: 'MQTT configuration flow is closed or cancelled.' };
|
|
59
|
+
}
|
|
47
60
|
}
|
|
48
61
|
}
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
2
|
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
|
3
|
+
import { NativeIntegrationOwner, type INativeSetupLease } from '../../core/classes.nativeintegrationowner.js';
|
|
3
4
|
import type { IDeviceFeatureWriteRequest, IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
|
4
5
|
import { MqttClient } from './mqtt.classes.client.js';
|
|
5
6
|
import { MqttConfigFlow } from './mqtt.classes.configflow.js';
|
|
6
7
|
import { createMqttDiscoveryDescriptor } from './mqtt.discovery.js';
|
|
7
8
|
import { MqttMapper } from './mqtt.mapper.js';
|
|
8
|
-
import
|
|
9
|
+
import { MqttSession } from './mqtt.classes.session.js';
|
|
10
|
+
import { normalizeMqttIntegrationConfig } from './mqtt.validation.js';
|
|
11
|
+
import type { IMqttConfig, IMqttIntegrationOptions, IMqttPublishOptions } from './mqtt.types.js';
|
|
9
12
|
|
|
10
13
|
export class MqttIntegration extends BaseIntegration<IMqttConfig> {
|
|
11
14
|
public readonly domain = 'mqtt';
|
|
12
15
|
public readonly displayName = 'MQTT';
|
|
13
16
|
public readonly status = 'control-runtime' as const;
|
|
14
17
|
public readonly discoveryDescriptor = createMqttDiscoveryDescriptor();
|
|
15
|
-
public readonly configFlow
|
|
18
|
+
public readonly configFlow: MqttConfigFlow;
|
|
19
|
+
readonly #owner: NativeIntegrationOwner;
|
|
20
|
+
readonly #options: IMqttIntegrationOptions;
|
|
16
21
|
public readonly metadata = {
|
|
17
22
|
source: 'home-assistant/core',
|
|
18
23
|
upstreamPath: 'homeassistant/components/mqtt',
|
|
@@ -22,20 +27,31 @@ export class MqttIntegration extends BaseIntegration<IMqttConfig> {
|
|
|
22
27
|
qualityScale: 'platinum',
|
|
23
28
|
};
|
|
24
29
|
|
|
30
|
+
constructor(optionsArg: IMqttIntegrationOptions = {}) {
|
|
31
|
+
super();
|
|
32
|
+
this.#owner = new NativeIntegrationOwner('mqtt', { homeId: optionsArg.homeId, capacity: optionsArg.capacity });
|
|
33
|
+
this.#options = Object.freeze({ resolveCredentials: optionsArg.resolveCredentials });
|
|
34
|
+
this.configFlow = new MqttConfigFlow(this.#owner);
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
public async setup(configArg: IMqttConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
const config = normalizeMqttIntegrationConfig(configArg);
|
|
39
|
+
return this.#owner.runSetup(contextArg, config.homeId, async (leaseArg) => {
|
|
40
|
+
const session = new MqttSession(config, leaseArg, this.#options.resolveCredentials);
|
|
41
|
+
let runtime: MqttRuntime | undefined;
|
|
42
|
+
let cleanupAttempts = 0;
|
|
43
|
+
const closeSession = (): Promise<void> => cleanupAttempts++ === 0 ? session.close() : session.retryCleanup();
|
|
44
|
+
leaseArg.own(() => runtime ? runtime.cleanup() : closeSession());
|
|
45
|
+
const client = await session.start();
|
|
46
|
+
leaseArg.assertOpen();
|
|
47
|
+
runtime = new MqttRuntime(client, config, leaseArg, closeSession);
|
|
48
|
+
leaseArg.promoteToRuntime();
|
|
30
49
|
return runtime;
|
|
31
|
-
}
|
|
32
|
-
try { await runtime.destroy(); }
|
|
33
|
-
catch (cleanupError) { throw new AggregateError([error, cleanupError], 'MQTT setup and cleanup failed.'); }
|
|
34
|
-
throw error;
|
|
35
|
-
}
|
|
50
|
+
});
|
|
36
51
|
}
|
|
37
52
|
|
|
38
|
-
public
|
|
53
|
+
public destroy(): Promise<void> { return this.#owner.close(); }
|
|
54
|
+
public retryCleanup(): Promise<void> { return this.#owner.retryCleanup(); }
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
export class HomeAssistantMqttIntegration extends MqttIntegration {}
|
|
@@ -51,25 +67,30 @@ class MqttRuntime implements IIntegrationRuntime {
|
|
|
51
67
|
private updateRequested = false;
|
|
52
68
|
private updating = false;
|
|
53
69
|
private destroyed = false;
|
|
70
|
+
private readonly operations = new Set<Promise<unknown>>();
|
|
71
|
+
private readonly failures = new Set<unknown>();
|
|
72
|
+
private readonly ownerAbort = (): void => { this.destroyed = true; };
|
|
54
73
|
|
|
55
|
-
constructor(private readonly client: MqttClient
|
|
74
|
+
constructor(private readonly client: MqttClient, private readonly config: IMqttConfig,
|
|
75
|
+
private readonly lease: INativeSetupLease, private readonly closeSession: () => Promise<void>) {
|
|
56
76
|
this.removeMessageHandler = client.onMessage(() => this.queueUpdate());
|
|
57
77
|
this.removeConnectionHandler = client.onConnectionState((stateArg) => {
|
|
58
78
|
if (stateArg.error) this.emit({ type: 'error', integrationDomain: this.domain, data: { message: stateArg.error.message }, timestamp: Date.now() });
|
|
59
79
|
this.queueUpdate();
|
|
60
80
|
});
|
|
81
|
+
lease.signal.addEventListener('abort', this.ownerAbort, { once: true });
|
|
61
82
|
}
|
|
62
83
|
|
|
63
84
|
public isHealthy(): boolean {
|
|
64
|
-
return this.client.isHealthy();
|
|
85
|
+
return !this.destroyed && this.client.isHealthy();
|
|
65
86
|
}
|
|
66
87
|
|
|
67
88
|
public async devices(abortSignalArg?: AbortSignal): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
|
|
68
|
-
return MqttMapper.toDevices(await this.client.getSnapshot(abortSignalArg));
|
|
89
|
+
return this.operation(async () => MqttMapper.toDevices(await this.client.getSnapshot(this.signal(abortSignalArg))));
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
public async entities(): Promise<IIntegrationEntity[]> {
|
|
72
|
-
return MqttMapper.toEntities(await this.client.getSnapshot());
|
|
93
|
+
return this.operation(async () => MqttMapper.toEntities(await this.client.getSnapshot(this.signal())));
|
|
73
94
|
}
|
|
74
95
|
|
|
75
96
|
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void, abortSignalArg?: AbortSignal): Promise<() => Promise<void>> {
|
|
@@ -92,35 +113,64 @@ class MqttRuntime implements IIntegrationRuntime {
|
|
|
92
113
|
}
|
|
93
114
|
|
|
94
115
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
116
|
+
if (this.config.mode === 'selected-topics') return { success: false, error: 'Selected MQTT topic mappings are read-only.' };
|
|
117
|
+
return this.operation(async () => {
|
|
118
|
+
try {
|
|
119
|
+
let command: IMqttPublishOptions | undefined;
|
|
120
|
+
if (requestArg.domain === 'mqtt') {
|
|
121
|
+
if (requestArg.service !== 'publish') return { success: false, error: 'Unsupported MQTT broker service.' };
|
|
122
|
+
command = this.publishOptionsFromRequest(requestArg);
|
|
123
|
+
} else command = MqttMapper.commandForService(await this.client.getSnapshot(), requestArg);
|
|
124
|
+
if (!command) return { success: false, error: 'MQTT service requires an available, unambiguous supported target and valid input.' };
|
|
125
|
+
await this.client.publish(command, this.signal());
|
|
126
|
+
return { success: true, data: { transportAccepted: true, brokerAcknowledged: command.qos === 1 || command.qos === 2, physicalOutcomeConfirmed: false } };
|
|
127
|
+
} catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; }
|
|
128
|
+
});
|
|
105
129
|
}
|
|
106
130
|
|
|
107
131
|
public async writeFeature(requestArg: IDeviceFeatureWriteRequest, abortSignalArg?: AbortSignal): Promise<IServiceCallResult> {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
132
|
+
if (this.config.mode === 'selected-topics') return { success: false, error: 'Selected MQTT topic mappings are read-only.' };
|
|
133
|
+
return this.operation(async () => {
|
|
134
|
+
try {
|
|
135
|
+
const signal = this.signal(abortSignalArg);
|
|
136
|
+
const command = MqttMapper.commandForFeature(await this.client.getSnapshot(signal), requestArg);
|
|
137
|
+
if (!command) return { success: false, error: 'MQTT feature requires an available, unambiguous supported target and valid value.' };
|
|
138
|
+
await this.client.publish(command, signal);
|
|
139
|
+
return { success: true, data: { transportAccepted: true, brokerAcknowledged: command.qos === 1 || command.qos === 2, physicalOutcomeConfirmed: false } };
|
|
140
|
+
} catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; }
|
|
141
|
+
});
|
|
114
142
|
}
|
|
115
143
|
|
|
116
|
-
public
|
|
144
|
+
public destroy(): Promise<void> { return this.lease.close(); }
|
|
145
|
+
|
|
146
|
+
public async cleanup(): Promise<void> {
|
|
117
147
|
this.destroyed = true;
|
|
118
148
|
this.removeMessageHandler();
|
|
119
149
|
this.removeConnectionHandler();
|
|
120
|
-
|
|
150
|
+
const cleanups = [...this.subscriptionCleanups];
|
|
121
151
|
this.handlers.clear();
|
|
122
|
-
|
|
123
|
-
await
|
|
152
|
+
this.lease.signal.removeEventListener('abort', this.ownerAbort);
|
|
153
|
+
const results = await Promise.allSettled([
|
|
154
|
+
...cleanups.map((cleanup) => Promise.resolve().then(cleanup)), this.closeSession(), this.updatePromise,
|
|
155
|
+
...[...this.operations].map((operation) => operation.then(() => undefined, () => undefined)),
|
|
156
|
+
]);
|
|
157
|
+
const failures = [...this.failures, ...results.flatMap((result) => result.status === 'rejected' ? [result.reason as unknown] : [])];
|
|
158
|
+
this.failures.clear();
|
|
159
|
+
if (failures.length) throw new AggregateError(failures, 'MQTT runtime cleanup failed; retry cleanup explicitly.');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private signal(signalArg?: AbortSignal): AbortSignal {
|
|
163
|
+
this.lease.assertOpen();
|
|
164
|
+
if (this.destroyed) throw new Error('MQTT runtime has been destroyed.');
|
|
165
|
+
return signalArg ? AbortSignal.any([this.lease.signal, signalArg]) : this.lease.signal;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private operation<TValue>(workArg: () => Promise<TValue>): Promise<TValue> {
|
|
169
|
+
this.signal();
|
|
170
|
+
const work = Promise.resolve().then(workArg);
|
|
171
|
+
this.operations.add(work);
|
|
172
|
+
void work.then(() => { this.operations.delete(work); }, () => { this.operations.delete(work); });
|
|
173
|
+
return work;
|
|
124
174
|
}
|
|
125
175
|
|
|
126
176
|
private queueUpdate(): void {
|
|
@@ -129,7 +179,16 @@ class MqttRuntime implements IIntegrationRuntime {
|
|
|
129
179
|
if (this.updating) return;
|
|
130
180
|
this.updating = true;
|
|
131
181
|
this.updatePromise = this.updateSnapshots().catch((error: unknown) => {
|
|
132
|
-
if (!this.destroyed
|
|
182
|
+
if (!this.destroyed || error instanceof AggregateError) this.failures.add(error);
|
|
183
|
+
if (!this.destroyed) {
|
|
184
|
+
try { this.emit({ type: 'error', integrationDomain: this.domain, data: { message: error instanceof Error ? error.message : String(error) }, timestamp: Date.now() }); }
|
|
185
|
+
catch (callbackError) { this.failures.add(callbackError); }
|
|
186
|
+
}
|
|
187
|
+
if (this.failures.size) {
|
|
188
|
+
this.destroyed = true;
|
|
189
|
+
// Stop admitting updates after a delivery failure; the retained lease owns reporting/retry.
|
|
190
|
+
void this.lease.close().catch(() => undefined);
|
|
191
|
+
}
|
|
133
192
|
}).finally(() => {
|
|
134
193
|
this.updating = false;
|
|
135
194
|
if (this.updateRequested && !this.destroyed) this.queueUpdate();
|
|
@@ -158,7 +217,9 @@ class MqttRuntime implements IIntegrationRuntime {
|
|
|
158
217
|
}
|
|
159
218
|
|
|
160
219
|
private emit(eventArg: IIntegrationEvent): void {
|
|
161
|
-
|
|
220
|
+
const failures: unknown[] = [];
|
|
221
|
+
for (const handler of this.handlers) { try { handler(eventArg); } catch (error) { failures.push(error); } }
|
|
222
|
+
if (failures.length) throw new AggregateError(failures, 'MQTT event callbacks failed.');
|
|
162
223
|
}
|
|
163
224
|
|
|
164
225
|
private publishOptionsFromRequest(requestArg: IServiceCallRequest): IMqttPublishOptions | undefined {
|