@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,38 +1,35 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { NativeIntegrationOwner, type NativeIntegrationScope } from '../../core/classes.nativeintegrationowner.js';
|
|
2
|
+
import type { IConfigFlow, IConfigFlowStep, IDiscoveryCandidate, INativeConfigFlowContext } from '../../core/types.js';
|
|
2
3
|
import type { ISonosConfig } from './sonos.types.js';
|
|
3
4
|
import { isSonosDataRecord, normalizeSonosConfig } from './sonos.config.js';
|
|
4
5
|
|
|
5
6
|
export class SonosConfigFlow implements IConfigFlow<ISonosConfig> {
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
private readonly scope: NativeIntegrationScope;
|
|
8
|
+
constructor(scope?: NativeIntegrationScope, options: { readonly homeId?: string } = {}) {
|
|
9
|
+
this.scope = scope ?? new NativeIntegrationOwner('sonos', options).createFlowScope();
|
|
10
|
+
}
|
|
11
|
+
public async start(candidateArg: IDiscoveryCandidate, contextArg: INativeConfigFlowContext): Promise<IConfigFlowStep<ISonosConfig>> {
|
|
8
12
|
if (!isSonosDataRecord(candidateArg) || candidateArg.integrationDomain !== 'sonos') return { kind: 'error', error: 'Candidate does not belong to Sonos.' };
|
|
9
|
-
|
|
13
|
+
const candidate = { source: candidateArg.source, host: candidateArg.host, port: candidateArg.port, id: candidateArg.id };
|
|
14
|
+
const admitted = this.scope.admit(contextArg);
|
|
10
15
|
return {
|
|
11
|
-
kind: 'form',
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (valuesArg.port !== undefined && typeof valuesArg.port !== 'number') throw new Error('Sonos port must be numeric.');
|
|
26
|
-
return {
|
|
27
|
-
kind: 'done',
|
|
28
|
-
title: 'Sonos configured',
|
|
29
|
-
config: normalizeSonosConfig({
|
|
30
|
-
host: typeof valuesArg.host === 'string' ? valuesArg.host : candidateArg.host ?? '',
|
|
31
|
-
port: typeof valuesArg.port === 'number' ? valuesArg.port : candidateArg.port ?? 1400,
|
|
32
|
-
...(candidateArg.id ? { expectedUid: candidateArg.id } : {}),
|
|
33
|
-
}),
|
|
34
|
-
};
|
|
16
|
+
kind: 'form', title: 'Connect Sonos', description: 'Configure the selected local Sonos HTTP endpoint for the host-selected home.',
|
|
17
|
+
fields: [{ name: 'host', label: 'Host', type: 'text', required: true }, { name: 'port', label: 'Port', type: 'number' }],
|
|
18
|
+
submit: (values) => {
|
|
19
|
+
if (!isSonosDataRecord(values)) throw new Error('Sonos form values must be a plain object.');
|
|
20
|
+
if (Object.keys(values).some((key) => !['homeId', 'host', 'port'].includes(key))) throw new Error('Sonos form contains an unsupported field.');
|
|
21
|
+
if (values.homeId !== undefined && values.homeId !== admitted.homeId) throw new Error('Sonos submitted home conflicts with the host-selected home.');
|
|
22
|
+
if (values.host !== undefined && typeof values.host !== 'string') throw new Error('Sonos host must be text.');
|
|
23
|
+
if (values.port !== undefined && typeof values.port !== 'number') throw new Error('Sonos port must be numeric.');
|
|
24
|
+
const config = normalizeSonosConfig({ homeId: admitted.homeId, host: typeof values.host === 'string' ? values.host : candidate.host ?? '',
|
|
25
|
+
port: typeof values.port === 'number' ? values.port : candidate.port ?? 1400, ...(candidate.id ? { expectedUid: candidate.id } : {}) });
|
|
26
|
+
return this.scope.run(admitted, config.homeId, async (lease) => {
|
|
27
|
+
lease.assertOpen();
|
|
28
|
+
return { kind: 'done', title: 'Sonos configured', config };
|
|
29
|
+
});
|
|
35
30
|
},
|
|
36
31
|
};
|
|
37
32
|
}
|
|
33
|
+
public destroy(): Promise<void> { return this.scope.close(); }
|
|
34
|
+
public retryCleanup(): Promise<void> { return this.scope.retryCleanup(); }
|
|
38
35
|
}
|
|
@@ -1,60 +1,66 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
2
|
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
3
|
+
import { NativeIntegrationOwner, type INativeSetupLease } from '../../core/classes.nativeintegrationowner.js';
|
|
4
|
+
import type { IDeviceFeatureWriteRequest, IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, INativeIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
|
5
|
+
import { SonosClient, SonosCommandError, type ISonosOperationSession } from './sonos.classes.client.js';
|
|
5
6
|
import { SonosConfigFlow } from './sonos.classes.configflow.js';
|
|
6
7
|
import { createSonosDiscoveryDescriptor } from './sonos.discovery.js';
|
|
7
8
|
import { SonosMapper } from './sonos.mapper.js';
|
|
8
|
-
import { isSonosDataRecord } from './sonos.config.js';
|
|
9
|
-
import type { ISonosConfig, ISonosSnapshot } from './sonos.types.js';
|
|
9
|
+
import { isSonosDataRecord, normalizeSonosConfig, normalizeSonosOwnerOptions } from './sonos.config.js';
|
|
10
|
+
import type { ISonosConfig, ISonosControlResult, ISonosOwnerOptions, ISonosSnapshot } from './sonos.types.js';
|
|
10
11
|
|
|
11
12
|
export class SonosIntegration extends BaseIntegration<ISonosConfig> {
|
|
12
13
|
public readonly domain = 'sonos';
|
|
13
14
|
public readonly displayName = 'Sonos';
|
|
14
15
|
public readonly status = 'control-runtime' as const;
|
|
15
16
|
public readonly discoveryDescriptor = createSonosDiscoveryDescriptor();
|
|
16
|
-
public readonly configFlow
|
|
17
|
+
public readonly configFlow: SonosConfigFlow;
|
|
17
18
|
public readonly metadata = { source: 'native-typescript', integrationType: 'device', iotClass: 'local_poll' };
|
|
18
|
-
private readonly
|
|
19
|
-
private readonly
|
|
20
|
-
private readonly lifetime = new AbortController();
|
|
19
|
+
private readonly owner: NativeIntegrationOwner;
|
|
20
|
+
private readonly options: Readonly<ISonosOwnerOptions>;
|
|
21
21
|
private closePromise?: Promise<void>;
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
23
|
+
constructor(options: ISonosOwnerOptions = {}) {
|
|
24
|
+
super();
|
|
25
|
+
this.options = normalizeSonosOwnerOptions(options);
|
|
26
|
+
this.owner = new NativeIntegrationOwner('sonos', this.options.homeId === undefined ? {} : { homeId: this.options.homeId });
|
|
27
|
+
this.configFlow = new SonosConfigFlow(this.owner.createFlowScope());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public setup(config: ISonosConfig, context: INativeIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
|
31
|
+
try {
|
|
32
|
+
const selected = normalizeSonosConfig(config);
|
|
33
|
+
return this.owner.runSetup(context, selected.homeId, async (lease) => {
|
|
34
|
+
const client = new SonosClient(selected, lease.context, this.options, { owner: this.owner, lease });
|
|
35
|
+
let runtime: SonosRuntime | undefined;
|
|
36
|
+
let cleanupStarted = false;
|
|
37
|
+
lease.own(() => {
|
|
38
|
+
const retry = cleanupStarted;
|
|
39
|
+
cleanupStarted = true;
|
|
40
|
+
return runtime ? runtime.closeOwnedResources(retry) : retry ? client.retryCleanup() : client.destroy();
|
|
41
|
+
});
|
|
42
|
+
const initial = await client.withSetupLease(lease, (session) => session.getSnapshot());
|
|
43
|
+
lease.assertOpen();
|
|
44
|
+
runtime = new SonosRuntime(client, initial, lease);
|
|
45
|
+
lease.promoteToRuntime();
|
|
33
46
|
return runtime;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Sonos setup and cleanup failed.'); }
|
|
37
|
-
throw error;
|
|
38
|
-
}
|
|
39
|
-
})();
|
|
40
|
-
this.setups.add(pending);
|
|
41
|
-
void pending.then(() => this.setups.delete(pending), () => this.setups.delete(pending));
|
|
42
|
-
return pending;
|
|
47
|
+
});
|
|
48
|
+
} catch (error) { return Promise.reject(error); }
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
public destroy(): Promise<void> {
|
|
46
52
|
if (!this.closePromise) {
|
|
47
|
-
|
|
48
|
-
this.closePromise = (async () => {
|
|
49
|
-
await Promise.allSettled(
|
|
50
|
-
const results = await Promise.allSettled([...this.runtimes].map((runtime) => runtime.destroy()));
|
|
51
|
-
this.runtimes.clear();
|
|
53
|
+
let closing: Promise<void>[] = [];
|
|
54
|
+
this.closePromise = Promise.resolve().then(async () => {
|
|
55
|
+
const results = await Promise.allSettled(closing);
|
|
52
56
|
const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
|
|
53
|
-
if (failures.length) throw new AggregateError(failures.map((result) => result.reason), 'Sonos
|
|
54
|
-
})
|
|
57
|
+
if (failures.length) throw new AggregateError(failures.map((result) => result.reason), 'Sonos owned cleanup failed; handles remain retained.');
|
|
58
|
+
});
|
|
59
|
+
closing = [this.configFlow.destroy(), this.owner.close()];
|
|
55
60
|
}
|
|
56
61
|
return this.closePromise;
|
|
57
62
|
}
|
|
63
|
+
public retryCleanup(): Promise<void> { return this.owner.retryCleanup(); }
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
export class HomeAssistantSonosIntegration extends SonosIntegration {}
|
|
@@ -64,6 +70,7 @@ interface ISonosSubscription {
|
|
|
64
70
|
job: Promise<void>;
|
|
65
71
|
failed: boolean;
|
|
66
72
|
failure?: unknown;
|
|
73
|
+
stopPromise?: Promise<void>;
|
|
67
74
|
stop(): Promise<void>;
|
|
68
75
|
}
|
|
69
76
|
|
|
@@ -74,178 +81,198 @@ class SonosRuntime implements IIntegrationRuntime {
|
|
|
74
81
|
private healthy = true;
|
|
75
82
|
private closed = false;
|
|
76
83
|
private closePromise?: Promise<void>;
|
|
77
|
-
private readonly parentAbort?: () => void;
|
|
78
|
-
|
|
79
|
-
constructor(private readonly client: SonosClient, private snapshot: ISonosSnapshot, private readonly context: IIntegrationSetupContext = {}, private readonly onClosed?: () => void) {
|
|
80
|
-
if (context.abortSignal) {
|
|
81
|
-
this.parentAbort = () => this.lifetime.abort(context.abortSignal?.reason);
|
|
82
|
-
context.abortSignal.addEventListener('abort', this.parentAbort, { once: true });
|
|
83
|
-
if (context.abortSignal.aborted) this.parentAbort();
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
84
|
|
|
87
|
-
|
|
85
|
+
constructor(private readonly client: SonosClient, private snapshot: ISonosSnapshot, private readonly lease: INativeSetupLease) {}
|
|
86
|
+
public isHealthy(): boolean { return this.healthy && !this.closed && !this.lease.signal.aborted; }
|
|
88
87
|
|
|
89
|
-
public
|
|
90
|
-
return SonosMapper.toDevices(await this.read(signal)
|
|
88
|
+
public devices(signal?: AbortSignal): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
|
|
89
|
+
return this.observe(async (session) => SonosMapper.toDevices(await this.read(session)), signal);
|
|
91
90
|
}
|
|
91
|
+
public entities(): Promise<IIntegrationEntity[]> { return this.observe(async (session) => SonosMapper.toEntities(await this.read(session))); }
|
|
92
92
|
|
|
93
|
-
public
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
} else throw new Error('Unsupported Sonos feature.');
|
|
120
|
-
return this.readback(signal);
|
|
93
|
+
public writeFeature(request: IDeviceFeatureWriteRequest, signal?: AbortSignal): Promise<IServiceCallResult> {
|
|
94
|
+
try {
|
|
95
|
+
assertDataRecord(request, ['device', 'feature', 'value']); assertDataRecord(request.device); assertDataRecord(request.feature);
|
|
96
|
+
const deviceId = request.device.id; const integrationDomain = request.device.integrationDomain; const featureId = request.feature.id; const value = request.value;
|
|
97
|
+
this.assertControlAllowed();
|
|
98
|
+
return this.observe(async (session) => {
|
|
99
|
+
const device = SonosMapper.toDevices(await this.read(session))[0];
|
|
100
|
+
if (integrationDomain !== this.domain || deviceId !== device.id) throw new Error('Sonos feature target does not belong to the selected renderer.');
|
|
101
|
+
const feature = device.features.find((feature) => feature.id === featureId);
|
|
102
|
+
if (!feature?.writable || feature.capability !== 'media') throw new Error('Sonos feature is not writable for this renderer.');
|
|
103
|
+
let result: ISonosControlResult;
|
|
104
|
+
if (feature.id === 'volume') {
|
|
105
|
+
if (typeof value !== 'number') throw new Error('Sonos volume must be numeric.');
|
|
106
|
+
result = await session.setVolume(value);
|
|
107
|
+
} else if (feature.id === 'muted') {
|
|
108
|
+
if (typeof value !== 'boolean') throw new Error('Sonos mute must be boolean.');
|
|
109
|
+
result = await session.setMute(value);
|
|
110
|
+
} else if (feature.id === 'playback') {
|
|
111
|
+
if (value === 'playing') result = await session.play();
|
|
112
|
+
else if (value === 'paused') result = await session.pause();
|
|
113
|
+
else if (value === 'stopped') result = await session.stop();
|
|
114
|
+
else throw new Error('Sonos playback must be playing, paused or stopped.');
|
|
115
|
+
} else throw new Error('Unsupported Sonos feature.');
|
|
116
|
+
return this.result(result);
|
|
117
|
+
}, signal);
|
|
118
|
+
} catch (error) { return Promise.reject(error); }
|
|
121
119
|
}
|
|
122
120
|
|
|
123
|
-
public
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
...(request.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
121
|
+
public callService(request: IServiceCallRequest): Promise<IServiceCallResult> {
|
|
122
|
+
try {
|
|
123
|
+
assertDataRecord(request, ['domain', 'service', 'target', 'data']); assertDataRecord(request.target, ['deviceId', 'entityId']);
|
|
124
|
+
if (request.data !== undefined) assertDataRecord(request.data);
|
|
125
|
+
const selected: IServiceCallRequest = { domain: request.domain, service: request.service,
|
|
126
|
+
target: { ...(request.target.deviceId === undefined ? {} : { deviceId: request.target.deviceId }), ...(request.target.entityId === undefined ? {} : { entityId: request.target.entityId }) },
|
|
127
|
+
...(request.data === undefined ? {} : { data: { ...request.data } }) };
|
|
128
|
+
if (selected.domain !== 'media_player' || typeof selected.service !== 'string') throw new Error('Unsupported Sonos service domain.');
|
|
129
|
+
this.assertControlAllowed();
|
|
130
|
+
return this.observe(async (session) => {
|
|
131
|
+
const snapshot = await this.read(session);
|
|
132
|
+
const deviceId = SonosMapper.deviceId(snapshot); const entityId = SonosMapper.entityId(snapshot.homeId, snapshot.speakerInfo.uid);
|
|
133
|
+
if ((!selected.target.deviceId && !selected.target.entityId) || (selected.target.deviceId !== undefined && selected.target.deviceId !== deviceId)
|
|
134
|
+
|| (selected.target.entityId !== undefined && selected.target.entityId !== entityId)) throw new Error('Sonos service target does not belong to the selected renderer.');
|
|
135
|
+
const data = selected.data ?? {}; assertDataRecord(data);
|
|
136
|
+
let result: ISonosControlResult;
|
|
137
|
+
if (['play', 'pause', 'stop', 'next_track', 'previous_track'].includes(selected.service)) {
|
|
138
|
+
assertDataRecord(data, []);
|
|
139
|
+
if (selected.service === 'play') result = await session.play();
|
|
140
|
+
else if (selected.service === 'pause') result = await session.pause();
|
|
141
|
+
else if (selected.service === 'stop') result = await session.stop();
|
|
142
|
+
else if (selected.service === 'next_track') result = await session.next();
|
|
143
|
+
else result = await session.previous();
|
|
144
|
+
} else if (selected.service === 'volume_set') {
|
|
145
|
+
assertDataRecord(data, ['volume', 'volume_level']);
|
|
146
|
+
if (Object.hasOwn(data, 'volume') === Object.hasOwn(data, 'volume_level')) throw new Error('Sonos volume_set requires exactly one volume argument.');
|
|
147
|
+
const value = Object.hasOwn(data, 'volume') ? data.volume : data.volume_level;
|
|
148
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('Sonos volume must be finite.');
|
|
149
|
+
const percent = Math.round(value * 100);
|
|
150
|
+
if (Object.hasOwn(data, 'volume_level') && (value < 0 || value > 1 || Math.abs(value * 100 - percent) > 1e-8)) throw new Error('Sonos volume_level must represent an integer percent from 0 to 100.');
|
|
151
|
+
result = await session.setVolume(Object.hasOwn(data, 'volume') ? value : percent);
|
|
152
|
+
} else if (selected.service === 'volume_mute') {
|
|
153
|
+
assertDataRecord(data, ['muted', 'is_volume_muted']);
|
|
154
|
+
if (Object.hasOwn(data, 'muted') === Object.hasOwn(data, 'is_volume_muted')) throw new Error('Sonos volume_mute requires exactly one mute argument.');
|
|
155
|
+
const value = Object.hasOwn(data, 'muted') ? data.muted : data.is_volume_muted;
|
|
156
|
+
if (typeof value !== 'boolean') throw new Error('Sonos mute must be boolean.');
|
|
157
|
+
result = await session.setMute(value);
|
|
158
|
+
} else if (selected.service === 'play_media') {
|
|
159
|
+
assertDataRecord(data, ['uri', 'media_content_id', 'title', 'mime_type']);
|
|
160
|
+
if (Object.hasOwn(data, 'uri') === Object.hasOwn(data, 'media_content_id')) throw new Error('Sonos play_media requires exactly one URI argument.');
|
|
161
|
+
const uri = Object.hasOwn(data, 'uri') ? data.uri : data.media_content_id;
|
|
162
|
+
if (typeof uri !== 'string' || (data.title !== undefined && typeof data.title !== 'string') || (data.mime_type !== undefined && typeof data.mime_type !== 'string')) throw new Error('Sonos media URI, title and MIME type must be text.');
|
|
163
|
+
result = await session.playUri(uri, { title: data.title, mimeType: data.mime_type });
|
|
164
|
+
} else throw new Error(`Unsupported Sonos service: ${selected.service}`);
|
|
165
|
+
return this.result(result);
|
|
166
|
+
});
|
|
167
|
+
} catch (error) { return Promise.reject(error); }
|
|
169
168
|
}
|
|
170
169
|
|
|
171
|
-
public
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
170
|
+
public subscribe(handler: (event: IIntegrationEvent) => void, signal?: AbortSignal): Promise<() => Promise<void>> {
|
|
171
|
+
try {
|
|
172
|
+
this.lease.assertOpen();
|
|
173
|
+
if (typeof handler !== 'function') throw new Error('Sonos subscription handler must be callable.');
|
|
174
|
+
if (this.subscriptions.size >= 16) throw new Error('Sonos runtime supports at most 16 subscriptions.');
|
|
175
|
+
const controller = new AbortController();
|
|
176
|
+
const combined = AbortSignal.any([this.lease.signal, this.lifetime.signal, controller.signal, ...(signal ? [signal] : [])]);
|
|
177
|
+
combined.throwIfAborted();
|
|
178
|
+
const subscription: ISonosSubscription = {
|
|
179
|
+
controller, job: Promise.resolve(), failed: false,
|
|
180
|
+
stop: () => {
|
|
181
|
+
if (!subscription.stopPromise) {
|
|
182
|
+
subscription.stopPromise = Promise.resolve().then(async () => {
|
|
183
|
+
await subscription.job;
|
|
184
|
+
this.subscriptions.delete(subscription);
|
|
185
|
+
if (subscription.failed) throw subscription.failure;
|
|
186
|
+
});
|
|
187
|
+
controller.abort(new Error('Sonos subscription stopped.'));
|
|
188
|
+
}
|
|
189
|
+
return subscription.stopPromise;
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
// Reservation precedes the initial read and the first caller callback.
|
|
193
|
+
this.subscriptions.add(subscription);
|
|
194
|
+
const publish = async (type: IIntegrationEvent['type'], data: unknown): Promise<void> => {
|
|
195
|
+
try { await handler(this.event(type, data)); }
|
|
196
|
+
catch (error) { subscription.failed = true; subscription.failure = error; throw error; }
|
|
197
|
+
};
|
|
198
|
+
const initial = this.observe(async (session) => {
|
|
199
|
+
const snapshot = await this.read(session); combined.throwIfAborted();
|
|
200
|
+
await publish('state_changed', snapshot); combined.throwIfAborted();
|
|
201
|
+
}, combined);
|
|
202
|
+
subscription.job = initial.then(async () => {
|
|
203
|
+
while (!combined.aborted) {
|
|
204
|
+
await wait(this.client.config.pollIntervalMs, combined);
|
|
189
205
|
if (combined.aborted) return;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
206
|
+
await this.observe(async (session) => {
|
|
207
|
+
let snapshot: ISonosSnapshot;
|
|
208
|
+
try { snapshot = await this.read(session); }
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (combined.aborted) return;
|
|
211
|
+
await publish('availability_changed', { available: false });
|
|
212
|
+
await publish('error', { message: error instanceof Error ? error.message : 'Sonos read failed.' });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
await publish('state_changed', snapshot);
|
|
216
|
+
}, combined);
|
|
193
217
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
controller, job, failed: false,
|
|
203
|
-
stop: async () => {
|
|
204
|
-
controller.abort(new Error('Sonos subscription stopped.'));
|
|
205
|
-
await job;
|
|
206
|
-
this.subscriptions.delete(subscription);
|
|
207
|
-
if (subscription.failed) throw subscription.failure;
|
|
208
|
-
},
|
|
209
|
-
};
|
|
210
|
-
this.subscriptions.add(subscription);
|
|
211
|
-
return subscription.stop;
|
|
218
|
+
}).catch((error: unknown) => {
|
|
219
|
+
if (!combined.aborted || subscription.failed) { subscription.failed = true; subscription.failure = error; this.healthy = false; controller.abort(error); }
|
|
220
|
+
}).then(() => { if (!subscription.failed) this.subscriptions.delete(subscription); });
|
|
221
|
+
return initial.then(() => subscription.stop, async (error: unknown) => {
|
|
222
|
+
try { await subscription.stop(); } catch (cleanupError) { if (cleanupError !== error) throw new AggregateError([error, cleanupError], 'Sonos subscription admission and cleanup failed.'); }
|
|
223
|
+
throw error;
|
|
224
|
+
});
|
|
225
|
+
} catch (error) { return Promise.reject(error); }
|
|
212
226
|
}
|
|
213
227
|
|
|
214
|
-
public destroy(): Promise<void> {
|
|
228
|
+
public destroy(): Promise<void> { return this.lease.close(); }
|
|
229
|
+
|
|
230
|
+
/** The owner's retained composite handle invokes this, including explicit cleanup retry. */
|
|
231
|
+
public closeOwnedResources(retry = false): Promise<void> {
|
|
232
|
+
if (retry) return this.drain(true);
|
|
215
233
|
if (!this.closePromise) {
|
|
216
|
-
this.closed = true;
|
|
217
|
-
this.
|
|
234
|
+
this.closed = true; this.healthy = false;
|
|
235
|
+
this.closePromise = Promise.resolve().then(() => this.drain(false));
|
|
218
236
|
this.lifetime.abort(new Error('Sonos runtime is closed.'));
|
|
219
|
-
if (this.parentAbort) this.context.abortSignal?.removeEventListener('abort', this.parentAbort);
|
|
220
|
-
this.closePromise = (async () => {
|
|
221
|
-
const results = await Promise.allSettled([this.client.destroy(), ...[...this.subscriptions].map((subscription) => subscription.stop())]);
|
|
222
|
-
this.onClosed?.();
|
|
223
|
-
const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
|
|
224
|
-
if (failures.length) throw new AggregateError(failures.map((result) => result.reason), 'Sonos cleanup failed.');
|
|
225
|
-
})();
|
|
226
237
|
}
|
|
227
238
|
return this.closePromise;
|
|
228
239
|
}
|
|
240
|
+
private async drain(retry: boolean): Promise<void> {
|
|
241
|
+
const results = await Promise.allSettled([
|
|
242
|
+
Promise.resolve().then(() => retry ? this.client.retryCleanup() : this.client.destroy()),
|
|
243
|
+
...[...this.subscriptions].map((subscription) => Promise.resolve().then(() => subscription.stop())),
|
|
244
|
+
]);
|
|
245
|
+
const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
|
|
246
|
+
if (failures.length) throw new AggregateError(failures.map((result) => result.reason), 'Sonos cleanup or subscriber failed; ownership remains retained.');
|
|
247
|
+
}
|
|
229
248
|
|
|
230
|
-
private
|
|
231
|
-
try {
|
|
249
|
+
private observe<TResult>(work: (session: ISonosOperationSession) => Promise<TResult>, signal?: AbortSignal): Promise<TResult> {
|
|
250
|
+
try {
|
|
251
|
+
this.lease.assertOpen();
|
|
252
|
+
return this.client.withOperation(async (session) => {
|
|
253
|
+
try { return await work(session); }
|
|
254
|
+
catch (error) { if (error instanceof SonosCommandError && error.effectOutcome === 'indeterminate') this.healthy = false; throw error; }
|
|
255
|
+
}, signal);
|
|
256
|
+
}
|
|
257
|
+
catch (error) { return Promise.reject(error); }
|
|
258
|
+
}
|
|
259
|
+
private async read(session: ISonosOperationSession): Promise<ISonosSnapshot> {
|
|
260
|
+
try { this.snapshot = await session.getSnapshot(); this.healthy = true; return this.snapshot; }
|
|
232
261
|
catch (error) { this.healthy = false; throw error; }
|
|
233
262
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return { success: true, data: { devices: SonosMapper.toDevices(snapshot), entities: SonosMapper.toEntities(snapshot) } };
|
|
263
|
+
private assertControlAllowed(): void {
|
|
264
|
+
this.lease.assertOpen();
|
|
265
|
+
if (!this.client.controlAllowed) throw new Error('Sonos control requires explicit runtime owner permission.');
|
|
238
266
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
267
|
+
private result(result: ISonosControlResult): IServiceCallResult {
|
|
268
|
+
this.snapshot = result.snapshot; this.healthy = true;
|
|
269
|
+
return { success: true, data: { acknowledgedActions: result.acknowledgedActions, outcome: result.outcome, requestedValue: result.requestedValue, observedValue: result.observedValue,
|
|
270
|
+
physicalEffectConfirmed: false, observationAtomic: false, observedAt: result.snapshot.observedAt,
|
|
271
|
+
devices: SonosMapper.toDevices(result.snapshot), entities: SonosMapper.toEntities(result.snapshot) } };
|
|
244
272
|
}
|
|
245
|
-
|
|
246
273
|
private event(type: IIntegrationEvent['type'], data: unknown): IIntegrationEvent {
|
|
247
|
-
return { type, integrationDomain: this.domain, deviceId: SonosMapper.deviceId(this.snapshot),
|
|
248
|
-
|
|
274
|
+
return { type, integrationDomain: this.domain, deviceId: SonosMapper.deviceId(this.snapshot), entityId: SonosMapper.entityId(this.snapshot.homeId, this.snapshot.speakerInfo.uid),
|
|
275
|
+
data: structuredClone(data), timestamp: Date.now() };
|
|
249
276
|
}
|
|
250
277
|
}
|
|
251
278
|
|
|
@@ -256,12 +283,10 @@ function assertDataRecord(value: unknown, allowed?: string[]): asserts value is
|
|
|
256
283
|
if (typeof key !== 'string' || !descriptor || !('value' in descriptor) || (allowed && !allowed.includes(key))) throw new Error('Sonos input contains an unsupported field.');
|
|
257
284
|
}
|
|
258
285
|
}
|
|
259
|
-
|
|
260
286
|
function wait(milliseconds: number, signal: AbortSignal): Promise<void> {
|
|
261
287
|
return new Promise((resolve) => {
|
|
262
288
|
const finish = (): void => { clearTimeout(timer); signal.removeEventListener('abort', finish); resolve(); };
|
|
263
289
|
const timer = setTimeout(finish, milliseconds);
|
|
264
|
-
signal.addEventListener('abort', finish, { once: true });
|
|
265
|
-
if (signal.aborted) finish();
|
|
290
|
+
signal.addEventListener('abort', finish, { once: true }); if (signal.aborted) finish();
|
|
266
291
|
});
|
|
267
292
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { ISonosConfig } from './sonos.types.js';
|
|
1
|
+
import type { ISonosConfig, ISonosEndpointConfig, ISonosOwnerOptions } from './sonos.types.js';
|
|
2
2
|
|
|
3
3
|
export interface INormalizedSonosConfig extends ISonosConfig {
|
|
4
|
+
readonly homeId: string;
|
|
4
5
|
readonly host: string;
|
|
5
6
|
readonly port: number;
|
|
6
7
|
readonly expectedUid?: string;
|
|
@@ -9,6 +10,19 @@ export interface INormalizedSonosConfig extends ISonosConfig {
|
|
|
9
10
|
readonly pollIntervalMs: number;
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
export function normalizeSonosHome(value: unknown): string {
|
|
14
|
+
if (typeof value !== 'string' || !value || value.length > 256 || value.trim() !== value || /[\p{Cc}\p{Cs}]/u.test(value)) {
|
|
15
|
+
throw new Error('Sonos homeId must be a bounded nonempty identifier without surrounding whitespace or control characters.');
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeSonosOwnerOptions(input: ISonosOwnerOptions): Readonly<ISonosOwnerOptions> {
|
|
21
|
+
if (!isSonosDataRecord(input) || Object.keys(input).some((key) => !['homeId', 'allowControl'].includes(key))) throw new Error('Sonos owner options contain unsupported fields.');
|
|
22
|
+
if (input.allowControl !== undefined && typeof input.allowControl !== 'boolean') throw new Error('Sonos allowControl must be boolean.');
|
|
23
|
+
return Object.freeze({ ...(input.homeId === undefined ? {} : { homeId: normalizeSonosHome(input.homeId) }), allowControl: input.allowControl === true });
|
|
24
|
+
}
|
|
25
|
+
|
|
12
26
|
export function normalizeSonosUid(value: unknown): string {
|
|
13
27
|
if (typeof value !== 'string' || !/^RINCON_[0-9a-f]{12}0?1400$/i.test(value)) {
|
|
14
28
|
throw new Error('Sonos player identity must be a RINCON UUID.');
|
|
@@ -20,11 +34,19 @@ export function isSonosDataRecord(value: unknown): value is Record<string, unkno
|
|
|
20
34
|
if (!value || typeof value !== 'object' || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return false;
|
|
21
35
|
return Reflect.ownKeys(value).every((key) => {
|
|
22
36
|
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
23
|
-
return typeof key === 'string' && descriptor !== undefined && 'value' in descriptor;
|
|
37
|
+
return typeof key === 'string' && descriptor !== undefined && descriptor.enumerable === true && 'value' in descriptor;
|
|
24
38
|
});
|
|
25
39
|
}
|
|
26
40
|
|
|
27
41
|
export function normalizeSonosConfig(input: ISonosConfig): INormalizedSonosConfig {
|
|
42
|
+
if (!isSonosDataRecord(input) || !Object.hasOwn(input, 'homeId')) throw new Error('Sonos configuration requires its own homeId.');
|
|
43
|
+
const homeId = normalizeSonosHome(input.homeId);
|
|
44
|
+
const { homeId: _homeId, ...endpoint } = input;
|
|
45
|
+
return Object.freeze({ ...normalizeSonosEndpoint(endpoint), homeId });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Discovery validates an endpoint only; it cannot select an owning home. */
|
|
49
|
+
export function normalizeSonosEndpoint(input: ISonosEndpointConfig): Omit<INormalizedSonosConfig, 'homeId'> {
|
|
28
50
|
if (!isSonosDataRecord(input) || !Object.hasOwn(input, 'host')) throw new Error('Sonos configuration must be a plain object containing its own host field.');
|
|
29
51
|
const allowed = new Set(['host', 'port', 'expectedUid', 'timeoutMs', 'maxResponseBytes', 'pollIntervalMs']);
|
|
30
52
|
for (const key of Reflect.ownKeys(input)) {
|