@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,14 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
|
+
import { NativeHomeAdmission, type INativeAdmissionContext } from '../../core/classes.nativehomeadmission.js';
|
|
3
|
+
import { NativeIntegrationOwner, type INativeSetupLease } from '../../core/classes.nativeintegrationowner.js';
|
|
4
|
+
import type { INativeIntegrationSetupContext } from '../../core/types.js';
|
|
2
5
|
import type * as homeAssistantWebsocket from 'home-assistant-js-websocket';
|
|
3
6
|
import type {
|
|
4
7
|
IHomeAssistantHomeassistantConfig,
|
|
5
8
|
IHomeAssistantClient,
|
|
6
9
|
IHomeAssistantEntityRegistryForDisplay,
|
|
7
10
|
IHomeAssistantNormalizedConfig,
|
|
11
|
+
IHomeAssistantOperationSession,
|
|
8
12
|
IHomeAssistantServiceCall,
|
|
9
13
|
IHomeAssistantWebsocketApi,
|
|
10
14
|
IHomeAssistantTargetScope,
|
|
@@ -18,6 +22,27 @@ const maximumTimerMs = 2_147_483_647;
|
|
|
18
22
|
const reconnectRefreshBackoffBaseMs = 1000;
|
|
19
23
|
const reconnectRefreshBackoffMaximumMs = 30_000;
|
|
20
24
|
|
|
25
|
+
interface IHomeAssistantClientOwnership {
|
|
26
|
+
readonly owner: NativeIntegrationOwner;
|
|
27
|
+
readonly lease: INativeSetupLease;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface IHomeAssistantCleanup {
|
|
31
|
+
readonly close: () => Promise<void>;
|
|
32
|
+
readonly connection: boolean;
|
|
33
|
+
status: 'idle' | 'pending' | 'closed' | 'failed';
|
|
34
|
+
promise?: Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface IHomeAssistantSubscription {
|
|
38
|
+
readonly handler: () => void | Promise<void>;
|
|
39
|
+
readonly pending: Set<Promise<unknown>>;
|
|
40
|
+
readonly failures: Set<unknown>;
|
|
41
|
+
readonly removeAbortListener: () => void;
|
|
42
|
+
active: boolean;
|
|
43
|
+
closePromise?: Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
21
46
|
type TConnectionGenerationResult<TResult> =
|
|
22
47
|
| { status: 'completed'; value: TResult }
|
|
23
48
|
| { status: 'changed' };
|
|
@@ -148,7 +173,13 @@ export const normalizeHomeAssistantConfig = (
|
|
|
148
173
|
|
|
149
174
|
export class HomeAssistantClient implements IHomeAssistantClient {
|
|
150
175
|
private readonly config: IHomeAssistantNormalizedConfig;
|
|
151
|
-
private readonly
|
|
176
|
+
private readonly context: INativeAdmissionContext;
|
|
177
|
+
private readonly owner: NativeIntegrationOwner;
|
|
178
|
+
private readonly standalone: boolean;
|
|
179
|
+
private lease?: INativeSetupLease;
|
|
180
|
+
private initStarted = false;
|
|
181
|
+
private ownedDestroyPromise?: Promise<void>;
|
|
182
|
+
private readonly handlers = new Set<IHomeAssistantSubscription>();
|
|
152
183
|
private readonly lifetimeAbortController = new AbortController();
|
|
153
184
|
private readonly ownedSockets = new Set<homeAssistantWebsocket.HaWebSocket>();
|
|
154
185
|
private connection?: homeAssistantWebsocket.Connection;
|
|
@@ -158,7 +189,9 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
158
189
|
private entityPlatforms: Record<string, string> = {};
|
|
159
190
|
private services = new Set<string>();
|
|
160
191
|
private readonly catalogueUnsubscribes = new Set<() => Promise<void>>();
|
|
161
|
-
private readonly
|
|
192
|
+
private readonly actualWork = new Set<Promise<unknown>>();
|
|
193
|
+
private readonly cleanup = new Set<IHomeAssistantCleanup>();
|
|
194
|
+
private retryPromise?: Promise<void>;
|
|
162
195
|
private connectionReady = false;
|
|
163
196
|
private snapshotReady = false;
|
|
164
197
|
private requiredEntitiesPresent = true;
|
|
@@ -192,12 +225,12 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
192
225
|
return;
|
|
193
226
|
}
|
|
194
227
|
const initializationDeadline = Date.now() + this.config.initialSnapshotTimeoutMs;
|
|
195
|
-
void this.refreshAuthoritativeSnapshot(
|
|
228
|
+
void this.trackWork(this.refreshAuthoritativeSnapshot(
|
|
196
229
|
connection,
|
|
197
230
|
generation,
|
|
198
231
|
this.lifetimeAbortController.signal,
|
|
199
232
|
initializationDeadline
|
|
200
|
-
).then((refreshedArg) => {
|
|
233
|
+
)).then((refreshedArg) => {
|
|
201
234
|
if (
|
|
202
235
|
refreshedArg
|
|
203
236
|
&& !this.isStopping()
|
|
@@ -228,22 +261,70 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
228
261
|
|
|
229
262
|
constructor(
|
|
230
263
|
configArg: IHomeAssistantHomeassistantConfig,
|
|
231
|
-
|
|
232
|
-
private readonly
|
|
264
|
+
contextArg: INativeIntegrationSetupContext,
|
|
265
|
+
private readonly api: IHomeAssistantWebsocketApi = plugins.homeAssistantWebsocket,
|
|
266
|
+
private readonly webSocketFactory?: THomeAssistantWebSocketFactory,
|
|
267
|
+
ownershipArg?: IHomeAssistantClientOwnership
|
|
233
268
|
) {
|
|
234
269
|
this.config = normalizeHomeAssistantConfig(configArg);
|
|
270
|
+
this.context = new NativeHomeAdmission('homeassistant').admit(contextArg, this.config.homeId);
|
|
271
|
+
if (ownershipArg && !(ownershipArg.owner instanceof NativeIntegrationOwner)) throw new TypeError('Home Assistant requires an actual native owner.');
|
|
272
|
+
this.standalone = ownershipArg === undefined;
|
|
273
|
+
this.owner = ownershipArg?.owner ?? new NativeIntegrationOwner('homeassistant', { homeId: this.context.homeId });
|
|
274
|
+
this.owner.admission.admit(this.context, this.config.homeId);
|
|
275
|
+
if (ownershipArg) {
|
|
276
|
+
ownershipArg.lease.assertOpen();
|
|
277
|
+
if (ownershipArg.lease.context.homeId !== this.context.homeId || ownershipArg.lease.context.configStore !== this.context.configStore) {
|
|
278
|
+
throw new Error('Home Assistant client ownership conflicts with its admitted context.');
|
|
279
|
+
}
|
|
280
|
+
this.lease = ownershipArg.lease;
|
|
281
|
+
}
|
|
235
282
|
this.requiredEntitiesPresent = !this.config.requireEntityIds;
|
|
236
283
|
}
|
|
237
284
|
|
|
238
285
|
public get connected(): boolean {
|
|
239
|
-
return this.
|
|
286
|
+
return !this.lease?.signal.aborted
|
|
287
|
+
&& !this.isStopping()
|
|
288
|
+
&& this.connectionReady
|
|
240
289
|
&& this.snapshotReady
|
|
241
290
|
&& this.requiredEntitiesPresent
|
|
242
291
|
&& (!this.config.requireEntityRegistry || [...this.config.entityIds!]
|
|
243
292
|
.every((entityIdArg) => Boolean(this.entityPlatforms[entityIdArg])));
|
|
244
293
|
}
|
|
245
294
|
|
|
246
|
-
public
|
|
295
|
+
public init(abortSignalArg?: AbortSignal): Promise<void> {
|
|
296
|
+
try {
|
|
297
|
+
if (this.initStarted || this.lifecycle !== 'idle') throw new Error('Home Assistant client cannot initialize again.');
|
|
298
|
+
if (abortSignalArg !== undefined && !(abortSignalArg instanceof AbortSignal)) throw new TypeError('Home Assistant cancellation must be an AbortSignal.');
|
|
299
|
+
this.initStarted = true;
|
|
300
|
+
if (!this.standalone) return this.initializeOwned(AbortSignal.any([this.lease!.signal, ...(abortSignalArg ? [abortSignalArg] : [])]));
|
|
301
|
+
const signal = AbortSignal.any([...(this.context.abortSignal ? [this.context.abortSignal] : []), ...(abortSignalArg ? [abortSignalArg] : [])]);
|
|
302
|
+
return this.owner.runSetup({ ...this.context, abortSignal: signal }, this.config.homeId, async (leaseArg) => {
|
|
303
|
+
this.lease = leaseArg;
|
|
304
|
+
let cleanupStarted = false;
|
|
305
|
+
leaseArg.own(() => {
|
|
306
|
+
const retry = cleanupStarted;
|
|
307
|
+
cleanupStarted = true;
|
|
308
|
+
return retry ? this.retryOwnedCleanup() : this.destroyOwned();
|
|
309
|
+
});
|
|
310
|
+
await this.initializeOwned(leaseArg.signal);
|
|
311
|
+
leaseArg.assertOpen();
|
|
312
|
+
leaseArg.promoteToRuntime();
|
|
313
|
+
});
|
|
314
|
+
} catch (errorArg) { return Promise.reject(errorArg); }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private initializeOwned(signalArg: AbortSignal): Promise<void> {
|
|
318
|
+
// Cleanup runs after actual initialization settles, avoiding setup/close self-wait.
|
|
319
|
+
const work = this.trackWork(Promise.resolve().then(() => this.initialize(signalArg)));
|
|
320
|
+
return work.catch(async (errorArg: unknown) => {
|
|
321
|
+
try { await this.destroyOwned(); }
|
|
322
|
+
catch (cleanupErrorArg) { throw new AggregateError([errorArg, cleanupErrorArg], 'Home Assistant initialization and cleanup both failed.'); }
|
|
323
|
+
throw errorArg;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private async initialize(abortSignalArg?: AbortSignal): Promise<void> {
|
|
247
328
|
if (this.lifecycle !== 'idle') {
|
|
248
329
|
throw new Error(
|
|
249
330
|
'Home Assistant client cannot initialize from lifecycle state '
|
|
@@ -314,16 +395,6 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
314
395
|
}
|
|
315
396
|
throw new Error('Home Assistant client was destroyed during initialization.');
|
|
316
397
|
}
|
|
317
|
-
} catch (errorArg) {
|
|
318
|
-
try {
|
|
319
|
-
await this.destroy();
|
|
320
|
-
} catch (cleanupErrorArg) {
|
|
321
|
-
throw new AggregateError(
|
|
322
|
-
[errorArg, cleanupErrorArg],
|
|
323
|
-
'Home Assistant initialization and cleanup both failed.'
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
throw errorArg;
|
|
327
398
|
} finally {
|
|
328
399
|
abortSignalArg?.removeEventListener('abort', propagateAbort);
|
|
329
400
|
if (this.setupAbortController === setupAbortController) {
|
|
@@ -348,25 +419,106 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
348
419
|
return { homeId: this.config.homeId, instanceId: this.config.instanceId };
|
|
349
420
|
}
|
|
350
421
|
|
|
351
|
-
public subscribe(handlerArg: () => void): () => void {
|
|
422
|
+
public subscribe(handlerArg: () => void | Promise<void>, abortSignalArg?: AbortSignal): () => Promise<void> {
|
|
423
|
+
this.lease?.assertOpen();
|
|
352
424
|
if (this.isStopping()) {
|
|
353
425
|
throw new Error('Home Assistant client is destroyed.');
|
|
354
426
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
427
|
+
if (typeof handlerArg !== 'function' || (abortSignalArg !== undefined && !(abortSignalArg instanceof AbortSignal))) {
|
|
428
|
+
throw new TypeError('Home Assistant subscriber and cancellation must be a function and AbortSignal.');
|
|
429
|
+
}
|
|
430
|
+
abortSignalArg?.throwIfAborted();
|
|
431
|
+
const onAbort = (): void => { void unsubscribe().catch(() => undefined); };
|
|
432
|
+
const subscription: IHomeAssistantSubscription = {
|
|
433
|
+
handler: handlerArg, active: true, pending: new Set(), failures: new Set(),
|
|
434
|
+
removeAbortListener: () => abortSignalArg?.removeEventListener('abort', onAbort),
|
|
435
|
+
};
|
|
436
|
+
const unsubscribe = (): Promise<void> => {
|
|
437
|
+
if (!subscription.closePromise) {
|
|
438
|
+
subscription.active = false;
|
|
439
|
+
subscription.removeAbortListener();
|
|
440
|
+
subscription.closePromise = Promise.resolve().then(async () => {
|
|
441
|
+
await Promise.allSettled([...subscription.pending]);
|
|
442
|
+
if (subscription.failures.size) throw new AggregateError([...subscription.failures], 'Home Assistant subscriber cleanup failed.');
|
|
443
|
+
this.handlers.delete(subscription);
|
|
444
|
+
});
|
|
360
445
|
}
|
|
361
|
-
|
|
362
|
-
this.handlers.delete(handlerArg);
|
|
446
|
+
return subscription.closePromise;
|
|
363
447
|
};
|
|
448
|
+
this.handlers.add(subscription);
|
|
449
|
+
abortSignalArg?.addEventListener('abort', onAbort, { once: true });
|
|
450
|
+
if (abortSignalArg?.aborted) onAbort();
|
|
451
|
+
return unsubscribe;
|
|
364
452
|
}
|
|
365
453
|
|
|
366
|
-
|
|
454
|
+
/** Caller cancellation may settle first; the owner retains the actual continuation. */
|
|
455
|
+
public withOperation<TResult>(
|
|
456
|
+
workArg: (sessionArg: IHomeAssistantOperationSession) => Promise<TResult>,
|
|
457
|
+
abortSignalArg?: AbortSignal
|
|
458
|
+
): Promise<TResult> {
|
|
459
|
+
try {
|
|
460
|
+
const admitted = this.admitOperation(workArg, abortSignalArg);
|
|
461
|
+
return this.raceAbort(admitted.promise, admitted.signal);
|
|
462
|
+
} catch (errorArg) { return Promise.reject(errorArg); }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private admitOperation<TResult>(
|
|
466
|
+
workArg: (sessionArg: IHomeAssistantOperationSession) => Promise<TResult>,
|
|
467
|
+
abortSignalArg?: AbortSignal
|
|
468
|
+
): { promise: Promise<TResult>; signal: AbortSignal } {
|
|
469
|
+
this.lease?.assertOpen();
|
|
470
|
+
if (this.isStopping()) throw new Error('Home Assistant client is destroyed.');
|
|
471
|
+
if (typeof workArg !== 'function' || (abortSignalArg !== undefined && !(abortSignalArg instanceof AbortSignal))) {
|
|
472
|
+
throw new TypeError('Home Assistant operation and cancellation must be a function and AbortSignal.');
|
|
473
|
+
}
|
|
474
|
+
const cancellation = new AbortController();
|
|
475
|
+
const signal = AbortSignal.any([this.lifetimeAbortController.signal, cancellation.signal,
|
|
476
|
+
...(this.lease ? [this.lease.signal] : []), ...(this.context.abortSignal ? [this.context.abortSignal] : []),
|
|
477
|
+
...(abortSignalArg ? [abortSignalArg] : [])]);
|
|
478
|
+
signal.throwIfAborted();
|
|
479
|
+
const operation = this.owner.runOperation({ ...this.context, abortSignal: signal }, this.config.homeId, async (leaseArg) => {
|
|
480
|
+
const pending = new Set<Promise<unknown>>();
|
|
481
|
+
let active = true;
|
|
482
|
+
const session: IHomeAssistantOperationSession = Object.freeze({
|
|
483
|
+
signal: leaseArg.signal,
|
|
484
|
+
callService: (requestArg: IHomeAssistantServiceCall): Promise<unknown> => {
|
|
485
|
+
try {
|
|
486
|
+
leaseArg.assertOpen();
|
|
487
|
+
if (!active) throw new Error('Home Assistant service session is closed.');
|
|
488
|
+
return this.dispatchService(requestArg, leaseArg.signal, pending, (reasonArg) => cancellation.abort(reasonArg));
|
|
489
|
+
} catch (errorArg) { return Promise.reject(errorArg); }
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
try { return await workArg(session); }
|
|
493
|
+
finally {
|
|
494
|
+
active = false;
|
|
495
|
+
// An abort race is not settlement of the effect dispatched to the foreign API.
|
|
496
|
+
await Promise.allSettled([...pending]);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
return { promise: this.trackWork(operation), signal };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
public callService(
|
|
367
503
|
requestArg: IHomeAssistantServiceCall,
|
|
368
504
|
abortSignalArg?: AbortSignal
|
|
369
505
|
): Promise<unknown> {
|
|
506
|
+
try {
|
|
507
|
+
homeAssistantRecord(requestArg);
|
|
508
|
+
homeAssistantRecord(requestArg.target);
|
|
509
|
+
if (requestArg.data !== undefined) homeAssistantRecord(requestArg.data);
|
|
510
|
+
const request = structuredClone(requestArg);
|
|
511
|
+
return this.withOperation((sessionArg) => sessionArg.callService(request), abortSignalArg);
|
|
512
|
+
} catch (errorArg) { return Promise.reject(errorArg); }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private async dispatchService(
|
|
516
|
+
requestArg: IHomeAssistantServiceCall,
|
|
517
|
+
abortSignalArg: AbortSignal,
|
|
518
|
+
pendingArg: Set<Promise<unknown>>,
|
|
519
|
+
cancelOperationArg: (reasonArg: Error) => void
|
|
520
|
+
): Promise<unknown> {
|
|
521
|
+
this.lease?.assertOpen();
|
|
370
522
|
if (abortSignalArg?.aborted) {
|
|
371
523
|
throw abortError(abortSignalArg);
|
|
372
524
|
}
|
|
@@ -410,26 +562,28 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
410
562
|
throw abortError(operationAbortController.signal);
|
|
411
563
|
}
|
|
412
564
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
413
|
-
let resolveSettlement!: () => void;
|
|
414
|
-
const settlement = new Promise<void>((resolve) => { resolveSettlement = resolve; });
|
|
415
|
-
this.serviceSettlements.add(settlement);
|
|
416
565
|
this.activeServiceCallCount++;
|
|
417
566
|
try {
|
|
418
|
-
const operationPromise = this.api.callService(
|
|
567
|
+
const operationPromise = this.trackWork(this.api.callService(
|
|
419
568
|
connection,
|
|
420
569
|
request.domain,
|
|
421
570
|
request.service,
|
|
422
571
|
request.data,
|
|
423
572
|
request.target
|
|
424
|
-
);
|
|
573
|
+
));
|
|
574
|
+
pendingArg.add(operationPromise);
|
|
575
|
+
const settled = (): void => { pendingArg.delete(operationPromise); };
|
|
576
|
+
void operationPromise.then(settled, settled);
|
|
425
577
|
operationAbortController.signal.addEventListener('abort', cancelPendingCommand, { once: true });
|
|
426
578
|
if (operationAbortController.signal.aborted) {
|
|
427
579
|
cancelPendingCommand();
|
|
428
580
|
}
|
|
429
581
|
timeout = setTimeout(() => {
|
|
430
|
-
|
|
582
|
+
const reason = new Error(
|
|
431
583
|
`Home Assistant service call did not complete within ${this.config.serviceCallTimeoutMs} ms.`
|
|
432
|
-
)
|
|
584
|
+
);
|
|
585
|
+
operationAbortController.abort(reason);
|
|
586
|
+
cancelOperationArg(reason);
|
|
433
587
|
}, this.config.serviceCallTimeoutMs);
|
|
434
588
|
return await this.raceAbort(operationPromise, operationAbortController.signal);
|
|
435
589
|
} finally {
|
|
@@ -440,38 +594,73 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
440
594
|
abortSignalArg?.removeEventListener('abort', propagateAbort);
|
|
441
595
|
operationAbortController.signal.removeEventListener('abort', cancelPendingCommand);
|
|
442
596
|
this.lifetimeAbortController.signal.removeEventListener('abort', propagateLifetimeAbort);
|
|
443
|
-
resolveSettlement();
|
|
444
|
-
this.serviceSettlements.delete(settlement);
|
|
445
597
|
}
|
|
446
598
|
}
|
|
447
599
|
|
|
448
600
|
public destroy(): Promise<void> {
|
|
449
|
-
if (this.destroyPromise) {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
this.
|
|
463
|
-
|
|
464
|
-
|
|
601
|
+
if (!this.destroyPromise) {
|
|
602
|
+
this.lifecycle = 'destroying';
|
|
603
|
+
this.destroyPromise = Promise.resolve().then(() => this.standalone ? this.owner.close() : this.destroyOwned());
|
|
604
|
+
this.fenceSubscribers();
|
|
605
|
+
this.setupAbortController?.abort(new Error('Home Assistant client was destroyed.'));
|
|
606
|
+
this.lifetimeAbortController.abort(new Error('Home Assistant client was destroyed.'));
|
|
607
|
+
}
|
|
608
|
+
return this.destroyPromise;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private destroyOwned(): Promise<void> {
|
|
612
|
+
if (!this.ownedDestroyPromise) {
|
|
613
|
+
this.lifecycle = 'destroying';
|
|
614
|
+
this.ownedDestroyPromise = Promise.resolve().then(() => this.performDestroy(false));
|
|
615
|
+
this.fenceSubscribers();
|
|
616
|
+
this.setupAbortController?.abort(new Error('Home Assistant client was destroyed.'));
|
|
617
|
+
this.lifetimeAbortController.abort(new Error('Home Assistant client was destroyed.'));
|
|
618
|
+
}
|
|
619
|
+
return this.ownedDestroyPromise;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
public retryCleanup(): Promise<void> {
|
|
623
|
+
if (!this.destroyPromise && !this.ownedDestroyPromise) return Promise.reject(new Error('Home Assistant cleanup retry requires destruction.'));
|
|
624
|
+
return this.standalone ? this.owner.retryCleanup() : this.retryOwnedCleanup();
|
|
465
625
|
}
|
|
466
626
|
|
|
467
|
-
private
|
|
627
|
+
private retryOwnedCleanup(): Promise<void> {
|
|
628
|
+
if (!this.retryPromise) {
|
|
629
|
+
const retry = Promise.resolve().then(async () => {
|
|
630
|
+
await this.ownedDestroyPromise?.catch(() => undefined);
|
|
631
|
+
await this.performDestroy(true);
|
|
632
|
+
});
|
|
633
|
+
this.retryPromise = retry;
|
|
634
|
+
const settled = (): void => { this.retryPromise = undefined; };
|
|
635
|
+
void retry.then(settled, settled);
|
|
636
|
+
}
|
|
637
|
+
return this.retryPromise;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private trackWork<TResult>(promiseArg: Promise<TResult>): Promise<TResult> {
|
|
641
|
+
this.actualWork.add(promiseArg);
|
|
642
|
+
const settled = (): void => { this.actualWork.delete(promiseArg); };
|
|
643
|
+
void promiseArg.then(settled, settled);
|
|
644
|
+
return promiseArg;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private ownCleanup(closeArg: () => Promise<void>, connectionArg = false): void {
|
|
648
|
+
this.cleanup.add({ close: closeArg, connection: connectionArg, status: 'idle' });
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private closeResource(resourceArg: IHomeAssistantCleanup, retryArg: boolean): Promise<void> {
|
|
652
|
+
if (retryArg && resourceArg.status === 'failed') resourceArg.promise = undefined;
|
|
653
|
+
if (!resourceArg.promise) {
|
|
654
|
+
resourceArg.status = 'pending';
|
|
655
|
+
resourceArg.promise = this.trackWork(Promise.resolve().then(resourceArg.close));
|
|
656
|
+
void resourceArg.promise.then(() => { resourceArg.status = 'closed'; }, () => { resourceArg.status = 'failed'; });
|
|
657
|
+
}
|
|
658
|
+
return resourceArg.promise;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private async performDestroy(retryArg: boolean): Promise<void> {
|
|
468
662
|
this.clearReconnectRefreshTimer();
|
|
469
663
|
this.reconnectRefreshFailureCount = 0;
|
|
470
|
-
this.setupAbortController?.abort(new Error('Home Assistant client was destroyed.'));
|
|
471
|
-
this.setupAbortController = undefined;
|
|
472
|
-
this.lifetimeAbortController.abort(new Error('Home Assistant client was destroyed.'));
|
|
473
|
-
|
|
474
|
-
const unsubscribe = this.stateUnsubscribe;
|
|
475
664
|
const connection = this.connection;
|
|
476
665
|
const connectionSocket = connection?.socket;
|
|
477
666
|
this.connection = undefined;
|
|
@@ -479,40 +668,56 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
479
668
|
connection.removeEventListener('ready', this.handleReady);
|
|
480
669
|
connection.removeEventListener('disconnected', this.handleDisconnected);
|
|
481
670
|
}
|
|
671
|
+
this.advanceConnectionGeneration();
|
|
672
|
+
this.connectionReady = false;
|
|
673
|
+
this.snapshotReady = false;
|
|
674
|
+
this.requiredEntitiesPresent = false;
|
|
675
|
+
this.bufferedStateEvents.clear();
|
|
676
|
+
this.upstreamSnapshot = {};
|
|
677
|
+
this.snapshot = {};
|
|
678
|
+
this.entityPlatforms = {};
|
|
679
|
+
this.services.clear();
|
|
680
|
+
this.fenceSubscribers();
|
|
681
|
+
if (retryArg) for (const subscription of this.handlers) subscription.failures.clear();
|
|
682
|
+
|
|
683
|
+
const failures: unknown[] = [];
|
|
684
|
+
const attempted = new Set<IHomeAssistantCleanup>();
|
|
685
|
+
const closeGroup = async (connectionsArg: boolean): Promise<void> => {
|
|
686
|
+
const entries = [...this.cleanup].filter((entry) => entry.connection === connectionsArg && !attempted.has(entry));
|
|
687
|
+
entries.forEach((entry) => attempted.add(entry));
|
|
688
|
+
const results = await Promise.allSettled(entries.map((entry) => {
|
|
689
|
+
const actual = this.closeResource(entry, retryArg);
|
|
690
|
+
// A missing unsubscribe acknowledgement must not prevent sibling socket close.
|
|
691
|
+
// The actual promise remains in the ledger after the deadline rejects.
|
|
692
|
+
return connectionsArg ? actual : this.settleStateUnsubscribe(() => actual);
|
|
693
|
+
}));
|
|
694
|
+
for (const result of results) if (result.status === 'rejected') failures.push(result.reason as unknown);
|
|
695
|
+
};
|
|
482
696
|
try {
|
|
483
|
-
await
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
697
|
+
await closeGroup(false);
|
|
698
|
+
await closeGroup(true);
|
|
699
|
+
const sockets = await Promise.allSettled([this.drainOwnedSockets(connectionSocket)]);
|
|
700
|
+
for (const result of sockets) if (result.status === 'rejected') failures.push(result.reason as unknown);
|
|
701
|
+
// Foreign continuations can produce late connections or unsubscribe handles.
|
|
702
|
+
// Join real settlement, then close every newly adopted handle, without replay.
|
|
703
|
+
while (this.actualWork.size || [...this.cleanup].some((entry) => !attempted.has(entry))) {
|
|
704
|
+
await Promise.allSettled([...this.actualWork]);
|
|
705
|
+
await closeGroup(false);
|
|
706
|
+
await closeGroup(true);
|
|
707
|
+
const lateSockets = await Promise.allSettled([this.drainOwnedSockets()]);
|
|
708
|
+
for (const result of lateSockets) if (result.status === 'rejected') failures.push(result.reason as unknown);
|
|
493
709
|
}
|
|
494
|
-
|
|
495
|
-
if (
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
}
|
|
499
|
-
connection.close();
|
|
710
|
+
for (const entry of [...this.cleanup]) if (entry.status === 'closed') this.cleanup.delete(entry);
|
|
711
|
+
if (!this.cleanup.size) {
|
|
712
|
+
this.stateUnsubscribe = undefined;
|
|
713
|
+
this.catalogueUnsubscribes.clear();
|
|
500
714
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
this.advanceConnectionGeneration();
|
|
505
|
-
this.connectionReady = false;
|
|
506
|
-
this.snapshotReady = false;
|
|
507
|
-
this.requiredEntitiesPresent = false;
|
|
508
|
-
this.bufferedStateEvents.clear();
|
|
509
|
-
this.upstreamSnapshot = {};
|
|
510
|
-
this.snapshot = {};
|
|
511
|
-
this.entityPlatforms = {};
|
|
512
|
-
this.services.clear();
|
|
513
|
-
this.handlers.clear();
|
|
715
|
+
for (const subscription of [...this.handlers]) {
|
|
716
|
+
failures.push(...subscription.failures);
|
|
717
|
+
if (!subscription.failures.size) this.handlers.delete(subscription);
|
|
514
718
|
}
|
|
515
|
-
|
|
719
|
+
if (failures.length) throw new AggregateError([...new Set(failures)], 'Home Assistant cleanup failed; handles remain owned for explicit retry.');
|
|
720
|
+
} finally { this.lifecycle = 'destroyed'; }
|
|
516
721
|
}
|
|
517
722
|
|
|
518
723
|
private async createConnection(
|
|
@@ -526,7 +731,7 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
526
731
|
throw abortError(abortSignalArg);
|
|
527
732
|
}
|
|
528
733
|
try {
|
|
529
|
-
const connectionPromise = this.api.createConnection({
|
|
734
|
+
const connectionPromise = this.trackWork(this.api.createConnection({
|
|
530
735
|
auth: authArg,
|
|
531
736
|
setupRetry: 0,
|
|
532
737
|
createSocket: (optionsArg) => this.createManagedSocket(
|
|
@@ -535,7 +740,10 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
535
740
|
abortSignalArg,
|
|
536
741
|
initializationDeadlineArg
|
|
537
742
|
),
|
|
538
|
-
})
|
|
743
|
+
}).then((connectionArg) => {
|
|
744
|
+
this.ownCleanup(async () => { connectionArg.close(); }, true);
|
|
745
|
+
return connectionArg;
|
|
746
|
+
}));
|
|
539
747
|
try {
|
|
540
748
|
return await this.raceAbortWithDeadline(
|
|
541
749
|
connectionPromise,
|
|
@@ -545,9 +753,6 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
545
753
|
);
|
|
546
754
|
} catch (errorArg) {
|
|
547
755
|
this.closeOwnedSockets();
|
|
548
|
-
void connectionPromise.then((connectionArg) => {
|
|
549
|
-
connectionArg.close();
|
|
550
|
-
}).catch(() => undefined);
|
|
551
756
|
throw errorArg;
|
|
552
757
|
}
|
|
553
758
|
} catch (errorArg) {
|
|
@@ -584,14 +789,16 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
584
789
|
? `Home Assistant connection and authentication did not complete within ${this.config.initialSnapshotTimeoutMs} ms.`
|
|
585
790
|
: `Home Assistant reconnect authentication did not complete within ${this.config.initialSnapshotTimeoutMs} ms.`;
|
|
586
791
|
|
|
792
|
+
abortSignal.throwIfAborted();
|
|
793
|
+
if (this.isStopping()) throw new Error('Home Assistant client was destroyed.');
|
|
587
794
|
if (this.webSocketFactory) {
|
|
588
795
|
return this.createOwnedSocket(authArg, abortSignal, deadline, timeoutMessage);
|
|
589
796
|
}
|
|
590
797
|
|
|
591
|
-
const socketPromise = this.api.createSocket({
|
|
798
|
+
const socketPromise = this.trackWork(this.api.createSocket({
|
|
592
799
|
...optionsArg,
|
|
593
800
|
setupRetry: 0,
|
|
594
|
-
});
|
|
801
|
+
}).then((socketArg) => { this.trackOwnedSocket(socketArg); return socketArg; }));
|
|
595
802
|
try {
|
|
596
803
|
const socket = await this.raceAbortWithDeadline(
|
|
597
804
|
socketPromise,
|
|
@@ -606,12 +813,8 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
606
813
|
}
|
|
607
814
|
throw new Error('Home Assistant client discarded a late socket after destruction.');
|
|
608
815
|
}
|
|
609
|
-
this.trackOwnedSocket(socket);
|
|
610
816
|
return socket;
|
|
611
817
|
} catch (errorArg) {
|
|
612
|
-
void socketPromise.then((socketArg) => {
|
|
613
|
-
socketArg.close();
|
|
614
|
-
}).catch(() => undefined);
|
|
615
818
|
throw errorArg;
|
|
616
819
|
}
|
|
617
820
|
}
|
|
@@ -763,7 +966,7 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
763
966
|
private async drainOwnedSockets(connectionSocketArg?: homeAssistantWebsocket.HaWebSocket): Promise<void> {
|
|
764
967
|
const sockets = new Set(this.ownedSockets);
|
|
765
968
|
if (connectionSocketArg) sockets.add(connectionSocketArg);
|
|
766
|
-
await Promise.
|
|
969
|
+
const results = await Promise.allSettled([...sockets].map((socket) => new Promise<void>((resolve, reject) => {
|
|
767
970
|
if (socket.readyState === 3) { this.ownedSockets.delete(socket); resolve(); return; }
|
|
768
971
|
const finish = (error?: Error): void => {
|
|
769
972
|
clearTimeout(timeout);
|
|
@@ -777,6 +980,8 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
777
980
|
try { if (socket.readyState !== 2) socket.close(); }
|
|
778
981
|
catch (error) { finish(error instanceof Error ? error : new Error('Home Assistant socket close failed.')); }
|
|
779
982
|
})));
|
|
983
|
+
const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected').map((result) => result.reason as unknown);
|
|
984
|
+
if (failures.length) throw new AggregateError(failures, 'Home Assistant socket cleanup failed.');
|
|
780
985
|
}
|
|
781
986
|
|
|
782
987
|
private async raceAbort<T>(
|
|
@@ -904,34 +1109,29 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
904
1109
|
abortSignalArg: AbortSignal,
|
|
905
1110
|
initializationDeadlineArg: number
|
|
906
1111
|
): Promise<void> {
|
|
1112
|
+
const adopt = (promiseArg: Promise<() => Promise<void>>): Promise<() => Promise<void>> => this.trackWork(promiseArg.then((unsubscribeArg) => {
|
|
1113
|
+
this.ownCleanup(unsubscribeArg);
|
|
1114
|
+
return unsubscribeArg;
|
|
1115
|
+
}));
|
|
907
1116
|
const unsubscribe = await this.raceAbortWithDeadline(
|
|
908
|
-
connectionArg.subscribeEvents<homeAssistantWebsocket.StateChangedEvent>((eventArg) => {
|
|
1117
|
+
adopt(connectionArg.subscribeEvents<homeAssistantWebsocket.StateChangedEvent>((eventArg) => {
|
|
909
1118
|
this.handleStateChangedEvent(eventArg);
|
|
910
|
-
}, 'state_changed'),
|
|
1119
|
+
}, 'state_changed')),
|
|
911
1120
|
abortSignalArg,
|
|
912
1121
|
initializationDeadlineArg,
|
|
913
1122
|
`Home Assistant did not acknowledge the state subscription within ${this.config.initialSnapshotTimeoutMs} ms.`
|
|
914
1123
|
);
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
if (abortSignalArg.aborted) {
|
|
918
|
-
throw abortError(abortSignalArg);
|
|
919
|
-
}
|
|
920
|
-
throw new Error('Home Assistant client was destroyed during state subscription.');
|
|
921
|
-
}
|
|
1124
|
+
abortSignalArg.throwIfAborted();
|
|
1125
|
+
if (this.wasDestroyed()) throw new Error('Home Assistant client was destroyed during state subscription.');
|
|
922
1126
|
this.stateUnsubscribe = unsubscribe;
|
|
923
1127
|
for (const eventType of ['service_registered', 'service_removed', 'entity_registry_updated']) {
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
} catch (errorArg) {
|
|
932
|
-
void unsubscribePromise.then((lateUnsubscribe) => this.settleStateUnsubscribe(lateUnsubscribe)).catch(() => undefined);
|
|
933
|
-
throw errorArg;
|
|
934
|
-
}
|
|
1128
|
+
abortSignalArg.throwIfAborted();
|
|
1129
|
+
const unsubscribeCatalogue = await this.raceAbortWithDeadline(
|
|
1130
|
+
adopt(connectionArg.subscribeEvents(() => this.handleReady(), eventType)),
|
|
1131
|
+
abortSignalArg, initializationDeadlineArg,
|
|
1132
|
+
'Home Assistant did not acknowledge its catalogue subscription before the startup deadline.'
|
|
1133
|
+
);
|
|
1134
|
+
this.catalogueUnsubscribes.add(unsubscribeCatalogue);
|
|
935
1135
|
}
|
|
936
1136
|
}
|
|
937
1137
|
|
|
@@ -1266,7 +1466,7 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
1266
1466
|
return { status: 'changed' } as const;
|
|
1267
1467
|
}
|
|
1268
1468
|
try {
|
|
1269
|
-
const value = await operationArg();
|
|
1469
|
+
const value = await this.trackWork(operationArg());
|
|
1270
1470
|
if (generationArg !== this.connectionGeneration || !this.connectionReady) {
|
|
1271
1471
|
return { status: 'changed' } as const;
|
|
1272
1472
|
}
|
|
@@ -1333,12 +1533,40 @@ export class HomeAssistantClient implements IHomeAssistantClient {
|
|
|
1333
1533
|
}
|
|
1334
1534
|
|
|
1335
1535
|
private emitChange(): void {
|
|
1336
|
-
|
|
1536
|
+
if (this.isStopping()) return;
|
|
1537
|
+
for (const subscription of this.handlers) {
|
|
1538
|
+
if (!subscription.active) continue;
|
|
1337
1539
|
try {
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1540
|
+
const notification = this.admitOperation(async (sessionArg) => {
|
|
1541
|
+
if (!subscription.active || sessionArg.signal.aborted) return;
|
|
1542
|
+
try { await subscription.handler(); }
|
|
1543
|
+
catch (errorArg) {
|
|
1544
|
+
subscription.failures.add(errorArg);
|
|
1545
|
+
subscription.active = false;
|
|
1546
|
+
}
|
|
1547
|
+
}).promise;
|
|
1548
|
+
subscription.pending.add(notification);
|
|
1549
|
+
const settled = (): void => { subscription.pending.delete(notification); };
|
|
1550
|
+
void notification.then(settled, (errorArg: unknown) => {
|
|
1551
|
+
if (!this.isStopping() && !this.lease?.signal.aborted) {
|
|
1552
|
+
subscription.failures.add(errorArg);
|
|
1553
|
+
subscription.active = false;
|
|
1554
|
+
}
|
|
1555
|
+
settled();
|
|
1556
|
+
});
|
|
1557
|
+
} catch (errorArg) {
|
|
1558
|
+
if (!this.isStopping() && !this.lease?.signal.aborted) {
|
|
1559
|
+
subscription.failures.add(errorArg);
|
|
1560
|
+
subscription.active = false;
|
|
1561
|
+
}
|
|
1341
1562
|
}
|
|
1342
1563
|
}
|
|
1343
1564
|
}
|
|
1565
|
+
|
|
1566
|
+
private fenceSubscribers(): void {
|
|
1567
|
+
for (const subscription of this.handlers) {
|
|
1568
|
+
subscription.active = false;
|
|
1569
|
+
subscription.removeAbortListener();
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1344
1572
|
}
|