@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,10 +1,15 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
|
-
import type { IEsphomeClientCommand, IEsphomeCommandResult, IEsphomeConfig, IEsphomeEntityDescriptor, IEsphomeEntityState, IEsphomeEvent, IEsphomeSnapshot } from './esphome.types.js';
|
|
3
|
-
import {
|
|
2
|
+
import type { IEsphomeClientCommand, IEsphomeCommandResult, IEsphomeConfig, IEsphomeEntityDescriptor, IEsphomeEntityState, IEsphomeEvent, IEsphomeSnapshot, TEsphomeCredentials } from './esphome.types.js';
|
|
3
|
+
import { captureEsphomeConfig, captureEsphomeCredentials, esphomeMac } from './esphome.validation.js';
|
|
4
4
|
|
|
5
5
|
type TEsphomeEventHandler = (eventArg: IEsphomeEvent) => void;
|
|
6
6
|
type TNativeClient = plugins.esphomeNativeApi.EspHomeClient;
|
|
7
7
|
type TNativeEntity = plugins.esphomeNativeApi.Entity;
|
|
8
|
+
interface IEsphomeCleanup {
|
|
9
|
+
readonly cleanup: () => void;
|
|
10
|
+
failed: boolean;
|
|
11
|
+
promise?: Promise<void>;
|
|
12
|
+
}
|
|
8
13
|
|
|
9
14
|
const readablePlatforms = new Set(['light', 'switch', 'fan', 'cover', 'climate', 'number', 'select', 'text', 'sensor', 'binary_sensor', 'text_sensor']);
|
|
10
15
|
const writablePlatforms = new Set(['light', 'switch', 'fan', 'cover', 'number', 'select', 'text']);
|
|
@@ -12,12 +17,12 @@ const writablePlatforms = new Set(['light', 'switch', 'fan', 'cover', 'number',
|
|
|
12
17
|
export class EsphomeClient {
|
|
13
18
|
private readonly connection: EsphomeNativeApiConnection;
|
|
14
19
|
|
|
15
|
-
constructor(configArg: IEsphomeConfig, nativeClientArg?: TNativeClient) {
|
|
16
|
-
this.connection = new EsphomeNativeApiConnection(configArg, nativeClientArg);
|
|
20
|
+
constructor(configArg: IEsphomeConfig, nativeClientArg?: TNativeClient, credentialsArg: TEsphomeCredentials = {}) {
|
|
21
|
+
this.connection = new EsphomeNativeApiConnection(configArg, nativeClientArg, credentialsArg);
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
public
|
|
20
|
-
|
|
24
|
+
public connectLive(signalArg?: AbortSignal): Promise<void> {
|
|
25
|
+
return this.connection.connect(signalArg);
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
public isHealthy(): boolean {
|
|
@@ -28,16 +33,20 @@ export class EsphomeClient {
|
|
|
28
33
|
return this.connection.getSnapshot();
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
public onEvent(handlerArg: TEsphomeEventHandler): () => void {
|
|
32
|
-
return this.connection.onEvent(handlerArg);
|
|
36
|
+
public onEvent(handlerArg: TEsphomeEventHandler, signalArg?: AbortSignal): () => void {
|
|
37
|
+
return this.connection.onEvent(handlerArg, signalArg);
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
public async sendCommand(commandArg: IEsphomeClientCommand, signalArg?: AbortSignal): Promise<IEsphomeCommandResult> {
|
|
36
41
|
return this.connection.sendCommand(commandArg, signalArg);
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
public
|
|
40
|
-
|
|
44
|
+
public destroy(): Promise<void> {
|
|
45
|
+
return this.connection.destroy();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public retryCleanup(): Promise<void> {
|
|
49
|
+
return this.connection.retryCleanup();
|
|
41
50
|
}
|
|
42
51
|
}
|
|
43
52
|
|
|
@@ -48,83 +57,88 @@ export class EsphomeNativeApiConnection {
|
|
|
48
57
|
private readonly setupTimeoutMs: number;
|
|
49
58
|
private readonly commandTimeoutMs: number;
|
|
50
59
|
private readonly requireEncryption: boolean;
|
|
60
|
+
private readonly config: IEsphomeConfig;
|
|
51
61
|
private readonly handlers = new Set<TEsphomeEventHandler>();
|
|
52
|
-
private readonly
|
|
62
|
+
private readonly cleanup = new Set<IEsphomeCleanup>();
|
|
63
|
+
private readonly operations = new Set<Promise<unknown>>();
|
|
53
64
|
private readonly pendingCommands = new Set<string>();
|
|
54
65
|
private readonly lifetime = new AbortController();
|
|
55
66
|
private ready = false;
|
|
56
67
|
private connecting = false;
|
|
57
68
|
private destroyed = false;
|
|
69
|
+
private subscriptionsStarted = false;
|
|
70
|
+
private identityMismatch = false;
|
|
71
|
+
private closePromise?: Promise<void>;
|
|
72
|
+
private retryPromise?: Promise<void>;
|
|
58
73
|
|
|
59
|
-
constructor(configArg: IEsphomeConfig, nativeClientArg?: TNativeClient) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (configArg.manualEntries && configArg.manualEntries.length > 1) {
|
|
68
|
-
throw new Error('Configure each ESPHome native API device as a separate integration instance.');
|
|
69
|
-
}
|
|
70
|
-
this.host = (configArg.host || manual?.host || '').trim();
|
|
71
|
-
if (!this.host || /\s/.test(this.host)) {
|
|
72
|
-
throw new Error('ESPHome native API requires a valid host.');
|
|
73
|
-
}
|
|
74
|
-
this.port = configArg.port ?? manual?.port ?? esphomeDefaultNativeApiPort;
|
|
75
|
-
if (!Number.isInteger(this.port) || this.port < 1 || this.port > 65535) {
|
|
76
|
-
throw new Error('ESPHome native API port must be an integer from 1 through 65535.');
|
|
77
|
-
}
|
|
78
|
-
this.setupTimeoutMs = this.timeout(configArg.initialSnapshotTimeoutMs, 15000);
|
|
79
|
-
this.commandTimeoutMs = this.timeout(configArg.commandTimeoutMs, 5000);
|
|
80
|
-
const keys = [configArg.encryptionKey, configArg.noisePsk, manual?.encryptionKey, manual?.noisePsk].filter((valueArg): valueArg is string => valueArg !== undefined);
|
|
81
|
-
if (new Set(keys).size > 1) throw new Error('ESPHome encryption key configuration is conflicting.');
|
|
82
|
-
const key = keys[0];
|
|
83
|
-
if (key !== undefined && (!/^[A-Za-z0-9+/]{43}=$/.test(key) || Buffer.from(key, 'base64').length !== 32 || Buffer.from(key, 'base64').toString('base64') !== key)) {
|
|
84
|
-
throw new Error('ESPHome encryption key must be a canonical base64-encoded 32-byte key.');
|
|
85
|
-
}
|
|
86
|
-
this.requireEncryption = key !== undefined;
|
|
74
|
+
constructor(configArg: IEsphomeConfig, nativeClientArg?: TNativeClient, credentialsArg: TEsphomeCredentials = {}) {
|
|
75
|
+
this.config = captureEsphomeConfig(configArg);
|
|
76
|
+
const credentials = captureEsphomeCredentials(credentialsArg);
|
|
77
|
+
this.host = this.config.host;
|
|
78
|
+
this.port = this.config.port ?? 6053;
|
|
79
|
+
this.setupTimeoutMs = this.config.initialSnapshotTimeoutMs ?? 15000;
|
|
80
|
+
this.commandTimeoutMs = this.config.commandTimeoutMs ?? 5000;
|
|
81
|
+
this.requireEncryption = credentials.psk != null;
|
|
87
82
|
this.native = nativeClientArg ?? new plugins.esphomeNativeApi.EspHomeClient({
|
|
88
83
|
host: this.host,
|
|
89
84
|
port: this.port,
|
|
90
|
-
psk:
|
|
91
|
-
serverName:
|
|
85
|
+
psk: credentials.psk,
|
|
86
|
+
serverName: this.config.deviceName,
|
|
92
87
|
connectTimeoutMs: this.setupTimeoutMs,
|
|
93
88
|
});
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
89
|
+
// Construction is inert: own the native client before invoking any subscription callbacks.
|
|
90
|
+
this.cleanup.add({ cleanup: () => this.native.disconnect(), failed: false });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private startSubscriptions(): void {
|
|
94
|
+
if (this.subscriptionsStarted) return;
|
|
95
|
+
this.subscriptionsStarted = true;
|
|
96
|
+
const telemetry = this.native.on('telemetry', (eventArg) => {
|
|
97
|
+
this.refreshReadiness();
|
|
98
|
+
if (!this.identityMismatch) this.emit({ type: 'state_changed', entityId: this.eventEntityId(eventArg), data: eventArg });
|
|
99
|
+
});
|
|
100
|
+
this.cleanup.add({ cleanup: () => telemetry[Symbol.dispose](), failed: false });
|
|
101
|
+
this.lifetime.signal.throwIfAborted();
|
|
102
|
+
const health = this.native.onHealthChange((healthArg) => {
|
|
103
|
+
if (healthArg.state === 'connected' && this.requireEncryption && !healthArg.encrypted) {
|
|
104
|
+
this.ready = false;
|
|
105
|
+
this.native.disconnect();
|
|
106
|
+
this.emit({ type: 'error', data: { message: 'ESPHome encryption is required; the plaintext session was rejected.' } });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this.refreshReadiness();
|
|
110
|
+
this.emit({ type: 'availability_changed', data: { available: this.isHealthy() } });
|
|
111
|
+
});
|
|
112
|
+
this.cleanup.add({ cleanup: () => health[Symbol.dispose](), failed: false });
|
|
113
|
+
this.lifetime.signal.throwIfAborted();
|
|
110
114
|
}
|
|
111
115
|
|
|
112
|
-
public
|
|
113
|
-
if (this.destroyed || this.connecting || this.isHealthy())
|
|
116
|
+
public connect(signalArg?: AbortSignal): Promise<void> {
|
|
117
|
+
if (this.destroyed || this.connecting || this.isHealthy()) return Promise.reject(new Error('ESPHome connection is destroyed, already connecting, or already connected.'));
|
|
114
118
|
this.connecting = true;
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
119
|
+
const operation = this.track(async () => {
|
|
120
|
+
const signal = AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(this.setupTimeoutMs), ...(signalArg ? [signalArg] : [])]);
|
|
121
|
+
try {
|
|
122
|
+
signal.throwIfAborted();
|
|
123
|
+
this.startSubscriptions();
|
|
124
|
+
await this.native.connect({ signal });
|
|
125
|
+
if (this.requireEncryption && !this.native.health().encrypted) throw new Error('ESPHome encryption is required; the plaintext session was rejected.');
|
|
126
|
+
if (this.native.deviceInfo()?.usesPassword) throw new Error('ESPHome legacy API password authentication is unsupported.');
|
|
127
|
+
this.assertIdentity();
|
|
128
|
+
await this.waitForInitialStates(signal);
|
|
129
|
+
} catch {
|
|
130
|
+
// SDK diagnostics may contain credentials; do not expose their messages or causes.
|
|
131
|
+
throw new Error('ESPHome native setup failed or was cancelled.');
|
|
132
|
+
} finally {
|
|
133
|
+
this.connecting = false;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
// Join cleanup only after the tracked connect operation settles, so cleanup never awaits itself.
|
|
137
|
+
return operation.catch(async (errorArg: unknown) => {
|
|
138
|
+
try { await this.destroy(); }
|
|
139
|
+
catch (cleanupErrorArg) { throw new AggregateError([errorArg, cleanupErrorArg], 'ESPHome setup and owned cleanup failed.'); }
|
|
140
|
+
throw errorArg;
|
|
141
|
+
});
|
|
128
142
|
}
|
|
129
143
|
|
|
130
144
|
public isHealthy(): boolean {
|
|
@@ -132,9 +146,11 @@ export class EsphomeNativeApiConnection {
|
|
|
132
146
|
}
|
|
133
147
|
|
|
134
148
|
public getSnapshot(): IEsphomeSnapshot {
|
|
149
|
+
if (this.identityMismatch) throw new Error('ESPHome selected device identity changed; native observations are unavailable.');
|
|
135
150
|
const connected = this.isHealthy();
|
|
136
151
|
const entities = this.native.entitiesByDevice(undefined).filter((entityArg) => readablePlatforms.has(entityArg.type));
|
|
137
152
|
return {
|
|
153
|
+
homeId: this.config.homeId,
|
|
138
154
|
host: this.host,
|
|
139
155
|
port: this.port,
|
|
140
156
|
connected,
|
|
@@ -155,13 +171,31 @@ export class EsphomeNativeApiConnection {
|
|
|
155
171
|
};
|
|
156
172
|
}
|
|
157
173
|
|
|
158
|
-
public onEvent(handlerArg: TEsphomeEventHandler): () => void {
|
|
174
|
+
public onEvent(handlerArg: TEsphomeEventHandler, signalArg?: AbortSignal): () => void {
|
|
159
175
|
if (this.destroyed) throw new Error('ESPHome connection was destroyed.');
|
|
176
|
+
signalArg?.throwIfAborted();
|
|
177
|
+
let active = true;
|
|
178
|
+
const dispose = (): void => {
|
|
179
|
+
if (!active) return;
|
|
180
|
+
signalArg?.removeEventListener('abort', dispose);
|
|
181
|
+
this.handlers.delete(handlerArg);
|
|
182
|
+
active = false;
|
|
183
|
+
this.cleanup.delete(resource);
|
|
184
|
+
};
|
|
185
|
+
const resource: IEsphomeCleanup = { cleanup: dispose, failed: false };
|
|
186
|
+
// Own the handler and caller-signal listener before the foreign registration method.
|
|
187
|
+
this.cleanup.add(resource);
|
|
160
188
|
this.handlers.add(handlerArg);
|
|
161
|
-
|
|
189
|
+
signalArg?.addEventListener('abort', dispose, { once: true });
|
|
190
|
+
if (signalArg?.aborted) dispose();
|
|
191
|
+
return dispose;
|
|
162
192
|
}
|
|
163
193
|
|
|
164
|
-
public
|
|
194
|
+
public sendCommand(commandArg: IEsphomeClientCommand, signalArg?: AbortSignal): Promise<IEsphomeCommandResult> {
|
|
195
|
+
return this.track(() => this.executeTrackedCommand(commandArg, signalArg));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async executeTrackedCommand(commandArg: IEsphomeClientCommand, signalArg?: AbortSignal): Promise<IEsphomeCommandResult> {
|
|
165
199
|
signalArg?.throwIfAborted();
|
|
166
200
|
if (!this.isHealthy()) return { success: false, error: 'ESPHome is unavailable or has not received its authoritative initial states.' };
|
|
167
201
|
const entity = this.native.entitiesByDevice(undefined).find((entityArg) => entityArg.key === commandArg.key && entityArg.type === commandArg.platform && (entityArg.deviceId ?? 0) === (commandArg.deviceId ?? 0));
|
|
@@ -174,22 +208,56 @@ export class EsphomeNativeApiConnection {
|
|
|
174
208
|
try {
|
|
175
209
|
const state = await this.executeCommand(entity, commandArg.payload, signalArg);
|
|
176
210
|
return { success: true, data: structuredClone(state) };
|
|
177
|
-
} catch
|
|
178
|
-
return { success: false, error:
|
|
211
|
+
} catch {
|
|
212
|
+
return { success: false, error: 'ESPHome command failed or was cancelled; its physical effect is unknown.' };
|
|
179
213
|
} finally {
|
|
180
214
|
this.pendingCommands.delete(plugins.esphomeNativeApi.entityId(entity.type, entity.objectId));
|
|
181
215
|
}
|
|
182
216
|
}
|
|
183
217
|
|
|
184
|
-
public
|
|
185
|
-
if (this.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
this.
|
|
218
|
+
public destroy(): Promise<void> {
|
|
219
|
+
if (!this.closePromise) {
|
|
220
|
+
this.destroyed = true;
|
|
221
|
+
this.ready = false;
|
|
222
|
+
this.closePromise = Promise.resolve().then(() => this.drain(false));
|
|
223
|
+
this.lifetime.abort(new Error('ESPHome connection was destroyed.'));
|
|
224
|
+
}
|
|
225
|
+
return this.closePromise;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
public retryCleanup(): Promise<void> {
|
|
229
|
+
if (!this.closePromise) throw new Error('ESPHome cleanup retry requires a closed client.');
|
|
230
|
+
if (!this.retryPromise) {
|
|
231
|
+
this.retryPromise = Promise.resolve().then(() => this.drain(true));
|
|
232
|
+
const settled = () => { this.retryPromise = undefined; };
|
|
233
|
+
void this.retryPromise.then(settled, settled);
|
|
234
|
+
}
|
|
235
|
+
return this.retryPromise;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private track<T>(workArg: () => Promise<T>): Promise<T> {
|
|
239
|
+
if (this.destroyed) return Promise.reject(new Error('ESPHome connection was destroyed.'));
|
|
240
|
+
// Store the actual operation before invoking native or consumer callbacks.
|
|
241
|
+
const operation = Promise.resolve().then(workArg);
|
|
242
|
+
this.operations.add(operation);
|
|
243
|
+
const settled = () => this.operations.delete(operation);
|
|
244
|
+
void operation.then(settled, settled);
|
|
245
|
+
return operation;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private async drain(retryArg: boolean): Promise<void> {
|
|
249
|
+
const cleanup = [...this.cleanup].reverse().map((resourceArg) => {
|
|
250
|
+
if (retryArg && resourceArg.failed) { resourceArg.promise = undefined; resourceArg.failed = false; }
|
|
251
|
+
resourceArg.promise ??= Promise.resolve().then(() => resourceArg.cleanup()).then(() => undefined, () => {
|
|
252
|
+
resourceArg.failed = true;
|
|
253
|
+
throw new Error('ESPHome owned resource cleanup failed.');
|
|
254
|
+
});
|
|
255
|
+
return resourceArg.promise;
|
|
256
|
+
});
|
|
257
|
+
const [results] = await Promise.all([Promise.allSettled(cleanup), Promise.allSettled([...this.operations])]);
|
|
192
258
|
this.handlers.clear();
|
|
259
|
+
const failures = results.filter((resultArg) => resultArg.status === 'rejected');
|
|
260
|
+
if (failures.length) throw new AggregateError(failures.map(() => new Error('ESPHome owned resource cleanup failed.')), 'ESPHome cleanup failed; resources remain retained for explicit retry.');
|
|
193
261
|
}
|
|
194
262
|
|
|
195
263
|
private async executeCommand(entityArg: TNativeEntity, payloadArg: Record<string, unknown>, signalArg?: AbortSignal): Promise<unknown> {
|
|
@@ -253,14 +321,32 @@ export class EsphomeNativeApiConnection {
|
|
|
253
321
|
|
|
254
322
|
private refreshReadiness(): void {
|
|
255
323
|
const health = this.native.health();
|
|
256
|
-
|
|
324
|
+
if (health.state === 'connected') {
|
|
325
|
+
try { this.assertIdentity(); }
|
|
326
|
+
catch {
|
|
327
|
+
this.identityMismatch = true;
|
|
328
|
+
this.ready = false;
|
|
329
|
+
this.native.disconnect();
|
|
330
|
+
this.emit({ type: 'error', data: { message: 'ESPHome selected native device identity changed.' } });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
this.ready = !this.destroyed && !this.identityMismatch && health.state === 'connected' && (!this.requireEncryption || health.encrypted) && this.native.entitiesByDevice(undefined).filter((entityArg) => readablePlatforms.has(entityArg.type)).every((entityArg) => this.native.latest(plugins.esphomeNativeApi.entityId(entityArg.type, entityArg.objectId)) != null);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private assertIdentity(): void {
|
|
338
|
+
const info = this.native.deviceInfo();
|
|
339
|
+
if (!info || esphomeMac(info.macAddress) !== this.config.expectedMacAddress || info.name !== this.config.deviceName
|
|
340
|
+
|| (this.config.expectedModel !== undefined && info.model !== this.config.expectedModel)) {
|
|
341
|
+
throw new Error('ESPHome native device identity does not match the selected endpoint.');
|
|
342
|
+
}
|
|
257
343
|
}
|
|
258
344
|
|
|
259
345
|
private entityId(entityArg: TNativeEntity): string {
|
|
260
346
|
const macAddress = this.native.deviceInfo()?.macAddress?.toLowerCase().replace(/[^a-f0-9]/g, '');
|
|
261
347
|
const identity = macAddress?.length === 12 ? macAddress : `${this.host}:${this.port}`;
|
|
262
348
|
const digest = plugins.crypto.createHash('sha256').update(JSON.stringify([identity, entityArg.type, entityArg.key, entityArg.deviceId ?? 0])).digest('hex').slice(0, 24);
|
|
263
|
-
return
|
|
349
|
+
return `home.${encodeURIComponent(this.config.homeId)}.${entityArg.type}.esphome_${digest}`;
|
|
264
350
|
}
|
|
265
351
|
|
|
266
352
|
private eventEntityId(eventArg: plugins.esphomeNativeApi.TelemetryEvent): string | undefined {
|
|
@@ -285,12 +371,11 @@ export class EsphomeNativeApiConnection {
|
|
|
285
371
|
}
|
|
286
372
|
|
|
287
373
|
private emit(eventArg: IEsphomeEvent): void {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (
|
|
294
|
-
return value;
|
|
374
|
+
const failures: unknown[] = [];
|
|
375
|
+
for (const handler of this.handlers) {
|
|
376
|
+
try { handler({ ...structuredClone(eventArg), timestamp: Date.now() }); }
|
|
377
|
+
catch (error) { failures.push(error); }
|
|
378
|
+
}
|
|
379
|
+
if (failures.length) throw new AggregateError(failures.map(() => new Error('ESPHome subscriber callback failed.')), 'ESPHome event callbacks failed.');
|
|
295
380
|
}
|
|
296
381
|
}
|
|
@@ -1,49 +1,56 @@
|
|
|
1
1
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
|
2
|
+
import { NativeIntegrationOwner, type NativeIntegrationScope } from '../../core/classes.nativeintegrationowner.js';
|
|
2
3
|
import type { IEsphomeConfig } from './esphome.types.js';
|
|
3
|
-
import {
|
|
4
|
+
import { captureEsphomeConfig, esphomeData } from './esphome.validation.js';
|
|
4
5
|
|
|
5
6
|
export class EsphomeConfigFlow implements IConfigFlow<IEsphomeConfig> {
|
|
7
|
+
private readonly scope: NativeIntegrationScope;
|
|
8
|
+
|
|
9
|
+
constructor(ownerArg: NativeIntegrationOwner = new NativeIntegrationOwner('esphome')) {
|
|
10
|
+
this.scope = ownerArg.createFlowScope();
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IEsphomeConfig>> {
|
|
7
|
-
|
|
14
|
+
const context = this.scope.admit(contextArg);
|
|
15
|
+
// Discovery offers endpoint hints; its metadata, home fields and credentials are never copied.
|
|
16
|
+
const hints: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
17
|
+
for (const key of ['host', 'port', 'name']) {
|
|
18
|
+
const field = Object.getOwnPropertyDescriptor(candidateArg, key);
|
|
19
|
+
if (field && (!field.enumerable || !('value' in field))) throw new TypeError('ESPHome discovery hints must be ordinary data fields.');
|
|
20
|
+
if (field) hints[key] = field.value as unknown;
|
|
21
|
+
}
|
|
22
|
+
const hint = Object.freeze(hints);
|
|
8
23
|
return {
|
|
9
24
|
kind: 'form',
|
|
10
25
|
title: 'Connect ESPHome',
|
|
11
|
-
description: '
|
|
26
|
+
description: 'Confirm the selected native endpoint, node name and MAC address. Provision credentials outside device configuration.',
|
|
12
27
|
fields: [
|
|
13
28
|
{ name: 'host', label: 'Host', type: 'text', required: true },
|
|
14
29
|
{ name: 'port', label: 'Native API port', type: 'number' },
|
|
15
|
-
{ name: '
|
|
30
|
+
{ name: 'deviceName', label: 'Selected native node name', type: 'text', required: true },
|
|
31
|
+
{ name: 'expectedMacAddress', label: 'Selected device MAC address', type: 'text', required: true },
|
|
32
|
+
{ name: 'expectedModel', label: 'Selected model (optional)', type: 'text' },
|
|
33
|
+
{ name: 'credentialKey', label: 'Credential reference (optional)', type: 'text' },
|
|
34
|
+
{ name: 'credentialSource', label: 'Credential source', type: 'select', options: [{ label: 'External provider', value: 'provider' }, { label: 'Encrypted store', value: 'store' }] },
|
|
16
35
|
],
|
|
17
|
-
submit: async (valuesArg) =>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
config
|
|
21
|
-
host:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
model: candidateArg.model,
|
|
33
|
-
host: candidateArg.host,
|
|
34
|
-
port: candidateArg.port || esphomeDefaultNativeApiPort,
|
|
35
|
-
apiEncryptionSupported: Boolean(candidateArg.metadata?.encryptionRequired),
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
|
-
}),
|
|
36
|
+
submit: async (valuesArg) => {
|
|
37
|
+
const values = esphomeData(valuesArg, ['homeId', 'host', 'port', 'deviceName', 'expectedMacAddress', 'expectedModel', 'credentialKey', 'credentialSource'], 'ESPHome submitted configuration');
|
|
38
|
+
if (values.homeId !== undefined && values.homeId !== context.homeId) throw new Error('ESPHome form home does not match the selected host home.');
|
|
39
|
+
const config = captureEsphomeConfig({
|
|
40
|
+
homeId: context.homeId, host: (values.host ?? hint.host) as string, port: (values.port ?? hint.port) as number | undefined,
|
|
41
|
+
name: hint.name as string | undefined, deviceName: values.deviceName as string, expectedMacAddress: values.expectedMacAddress as string,
|
|
42
|
+
expectedModel: values.expectedModel as string | undefined, credentialKey: values.credentialKey as string | undefined,
|
|
43
|
+
credentialSource: values.credentialSource as 'provider' | 'store' | undefined,
|
|
44
|
+
});
|
|
45
|
+
return this.scope.run(context, config.homeId, async (leaseArg) => {
|
|
46
|
+
leaseArg.assertOpen();
|
|
47
|
+
if (config.credentialSource === 'store' && !leaseArg.context.configStore) throw new Error('ESPHome store credential references require a scoped NoSqlConfigStore.');
|
|
48
|
+
return { kind: 'done', title: 'ESPHome configured', config };
|
|
49
|
+
});
|
|
50
|
+
},
|
|
39
51
|
};
|
|
40
52
|
}
|
|
41
53
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
private numberValue(valueArg: unknown): number | undefined {
|
|
47
|
-
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
|
48
|
-
}
|
|
54
|
+
public destroy(): Promise<void> { return this.scope.close(); }
|
|
55
|
+
public retryCleanup(): Promise<void> { return this.scope.retryCleanup(); }
|
|
49
56
|
}
|
|
@@ -1,18 +1,20 @@
|
|
|
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 { EsphomeClient } from './esphome.classes.client.js';
|
|
5
6
|
import { EsphomeConfigFlow } from './esphome.classes.configflow.js';
|
|
6
7
|
import { createEsphomeDiscoveryDescriptor } from './esphome.discovery.js';
|
|
7
8
|
import { EsphomeMapper } from './esphome.mapper.js';
|
|
8
|
-
import type { IEsphomeConfig } from './esphome.types.js';
|
|
9
|
+
import type { IEsphomeConfig, IEsphomeIntegrationOptions, TEsphomeCredentials } from './esphome.types.js';
|
|
10
|
+
import { captureEsphomeConfig, captureEsphomeCredentials, esphomeData } from './esphome.validation.js';
|
|
9
11
|
|
|
10
12
|
export class EsphomeIntegration extends BaseIntegration<IEsphomeConfig> {
|
|
11
13
|
public readonly domain = 'esphome';
|
|
12
14
|
public readonly displayName = 'ESPHome';
|
|
13
15
|
public readonly status = 'control-runtime' as const;
|
|
14
16
|
public readonly discoveryDescriptor = createEsphomeDiscoveryDescriptor();
|
|
15
|
-
public readonly configFlow
|
|
17
|
+
public readonly configFlow: EsphomeConfigFlow;
|
|
16
18
|
public readonly metadata = {
|
|
17
19
|
integrationType: 'device',
|
|
18
20
|
iotClass: 'local_push',
|
|
@@ -21,35 +23,61 @@ export class EsphomeIntegration extends BaseIntegration<IEsphomeConfig> {
|
|
|
21
23
|
minimumFirmwareVersion: '2025.10',
|
|
22
24
|
zeroconf: ['_esphomelib._tcp.local.'],
|
|
23
25
|
};
|
|
24
|
-
private readonly
|
|
25
|
-
private readonly
|
|
26
|
-
|
|
26
|
+
private readonly owner: NativeIntegrationOwner;
|
|
27
|
+
private readonly resolveCredentials?: IEsphomeIntegrationOptions['resolveCredentials'];
|
|
28
|
+
|
|
29
|
+
constructor(optionsArg: IEsphomeIntegrationOptions = {}) {
|
|
30
|
+
super();
|
|
31
|
+
const options = esphomeData(optionsArg, ['homeId', 'capacity', 'resolveCredentials'], 'ESPHome integration options');
|
|
32
|
+
if (options.resolveCredentials !== undefined && typeof options.resolveCredentials !== 'function') throw new TypeError('ESPHome resolveCredentials must be an asynchronous callback.');
|
|
33
|
+
this.resolveCredentials = options.resolveCredentials as IEsphomeIntegrationOptions['resolveCredentials'];
|
|
34
|
+
this.owner = new NativeIntegrationOwner(this.domain, { homeId: options.homeId as string | undefined, capacity: options.capacity as number | undefined });
|
|
35
|
+
this.configFlow = new EsphomeConfigFlow(this.owner);
|
|
36
|
+
}
|
|
27
37
|
|
|
28
38
|
public async setup(configArg: IEsphomeConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
const config = captureEsphomeConfig(configArg);
|
|
40
|
+
return this.owner.runSetup(contextArg, config.homeId, async (leaseArg) => {
|
|
41
|
+
const timeout = config.initialSnapshotTimeoutMs ?? 15000;
|
|
42
|
+
const monotonicDeadline = performance.now() + timeout;
|
|
43
|
+
const signal = AbortSignal.any([leaseArg.signal, AbortSignal.timeout(timeout)]);
|
|
44
|
+
let credentials: TEsphomeCredentials = {};
|
|
45
|
+
if (config.credentialKey !== undefined) {
|
|
46
|
+
const request = Object.freeze({ capturedHomeId: leaseArg.context.homeId, credentialKey: config.credentialKey, signal, monotonicDeadline });
|
|
47
|
+
// Setup is retained before the deferred callback. Adopt its real completion, never an abort race.
|
|
48
|
+
const provision = Promise.resolve().then(() => {
|
|
49
|
+
signal.throwIfAborted();
|
|
50
|
+
if (config.credentialSource === 'store') {
|
|
51
|
+
if (!leaseArg.context.configStore) throw new Error('ESPHome store credential references require a scoped NoSqlConfigStore.');
|
|
52
|
+
return leaseArg.context.configStore.get<TEsphomeCredentials>(request.credentialKey);
|
|
53
|
+
}
|
|
54
|
+
if (!this.resolveCredentials) throw new Error('ESPHome provider credential references require resolveCredentials.');
|
|
55
|
+
return this.resolveCredentials(request);
|
|
56
|
+
});
|
|
57
|
+
try { credentials = captureEsphomeCredentials(await provision); }
|
|
58
|
+
catch { throw new Error('ESPHome credentials could not be resolved.'); }
|
|
59
|
+
}
|
|
60
|
+
leaseArg.assertOpen();
|
|
61
|
+
signal.throwIfAborted();
|
|
62
|
+
const client = new EsphomeClient(config, undefined, credentials);
|
|
63
|
+
let cleanupAttempted = false;
|
|
64
|
+
leaseArg.own(() => {
|
|
65
|
+
if (cleanupAttempted) return client.retryCleanup();
|
|
66
|
+
cleanupAttempted = true;
|
|
67
|
+
return client.destroy();
|
|
68
|
+
});
|
|
69
|
+
await client.connectLive(signal);
|
|
70
|
+
leaseArg.assertOpen();
|
|
71
|
+
signal.throwIfAborted();
|
|
72
|
+
const runtime = new EsphomeRuntime(client, leaseArg);
|
|
73
|
+
leaseArg.promoteToRuntime();
|
|
37
74
|
return runtime;
|
|
38
|
-
}
|
|
39
|
-
await client.destroy();
|
|
40
|
-
throw error;
|
|
41
|
-
} finally {
|
|
42
|
-
this.pendingClients.delete(client);
|
|
43
|
-
}
|
|
75
|
+
});
|
|
44
76
|
}
|
|
45
77
|
|
|
46
|
-
public
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
...[...this.runtimes].map((runtimeArg) => runtimeArg.destroy()),
|
|
50
|
-
...[...this.pendingClients].map((clientArg) => clientArg.destroy()),
|
|
51
|
-
]);
|
|
52
|
-
}
|
|
78
|
+
public destroy(): Promise<void> { return this.owner.close(); }
|
|
79
|
+
|
|
80
|
+
public retryCleanup(): Promise<void> { return this.owner.retryCleanup(); }
|
|
53
81
|
}
|
|
54
82
|
|
|
55
83
|
export class HomeAssistantEsphomeIntegration extends EsphomeIntegration {}
|
|
@@ -57,22 +85,25 @@ export class HomeAssistantEsphomeIntegration extends EsphomeIntegration {}
|
|
|
57
85
|
export class EsphomeRuntime implements IIntegrationRuntime {
|
|
58
86
|
public readonly domain = 'esphome';
|
|
59
87
|
|
|
60
|
-
constructor(private readonly client: EsphomeClient, private readonly
|
|
88
|
+
constructor(private readonly client: EsphomeClient, private readonly lease?: INativeSetupLease) {}
|
|
61
89
|
|
|
62
90
|
public isHealthy(): boolean {
|
|
63
91
|
return this.client.isHealthy();
|
|
64
92
|
}
|
|
65
93
|
|
|
66
94
|
public async devices(signalArg?: AbortSignal): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
|
|
95
|
+
this.lease?.assertOpen();
|
|
67
96
|
signalArg?.throwIfAborted();
|
|
68
97
|
return EsphomeMapper.toDevices(await this.client.getSnapshot());
|
|
69
98
|
}
|
|
70
99
|
|
|
71
100
|
public async entities(): Promise<IIntegrationEntity[]> {
|
|
101
|
+
this.lease?.assertOpen();
|
|
72
102
|
return EsphomeMapper.toEntities(await this.client.getSnapshot());
|
|
73
103
|
}
|
|
74
104
|
|
|
75
105
|
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void, signalArg?: AbortSignal): Promise<() => Promise<void>> {
|
|
106
|
+
this.lease?.assertOpen();
|
|
76
107
|
signalArg?.throwIfAborted();
|
|
77
108
|
const unsubscribe = this.client.onEvent((eventArg) => {
|
|
78
109
|
handlerArg({
|
|
@@ -83,14 +114,12 @@ export class EsphomeRuntime implements IIntegrationRuntime {
|
|
|
83
114
|
data: eventArg.data,
|
|
84
115
|
timestamp: eventArg.timestamp || Date.now(),
|
|
85
116
|
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
signalArg?.addEventListener('abort', cleanup, { once: true });
|
|
89
|
-
if (signalArg?.aborted) cleanup();
|
|
90
|
-
return async () => cleanup();
|
|
117
|
+
}, signalArg);
|
|
118
|
+
return async () => unsubscribe();
|
|
91
119
|
}
|
|
92
120
|
|
|
93
121
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
|
122
|
+
this.lease?.assertOpen();
|
|
94
123
|
if (!requestArg.target.entityId && !requestArg.target.deviceId) return { success: false, error: 'ESPHome service calls require an explicit entity or device target.' };
|
|
95
124
|
const command = EsphomeMapper.commandForService(await this.client.getSnapshot(), requestArg);
|
|
96
125
|
if (!command) return { success: false, error: `ESPHome service ${requestArg.domain}.${requestArg.service} has no unique supported native command target.` };
|
|
@@ -98,6 +127,7 @@ export class EsphomeRuntime implements IIntegrationRuntime {
|
|
|
98
127
|
}
|
|
99
128
|
|
|
100
129
|
public async writeFeature(requestArg: IDeviceFeatureWriteRequest, signalArg?: AbortSignal): Promise<IServiceCallResult> {
|
|
130
|
+
this.lease?.assertOpen();
|
|
101
131
|
signalArg?.throwIfAborted();
|
|
102
132
|
if (requestArg.device.integrationDomain !== this.domain) return { success: false, error: 'ESPHome feature writes require an ESPHome device target.' };
|
|
103
133
|
const command = EsphomeMapper.commandForFeature(await this.client.getSnapshot(), requestArg.device.id, requestArg.feature.id, requestArg.value);
|
|
@@ -105,8 +135,5 @@ export class EsphomeRuntime implements IIntegrationRuntime {
|
|
|
105
135
|
return this.client.sendCommand(command, signalArg);
|
|
106
136
|
}
|
|
107
137
|
|
|
108
|
-
public
|
|
109
|
-
await this.client.destroy();
|
|
110
|
-
this.onDestroy?.();
|
|
111
|
-
}
|
|
138
|
+
public destroy(): Promise<void> { return this.lease ? this.lease.close() : this.client.destroy(); }
|
|
112
139
|
}
|