@smarthome.exchange/integrations 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +35 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/core/classes.nativeintegrationowner.d.ts +2 -0
- package/dist_ts/core/classes.nativeintegrationowner.js +5 -1
- package/dist_ts/core/classes.runtimemanager.d.ts +2 -0
- package/dist_ts/core/classes.runtimemanager.js +11 -3
- package/dist_ts/core/types.d.ts +2 -0
- package/dist_ts/integrations/deconz/deconz.classes.client.d.ts +37 -29
- package/dist_ts/integrations/deconz/deconz.classes.client.js +521 -294
- package/dist_ts/integrations/deconz/deconz.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/deconz/deconz.classes.configflow.js +63 -33
- package/dist_ts/integrations/deconz/deconz.classes.integration.d.ts +4 -3
- package/dist_ts/integrations/deconz/deconz.classes.integration.js +85 -40
- package/dist_ts/integrations/deconz/deconz.mapper.d.ts +1 -0
- package/dist_ts/integrations/deconz/deconz.mapper.js +24 -6
- package/dist_ts/integrations/deconz/deconz.types.d.ts +43 -6
- package/dist_ts/integrations/deconz/deconz.validation.d.ts +7 -1
- package/dist_ts/integrations/deconz/deconz.validation.js +215 -4
- package/dist_ts/integrations/esphome/esphome.classes.client.d.ts +19 -7
- package/dist_ts/integrations/esphome/esphome.classes.client.js +183 -89
- package/dist_ts/integrations/esphome/esphome.classes.configflow.d.ts +5 -2
- package/dist_ts/integrations/esphome/esphome.classes.configflow.js +43 -33
- package/dist_ts/integrations/esphome/esphome.classes.integration.d.ts +8 -6
- package/dist_ts/integrations/esphome/esphome.classes.integration.js +71 -43
- package/dist_ts/integrations/esphome/esphome.discovery.js +4 -12
- package/dist_ts/integrations/esphome/esphome.mapper.d.ts +2 -2
- package/dist_ts/integrations/esphome/esphome.mapper.js +6 -4
- package/dist_ts/integrations/esphome/esphome.types.d.ts +31 -6
- package/dist_ts/integrations/esphome/esphome.types.js +1 -1
- package/dist_ts/integrations/esphome/esphome.validation.d.ts +7 -0
- package/dist_ts/integrations/esphome/esphome.validation.js +66 -0
- package/dist_ts/integrations/homeassistant/homeassistant.classes.client.d.ts +32 -4
- package/dist_ts/integrations/homeassistant/homeassistant.classes.client.js +345 -116
- package/dist_ts/integrations/homeassistant/homeassistant.classes.integration.d.ts +8 -5
- package/dist_ts/integrations/homeassistant/homeassistant.classes.integration.js +34 -8
- package/dist_ts/integrations/homeassistant/homeassistant.classes.runtime.d.ts +7 -4
- package/dist_ts/integrations/homeassistant/homeassistant.classes.runtime.js +44 -56
- package/dist_ts/integrations/homeassistant/homeassistant.types.d.ts +12 -1
- package/dist_ts/integrations/hue/hue.classes.client.d.ts +6 -1
- package/dist_ts/integrations/hue/hue.classes.client.js +120 -45
- package/dist_ts/integrations/hue/hue.classes.configflow.js +16 -7
- package/dist_ts/integrations/hue/hue.classes.integration.d.ts +2 -0
- package/dist_ts/integrations/hue/hue.classes.integration.js +103 -30
- package/dist_ts/integrations/hue/hue.validation.d.ts +1 -0
- package/dist_ts/integrations/hue/hue.validation.js +14 -4
- package/dist_ts/integrations/mqtt/mqtt.classes.client.d.ts +15 -2
- package/dist_ts/integrations/mqtt/mqtt.classes.client.js +173 -23
- package/dist_ts/integrations/mqtt/mqtt.classes.configflow.d.ts +5 -1
- package/dist_ts/integrations/mqtt/mqtt.classes.configflow.js +53 -33
- package/dist_ts/integrations/mqtt/mqtt.classes.integration.d.ts +4 -1
- package/dist_ts/integrations/mqtt/mqtt.classes.integration.js +127 -56
- package/dist_ts/integrations/mqtt/mqtt.classes.session.d.ts +13 -0
- package/dist_ts/integrations/mqtt/mqtt.classes.session.js +130 -0
- package/dist_ts/integrations/mqtt/mqtt.mapper.d.ts +3 -0
- package/dist_ts/integrations/mqtt/mqtt.mapper.js +44 -1
- package/dist_ts/integrations/mqtt/mqtt.types.d.ts +46 -0
- package/dist_ts/integrations/mqtt/mqtt.validation.d.ts +7 -0
- package/dist_ts/integrations/mqtt/mqtt.validation.js +180 -0
- package/dist_ts/integrations/samsungtv/samsungtv.classes.client.d.ts +4 -2
- package/dist_ts/integrations/samsungtv/samsungtv.classes.client.js +41 -16
- package/dist_ts/integrations/samsungtv/samsungtv.classes.configflow.d.ts +5 -0
- package/dist_ts/integrations/samsungtv/samsungtv.classes.configflow.js +20 -7
- package/dist_ts/integrations/samsungtv/samsungtv.classes.integration.d.ts +1 -0
- package/dist_ts/integrations/samsungtv/samsungtv.classes.integration.js +53 -50
- package/dist_ts/integrations/samsungtv/samsungtv.types.d.ts +4 -1
- package/dist_ts/integrations/shelly/shelly.classes.client.d.ts +26 -30
- package/dist_ts/integrations/shelly/shelly.classes.client.js +504 -276
- package/dist_ts/integrations/shelly/shelly.classes.configflow.d.ts +8 -3
- package/dist_ts/integrations/shelly/shelly.classes.configflow.js +43 -38
- package/dist_ts/integrations/shelly/shelly.classes.integration.d.ts +6 -6
- package/dist_ts/integrations/shelly/shelly.classes.integration.js +103 -101
- package/dist_ts/integrations/shelly/shelly.classes.subscription.d.ts +4 -0
- package/dist_ts/integrations/shelly/shelly.classes.subscription.js +24 -2
- package/dist_ts/integrations/shelly/shelly.mapper.d.ts +1 -2
- package/dist_ts/integrations/shelly/shelly.mapper.js +21 -20
- package/dist_ts/integrations/shelly/shelly.rpc.js +4 -1
- package/dist_ts/integrations/shelly/shelly.types.d.ts +42 -2
- package/dist_ts/integrations/shelly/shelly.types.js +11 -2
- package/dist_ts/integrations/shelly/shelly.validation.d.ts +7 -0
- package/dist_ts/integrations/shelly/shelly.validation.js +79 -0
- package/dist_ts/integrations/sonos/sonos.classes.client.d.ts +79 -22
- package/dist_ts/integrations/sonos/sonos.classes.client.js +427 -139
- package/dist_ts/integrations/sonos/sonos.classes.configflow.d.ts +9 -2
- package/dist_ts/integrations/sonos/sonos.classes.configflow.js +26 -28
- package/dist_ts/integrations/sonos/sonos.classes.integration.d.ts +7 -6
- package/dist_ts/integrations/sonos/sonos.classes.integration.js +289 -224
- package/dist_ts/integrations/sonos/sonos.config.d.ts +6 -1
- package/dist_ts/integrations/sonos/sonos.config.js +23 -2
- package/dist_ts/integrations/sonos/sonos.mapper.d.ts +1 -1
- package/dist_ts/integrations/sonos/sonos.mapper.js +14 -9
- package/dist_ts/integrations/sonos/sonos.matchers.js +3 -3
- package/dist_ts/integrations/sonos/sonos.types.d.ts +26 -1
- package/dist_ts/integrations/tasmota/tasmota.classes.integration.d.ts +5 -0
- package/dist_ts/integrations/tasmota/tasmota.classes.integration.js +23 -7
- package/dist_ts/integrations/tasmota/tasmota.classes.runtime.d.ts +19 -5
- package/dist_ts/integrations/tasmota/tasmota.classes.runtime.js +115 -41
- package/dist_ts/integrations/tasmota/tasmota.config.js +23 -1
- package/dist_ts/integrations/tasmota/tasmota.credentials.d.ts +11 -0
- package/dist_ts/integrations/tasmota/tasmota.credentials.js +97 -0
- package/dist_ts/integrations/tasmota/tasmota.discovery.d.ts +7 -1
- package/dist_ts/integrations/tasmota/tasmota.discovery.js +108 -30
- package/dist_ts/integrations/tasmota/tasmota.mapper.d.ts +2 -2
- package/dist_ts/integrations/tasmota/tasmota.mapper.js +7 -7
- package/dist_ts/integrations/tasmota/tasmota.types.d.ts +41 -2
- package/dist_ts/integrations/tuya/tuya.classes.integration.d.ts +4 -1
- package/dist_ts/integrations/tuya/tuya.classes.integration.js +79 -110
- package/dist_ts/integrations/tuya/tuya.classes.runtime.d.ts +5 -2
- package/dist_ts/integrations/tuya/tuya.classes.runtime.js +73 -26
- package/dist_ts/integrations/tuya/tuya.mapper.js +2 -2
- package/dist_ts/integrations/tuya/tuya.types.d.ts +3 -0
- package/dist_ts/integrations/tuya/tuya.validation.d.ts +2 -2
- package/dist_ts/integrations/tuya/tuya.validation.js +6 -11
- package/dist_ts/integrations/webostv/webostv.classes.client.d.ts +6 -5
- package/dist_ts/integrations/webostv/webostv.classes.client.js +147 -76
- package/dist_ts/integrations/webostv/webostv.classes.configflow.d.ts +7 -2
- package/dist_ts/integrations/webostv/webostv.classes.configflow.js +36 -20
- package/dist_ts/integrations/webostv/webostv.classes.integration.d.ts +4 -4
- package/dist_ts/integrations/webostv/webostv.classes.integration.js +46 -54
- package/dist_ts/integrations/webostv/webostv.mapper.js +5 -5
- package/dist_ts/integrations/webostv/webostv.types.d.ts +16 -3
- package/dist_ts/integrations/webostv/webostv.types.js +1 -1
- package/dist_ts/integrations/webostv/webostv.validation.d.ts +3 -1
- package/dist_ts/integrations/webostv/webostv.validation.js +23 -1
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.js +18 -8
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.d.ts +4 -1
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.js +54 -6
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.d.ts +12 -6
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.js +73 -59
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.d.ts +3 -2
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.js +17 -12
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.types.d.ts +31 -2
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.d.ts +9 -1
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.js +57 -7
- package/dist_ts/integrations/zwave_js/zwave_js.classes.client.d.ts +10 -4
- package/dist_ts/integrations/zwave_js/zwave_js.classes.client.js +167 -31
- package/dist_ts/integrations/zwave_js/zwave_js.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/zwave_js/zwave_js.classes.configflow.js +57 -35
- package/dist_ts/integrations/zwave_js/zwave_js.classes.integration.d.ts +3 -1
- package/dist_ts/integrations/zwave_js/zwave_js.classes.integration.js +59 -37
- package/dist_ts/integrations/zwave_js/zwave_js.discovery.js +3 -3
- package/dist_ts/integrations/zwave_js/zwave_js.mapper.js +41 -24
- package/dist_ts/integrations/zwave_js/zwave_js.types.d.ts +25 -3
- package/dist_ts/integrations/zwave_js/zwave_js.validation.d.ts +6 -2
- package/dist_ts/integrations/zwave_js/zwave_js.validation.js +52 -17
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/package.json +8 -8
- package/readme.md +25 -6
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/core/classes.nativeintegrationowner.ts +5 -0
- package/ts/core/classes.runtimemanager.ts +11 -2
- package/ts/core/types.ts +2 -0
- package/ts/integrations/deconz/deconz.classes.client.ts +348 -252
- package/ts/integrations/deconz/deconz.classes.configflow.ts +57 -29
- package/ts/integrations/deconz/deconz.classes.integration.ts +69 -37
- package/ts/integrations/deconz/deconz.mapper.ts +22 -5
- package/ts/integrations/deconz/deconz.types.ts +49 -6
- package/ts/integrations/deconz/deconz.validation.ts +177 -4
- package/ts/integrations/deconz/readme.md +15 -1
- package/ts/integrations/esphome/esphome.classes.client.ts +179 -94
- package/ts/integrations/esphome/esphome.classes.configflow.ts +40 -33
- package/ts/integrations/esphome/esphome.classes.integration.ts +63 -36
- package/ts/integrations/esphome/esphome.discovery.ts +3 -12
- package/ts/integrations/esphome/esphome.mapper.ts +9 -7
- package/ts/integrations/esphome/esphome.types.ts +35 -6
- package/ts/integrations/esphome/esphome.validation.ts +66 -0
- package/ts/integrations/esphome/readme.md +20 -6
- package/ts/integrations/homeassistant/homeassistant.classes.client.ts +353 -125
- package/ts/integrations/homeassistant/homeassistant.classes.integration.ts +35 -18
- package/ts/integrations/homeassistant/homeassistant.classes.runtime.ts +50 -63
- package/ts/integrations/homeassistant/homeassistant.types.ts +17 -1
- package/ts/integrations/homeassistant/readme.md +22 -6
- package/ts/integrations/hue/hue.classes.client.ts +110 -41
- package/ts/integrations/hue/hue.classes.configflow.ts +12 -6
- package/ts/integrations/hue/hue.classes.integration.ts +92 -26
- package/ts/integrations/hue/hue.validation.ts +13 -3
- package/ts/integrations/hue/readme.md +3 -3
- package/ts/integrations/mqtt/mqtt.classes.client.ts +136 -19
- package/ts/integrations/mqtt/mqtt.classes.configflow.ts +45 -32
- package/ts/integrations/mqtt/mqtt.classes.integration.ts +99 -38
- package/ts/integrations/mqtt/mqtt.classes.session.ts +118 -0
- package/ts/integrations/mqtt/mqtt.mapper.ts +43 -1
- package/ts/integrations/mqtt/mqtt.types.ts +52 -0
- package/ts/integrations/mqtt/mqtt.validation.ts +157 -0
- package/ts/integrations/mqtt/readme.md +12 -3
- package/ts/integrations/samsungtv/readme.md +11 -5
- package/ts/integrations/samsungtv/samsungtv.classes.client.ts +40 -15
- package/ts/integrations/samsungtv/samsungtv.classes.configflow.ts +21 -6
- package/ts/integrations/samsungtv/samsungtv.classes.integration.ts +53 -38
- package/ts/integrations/samsungtv/samsungtv.types.ts +4 -1
- package/ts/integrations/shelly/readme.md +22 -7
- package/ts/integrations/shelly/shelly.classes.client.ts +386 -269
- package/ts/integrations/shelly/shelly.classes.configflow.ts +43 -38
- package/ts/integrations/shelly/shelly.classes.integration.ts +92 -111
- package/ts/integrations/shelly/shelly.classes.subscription.ts +24 -1
- package/ts/integrations/shelly/shelly.mapper.ts +19 -20
- package/ts/integrations/shelly/shelly.rpc.ts +2 -0
- package/ts/integrations/shelly/shelly.types.ts +50 -2
- package/ts/integrations/shelly/shelly.validation.ts +76 -0
- package/ts/integrations/sonos/readme.md +117 -38
- package/ts/integrations/sonos/sonos.classes.client.ts +392 -123
- package/ts/integrations/sonos/sonos.classes.configflow.ts +25 -28
- package/ts/integrations/sonos/sonos.classes.integration.ts +211 -186
- package/ts/integrations/sonos/sonos.config.ts +24 -2
- package/ts/integrations/sonos/sonos.mapper.ts +13 -8
- package/ts/integrations/sonos/sonos.matchers.ts +2 -2
- package/ts/integrations/sonos/sonos.types.ts +30 -1
- package/ts/integrations/tasmota/readme.md +16 -5
- package/ts/integrations/tasmota/tasmota.classes.integration.ts +24 -6
- package/ts/integrations/tasmota/tasmota.classes.runtime.ts +112 -43
- package/ts/integrations/tasmota/tasmota.config.ts +17 -0
- package/ts/integrations/tasmota/tasmota.credentials.ts +92 -0
- package/ts/integrations/tasmota/tasmota.discovery.ts +87 -24
- package/ts/integrations/tasmota/tasmota.mapper.ts +6 -6
- package/ts/integrations/tasmota/tasmota.types.ts +43 -2
- package/ts/integrations/tuya/readme.md +52 -10
- package/ts/integrations/tuya/tuya.classes.integration.ts +69 -98
- package/ts/integrations/tuya/tuya.classes.runtime.ts +70 -24
- package/ts/integrations/tuya/tuya.mapper.ts +1 -1
- package/ts/integrations/tuya/tuya.types.ts +4 -0
- package/ts/integrations/tuya/tuya.validation.ts +5 -8
- package/ts/integrations/webostv/readme.md +115 -22
- package/ts/integrations/webostv/webostv.classes.client.ts +123 -50
- package/ts/integrations/webostv/webostv.classes.configflow.ts +35 -15
- package/ts/integrations/webostv/webostv.classes.integration.ts +44 -37
- package/ts/integrations/webostv/webostv.mapper.ts +4 -4
- package/ts/integrations/webostv/webostv.types.ts +18 -3
- package/ts/integrations/webostv/webostv.validation.ts +22 -1
- package/ts/integrations/zigbee2mqtt/readme.md +22 -27
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.ts +16 -7
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.ts +53 -6
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.ts +62 -49
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.ts +17 -11
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.types.ts +35 -2
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.ts +53 -7
- package/ts/integrations/zwave_js/readme.md +42 -9
- package/ts/integrations/zwave_js/zwave_js.classes.client.ts +155 -31
- package/ts/integrations/zwave_js/zwave_js.classes.configflow.ts +55 -31
- package/ts/integrations/zwave_js/zwave_js.classes.integration.ts +57 -35
- package/ts/integrations/zwave_js/zwave_js.discovery.ts +2 -2
- package/ts/integrations/zwave_js/zwave_js.mapper.ts +37 -23
- package/ts/integrations/zwave_js/zwave_js.types.ts +26 -3
- package/ts/integrations/zwave_js/zwave_js.validation.ts +52 -18
- package/ts/plugins.ts +2 -1
|
@@ -1,169 +1,438 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
|
-
import {
|
|
2
|
+
import { NativeHomeAdmission, type INativeAdmissionContext } from '../../core/classes.nativehomeadmission.js';
|
|
3
|
+
import { NativeIntegrationOwner, type INativeOperationLease, type INativeSetupLease } from '../../core/classes.nativeintegrationowner.js';
|
|
4
|
+
import type { INativeIntegrationSetupContext } from '../../core/types.js';
|
|
5
|
+
import { isSonosDataRecord, normalizeSonosConfig, normalizeSonosOwnerOptions, normalizeSonosUid, type INormalizedSonosConfig } from './sonos.config.js';
|
|
3
6
|
import { parseSonosGroup } from './sonos.topology.js';
|
|
4
|
-
import type { ISonosConfig, ISonosGroup, ISonosMediaOptions, ISonosPositionInfo, ISonosSnapshot, ISonosSpeakerInfo, ISonosTransportInfo } from './sonos.types.js';
|
|
7
|
+
import type { ISonosCapabilities, ISonosConfig, ISonosControlResult, ISonosGroup, ISonosMediaOptions, ISonosOwnerOptions, ISonosPositionInfo, ISonosSnapshot, ISonosSpeakerInfo, ISonosTransportInfo } from './sonos.types.js';
|
|
5
8
|
import { readSonosXmlElement, readSonosText, readSonosOptionalText } from './sonos.xml.js';
|
|
6
9
|
|
|
7
10
|
const transportPath = '/MediaRenderer/AVTransport/Control';
|
|
11
|
+
const renderingPath = '/MediaRenderer/RenderingControl/Control';
|
|
12
|
+
const transportService = 'urn:schemas-upnp-org:service:AVTransport:1';
|
|
13
|
+
const renderingService = 'urn:schemas-upnp-org:service:RenderingControl:1';
|
|
14
|
+
|
|
15
|
+
interface ISonosDescription { speakerInfo: ISonosSpeakerInfo; device: Record<string, unknown>; }
|
|
16
|
+
interface ISonosResources {
|
|
17
|
+
soap: plugins.ecobridgeDeviceManager.UpnpSoapClient;
|
|
18
|
+
playback: plugins.ecobridgeDeviceManager.UpnpPlaybackController;
|
|
19
|
+
volume: plugins.ecobridgeDeviceManager.UpnpVolumeController;
|
|
20
|
+
}
|
|
21
|
+
interface ISonosCleanup { close(): Promise<void>; status: 'idle' | 'pending' | 'failed' | 'closed'; promise?: Promise<void>; }
|
|
22
|
+
interface ISonosClientOwnership { readonly owner: NativeIntegrationOwner; readonly lease: INativeSetupLease; }
|
|
23
|
+
|
|
24
|
+
/** A session cannot escape its admitted operation or outlive its deadline. */
|
|
25
|
+
export interface ISonosOperationSession {
|
|
26
|
+
getSnapshot(): Promise<ISonosSnapshot>;
|
|
27
|
+
getSpeakerInfo(): Promise<ISonosSpeakerInfo>;
|
|
28
|
+
getTransportInfo(): Promise<ISonosTransportInfo>;
|
|
29
|
+
getPositionInfo(): Promise<ISonosPositionInfo>;
|
|
30
|
+
getVolume(): Promise<number>;
|
|
31
|
+
getMute(): Promise<boolean>;
|
|
32
|
+
play(): Promise<ISonosControlResult>;
|
|
33
|
+
pause(): Promise<ISonosControlResult>;
|
|
34
|
+
stop(): Promise<ISonosControlResult>;
|
|
35
|
+
next(): Promise<ISonosControlResult>;
|
|
36
|
+
previous(): Promise<ISonosControlResult>;
|
|
37
|
+
setVolume(value: number): Promise<ISonosControlResult>;
|
|
38
|
+
setMute(value: boolean): Promise<ISonosControlResult>;
|
|
39
|
+
playUri(uri: string, options?: ISonosMediaOptions): Promise<ISonosControlResult>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class SonosCommandError extends Error {
|
|
43
|
+
public readonly physicalEffectConfirmed = false;
|
|
44
|
+
public readonly acknowledgedActions: readonly string[];
|
|
45
|
+
constructor(message: string, public readonly effectOutcome: 'not-sent' | 'rejected' | 'indeterminate', acknowledgedActions: readonly string[], cause?: unknown) {
|
|
46
|
+
super(message, { cause });
|
|
47
|
+
this.name = 'SonosCommandError';
|
|
48
|
+
this.acknowledgedActions = Object.freeze([...acknowledgedActions]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
8
51
|
|
|
9
52
|
export class SonosClient {
|
|
10
53
|
public readonly config: INormalizedSonosConfig;
|
|
54
|
+
public readonly controlAllowed: boolean;
|
|
55
|
+
private readonly context: INativeAdmissionContext;
|
|
56
|
+
private readonly owner: NativeIntegrationOwner;
|
|
57
|
+
private readonly standalone: boolean;
|
|
58
|
+
private readonly resourceLease: Promise<INativeSetupLease>;
|
|
59
|
+
private readonly setupLease?: INativeSetupLease;
|
|
11
60
|
private readonly lifetime = new AbortController();
|
|
12
|
-
private readonly soap: plugins.ecobridgeDeviceManager.UpnpSoapClient;
|
|
13
|
-
private readonly playback: plugins.ecobridgeDeviceManager.UpnpPlaybackController;
|
|
14
|
-
private readonly volume: plugins.ecobridgeDeviceManager.UpnpVolumeController;
|
|
15
61
|
private readonly operations = new Set<Promise<unknown>>();
|
|
62
|
+
private readonly cleanup = new Set<ISonosCleanup>();
|
|
63
|
+
private resources?: ISonosResources;
|
|
16
64
|
private selectedUid?: string;
|
|
65
|
+
private closed = false;
|
|
17
66
|
private closePromise?: Promise<void>;
|
|
67
|
+
private ownedClosePromise?: Promise<void>;
|
|
68
|
+
private retryPromise?: Promise<void>;
|
|
18
69
|
|
|
19
|
-
|
|
70
|
+
/** The explicit context remains required even when constructor home is constrained. */
|
|
71
|
+
constructor(config: ISonosConfig, context: INativeIntegrationSetupContext, options: ISonosOwnerOptions = {}, ownership?: ISonosClientOwnership) {
|
|
20
72
|
this.config = normalizeSonosConfig(config);
|
|
21
|
-
const
|
|
22
|
-
this.
|
|
73
|
+
const selected = normalizeSonosOwnerOptions(options);
|
|
74
|
+
this.context = new NativeHomeAdmission('sonos', selected.homeId === undefined ? {} : { homeId: selected.homeId }).admit(context, this.config.homeId);
|
|
75
|
+
this.controlAllowed = selected.allowControl === true;
|
|
76
|
+
if (ownership && !(ownership.owner instanceof NativeIntegrationOwner)) throw new Error('Sonos requires an actual native integration owner.');
|
|
77
|
+
this.standalone = ownership === undefined;
|
|
78
|
+
this.owner = ownership?.owner ?? new NativeIntegrationOwner('sonos', { homeId: this.context.homeId });
|
|
79
|
+
// This admission is independent of config, discovery, and the shared owner's prior binding.
|
|
80
|
+
this.owner.admission.admit(this.context, this.config.homeId);
|
|
81
|
+
if (ownership) {
|
|
82
|
+
ownership.lease.assertOpen();
|
|
83
|
+
if (ownership.lease.context.homeId !== this.context.homeId || ownership.lease.context.configStore !== this.context.configStore) throw new Error('Sonos client ownership does not match its admitted context.');
|
|
84
|
+
this.setupLease = ownership.lease;
|
|
85
|
+
this.resourceLease = Promise.resolve(ownership.lease);
|
|
86
|
+
} else {
|
|
87
|
+
let cleanupStarted = false;
|
|
88
|
+
this.resourceLease = this.owner.runSetup(this.context, this.config.homeId, async (lease) => {
|
|
89
|
+
lease.own(() => { const retry = cleanupStarted; cleanupStarted = true; return this.closeOwnedResources(retry); });
|
|
90
|
+
lease.promoteToRuntime();
|
|
91
|
+
return lease;
|
|
92
|
+
});
|
|
93
|
+
// The owning ledger retains failures; construction has no unhandled promise rejection.
|
|
94
|
+
void this.resourceLease.catch(() => undefined);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public getSnapshot(signal?: AbortSignal): Promise<ISonosSnapshot> { return this.withOperation((session) => session.getSnapshot(), signal); }
|
|
99
|
+
public getSpeakerInfo(signal?: AbortSignal): Promise<ISonosSpeakerInfo> { return this.withOperation((session) => session.getSpeakerInfo(), signal); }
|
|
100
|
+
public getTransportInfo(signal?: AbortSignal): Promise<ISonosTransportInfo> { return this.withOperation((session) => session.getTransportInfo(), signal); }
|
|
101
|
+
public getPositionInfo(signal?: AbortSignal): Promise<ISonosPositionInfo> { return this.withOperation((session) => session.getPositionInfo(), signal); }
|
|
102
|
+
public getVolume(signal?: AbortSignal): Promise<number> { return this.withOperation((session) => session.getVolume(), signal); }
|
|
103
|
+
public getMute(signal?: AbortSignal): Promise<boolean> { return this.withOperation((session) => session.getMute(), signal); }
|
|
104
|
+
public play(signal?: AbortSignal): Promise<ISonosControlResult> { return this.withOperation((session) => session.play(), signal); }
|
|
105
|
+
public pause(signal?: AbortSignal): Promise<ISonosControlResult> { return this.withOperation((session) => session.pause(), signal); }
|
|
106
|
+
public stop(signal?: AbortSignal): Promise<ISonosControlResult> { return this.withOperation((session) => session.stop(), signal); }
|
|
107
|
+
public next(signal?: AbortSignal): Promise<ISonosControlResult> { return this.withOperation((session) => session.next(), signal); }
|
|
108
|
+
public previous(signal?: AbortSignal): Promise<ISonosControlResult> { return this.withOperation((session) => session.previous(), signal); }
|
|
109
|
+
public setVolume(value: number, signal?: AbortSignal): Promise<ISonosControlResult> { return this.withOperation((session) => session.setVolume(value), signal); }
|
|
110
|
+
public setMute(value: boolean, signal?: AbortSignal): Promise<ISonosControlResult> { return this.withOperation((session) => session.setMute(value), signal); }
|
|
111
|
+
public playUri(uri: string, options: ISonosMediaOptions = {}, signal?: AbortSignal): Promise<ISonosControlResult> {
|
|
112
|
+
const selected = media(uri, options);
|
|
113
|
+
return this.withOperation((session) => session.playUri(selected.uri, selected.options), signal);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Registers the entire caller continuation, not only individual HTTP exchanges. */
|
|
117
|
+
public withOperation<TResult>(work: (session: ISonosOperationSession) => Promise<TResult>, signal?: AbortSignal): Promise<TResult> {
|
|
118
|
+
try {
|
|
119
|
+
this.assertOpen();
|
|
120
|
+
if (typeof work !== 'function' || (signal !== undefined && !(signal instanceof AbortSignal))) throw new Error('Sonos operation/cancellation is invalid.');
|
|
121
|
+
signal?.throwIfAborted();
|
|
122
|
+
const deadline = new AbortController();
|
|
123
|
+
const combined = AbortSignal.any([this.lifetime.signal, deadline.signal, ...(this.context.abortSignal ? [this.context.abortSignal] : []), ...(signal ? [signal] : [])]);
|
|
124
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
125
|
+
const operation = this.owner.runOperation({ ...this.context, abortSignal: combined }, this.config.homeId, async (lease) => {
|
|
126
|
+
lease.own(async () => { clearTimeout(timer); });
|
|
127
|
+
const resources = await this.resourceLease;
|
|
128
|
+
resources.assertOpen();
|
|
129
|
+
return this.runAdmitted(lease, resources, work);
|
|
130
|
+
});
|
|
131
|
+
// Capacity and home are reserved before allocation; deadline includes queued callback time.
|
|
132
|
+
timer = setTimeout(() => deadline.abort(new Error('Sonos operation deadline expired.')), this.config.timeoutMs);
|
|
133
|
+
void operation.then(() => clearTimeout(timer), () => clearTimeout(timer));
|
|
134
|
+
return operation;
|
|
135
|
+
} catch (error) { return Promise.reject(error); }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Integration setup already owns this exact lease; no second setup/operation pool is created. */
|
|
139
|
+
public withSetupLease<TResult>(lease: INativeSetupLease, work: (session: ISonosOperationSession) => Promise<TResult>): Promise<TResult> {
|
|
140
|
+
this.assertOpen();
|
|
141
|
+
if (lease !== this.setupLease) throw new Error('Sonos setup requires its exact owning lease.');
|
|
142
|
+
lease.assertOpen();
|
|
143
|
+
if (lease.context.homeId !== this.context.homeId || lease.context.configStore !== this.context.configStore) throw new Error('Sonos setup lease conflicts with client admission.');
|
|
144
|
+
return this.runAdmitted(lease, lease, work);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
public destroy(): Promise<void> {
|
|
148
|
+
if (!this.closePromise) {
|
|
149
|
+
this.closed = true;
|
|
150
|
+
let close: Promise<void>;
|
|
151
|
+
this.closePromise = Promise.resolve().then(() => close);
|
|
152
|
+
close = this.standalone ? this.owner.close() : this.closeOwnedResources(false);
|
|
153
|
+
this.lifetime.abort(new Error('Sonos client is closed.'));
|
|
154
|
+
}
|
|
155
|
+
return this.closePromise;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
public retryCleanup(): Promise<void> {
|
|
159
|
+
if (!this.closed) return Promise.reject(new Error('Sonos cleanup retry requires a closed client.'));
|
|
160
|
+
if (!this.retryPromise) {
|
|
161
|
+
const retry = Promise.resolve().then(() => this.standalone ? this.owner.retryCleanup() : this.closeOwnedResources(true));
|
|
162
|
+
this.retryPromise = retry;
|
|
163
|
+
const settled = (): void => { this.retryPromise = undefined; };
|
|
164
|
+
void retry.then(settled, settled);
|
|
165
|
+
}
|
|
166
|
+
return this.retryPromise;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private closeOwnedResources(retry: boolean): Promise<void> {
|
|
170
|
+
if (retry) return this.drain(true);
|
|
171
|
+
if (!this.ownedClosePromise) {
|
|
172
|
+
this.closed = true;
|
|
173
|
+
this.ownedClosePromise = Promise.resolve().then(() => this.drain(false));
|
|
174
|
+
this.lifetime.abort(new Error('Sonos client is closed.'));
|
|
175
|
+
}
|
|
176
|
+
return this.ownedClosePromise;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private async drain(retry: boolean): Promise<void> {
|
|
180
|
+
const closing = [...this.cleanup].map((entry) => {
|
|
181
|
+
if (retry && entry.status === 'failed') entry.promise = undefined;
|
|
182
|
+
if (!entry.promise) {
|
|
183
|
+
entry.status = 'pending';
|
|
184
|
+
entry.promise = Promise.resolve().then(() => entry.close()).then(() => { entry.status = 'closed'; }, (error: unknown) => { entry.status = 'failed'; throw error; });
|
|
185
|
+
}
|
|
186
|
+
return entry.promise;
|
|
187
|
+
});
|
|
188
|
+
const [results] = await Promise.all([Promise.allSettled(closing), Promise.allSettled([...this.operations])]);
|
|
189
|
+
const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
|
|
190
|
+
if (failures.length) throw new AggregateError(failures.map((result) => result.reason), 'Sonos client cleanup failed; handles remain retained.');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private allocate(lease: INativeSetupLease): ISonosResources {
|
|
194
|
+
this.assertOpen();
|
|
195
|
+
lease.assertOpen();
|
|
196
|
+
if (this.resources) return this.resources;
|
|
197
|
+
const soap = new plugins.ecobridgeDeviceManager.UpnpSoapClient(`http://${this.config.host}:${this.config.port}`, {
|
|
23
198
|
timeoutMs: this.config.timeoutMs, maxResponseBytes: this.config.maxResponseBytes,
|
|
24
|
-
abortSignal: this.signal
|
|
199
|
+
abortSignal: AbortSignal.any([this.lifetime.signal, lease.signal]),
|
|
25
200
|
});
|
|
26
|
-
this.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
modelNumber: readSonosOptionalText(device, 'modelNumber'), serialNumber: readSonosOptionalText(device, 'serialNum'),
|
|
60
|
-
softwareVersion: readSonosOptionalText(device, 'softwareVersion'),
|
|
201
|
+
this.cleanup.add({ close: () => soap.close(), status: 'idle' });
|
|
202
|
+
const playback = plugins.ecobridgeDeviceManager.UpnpPlaybackController.forSonos(this.config.host, this.config.port, soap);
|
|
203
|
+
this.cleanup.add({ close: () => playback.close(), status: 'idle' });
|
|
204
|
+
const volume = plugins.ecobridgeDeviceManager.UpnpVolumeController.forSonos(this.config.host, this.config.port, soap);
|
|
205
|
+
this.cleanup.add({ close: () => volume.close(), status: 'idle' });
|
|
206
|
+
this.resources = { soap, playback, volume };
|
|
207
|
+
return this.resources;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private runAdmitted<TResult>(lease: INativeOperationLease, resourceLease: INativeSetupLease, work: (session: ISonosOperationSession) => Promise<TResult>): Promise<TResult> {
|
|
211
|
+
const deadline = new AbortController();
|
|
212
|
+
const signal = AbortSignal.any([lease.signal, resourceLease.signal, this.lifetime.signal, deadline.signal]);
|
|
213
|
+
const timer = setTimeout(() => deadline.abort(new Error('Sonos operation deadline expired.')), this.config.timeoutMs);
|
|
214
|
+
let active = true;
|
|
215
|
+
let operation: Promise<TResult>;
|
|
216
|
+
operation = Promise.resolve().then(async () => {
|
|
217
|
+
this.assertOpen(); lease.assertOpen(); signal.throwIfAborted();
|
|
218
|
+
const resources = this.allocate(resourceLease);
|
|
219
|
+
const calls = new Set<Promise<unknown>>();
|
|
220
|
+
let controlStarted = false;
|
|
221
|
+
const invoke = <TValue>(callback: () => Promise<TValue>): Promise<TValue> => {
|
|
222
|
+
if (!active) throw new Error('Sonos operation session is closed.');
|
|
223
|
+
this.assertOpen(); lease.assertOpen(); signal.throwIfAborted();
|
|
224
|
+
let call: Promise<TValue>;
|
|
225
|
+
call = Promise.resolve().then(callback).finally(() => calls.delete(call));
|
|
226
|
+
calls.add(call);
|
|
227
|
+
void call.then(() => undefined, () => undefined);
|
|
228
|
+
return call;
|
|
229
|
+
};
|
|
230
|
+
const control = (callback: () => Promise<ISonosControlResult>): Promise<ISonosControlResult> => {
|
|
231
|
+
if (controlStarted) throw new Error('Sonos admits one control sequence per operation.');
|
|
232
|
+
controlStarted = true;
|
|
233
|
+
return invoke(callback);
|
|
61
234
|
};
|
|
62
|
-
|
|
235
|
+
const session: ISonosOperationSession = Object.freeze({
|
|
236
|
+
getSnapshot: () => invoke(() => this.readSnapshot(resources, signal)),
|
|
237
|
+
getSpeakerInfo: () => invoke(async () => (await this.readDescription(resources, signal)).speakerInfo),
|
|
238
|
+
getTransportInfo: () => invoke(() => this.readTransport(resources, signal)),
|
|
239
|
+
getPositionInfo: () => invoke(() => this.readPosition(resources, signal)),
|
|
240
|
+
getVolume: () => invoke(async () => { await this.readDescription(resources, signal); return resources.volume.getVolume(signal); }),
|
|
241
|
+
getMute: () => invoke(async () => { await this.readDescription(resources, signal); return resources.volume.getMute(signal); }),
|
|
242
|
+
play: () => control(() => this.transportControl(resources, 'Play', signal)),
|
|
243
|
+
pause: () => control(() => this.transportControl(resources, 'Pause', signal)),
|
|
244
|
+
stop: () => control(() => this.transportControl(resources, 'Stop', signal)),
|
|
245
|
+
next: () => control(() => this.transportControl(resources, 'Next', signal)),
|
|
246
|
+
previous: () => control(() => this.transportControl(resources, 'Previous', signal)),
|
|
247
|
+
setVolume: (value: number) => control(() => this.renderingControl(resources, 'SetVolume', value, signal)),
|
|
248
|
+
setMute: (value: boolean) => control(() => this.renderingControl(resources, 'SetMute', value, signal)),
|
|
249
|
+
playUri: (uri: string, options: ISonosMediaOptions = {}) => { const selected = media(uri, options); return control(() => this.mediaControl(resources, selected, signal)); },
|
|
250
|
+
});
|
|
251
|
+
let result: TResult;
|
|
252
|
+
try { result = await work(session); }
|
|
253
|
+
catch (error) { active = false; await Promise.allSettled([...calls]); throw error; }
|
|
254
|
+
active = false;
|
|
255
|
+
const results = await Promise.allSettled([...calls]);
|
|
256
|
+
const failures = results.filter((entry): entry is PromiseRejectedResult => entry.status === 'rejected');
|
|
257
|
+
if (failures.length) throw new AggregateError(failures.map((entry) => entry.reason), 'Sonos operation continuation failed.');
|
|
258
|
+
return result;
|
|
259
|
+
}).finally(() => { active = false; clearTimeout(timer); this.operations.delete(operation); });
|
|
260
|
+
this.operations.add(operation);
|
|
261
|
+
return operation;
|
|
63
262
|
}
|
|
64
263
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}, abortSignal);
|
|
264
|
+
private assertOpen(): void {
|
|
265
|
+
if (this.closed) throw new Error('Sonos client is closed.');
|
|
266
|
+
this.lifetime.signal.throwIfAborted();
|
|
267
|
+
this.context.abortSignal?.throwIfAborted();
|
|
70
268
|
}
|
|
71
269
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
270
|
+
private async readDescription(resources: ISonosResources, signal: AbortSignal): Promise<ISonosDescription> {
|
|
271
|
+
const root = readSonosXmlElement(resources.soap.parseXml(await resources.soap.getXml('/xml/device_description.xml', signal)).root, 'device description');
|
|
272
|
+
if (root['@_xmlns'] !== 'urn:schemas-upnp-org:device-1-0') throw new Error('Sonos device description has an invalid namespace.');
|
|
273
|
+
const device = readSonosXmlElement(root.device, 'renderer');
|
|
274
|
+
if (readSonosText(device, 'deviceType') !== 'urn:schemas-upnp-org:device:ZonePlayer:1' || !['sonos', 'sonos, inc.'].includes(readSonosText(device, 'manufacturer').toLowerCase())) throw new Error('Configured endpoint is not a Sonos ZonePlayer.');
|
|
275
|
+
const uid = normalizeSonosUid(readSonosText(device, 'UDN').replace(/^uuid:/i, ''));
|
|
276
|
+
if ((this.config.expectedUid ?? this.selectedUid) && uid !== (this.config.expectedUid ?? this.selectedUid)) throw new Error('Sonos renderer identity changed or does not match configuration.');
|
|
277
|
+
this.selectedUid ??= uid;
|
|
278
|
+
const xml = await resources.soap.soapAction('/DeviceProperties/Control', 'urn:schemas-upnp-org:service:DeviceProperties:1', 'GetZoneAttributes', {}, signal);
|
|
279
|
+
const zoneName = resources.soap.extractValue(xml, 'CurrentZoneName');
|
|
280
|
+
if (!zoneName.trim() || zoneName.length > 256) throw new Error('Sonos zone name is invalid.');
|
|
281
|
+
return { device, speakerInfo: { uid, zoneName, manufacturer: 'Sonos', modelName: readSonosOptionalText(device, 'modelName'), modelNumber: readSonosOptionalText(device, 'modelNumber'), serialNumber: readSonosOptionalText(device, 'serialNum'), softwareVersion: readSonosOptionalText(device, 'softwareVersion') } };
|
|
83
282
|
}
|
|
84
283
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
284
|
+
private async readSnapshot(resources: ISonosResources, signal: AbortSignal): Promise<ISonosSnapshot> {
|
|
285
|
+
const description = await this.readDescription(resources, signal);
|
|
286
|
+
const [group, transportInfo, positionInfo, volume, muted, transport, rendering] = await settledValues([
|
|
287
|
+
this.readGroup(resources, description.speakerInfo.uid, signal), this.readTransport(resources, signal), this.readPosition(resources, signal),
|
|
288
|
+
resources.volume.getVolume(signal), resources.volume.getMute(signal), this.transportCapabilities(resources, description.device, signal), this.renderingCapabilities(resources, description.device, signal),
|
|
289
|
+
] as const);
|
|
290
|
+
signal.throwIfAborted();
|
|
291
|
+
return { homeId: this.context.homeId, controlAllowed: this.controlAllowed, speakerInfo: description.speakerInfo, group, transportInfo, positionInfo, volume, muted,
|
|
292
|
+
capabilities: { ...transport, ...rendering }, observedAt: new Date().toISOString() };
|
|
293
|
+
}
|
|
92
294
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return
|
|
295
|
+
private async readGroup(resources: ISonosResources, uid: string, signal: AbortSignal): Promise<ISonosGroup> {
|
|
296
|
+
const xml = await resources.soap.soapAction('/ZoneGroupTopology/Control', 'urn:schemas-upnp-org:service:ZoneGroupTopology:1', 'GetZoneGroupState', {}, signal);
|
|
297
|
+
return parseSonosGroup(resources.soap.parseXml(resources.soap.extractValue(xml, 'ZoneGroupState')), uid);
|
|
298
|
+
}
|
|
299
|
+
private async readTransport(resources: ISonosResources, signal: AbortSignal): Promise<ISonosTransportInfo> {
|
|
300
|
+
const value = await resources.soap.getTransportInfo(transportPath, signal);
|
|
301
|
+
return { currentTransportState: value.state, currentTransportStatus: value.status, currentSpeed: value.speed };
|
|
302
|
+
}
|
|
303
|
+
private async readPosition(resources: ISonosResources, signal: AbortSignal): Promise<ISonosPositionInfo> {
|
|
304
|
+
const value = await resources.soap.getPositionInfo(transportPath, signal);
|
|
305
|
+
const metadata = value.trackMetadata && value.trackMetadata !== 'NOT_IMPLEMENTED' ? resources.soap.extractOptionalValues(value.trackMetadata, ['title', 'creator', 'artist', 'album', 'albumArtURI']) : {};
|
|
306
|
+
return { track: value.track, trackDuration: value.trackDuration, trackMetaData: value.trackMetadata, trackUri: value.trackUri, relativeTime: value.relativeTime,
|
|
307
|
+
title: metadata.title, artist: metadata.creator ?? metadata.artist, album: metadata.album, albumArtUri: metadata.albumArtURI };
|
|
96
308
|
}
|
|
97
309
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
310
|
+
private async serviceActions(resources: ISonosResources, device: Record<string, unknown>, serviceType: string, controlPath: string, signal: AbortSignal): Promise<string[]> {
|
|
311
|
+
const services: Record<string, unknown>[] = [];
|
|
312
|
+
const visit = (value: Record<string, unknown>, depth: number): void => {
|
|
313
|
+
if (depth > 8) throw new Error('Sonos device description nesting is excessive.');
|
|
314
|
+
if (value.serviceList !== undefined) for (const service of list(readSonosXmlElement(value.serviceList, 'service list').service)) {
|
|
315
|
+
services.push(readSonosXmlElement(service, 'service'));
|
|
316
|
+
if (services.length > 64) throw new Error('Sonos description contains too many services.');
|
|
317
|
+
}
|
|
318
|
+
if (value.deviceList !== undefined) for (const child of list(readSonosXmlElement(value.deviceList, 'device list').device)) visit(readSonosXmlElement(child, 'device'), depth + 1);
|
|
319
|
+
};
|
|
320
|
+
visit(device, 0);
|
|
321
|
+
const matches = services.filter((service) => readSonosText(service, 'serviceType') === serviceType);
|
|
322
|
+
if (matches.length !== 1) throw new Error('Sonos selected renderer must declare exactly one required service.');
|
|
323
|
+
const selected = matches[0];
|
|
324
|
+
const origin = new URL(`http://${this.config.host}:${this.config.port}`).origin;
|
|
325
|
+
const control = new URL(readSonosText(selected, 'controlURL'), origin);
|
|
326
|
+
const scpd = new URL(readSonosText(selected, 'SCPDURL'), origin);
|
|
327
|
+
if (control.origin !== origin || control.pathname !== controlPath || control.username || control.password || control.search || control.hash
|
|
328
|
+
|| scpd.origin !== origin || scpd.username || scpd.password || scpd.search || scpd.hash || !scpd.pathname.startsWith('/xml/')) throw new Error('Sonos service description must remain on the exact selected renderer.');
|
|
329
|
+
const root = readSonosXmlElement(resources.soap.parseXml(await resources.soap.getXml(scpd.pathname, signal)).scpd, 'service description');
|
|
330
|
+
if (root['@_xmlns'] !== 'urn:schemas-upnp-org:service-1-0') throw new Error('Sonos service description namespace is invalid.');
|
|
331
|
+
const actions = list(readSonosXmlElement(root.actionList, 'action list').action).map((action) => readSonosText(readSonosXmlElement(action, 'action'), 'name'));
|
|
332
|
+
if (!actions.length || actions.length > 256 || actions.some((action) => !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(action)) || new Set(actions).size !== actions.length) throw new Error('Sonos service actions are invalid or duplicated.');
|
|
333
|
+
return actions;
|
|
101
334
|
}
|
|
102
335
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (mimeType !== undefined && (typeof mimeType !== 'string' || !/^audio\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/i.test(mimeType))) throw new Error('Sonos MIME type must identify a specific audio format.');
|
|
112
|
-
if (title !== undefined && mimeType === undefined) throw new Error('Sonos titled media requires an explicit audio MIME type.');
|
|
113
|
-
const metadata = title === undefined ? '' : this.soap.generateDidlMetadata(title, uri, mimeType);
|
|
114
|
-
return this.transport(async (signal) => {
|
|
115
|
-
await this.soap.setAVTransportURI(transportPath, uri, metadata, signal);
|
|
116
|
-
await this.playback.play(undefined, signal);
|
|
117
|
-
}, signal);
|
|
336
|
+
private async transportCapabilities(resources: ISonosResources, device: Record<string, unknown>, signal: AbortSignal): Promise<Pick<ISonosCapabilities, 'transportActions' | 'declaredTransportActions'>> {
|
|
337
|
+
const declaredTransportActions = await this.serviceActions(resources, device, transportService, transportPath, signal);
|
|
338
|
+
if (!declaredTransportActions.includes('GetCurrentTransportActions')) throw new Error('Sonos renderer does not expose current transport action authority.');
|
|
339
|
+
const xml = await resources.soap.soapAction(transportPath, transportService, 'GetCurrentTransportActions', { InstanceID: 0 }, signal);
|
|
340
|
+
const value = resources.soap.extractValue(xml, 'Actions');
|
|
341
|
+
const transportActions = value === '' ? [] : value.split(',').map((action) => action.trim());
|
|
342
|
+
if (transportActions.length > 128 || transportActions.some((action) => !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(action)) || new Set(transportActions).size !== transportActions.length) throw new Error('Sonos current transport actions are malformed.');
|
|
343
|
+
return { transportActions, declaredTransportActions };
|
|
118
344
|
}
|
|
119
345
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
})();
|
|
346
|
+
private async renderingCapabilities(resources: ISonosResources, device: Record<string, unknown>, signal: AbortSignal): Promise<Pick<ISonosCapabilities, 'volumeWritable' | 'muteWritable'>> {
|
|
347
|
+
const actions = await this.serviceActions(resources, device, renderingService, renderingPath, signal);
|
|
348
|
+
let fixed = false;
|
|
349
|
+
if (actions.includes('GetOutputFixed')) {
|
|
350
|
+
const xml = await resources.soap.soapAction(renderingPath, renderingService, 'GetOutputFixed', { InstanceID: 0 }, signal);
|
|
351
|
+
const value = resources.soap.extractValue(xml, 'CurrentFixed');
|
|
352
|
+
if (!['0', '1', 'false', 'true'].includes(value)) throw new Error('Sonos fixed-output response is invalid.');
|
|
353
|
+
fixed = value === '1' || value === 'true';
|
|
129
354
|
}
|
|
130
|
-
return
|
|
355
|
+
return { volumeWritable: actions.includes('SetVolume') && !fixed, muteWritable: actions.includes('SetMute') };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private async transportAuthority(resources: ISonosResources, action: string, signal: AbortSignal): Promise<void> {
|
|
359
|
+
const description = await this.readDescription(resources, signal);
|
|
360
|
+
const [group, capabilities] = await settledValues([this.readGroup(resources, description.speakerInfo.uid, signal), this.transportCapabilities(resources, description.device, signal)] as const);
|
|
361
|
+
if (group.coordinatorUid !== description.speakerInfo.uid) throw new Error('Sonos playback requires selecting the group coordinator.');
|
|
362
|
+
if (!capabilities.declaredTransportActions.includes(action) || (action !== 'SetAVTransportURI' && !capabilities.transportActions.includes(action))) throw new Error(`Sonos renderer does not currently authorize ${action}.`);
|
|
363
|
+
signal.throwIfAborted();
|
|
131
364
|
}
|
|
132
365
|
|
|
133
|
-
private
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
366
|
+
private async transportControl(resources: ISonosResources, action: 'Play' | 'Pause' | 'Stop' | 'Next' | 'Previous', signal: AbortSignal): Promise<ISonosControlResult> {
|
|
367
|
+
const target = action === 'Play' ? 'PLAYING' : action === 'Pause' ? 'PAUSED_PLAYBACK' : action === 'Stop' ? 'STOPPED' : undefined;
|
|
368
|
+
return this.control(resources, signal, async (acknowledge, attempted) => {
|
|
369
|
+
await this.transportAuthority(resources, action, signal); attempted();
|
|
370
|
+
if (action === 'Play') await resources.playback.play(undefined, signal);
|
|
371
|
+
else if (action === 'Pause') await resources.playback.pause(signal);
|
|
372
|
+
else if (action === 'Stop') await resources.playback.stop(signal);
|
|
373
|
+
else if (action === 'Next') await resources.playback.next(signal);
|
|
374
|
+
else await resources.playback.previous(signal);
|
|
375
|
+
acknowledge(action);
|
|
376
|
+
}, target, (snapshot) => snapshot.transportInfo.currentTransportState);
|
|
138
377
|
}
|
|
139
378
|
|
|
140
|
-
private
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
379
|
+
private renderingControl(resources: ISonosResources, action: 'SetVolume' | 'SetMute', value: number | boolean, signal: AbortSignal): Promise<ISonosControlResult> {
|
|
380
|
+
if ((action === 'SetVolume' && (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 100))
|
|
381
|
+
|| (action === 'SetMute' && typeof value !== 'boolean')) throw new Error('Sonos rendering value must be an integer percent from 0 to 100 or boolean mute.');
|
|
382
|
+
return this.control(resources, signal, async (acknowledge, attempted) => {
|
|
383
|
+
const description = await this.readDescription(resources, signal);
|
|
384
|
+
const capabilities = await this.renderingCapabilities(resources, description.device, signal);
|
|
385
|
+
if (action === 'SetVolume' ? !capabilities.volumeWritable : !capabilities.muteWritable) throw new Error('Sonos rendering control is not currently writable.');
|
|
386
|
+
signal.throwIfAborted(); attempted();
|
|
387
|
+
if (action === 'SetVolume') await resources.volume.setVolume(value as number, signal);
|
|
388
|
+
else await resources.volume.setMute(value as boolean, signal);
|
|
389
|
+
acknowledge(action);
|
|
390
|
+
}, value, (snapshot) => action === 'SetVolume' ? snapshot.volume : snapshot.muted);
|
|
148
391
|
}
|
|
149
392
|
|
|
150
|
-
private
|
|
151
|
-
return
|
|
393
|
+
private mediaControl(resources: ISonosResources, selected: { uri: string; options: ISonosMediaOptions }, signal: AbortSignal): Promise<ISonosControlResult> {
|
|
394
|
+
return this.control(resources, signal, async (acknowledge, attempted) => {
|
|
395
|
+
await this.transportAuthority(resources, 'SetAVTransportURI', signal);
|
|
396
|
+
const metadata = selected.options.title === undefined ? '' : resources.soap.generateDidlMetadata(selected.options.title, selected.uri, selected.options.mimeType);
|
|
397
|
+
attempted(); await resources.soap.setAVTransportURI(transportPath, selected.uri, metadata, signal); acknowledge('SetAVTransportURI');
|
|
398
|
+
// URI admission changes transport state; authority must be observed again before the distinct Play effect.
|
|
399
|
+
await this.transportAuthority(resources, 'Play', signal);
|
|
400
|
+
attempted(); await resources.playback.play(undefined, signal); acknowledge('Play');
|
|
401
|
+
}, undefined, () => undefined);
|
|
152
402
|
}
|
|
153
403
|
|
|
154
|
-
private
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
404
|
+
private async control(resources: ISonosResources, signal: AbortSignal, dispatch: (acknowledge: (action: string) => void, attempted: () => void) => Promise<void>, requestedValue: string | number | boolean | undefined, observation: (snapshot: ISonosSnapshot) => string | number | boolean | undefined): Promise<ISonosControlResult> {
|
|
405
|
+
const acknowledgedActions: string[] = [];
|
|
406
|
+
let attempted = false;
|
|
407
|
+
try {
|
|
408
|
+
if (!this.controlAllowed) throw new Error('Sonos control requires explicit runtime owner permission.');
|
|
409
|
+
await dispatch((action) => acknowledgedActions.push(action), () => { attempted = true; });
|
|
410
|
+
const snapshot = await this.readSnapshot(resources, signal);
|
|
411
|
+
const observedValue = observation(snapshot);
|
|
412
|
+
return Object.freeze({ acknowledgedActions: Object.freeze([...acknowledgedActions]), snapshot, requestedValue, observedValue,
|
|
413
|
+
outcome: requestedValue === undefined ? 'observed-state' : requestedValue === observedValue ? 'observed-target' : 'observed-different', physicalEffectConfirmed: false });
|
|
414
|
+
} catch (error) {
|
|
415
|
+
const outcome = !attempted ? 'not-sent' : error instanceof plugins.ecobridgeDeviceManager.UpnpSoapFault && !acknowledgedActions.length ? 'rejected' : 'indeterminate';
|
|
416
|
+
throw new SonosCommandError(error instanceof Error ? error.message : 'Sonos control failed.', outcome, acknowledgedActions, error);
|
|
417
|
+
}
|
|
161
418
|
}
|
|
162
419
|
}
|
|
163
420
|
|
|
421
|
+
function media(uri: string, options: ISonosMediaOptions): { uri: string; options: ISonosMediaOptions } {
|
|
422
|
+
if (!isSonosDataRecord(options) || Object.keys(options).some((key) => !['title', 'mimeType'].includes(key))) throw new Error('Sonos media options contain unsupported fields.');
|
|
423
|
+
const { title, mimeType } = options;
|
|
424
|
+
if (typeof uri !== 'string' || uri.length > 4096 || /[\x00-\x20]/.test(uri)) throw new Error('Sonos media URI is invalid.');
|
|
425
|
+
const url = new URL(uri);
|
|
426
|
+
if (!['http:', 'https:', 'x-sonos-http:', 'x-rincon-mp3radio:'].includes(url.protocol) || !url.hostname || uri.slice(url.protocol.length, url.protocol.length + 2) !== '//' || url.username || url.password) throw new Error('Sonos media URI must use an uncredentialed supported stream URL.');
|
|
427
|
+
if (title !== undefined && (typeof title !== 'string' || !title.trim() || title.length > 256)) throw new Error('Sonos media title is invalid.');
|
|
428
|
+
if (mimeType !== undefined && (typeof mimeType !== 'string' || !/^audio\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/i.test(mimeType))) throw new Error('Sonos MIME type must identify a specific audio format.');
|
|
429
|
+
if (title !== undefined && mimeType === undefined) throw new Error('Sonos titled media requires an explicit audio MIME type.');
|
|
430
|
+
return { uri, options: Object.freeze({ title, mimeType }) };
|
|
431
|
+
}
|
|
432
|
+
function list(value: unknown): unknown[] { return value === undefined ? [] : Array.isArray(value) ? value : [value]; }
|
|
164
433
|
async function settledValues<TValues extends readonly unknown[]>(values: { [TKey in keyof TValues]: Promise<TValues[TKey]> }): Promise<TValues> {
|
|
165
434
|
const results = await Promise.allSettled(values);
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
435
|
+
const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
|
|
436
|
+
if (failures.length) throw new AggregateError(failures.map((result) => result.reason), 'Sonos observation failed.');
|
|
168
437
|
return results.map((result) => (result as PromiseFulfilledResult<unknown>).value) as unknown as TValues;
|
|
169
438
|
}
|