@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,7 +1,9 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
2
|
import { NoSqlConfigStore } from '../../core/classes.configstore.js';
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
3
|
+
import { NativeHomeAdmission, type INativeAdmissionContext } from '../../core/classes.nativehomeadmission.js';
|
|
4
|
+
import type { INativeIntegrationSetupContext } from '../../core/types.js';
|
|
5
|
+
import type { IWebostvCommandResult, IWebostvConfig, IWebostvInput, IWebostvIntegrationOptions, IWebostvSnapshot, TWebostvClientEvent, TWebostvClientHandler, TWebostvCommand } from './webostv.types.js';
|
|
6
|
+
import { normalizeWebostvConfig, normalizeWebostvOptions, webostvInteger, webostvObject, webostvString } from './webostv.validation.js';
|
|
5
7
|
|
|
6
8
|
interface IPendingRequest {
|
|
7
9
|
resolve(valueArg: Record<string, unknown>): void;
|
|
@@ -26,38 +28,47 @@ export class WebostvClient {
|
|
|
26
28
|
readonly #config: Readonly<IWebostvConfig>;
|
|
27
29
|
readonly #store: NoSqlConfigStore;
|
|
28
30
|
readonly #ownsStore: boolean;
|
|
31
|
+
readonly #context: INativeAdmissionContext;
|
|
32
|
+
readonly #allowedCommands: readonly TWebostvCommand[];
|
|
29
33
|
readonly #lifetime = new AbortController();
|
|
30
34
|
readonly #pending = new Map<string, IPendingRequest>();
|
|
31
35
|
readonly #subscriptions = new Map<string, (payloadArg: Record<string, unknown>) => void>();
|
|
32
|
-
readonly #handlers = new Set<
|
|
36
|
+
readonly #handlers = new Set<TWebostvClientHandler>();
|
|
37
|
+
readonly #operations = new Set<Promise<unknown>>();
|
|
38
|
+
readonly #handlerOperations = new Map<TWebostvClientHandler, Set<Promise<void>>>();
|
|
33
39
|
#socket?: plugins.ws.WebSocket;
|
|
34
40
|
#snapshot?: IWebostvSnapshot;
|
|
35
41
|
#sequence = 0;
|
|
36
42
|
#start?: Promise<IWebostvSnapshot>;
|
|
37
43
|
#destroy?: Promise<void>;
|
|
44
|
+
#retry?: Promise<void>;
|
|
38
45
|
#heartbeat?: ReturnType<typeof setInterval>;
|
|
39
46
|
#awaitingPong = false;
|
|
40
47
|
#ready = false;
|
|
41
48
|
#failure?: Error;
|
|
42
|
-
#
|
|
43
|
-
readonly #handlerCleanups = new Map<(eventArg: TWebostvClientEvent) => void, () => void>();
|
|
49
|
+
readonly #handlerCleanups = new Map<TWebostvClientHandler, () => void>();
|
|
44
50
|
#removeExternalAbort: () => void = () => undefined;
|
|
45
51
|
|
|
46
|
-
constructor(configArg: IWebostvConfig, optionsArg
|
|
52
|
+
constructor(configArg: IWebostvConfig, contextArg: INativeIntegrationSetupContext, optionsArg: IWebostvIntegrationOptions = {}) {
|
|
47
53
|
this.#config = normalizeWebostvConfig(configArg);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
const options = normalizeWebostvOptions(optionsArg);
|
|
55
|
+
this.#context = new NativeHomeAdmission('webostv', { homeId: options.homeId }).admit(contextArg, this.#config.homeId);
|
|
56
|
+
this.#allowedCommands = options.allowedCommands!;
|
|
57
|
+
if (this.#context.configStore) {
|
|
58
|
+
this.#store = this.#context.configStore;
|
|
52
59
|
this.#ownsStore = false;
|
|
53
60
|
} else {
|
|
54
|
-
if (!
|
|
55
|
-
this.#store = new NoSqlConfigStore({ db:
|
|
61
|
+
if (!options.db || !options.keyProvider) throw new TypeError('webOS requires an owner-supplied database and external encryption key provider.');
|
|
62
|
+
this.#store = new NoSqlConfigStore({ db: options.db, keyProvider: options.keyProvider, homeId: this.#context.homeId, integrationDomain: 'webostv' });
|
|
56
63
|
this.#ownsStore = true;
|
|
57
64
|
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#startAbortListener(): void {
|
|
68
|
+
const signalArg = this.#context.abortSignal;
|
|
58
69
|
if (signalArg) {
|
|
59
|
-
const abort = (): void => { void this.destroy(); };
|
|
60
|
-
if (signalArg.aborted)
|
|
70
|
+
const abort = (): void => { void this.destroy().catch(() => undefined); };
|
|
71
|
+
if (signalArg.aborted) abort();
|
|
61
72
|
else { signalArg.addEventListener('abort', abort, { once: true }); this.#removeExternalAbort = () => signalArg.removeEventListener('abort', abort); }
|
|
62
73
|
}
|
|
63
74
|
}
|
|
@@ -66,55 +77,99 @@ export class WebostvClient {
|
|
|
66
77
|
|
|
67
78
|
public async getSnapshot(signalArg?: AbortSignal): Promise<IWebostvSnapshot> {
|
|
68
79
|
if (signalArg?.aborted) throw new Error('webOS snapshot was aborted.');
|
|
69
|
-
if (!this.#start)
|
|
80
|
+
if (!this.#start) {
|
|
81
|
+
if (this.#destroy) throw new Error('webOS client is closed.');
|
|
82
|
+
// Track raw startup before foreign work; its rejection handler joins close only after settlement.
|
|
83
|
+
this.#start = this.#track(() => this.#initialize()).catch(async (errorArg: unknown) => {
|
|
84
|
+
try { await this.destroy(); }
|
|
85
|
+
catch (cleanupErrorArg) { throw new AggregateError([errorArg, cleanupErrorArg], 'webOS setup and cleanup failed.'); }
|
|
86
|
+
throw errorArg;
|
|
87
|
+
});
|
|
88
|
+
this.#startAbortListener();
|
|
89
|
+
}
|
|
70
90
|
await this.#start;
|
|
71
91
|
if (signalArg?.aborted) throw new Error('webOS snapshot was aborted.');
|
|
72
92
|
if (!this.#snapshot) throw new Error('webOS state is unavailable.');
|
|
73
93
|
return structuredClone({ ...this.#snapshot, connected: this.isHealthy() });
|
|
74
94
|
}
|
|
75
95
|
|
|
76
|
-
public
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
96
|
+
public execute(commandArg: TWebostvCommand, argumentArg?: unknown, signalArg?: AbortSignal): Promise<IWebostvCommandResult> {
|
|
97
|
+
return this.#track(async () => {
|
|
98
|
+
if (!this.#allowedCommands.includes(commandArg)) throw new Error('webOS command is not authorized by its owner.');
|
|
99
|
+
if (!this.isHealthy()) throw new Error('webOS is disconnected; command was not sent.');
|
|
100
|
+
let uri: string;
|
|
101
|
+
let payload: Record<string, unknown> = {};
|
|
102
|
+
if (commandArg === 'volume_set') { uri = 'audio/setVolume'; payload = { volume: webostvInteger(argumentArg, 'volume', 0, 100) }; }
|
|
103
|
+
else if (commandArg === 'volume_mute') {
|
|
104
|
+
if (typeof argumentArg !== 'boolean') throw new TypeError('webOS mute must be boolean.');
|
|
105
|
+
uri = 'audio/setMute'; payload = { mute: argumentArg };
|
|
106
|
+
} else if (commandArg === 'select_source') {
|
|
107
|
+
const inputId = webostvString(argumentArg, 'inputId');
|
|
108
|
+
if (!this.#snapshot?.inputs.some((inputArg) => inputArg.id === inputId)) throw new TypeError('webOS input must match an advertised input id.');
|
|
109
|
+
uri = 'tv/switchInput'; payload = { inputId };
|
|
110
|
+
} else {
|
|
111
|
+
if (!Object.hasOwn(commandEndpoints, commandArg) || argumentArg !== undefined) throw new TypeError('Unsupported webOS command or extra arguments.');
|
|
112
|
+
uri = commandEndpoints[commandArg];
|
|
113
|
+
}
|
|
114
|
+
await this.#request('request', uri, payload, undefined, signalArg);
|
|
115
|
+
// Correlated acceptance is not proof of physical completion. Read state independently.
|
|
116
|
+
try {
|
|
117
|
+
const observed: Partial<IWebostvSnapshot> = {};
|
|
118
|
+
for (const [field, endpoint] of Object.entries(endpoints)) {
|
|
119
|
+
this.#applyState(observed, field as keyof typeof endpoints, await this.#request('request', endpoint, {}, undefined, signalArg));
|
|
120
|
+
}
|
|
121
|
+
if (!this.isHealthy() || !this.#snapshot) throw new Error('webOS observation is unavailable.');
|
|
122
|
+
const observation = { ...this.#snapshot, ...observed, observedAt: new Date().toISOString() };
|
|
123
|
+
return { acknowledgement: 'tv-response', physicalCompletion: 'unverified', observationStatus: 'observed', observation: structuredClone(observation) };
|
|
124
|
+
} catch {
|
|
125
|
+
return { acknowledgement: 'tv-response', physicalCompletion: 'unverified', observationStatus: 'unavailable' };
|
|
126
|
+
}
|
|
127
|
+
});
|
|
93
128
|
}
|
|
94
129
|
|
|
95
|
-
public async subscribe(handlerArg:
|
|
130
|
+
public async subscribe(handlerArg: TWebostvClientHandler, signalArg?: AbortSignal): Promise<() => Promise<void>> {
|
|
96
131
|
await this.getSnapshot(signalArg);
|
|
97
132
|
if (signalArg?.aborted || this.#lifetime.signal.aborted) throw new Error('webOS subscription was aborted.');
|
|
98
133
|
if (this.#handlers.size >= 128) throw new Error('webOS subscriber capacity reached.');
|
|
99
|
-
|
|
134
|
+
if (typeof handlerArg !== 'function') throw new TypeError('webOS subscriber must be a function.');
|
|
135
|
+
const subscriber: TWebostvClientHandler = (eventArg) => handlerArg(eventArg);
|
|
100
136
|
this.#handlers.add(subscriber);
|
|
101
137
|
const remove = (): void => { this.#handlers.delete(subscriber); this.#handlerCleanups.delete(subscriber); signalArg?.removeEventListener('abort', remove); };
|
|
102
138
|
this.#handlerCleanups.set(subscriber, remove);
|
|
103
139
|
signalArg?.addEventListener('abort', remove, { once: true });
|
|
104
|
-
return async () =>
|
|
140
|
+
return async () => {
|
|
141
|
+
remove();
|
|
142
|
+
await Promise.allSettled([...(this.#handlerOperations.get(subscriber) ?? [])]);
|
|
143
|
+
};
|
|
105
144
|
}
|
|
106
145
|
|
|
107
146
|
public destroy(): Promise<void> {
|
|
108
147
|
if (this.#destroy) return this.#destroy;
|
|
109
|
-
this.#destroy = this.#close();
|
|
148
|
+
this.#destroy = Promise.resolve().then(() => this.#close());
|
|
149
|
+
this.#ready = false;
|
|
150
|
+
this.#lifetime.abort(new Error('webOS client was closed.'));
|
|
110
151
|
return this.#destroy;
|
|
111
152
|
}
|
|
112
153
|
|
|
154
|
+
public retryCleanup(): Promise<void> {
|
|
155
|
+
if (!this.#destroy) return Promise.reject(new Error('webOS cleanup retry requires terminal close.'));
|
|
156
|
+
if (!this.#retry) {
|
|
157
|
+
const retry = Promise.resolve().then(async () => {
|
|
158
|
+
await this.#destroy!.catch(() => undefined);
|
|
159
|
+
if (this.#ownsStore) await this.#store.retryCleanup();
|
|
160
|
+
});
|
|
161
|
+
this.#retry = retry;
|
|
162
|
+
const settled = (): void => { this.#retry = undefined; };
|
|
163
|
+
void retry.then(settled, settled);
|
|
164
|
+
}
|
|
165
|
+
return this.#retry;
|
|
166
|
+
}
|
|
167
|
+
|
|
113
168
|
async #close(): Promise<void> {
|
|
114
|
-
this.#ready = false;
|
|
115
|
-
this.#lifetime.abort(new Error('webOS client was closed.'));
|
|
116
169
|
this.#removeExternalAbort();
|
|
117
170
|
const closeStore = this.#ownsStore ? this.#store.close() : Promise.resolve();
|
|
171
|
+
// Install a rejection handler immediately; socket shutdown and callback drainage run concurrently.
|
|
172
|
+
const storeResult = Promise.allSettled([closeStore]);
|
|
118
173
|
clearInterval(this.#heartbeat);
|
|
119
174
|
for (const request of this.#pending.values()) { clearTimeout(request.timer); request.cleanup(); request.reject(new Error('webOS connection closed; effects already sent have an indeterminate outcome.')); }
|
|
120
175
|
this.#pending.clear(); this.#subscriptions.clear();
|
|
@@ -122,23 +177,21 @@ export class WebostvClient {
|
|
|
122
177
|
for (const cleanup of [...this.#handlerCleanups.values()]) cleanup();
|
|
123
178
|
this.#handlers.clear();
|
|
124
179
|
const socket = this.#socket;
|
|
125
|
-
if (
|
|
126
|
-
await new Promise<void>((resolveArg) => {
|
|
180
|
+
if (socket && socket.readyState !== plugins.ws.WebSocket.CLOSED) await new Promise<void>((resolveArg) => {
|
|
127
181
|
const timer = setTimeout(() => { socket.terminate(); }, 1000);
|
|
128
182
|
socket.once('close', () => { clearTimeout(timer); socket.removeAllListeners(); resolveArg(); });
|
|
129
183
|
if (socket.readyState === plugins.ws.WebSocket.CONNECTING) socket.terminate();
|
|
130
184
|
else socket.close(1000);
|
|
131
185
|
});
|
|
132
|
-
await Promise.
|
|
186
|
+
await Promise.allSettled([...this.#operations, ...[...this.#handlerOperations.values()].flatMap((operationsArg) => [...operationsArg])]);
|
|
187
|
+
const [result] = await storeResult;
|
|
188
|
+
if (result.status === 'rejected') throw new Error('webOS encrypted pairing cleanup failed; explicit retryCleanup is required.');
|
|
133
189
|
}
|
|
134
190
|
|
|
135
191
|
async #persist<TResult>(operationArg: () => Promise<TResult>): Promise<TResult> {
|
|
136
192
|
if (this.#lifetime.signal.aborted) throw new Error('webOS persistence was aborted.');
|
|
137
|
-
|
|
138
|
-
this.#storage = operation;
|
|
139
|
-
try { return await operation; }
|
|
193
|
+
try { return await operationArg(); }
|
|
140
194
|
catch { throw new Error(this.#lifetime.signal.aborted ? 'webOS persistence was aborted.' : 'webOS encrypted pairing storage failed.'); }
|
|
141
|
-
finally { if (this.#storage === operation) this.#storage = undefined; }
|
|
142
195
|
}
|
|
143
196
|
|
|
144
197
|
async #initialize(): Promise<IWebostvSnapshot> {
|
|
@@ -157,7 +210,8 @@ export class WebostvClient {
|
|
|
157
210
|
const hello = await this.#request('hello', undefined, { appId: 'exchange.smarthome', appName: 'smarthome.exchange' });
|
|
158
211
|
if (hello.deviceUUID !== this.#config.expectedDeviceUUID) throw new Error('webOS hello device UUID does not match the verified TV.');
|
|
159
212
|
// New webOS requires this request before registration; permission denial fails setup explicitly.
|
|
160
|
-
await this.#request('request', 'system/getSystemInfo', {});
|
|
213
|
+
const systemInfo = await this.#request('request', 'system/getSystemInfo', {});
|
|
214
|
+
const model = systemInfo.modelName === undefined ? undefined : webostvString(systemInfo.modelName, 'reported model');
|
|
161
215
|
const registered = await this.#request('register', undefined, {
|
|
162
216
|
pairingType: 'PROMPT', ...(clientKey ? { 'client-key': clientKey } : {}),
|
|
163
217
|
manifest: { manifestVersion: 1, appVersion: '1.0', permissions },
|
|
@@ -178,7 +232,7 @@ export class WebostvClient {
|
|
|
178
232
|
await this.#request('subscribe', uri, {}, (payloadArg) => update(field as keyof typeof endpoints, payloadArg));
|
|
179
233
|
}
|
|
180
234
|
if (initial.powerState === undefined || initial.volume === undefined || initial.muted === undefined || initial.currentAppId === undefined || initial.inputs === undefined) throw new Error('webOS did not supply a complete initial state.');
|
|
181
|
-
this.#snapshot = { homeId: this.#
|
|
235
|
+
this.#snapshot = { homeId: this.#context.homeId, deviceUUID: this.#config.expectedDeviceUUID, name: this.#config.name!, model, authorizedCommands: this.#allowedCommands, connected: true, powerState: initial.powerState, volume: initial.volume, muted: initial.muted, currentAppId: initial.currentAppId, inputs: initial.inputs, observedAt: new Date().toISOString() };
|
|
182
236
|
if (this.#lifetime.signal.aborted || this.#socket?.readyState !== plugins.ws.WebSocket.OPEN) throw new Error('webOS connection closed during setup.');
|
|
183
237
|
this.#ready = true;
|
|
184
238
|
this.#heartbeat = setInterval(() => {
|
|
@@ -288,13 +342,32 @@ export class WebostvClient {
|
|
|
288
342
|
request.reject(request.type === 'request' ? new Error(`${errorArg.message} Effects already sent have an indeterminate outcome.`) : errorArg);
|
|
289
343
|
}
|
|
290
344
|
this.#pending.clear();
|
|
291
|
-
void this.destroy();
|
|
345
|
+
void this.destroy().catch(() => undefined);
|
|
292
346
|
}
|
|
293
347
|
|
|
294
348
|
#emit(eventArg: TWebostvClientEvent): void {
|
|
295
349
|
for (const handler of [...this.#handlers]) {
|
|
296
|
-
|
|
297
|
-
|
|
350
|
+
const operations = this.#handlerOperations.get(handler) ?? new Set<Promise<void>>();
|
|
351
|
+
this.#handlerOperations.set(handler, operations);
|
|
352
|
+
const event = eventArg.type === 'state' ? { ...eventArg, snapshot: structuredClone(eventArg.snapshot) } : eventArg;
|
|
353
|
+
// Publish callback ownership before invoking user code, including close reentry.
|
|
354
|
+
const operation = Promise.resolve().then(() => {
|
|
355
|
+
const result = handler(event);
|
|
356
|
+
// A terminal callback may return the very close it triggered; never make close await itself.
|
|
357
|
+
return result === this.#destroy ? undefined : result;
|
|
358
|
+
}).catch(() => { this.#handlerCleanups.get(handler)?.(); });
|
|
359
|
+
operations.add(operation);
|
|
360
|
+
void operation.then(() => {
|
|
361
|
+
operations.delete(operation);
|
|
362
|
+
if (!operations.size) this.#handlerOperations.delete(handler);
|
|
363
|
+
});
|
|
298
364
|
}
|
|
299
365
|
}
|
|
366
|
+
|
|
367
|
+
#track<TResult>(operationArg: () => Promise<TResult>): Promise<TResult> {
|
|
368
|
+
const operation = Promise.resolve().then(operationArg);
|
|
369
|
+
this.#operations.add(operation);
|
|
370
|
+
void operation.then(() => this.#operations.delete(operation), () => this.#operations.delete(operation));
|
|
371
|
+
return operation;
|
|
372
|
+
}
|
|
300
373
|
}
|
|
@@ -1,22 +1,42 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { NativeIntegrationOwner, type INativeIntegrationOwnerOptions, type NativeIntegrationScope } from '../../core/classes.nativeintegrationowner.js';
|
|
2
|
+
import type { IConfigFlow, IConfigFlowStep, IDiscoveryCandidate, INativeConfigFlowContext } from '../../core/types.js';
|
|
2
3
|
import { createWebostvDiscoveryDescriptor } from './webostv.discovery.js';
|
|
3
4
|
import type { IWebostvConfig } from './webostv.types.js';
|
|
4
5
|
import { normalizeWebostvConfig, webostvKeys, webostvObject } from './webostv.validation.js';
|
|
5
6
|
|
|
6
7
|
export class WebostvConfigFlow implements IConfigFlow<IWebostvConfig> {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
8
|
+
readonly #owner: NativeIntegrationOwner;
|
|
9
|
+
readonly #scope: NativeIntegrationScope;
|
|
10
|
+
readonly #ownsOwner: boolean;
|
|
11
|
+
|
|
12
|
+
constructor(optionsArg: INativeIntegrationOwnerOptions = {}, ownerArg?: NativeIntegrationOwner) {
|
|
13
|
+
this.#owner = ownerArg ?? new NativeIntegrationOwner('webostv', optionsArg);
|
|
14
|
+
this.#ownsOwner = ownerArg === undefined;
|
|
15
|
+
this.#scope = this.#owner.createFlowScope();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public start(candidateArg: IDiscoveryCandidate, contextArg: INativeConfigFlowContext): Promise<IConfigFlowStep<IWebostvConfig>> {
|
|
19
|
+
const context = this.#scope.admit(contextArg);
|
|
20
|
+
return this.#scope.run(context, undefined, async () => {
|
|
21
|
+
const result = await createWebostvDiscoveryDescriptor().getValidators()[0].validate(candidateArg, context);
|
|
22
|
+
if (!result.matched || !result.candidate) return { kind: 'error', error: result.reason };
|
|
23
|
+
const candidate = result.candidate;
|
|
24
|
+
return { kind: 'form', title: 'Pair verified LG webOS TV', description: 'Pairing uses the selected home. Prompt mode requires confirmation on the TV.',
|
|
25
|
+
fields: [{ name: 'pairingMode', label: 'Pairing mode', type: 'select', required: true, options: [{ label: 'Use durable existing pairing', value: 'existing' }, { label: 'Confirm new pairing on TV', value: 'prompt' }] }, { name: 'allowUnencryptedConnection', label: 'Allow explicit cleartext local endpoint', type: 'boolean' }],
|
|
26
|
+
submit: async (valuesArg) => {
|
|
27
|
+
try {
|
|
28
|
+
return await this.#scope.run(context, context.homeId, async () => {
|
|
29
|
+
const values = webostvObject(valuesArg, 'webOS config flow values');
|
|
30
|
+
webostvKeys(values, ['pairingMode', 'allowUnencryptedConnection', 'certificateAuthority']);
|
|
31
|
+
const config = normalizeWebostvConfig({ ...values, homeId: context.homeId, endpoint: candidate.metadata!.endpoint, expectedDeviceUUID: candidate.metadata!.expectedDeviceUUID, name: candidate.name } as IWebostvConfig);
|
|
32
|
+
return { kind: 'done', config };
|
|
33
|
+
});
|
|
34
|
+
} catch (errorArg) { return { kind: 'error', error: errorArg instanceof Error ? errorArg.message : 'Invalid webOS config.' }; }
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
});
|
|
21
38
|
}
|
|
39
|
+
|
|
40
|
+
public close(): Promise<void> { return this.#ownsOwner ? this.#owner.close() : this.#scope.close(); }
|
|
41
|
+
public retryCleanup(): Promise<void> { return this.#ownsOwner ? this.#owner.retryCleanup() : this.#scope.retryCleanup(); }
|
|
22
42
|
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
2
|
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
|
3
|
-
import {
|
|
4
|
-
import type { IDeviceFeatureWriteRequest, IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime,
|
|
3
|
+
import { NativeIntegrationOwner } from '../../core/classes.nativeintegrationowner.js';
|
|
4
|
+
import type { IDeviceFeatureWriteRequest, IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, INativeIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
|
5
5
|
import { WebostvClient } from './webostv.classes.client.js';
|
|
6
6
|
import { WebostvConfigFlow } from './webostv.classes.configflow.js';
|
|
7
7
|
import { createWebostvDiscoveryDescriptor } from './webostv.discovery.js';
|
|
8
8
|
import { WebostvMapper } from './webostv.mapper.js';
|
|
9
9
|
import type { IWebostvConfig, IWebostvIntegrationOptions, IWebostvSnapshot, TWebostvCommand } from './webostv.types.js';
|
|
10
10
|
import { webostvProfile } from './webostv.types.js';
|
|
11
|
-
import { webostvKeys, webostvObject } from './webostv.validation.js';
|
|
11
|
+
import { normalizeWebostvConfig, normalizeWebostvOptions, webostvKeys, webostvObject } from './webostv.validation.js';
|
|
12
12
|
|
|
13
13
|
export class WebostvIntegration extends BaseIntegration<IWebostvConfig> {
|
|
14
14
|
public readonly domain = 'webostv';
|
|
@@ -16,51 +16,58 @@ export class WebostvIntegration extends BaseIntegration<IWebostvConfig> {
|
|
|
16
16
|
public readonly status = 'control-runtime' as const;
|
|
17
17
|
public readonly metadata = webostvProfile.metadata;
|
|
18
18
|
public readonly discoveryDescriptor = createWebostvDiscoveryDescriptor();
|
|
19
|
-
public readonly configFlow
|
|
20
|
-
readonly #
|
|
21
|
-
readonly #
|
|
22
|
-
#destroyed = false;
|
|
19
|
+
public readonly configFlow: WebostvConfigFlow;
|
|
20
|
+
readonly #owner: NativeIntegrationOwner;
|
|
21
|
+
readonly #options: Readonly<IWebostvIntegrationOptions>;
|
|
23
22
|
#destroy?: Promise<void>;
|
|
24
23
|
|
|
25
|
-
constructor(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const client = new WebostvClient(configArg, contextArg.configStore ?? this.options, contextArg.abortSignal);
|
|
31
|
-
this.#pending.add(client);
|
|
32
|
-
try {
|
|
33
|
-
const snapshot = await client.getSnapshot(contextArg.abortSignal);
|
|
34
|
-
if (this.#destroyed) throw new Error('webOS integration was destroyed during setup.');
|
|
35
|
-
const runtime = new WebostvRuntime(client, snapshot, () => this.#runtimes.delete(runtime));
|
|
36
|
-
this.#runtimes.add(runtime);
|
|
37
|
-
return runtime;
|
|
38
|
-
} catch (errorArg) { await client.destroy(); throw errorArg; }
|
|
39
|
-
finally { this.#pending.delete(client); }
|
|
24
|
+
constructor(optionsArg: IWebostvIntegrationOptions = {}) {
|
|
25
|
+
super();
|
|
26
|
+
this.#options = normalizeWebostvOptions(optionsArg);
|
|
27
|
+
this.#owner = new NativeIntegrationOwner(this.domain, { homeId: this.#options.homeId, capacity: this.#options.capacity });
|
|
28
|
+
this.configFlow = new WebostvConfigFlow({}, this.#owner);
|
|
40
29
|
}
|
|
41
30
|
|
|
42
|
-
public
|
|
43
|
-
|
|
44
|
-
this.#
|
|
45
|
-
|
|
46
|
-
|
|
31
|
+
public setup(configArg: IWebostvConfig, contextArg: INativeIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
|
32
|
+
const config = normalizeWebostvConfig(configArg);
|
|
33
|
+
return this.#owner.runSetup(contextArg, config.homeId, async (leaseArg) => {
|
|
34
|
+
const client = new WebostvClient(config, leaseArg.context, this.#options);
|
|
35
|
+
let cleanupStarted = false;
|
|
36
|
+
leaseArg.own(() => {
|
|
37
|
+
if (cleanupStarted) return client.retryCleanup();
|
|
38
|
+
cleanupStarted = true;
|
|
39
|
+
return client.destroy();
|
|
40
|
+
});
|
|
41
|
+
const snapshot = await client.getSnapshot();
|
|
42
|
+
leaseArg.promoteToRuntime();
|
|
43
|
+
return new WebostvRuntime(client, snapshot, () => leaseArg.close(), (promiseArg) => promiseArg === this.#destroy);
|
|
44
|
+
});
|
|
47
45
|
}
|
|
46
|
+
|
|
47
|
+
public destroy(): Promise<void> { return this.#destroy ??= this.#owner.close(); }
|
|
48
|
+
public retryCleanup(): Promise<void> { return this.#owner.retryCleanup(); }
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
export class HomeAssistantWebostvIntegration extends WebostvIntegration {}
|
|
51
52
|
|
|
52
53
|
class WebostvRuntime implements IIntegrationRuntime {
|
|
53
54
|
public readonly domain = 'webostv';
|
|
54
|
-
|
|
55
|
+
#destroy?: Promise<void>;
|
|
56
|
+
constructor(private readonly client: WebostvClient, private readonly identity: IWebostvSnapshot,
|
|
57
|
+
private readonly close: () => Promise<void>, private readonly isOwnerClose: (promiseArg: unknown) => boolean) {}
|
|
55
58
|
public isHealthy(): boolean { return this.client.isHealthy(); }
|
|
56
59
|
public async devices(signalArg?: AbortSignal): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> { return WebostvMapper.toDevices(await this.client.getSnapshot(signalArg)); }
|
|
57
60
|
public async entities(): Promise<IIntegrationEntity[]> { return WebostvMapper.toEntities(await this.client.getSnapshot()); }
|
|
58
61
|
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void, signalArg?: AbortSignal): Promise<() => Promise<void>> {
|
|
62
|
+
const handler = handlerArg as (eventArg: IIntegrationEvent) => void | Promise<void>;
|
|
59
63
|
return this.client.subscribe((eventArg) => {
|
|
60
64
|
const base = { integrationDomain: this.domain, deviceId: WebostvMapper.deviceId(this.identity), timestamp: Date.now() };
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
const result = eventArg.type === 'state'
|
|
66
|
+
? handler({ ...base, type: 'state_changed', entityId: `${base.deviceId}:media_player`, data: { entity: WebostvMapper.toEntities(eventArg.snapshot)[0] } })
|
|
67
|
+
: eventArg.type === 'availability'
|
|
68
|
+
? handler({ ...base, type: 'availability_changed', data: { available: eventArg.available } })
|
|
69
|
+
: handler({ ...base, type: 'error', data: { message: eventArg.error.message } });
|
|
70
|
+
return result === this.#destroy || this.isOwnerClose(result) ? undefined : result;
|
|
64
71
|
}, signalArg);
|
|
65
72
|
}
|
|
66
73
|
|
|
@@ -81,8 +88,7 @@ class WebostvRuntime implements IIntegrationRuntime {
|
|
|
81
88
|
else if (service === 'volume_mute') { webostvKeys(data, ['mute']); argument = data.mute; }
|
|
82
89
|
else if (service === 'select_source') { webostvKeys(data, ['inputId']); argument = data.inputId; }
|
|
83
90
|
else webostvKeys(data, []);
|
|
84
|
-
await this.client.execute(service as TWebostvCommand, argument);
|
|
85
|
-
return { success: true, data: { acknowledgement: 'tv-response', state: 'observe-subscriptions' } };
|
|
91
|
+
return { success: true, data: await this.client.execute(service as TWebostvCommand, argument) };
|
|
86
92
|
} catch (errorArg) { return { success: false, error: errorArg instanceof Error ? errorArg.message : 'webOS operation failed.' }; }
|
|
87
93
|
}
|
|
88
94
|
|
|
@@ -92,11 +98,12 @@ class WebostvRuntime implements IIntegrationRuntime {
|
|
|
92
98
|
const device = webostvObject(request.device, 'feature device');
|
|
93
99
|
const feature = webostvObject(request.feature, 'feature');
|
|
94
100
|
if (device.id !== WebostvMapper.deviceId(this.identity) || device.integrationDomain !== this.domain) throw new Error('webOS feature device is not owned by this runtime.');
|
|
95
|
-
|
|
96
|
-
|
|
101
|
+
let result;
|
|
102
|
+
if (feature.id === 'volume') result = await this.client.execute('volume_set', request.value, signalArg);
|
|
103
|
+
else if (feature.id === 'muted') result = await this.client.execute('volume_mute', request.value, signalArg);
|
|
97
104
|
else throw new Error('webOS feature is not writable.');
|
|
98
|
-
return { success: true, data:
|
|
105
|
+
return { success: true, data: result };
|
|
99
106
|
} catch (errorArg) { return { success: false, error: errorArg instanceof Error ? errorArg.message : 'webOS feature write failed.' }; }
|
|
100
107
|
}
|
|
101
|
-
public
|
|
108
|
+
public destroy(): Promise<void> { return this.#destroy ??= this.close(); }
|
|
102
109
|
}
|
|
@@ -13,15 +13,15 @@ export class WebostvMapper {
|
|
|
13
13
|
];
|
|
14
14
|
return [{
|
|
15
15
|
id: this.deviceId(snapshotArg), integrationDomain: 'webostv', name: snapshotArg.name,
|
|
16
|
-
protocol: 'http', manufacturer: 'LG', model:
|
|
16
|
+
protocol: 'http', manufacturer: 'LG', model: snapshotArg.model, online: snapshotArg.connected,
|
|
17
17
|
features: [
|
|
18
18
|
{ id: 'power', capability: 'media', name: 'Power state', readable: true, writable: false },
|
|
19
|
-
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable:
|
|
20
|
-
{ id: 'muted', capability: 'media', name: 'Muted', readable: true, writable:
|
|
19
|
+
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: snapshotArg.connected && snapshotArg.authorizedCommands.includes('volume_set'), unit: '%' },
|
|
20
|
+
{ id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: snapshotArg.connected && snapshotArg.authorizedCommands.includes('volume_mute') },
|
|
21
21
|
{ id: 'current_app', capability: 'media', name: 'Foreground application', readable: true, writable: false },
|
|
22
22
|
],
|
|
23
23
|
state: states.map(([featureId, value]) => ({ featureId, value, updatedAt: snapshotArg.observedAt })),
|
|
24
|
-
metadata: { homeId: snapshotArg.homeId, deviceUUID: snapshotArg.deviceUUID, transport: 'ssap-websocket', inputs: structuredClone(snapshotArg.inputs) },
|
|
24
|
+
metadata: { homeId: snapshotArg.homeId, deviceUUID: snapshotArg.deviceUUID, transport: 'ssap-websocket', inputs: structuredClone(snapshotArg.inputs), authorizedCommands: [...snapshotArg.authorizedCommands], modelKnown: snapshotArg.model !== undefined },
|
|
25
25
|
}];
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type * as plugins from '../../plugins.js';
|
|
2
2
|
import type { IConfigEncryptionKeyProvider } from '../../core/classes.configstore.js';
|
|
3
|
+
import type { INativeIntegrationOwnerOptions } from '../../core/classes.nativeintegrationowner.js';
|
|
3
4
|
|
|
4
5
|
export const webostvDomain = 'webostv';
|
|
5
6
|
export const webostvDefaultName = 'LG webOS TV';
|
|
@@ -19,9 +20,11 @@ export interface IWebostvConfig {
|
|
|
19
20
|
maxPendingRequests?: number;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
export interface IWebostvIntegrationOptions {
|
|
23
|
-
db
|
|
24
|
-
keyProvider
|
|
23
|
+
export interface IWebostvIntegrationOptions extends INativeIntegrationOwnerOptions {
|
|
24
|
+
db?: plugins.nosqldb.SmartdataDb;
|
|
25
|
+
keyProvider?: IConfigEncryptionKeyProvider;
|
|
26
|
+
/** Runtime-only owner permission. An omitted or empty list authorizes no effects. */
|
|
27
|
+
allowedCommands?: readonly TWebostvCommand[];
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
export interface IHomeAssistantWebostvConfig extends IWebostvConfig {}
|
|
@@ -30,7 +33,10 @@ export interface IWebostvSnapshot {
|
|
|
30
33
|
homeId: string;
|
|
31
34
|
deviceUUID: string;
|
|
32
35
|
name: string;
|
|
36
|
+
/** Actual system/getSystemInfo modelName, absent when the TV did not report one. */
|
|
37
|
+
model?: string;
|
|
33
38
|
connected: boolean;
|
|
39
|
+
authorizedCommands: readonly TWebostvCommand[];
|
|
34
40
|
powerState: string;
|
|
35
41
|
volume: number;
|
|
36
42
|
muted: boolean;
|
|
@@ -45,6 +51,15 @@ export type TWebostvClientEvent =
|
|
|
45
51
|
| { type: 'error'; error: Error };
|
|
46
52
|
export type TWebostvCommand = 'turn_on' | 'turn_off' | 'volume_set' | 'volume_up' | 'volume_down' | 'volume_mute' | 'select_source' | 'media_play' | 'media_pause' | 'media_stop';
|
|
47
53
|
|
|
54
|
+
export interface IWebostvCommandResult {
|
|
55
|
+
acknowledgement: 'tv-response';
|
|
56
|
+
physicalCompletion: 'unverified';
|
|
57
|
+
observationStatus: 'observed' | 'unavailable';
|
|
58
|
+
observation?: IWebostvSnapshot;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type TWebostvClientHandler = (eventArg: TWebostvClientEvent) => void | Promise<void>;
|
|
62
|
+
|
|
48
63
|
export const webostvProfile = {
|
|
49
64
|
domain: webostvDomain, displayName: webostvDefaultName, status: 'control-runtime',
|
|
50
65
|
metadata: { source: 'home-assistant/core', upstreamDomain: webostvDomain, localApi: { implemented: ['verified SSAP registration', 'encrypted durable pairing keys', 'authoritative state subscriptions'], explicitUnsupported: ['fixture or injected-client production configs', 'deep standby wake-on-LAN', 'next/previous media track', 'automatic command replay'] } },
|
|
@@ -1,4 +1,25 @@
|
|
|
1
|
-
import type { IWebostvConfig } from './webostv.types.js';
|
|
1
|
+
import type { IWebostvConfig, IWebostvIntegrationOptions, TWebostvCommand } from './webostv.types.js';
|
|
2
|
+
|
|
3
|
+
export const webostvCommands: readonly TWebostvCommand[] = Object.freeze(['turn_on', 'turn_off', 'volume_set', 'volume_up', 'volume_down', 'volume_mute', 'select_source', 'media_play', 'media_pause', 'media_stop']);
|
|
4
|
+
|
|
5
|
+
export const normalizeWebostvOptions = (valueArg: IWebostvIntegrationOptions = {}): Readonly<IWebostvIntegrationOptions> => {
|
|
6
|
+
const value = webostvObject(valueArg, 'webOS owner options');
|
|
7
|
+
webostvKeys(value, ['homeId', 'capacity', 'db', 'keyProvider', 'allowedCommands']);
|
|
8
|
+
if ((value.db === undefined) !== (value.keyProvider === undefined)) throw new TypeError('webOS persistence requires both database and keyProvider.');
|
|
9
|
+
const commands = value.allowedCommands ?? [];
|
|
10
|
+
if (!Array.isArray(commands) || commands.length > webostvCommands.length || Reflect.ownKeys(commands).some((keyArg) => {
|
|
11
|
+
const descriptor = Object.getOwnPropertyDescriptor(commands, keyArg);
|
|
12
|
+
return typeof keyArg !== 'string' || !descriptor || !('value' in descriptor) || (keyArg !== 'length' && !/^(0|[1-9][0-9]*)$/.test(keyArg));
|
|
13
|
+
})) throw new TypeError('webOS allowedCommands must be a finite data array.');
|
|
14
|
+
const captured: TWebostvCommand[] = [];
|
|
15
|
+
for (let index = 0; index < commands.length; index += 1) {
|
|
16
|
+
const command: unknown = Object.getOwnPropertyDescriptor(commands, String(index))?.value;
|
|
17
|
+
if (typeof command !== 'string' || !webostvCommands.includes(command as TWebostvCommand) || captured.includes(command as TWebostvCommand)) throw new TypeError('webOS allowedCommands contains an unsupported or duplicate command.');
|
|
18
|
+
captured.push(command as TWebostvCommand);
|
|
19
|
+
}
|
|
20
|
+
return Object.freeze({ homeId: value.homeId as string | undefined, capacity: value.capacity as number | undefined,
|
|
21
|
+
db: value.db as IWebostvIntegrationOptions['db'], keyProvider: value.keyProvider as IWebostvIntegrationOptions['keyProvider'], allowedCommands: Object.freeze(captured) });
|
|
22
|
+
};
|
|
2
23
|
|
|
3
24
|
export const webostvObject = (valueArg: unknown, nameArg: string): Record<string, unknown> => {
|
|
4
25
|
if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg) || ![Object.prototype, null].includes(Object.getPrototypeOf(valueArg))) throw new TypeError(`${nameArg} must be a plain object.`);
|