@smarthome.exchange/integrations 2.0.0 → 3.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 +35 -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 +6 -1
- package/dist_ts/integrations/hue/hue.classes.client.js +120 -45
- package/dist_ts/integrations/hue/hue.classes.configflow.js +16 -7
- package/dist_ts/integrations/hue/hue.classes.integration.d.ts +2 -0
- package/dist_ts/integrations/hue/hue.classes.integration.js +103 -30
- package/dist_ts/integrations/hue/hue.validation.d.ts +1 -0
- package/dist_ts/integrations/hue/hue.validation.js +14 -4
- 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 +8 -8
- 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 +110 -41
- package/ts/integrations/hue/hue.classes.configflow.ts +12 -6
- package/ts/integrations/hue/hue.classes.integration.ts +92 -26
- package/ts/integrations/hue/hue.validation.ts +13 -3
- package/ts/integrations/hue/readme.md +3 -3
- 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,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 {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { INativeOperationLease } from '../../core/classes.nativeintegrationowner.js';
|
|
2
|
+
import { MqttClient } from './mqtt.classes.client.js';
|
|
3
|
+
import type { IMqttConfig, IMqttTransportCredentials, TMqttCredentialResolver } from './mqtt.types.js';
|
|
4
|
+
import { mqttCredentials } from './mqtt.validation.js';
|
|
5
|
+
|
|
6
|
+
/** Owns actual caller credential work and the resulting transport, without owning the provider. */
|
|
7
|
+
export class MqttSession {
|
|
8
|
+
readonly #config: IMqttConfig & { homeId: string };
|
|
9
|
+
readonly #lease: INativeOperationLease;
|
|
10
|
+
readonly #resolver?: TMqttCredentialResolver;
|
|
11
|
+
readonly #lifetime = new AbortController();
|
|
12
|
+
readonly #signal: AbortSignal;
|
|
13
|
+
#provider?: Promise<IMqttTransportCredentials>;
|
|
14
|
+
#work?: Promise<MqttClient>;
|
|
15
|
+
#client?: MqttClient;
|
|
16
|
+
#closed = false;
|
|
17
|
+
#closePromise?: Promise<void>;
|
|
18
|
+
#retryPromise?: Promise<void>;
|
|
19
|
+
#providerDrained = false;
|
|
20
|
+
#transportClosed = false;
|
|
21
|
+
#transportCloseAttempted = false;
|
|
22
|
+
|
|
23
|
+
constructor(configArg: IMqttConfig & { homeId: string }, leaseArg: INativeOperationLease, resolverArg?: TMqttCredentialResolver) {
|
|
24
|
+
this.#config = configArg; this.#lease = leaseArg; this.#resolver = resolverArg;
|
|
25
|
+
this.#signal = AbortSignal.any([leaseArg.signal, this.#lifetime.signal]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public start(): Promise<MqttClient> {
|
|
29
|
+
if (!this.#work) this.#work = Promise.resolve().then(() => this.#start());
|
|
30
|
+
return this.#work;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public close(): Promise<void> {
|
|
34
|
+
if (!this.#closePromise) {
|
|
35
|
+
this.#closed = true;
|
|
36
|
+
this.#closePromise = Promise.resolve().then(() => this.#drain());
|
|
37
|
+
this.#lifetime.abort(new Error('MQTT session is closed.'));
|
|
38
|
+
}
|
|
39
|
+
return this.#closePromise;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public retryCleanup(): Promise<void> {
|
|
43
|
+
if (!this.#closed) return Promise.reject(new Error('MQTT cleanup can only be retried after close.'));
|
|
44
|
+
if (!this.#retryPromise) {
|
|
45
|
+
const retry = Promise.resolve().then(() => this.#drain());
|
|
46
|
+
this.#retryPromise = retry;
|
|
47
|
+
void retry.then(() => { this.#retryPromise = undefined; }, () => { this.#retryPromise = undefined; });
|
|
48
|
+
}
|
|
49
|
+
return this.#retryPromise;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#assertOpen(): void {
|
|
53
|
+
this.#lease.assertOpen();
|
|
54
|
+
this.#signal.throwIfAborted();
|
|
55
|
+
if (this.#closed) throw new Error('MQTT session is closed.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async #start(): Promise<MqttClient> {
|
|
59
|
+
this.#assertOpen();
|
|
60
|
+
const deadline = performance.now() + (this.#config.connectTimeoutMs ?? 7000);
|
|
61
|
+
let credentials: IMqttTransportCredentials = {};
|
|
62
|
+
if (this.#config.credentialKey) {
|
|
63
|
+
if (!this.#resolver) throw new Error('MQTT credential reference requires a caller resolver.');
|
|
64
|
+
const resolver = this.#resolver;
|
|
65
|
+
const request = Object.freeze({ homeId: this.#lease.context.homeId, credentialKey: this.#config.credentialKey,
|
|
66
|
+
signal: this.#signal, deadline });
|
|
67
|
+
// The microtask enters foreign code only after its actual promise is retained here.
|
|
68
|
+
this.#provider = Promise.resolve().then(() => {
|
|
69
|
+
this.#assertOpen();
|
|
70
|
+
return resolver(request);
|
|
71
|
+
}).then((value) => mqttCredentials(value), () => { throw new Error('MQTT caller credential resolution failed.'); });
|
|
72
|
+
const provider = this.#provider;
|
|
73
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
74
|
+
let abort: () => void = () => {};
|
|
75
|
+
const cancellation = new Promise<never>((_resolve, reject) => {
|
|
76
|
+
abort = () => reject(this.#signal.reason as unknown);
|
|
77
|
+
timer = setTimeout(() => {
|
|
78
|
+
const error = new Error('MQTT credential resolution timed out.');
|
|
79
|
+
reject(error);
|
|
80
|
+
this.#lifetime.abort(error);
|
|
81
|
+
}, Math.max(1, deadline - performance.now()));
|
|
82
|
+
this.#signal.addEventListener('abort', abort, { once: true });
|
|
83
|
+
if (this.#signal.aborted) abort();
|
|
84
|
+
});
|
|
85
|
+
try { credentials = await Promise.race([provider, cancellation]); }
|
|
86
|
+
finally { if (timer) clearTimeout(timer); this.#signal.removeEventListener('abort', abort); }
|
|
87
|
+
}
|
|
88
|
+
this.#assertOpen();
|
|
89
|
+
const remaining = Math.floor(deadline - performance.now());
|
|
90
|
+
if (remaining < 1) throw new Error('MQTT setup timed out.');
|
|
91
|
+
const client = new MqttClient({ ...this.#config, connectTimeoutMs: remaining }, credentials);
|
|
92
|
+
this.#client = client;
|
|
93
|
+
// The session is already the lease's owned cleanup before this allocation and start.
|
|
94
|
+
try { await client.start(this.#signal); }
|
|
95
|
+
catch (error) { if (this.#signal.aborted) throw this.#signal.reason as unknown; throw error; }
|
|
96
|
+
this.#assertOpen();
|
|
97
|
+
return client;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async #drain(): Promise<void> {
|
|
101
|
+
const provider = this.#provider;
|
|
102
|
+
const client = this.#client;
|
|
103
|
+
const results = await Promise.allSettled([
|
|
104
|
+
...(provider && !this.#providerDrained ? [provider.then(() => { this.#providerDrained = true; }, () => {
|
|
105
|
+
this.#providerDrained = true;
|
|
106
|
+
throw new Error('MQTT caller credential work failed while draining.');
|
|
107
|
+
})] : []),
|
|
108
|
+
...(client && !this.#transportClosed ? [Promise.resolve().then(() => {
|
|
109
|
+
const retry = this.#transportCloseAttempted;
|
|
110
|
+
this.#transportCloseAttempted = true;
|
|
111
|
+
return retry ? client.retryCleanup() : client.destroy();
|
|
112
|
+
}).then(() => { this.#transportClosed = true; })] : []),
|
|
113
|
+
...(this.#work ? [this.#work.then(() => undefined, () => undefined)] : []),
|
|
114
|
+
]);
|
|
115
|
+
const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason as unknown] : []);
|
|
116
|
+
if (failures.length) throw new AggregateError(failures, 'MQTT session cleanup failed; retry cleanup explicitly.');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
2
|
import type { IDeviceFeatureWriteRequest, IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
|
3
|
-
import type { IMqttConfig, IMqttDeviceInfo, IMqttEntityConfig, IMqttMessage, IMqttPayload, IMqttPublishOptions, IMqttSnapshot } from './mqtt.types.js';
|
|
3
|
+
import type { IMqttConfig, IMqttDeviceInfo, IMqttEntityConfig, IMqttMessage, IMqttPayload, IMqttPublishOptions, IMqttSnapshot, IMqttTopicMapping } from './mqtt.types.js';
|
|
4
4
|
|
|
5
5
|
const defaultDiscoveryPrefix = 'homeassistant';
|
|
6
6
|
const defaultPayloadOn = 'ON';
|
|
@@ -68,6 +68,8 @@ export class MqttMapper {
|
|
|
68
68
|
...(configArg.discovery === false ? [] : this.entitiesFromDiscoveryMessages(messages, configArg.discoveryPrefix || defaultDiscoveryPrefix)),
|
|
69
69
|
];
|
|
70
70
|
return {
|
|
71
|
+
homeId: configArg.homeId,
|
|
72
|
+
selectedMappings: configArg.mode === 'selected-topics' ? structuredClone(configArg.mappings ?? []) : undefined,
|
|
71
73
|
brokerInfo: {
|
|
72
74
|
host: configArg.host,
|
|
73
75
|
port: configArg.port || (configArg.protocol === 'mqtts' ? 8883 : 1883),
|
|
@@ -82,6 +84,7 @@ export class MqttMapper {
|
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
public static toDevices(snapshotArg: IMqttSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
|
87
|
+
if (snapshotArg.selectedMappings) return this.selectedDevices(snapshotArg);
|
|
85
88
|
const updatedAt = new Date().toISOString();
|
|
86
89
|
const devices = new Map<string, plugins.shxInterfaces.data.IDeviceDefinition>();
|
|
87
90
|
const brokerId = this.brokerDeviceId(snapshotArg);
|
|
@@ -145,6 +148,13 @@ export class MqttMapper {
|
|
|
145
148
|
}
|
|
146
149
|
|
|
147
150
|
public static toEntities(snapshotArg: IMqttSnapshot): IIntegrationEntity[] {
|
|
151
|
+
if (snapshotArg.selectedMappings) return snapshotArg.selectedMappings.map((mappingArg) => ({
|
|
152
|
+
id: mappingArg.entityId, uniqueId: mappingArg.entityId, integrationDomain: 'mqtt', deviceId: mappingArg.deviceId,
|
|
153
|
+
platform: 'sensor', name: mappingArg.name, state: this.selectedValue(snapshotArg, mappingArg.stateTopic),
|
|
154
|
+
attributes: { homeId: snapshotArg.homeId, stateTopic: mappingArg.stateTopic, availabilityTopic: mappingArg.availabilityTopic,
|
|
155
|
+
observationFormat: 'raw-utf8', identitySource: 'owner-mapping' },
|
|
156
|
+
available: this.selectedAvailable(snapshotArg, mappingArg),
|
|
157
|
+
}));
|
|
148
158
|
return snapshotArg.entities.map((entityArg) => ({
|
|
149
159
|
id: entityArg.defaultEntityId || `${this.toEntityPlatform(entityArg.platform)}.${this.slug(entityArg.uniqueId || entityArg.objectId || entityArg.name || 'mqtt')}`,
|
|
150
160
|
uniqueId: this.uniqueId(entityArg),
|
|
@@ -168,6 +178,7 @@ export class MqttMapper {
|
|
|
168
178
|
}
|
|
169
179
|
|
|
170
180
|
public static commandForService(snapshotArg: IMqttSnapshot, requestArg: IServiceCallRequest): IMqttPublishOptions | undefined {
|
|
181
|
+
if (snapshotArg.selectedMappings) return undefined;
|
|
171
182
|
const entity = this.findTargetEntity(snapshotArg, requestArg);
|
|
172
183
|
if (entity && requestArg.target.deviceId && this.deviceIdForEntity(entity, snapshotArg) !== requestArg.target.deviceId) return undefined;
|
|
173
184
|
if (!entity?.commandTopic || requestArg.domain !== entity.platform || !this.controlSupported(entity) || !this.entityAvailable(entity, snapshotArg)) {
|
|
@@ -209,6 +220,7 @@ export class MqttMapper {
|
|
|
209
220
|
}
|
|
210
221
|
|
|
211
222
|
public static commandForFeature(snapshotArg: IMqttSnapshot, requestArg: IDeviceFeatureWriteRequest): IMqttPublishOptions | undefined {
|
|
223
|
+
if (snapshotArg.selectedMappings) return undefined;
|
|
212
224
|
const entities = snapshotArg.entities.filter((entityArg) =>
|
|
213
225
|
this.deviceIdForEntity(entityArg, snapshotArg) === requestArg.device.id &&
|
|
214
226
|
this.featureForEntity(entityArg).id === requestArg.feature.id
|
|
@@ -584,6 +596,36 @@ export class MqttMapper {
|
|
|
584
596
|
return Array.isArray(valueArg) ? JSON.stringify(valueArg) : valueArg as plugins.shxInterfaces.data.TDeviceStateValue;
|
|
585
597
|
}
|
|
586
598
|
|
|
599
|
+
private static selectedValue(snapshotArg: IMqttSnapshot, topicArg: string): string | null {
|
|
600
|
+
const value = snapshotArg.messages.find((messageArg) => messageArg.topic === topicArg)?.payload;
|
|
601
|
+
return typeof value === 'string' ? value : null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private static selectedAvailable(snapshotArg: IMqttSnapshot, mappingArg: IMqttTopicMapping): boolean {
|
|
605
|
+
return snapshotArg.brokerInfo.connected && this.selectedValue(snapshotArg, mappingArg.stateTopic) !== null
|
|
606
|
+
&& (!mappingArg.availabilityTopic
|
|
607
|
+
|| this.selectedValue(snapshotArg, mappingArg.availabilityTopic) === (mappingArg.payloadAvailable ?? 'online'));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private static selectedDevices(snapshotArg: IMqttSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
|
611
|
+
const devices = new Map<string, plugins.shxInterfaces.data.IDeviceDefinition>();
|
|
612
|
+
for (const mapping of snapshotArg.selectedMappings ?? []) {
|
|
613
|
+
let device = devices.get(mapping.deviceId);
|
|
614
|
+
if (!device) {
|
|
615
|
+
device = { id: mapping.deviceId, name: mapping.deviceName, integrationDomain: 'mqtt', protocol: 'mqtt',
|
|
616
|
+
online: false, features: [], state: [], metadata: { homeId: snapshotArg.homeId, identitySource: 'owner-mapping',
|
|
617
|
+
brokerHost: snapshotArg.brokerInfo.host, physicalIdentityConfirmed: false } };
|
|
618
|
+
devices.set(mapping.deviceId, device);
|
|
619
|
+
}
|
|
620
|
+
device.online ||= this.selectedAvailable(snapshotArg, mapping);
|
|
621
|
+
device.features.push({ id: mapping.featureId, name: mapping.name, capability: 'sensor', readable: true, writable: false });
|
|
622
|
+
const message = snapshotArg.messages.find((itemArg) => itemArg.topic === mapping.stateTopic);
|
|
623
|
+
device.state.push({ featureId: mapping.featureId, value: this.selectedValue(snapshotArg, mapping.stateTopic),
|
|
624
|
+
updatedAt: new Date(message?.timestamp ?? Date.now()).toISOString() });
|
|
625
|
+
}
|
|
626
|
+
return [...devices.values()];
|
|
627
|
+
}
|
|
628
|
+
|
|
587
629
|
private static stringValue(valueArg: unknown): string | undefined {
|
|
588
630
|
return typeof valueArg === 'string' && valueArg ? valueArg : undefined;
|
|
589
631
|
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { TEntityPlatform } from '../../core/types.js';
|
|
2
|
+
import type { INativeIntegrationOwnerOptions } from '../../core/classes.nativeintegrationowner.js';
|
|
2
3
|
|
|
3
4
|
export interface IMqttConfig {
|
|
5
|
+
/** Required by MqttIntegration; raw MqttClient remains a reusable transport. */
|
|
6
|
+
homeId?: string;
|
|
7
|
+
mode?: 'discovery' | 'selected-topics';
|
|
8
|
+
credentialKey?: string;
|
|
9
|
+
allowUnencryptedLan?: boolean;
|
|
10
|
+
mappings?: IMqttTopicMapping[];
|
|
4
11
|
host?: string;
|
|
5
12
|
port?: number;
|
|
6
13
|
protocol?: 'mqtt' | 'mqtts';
|
|
@@ -26,6 +33,49 @@ export interface IMqttConfig {
|
|
|
26
33
|
maxBufferedBytes?: number;
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
/** Owner-defined identity and exact topics; payloads are opaque UTF-8 observations. */
|
|
37
|
+
export interface IMqttTopicMapping {
|
|
38
|
+
deviceId: string;
|
|
39
|
+
entityId: string;
|
|
40
|
+
featureId: string;
|
|
41
|
+
name: string;
|
|
42
|
+
deviceName: string;
|
|
43
|
+
stateTopic: string;
|
|
44
|
+
availabilityTopic?: string;
|
|
45
|
+
payloadAvailable?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface IMqttSelectedConfig extends IMqttConfig {
|
|
49
|
+
homeId: string;
|
|
50
|
+
mode: 'selected-topics';
|
|
51
|
+
mappings: IMqttTopicMapping[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Runtime-only subset of the documented MQTT.js TCP/TLS options. No file paths. */
|
|
55
|
+
export interface IMqttTransportCredentials {
|
|
56
|
+
username?: string;
|
|
57
|
+
password?: string;
|
|
58
|
+
ca?: string;
|
|
59
|
+
cert?: string;
|
|
60
|
+
key?: string;
|
|
61
|
+
servername?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface IMqttCredentialRequest {
|
|
65
|
+
readonly homeId: string;
|
|
66
|
+
readonly credentialKey: string;
|
|
67
|
+
readonly signal: AbortSignal;
|
|
68
|
+
/** Absolute performance.now() deadline. */
|
|
69
|
+
readonly deadline: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The caller owns retrieval, storage and cancellation of this borrowed provider. */
|
|
73
|
+
export type TMqttCredentialResolver = (requestArg: IMqttCredentialRequest) => Promise<IMqttTransportCredentials>;
|
|
74
|
+
|
|
75
|
+
export interface IMqttIntegrationOptions extends INativeIntegrationOwnerOptions {
|
|
76
|
+
readonly resolveCredentials?: TMqttCredentialResolver;
|
|
77
|
+
}
|
|
78
|
+
|
|
29
79
|
export interface IMqttConnectionState {
|
|
30
80
|
connected: boolean;
|
|
31
81
|
error?: Error;
|
|
@@ -61,6 +111,8 @@ export interface IMqttSnapshot {
|
|
|
61
111
|
brokerInfo: IMqttBrokerInfo;
|
|
62
112
|
entities: IMqttEntityConfig[];
|
|
63
113
|
messages: IMqttMessage[];
|
|
114
|
+
homeId?: string;
|
|
115
|
+
selectedMappings?: IMqttTopicMapping[];
|
|
64
116
|
}
|
|
65
117
|
|
|
66
118
|
export interface IMqttDeviceInfo {
|