Add native Sonos integration
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createSonosDiscoveryDescriptor } from '../../ts/integrations/sonos/index.js';
|
||||
|
||||
tap.test('matches Sonos SSDP ZonePlayer records', async () => {
|
||||
const descriptor = createSonosDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
st: 'urn:schemas-upnp-org:device:ZonePlayer:1',
|
||||
usn: 'uuid:RINCON_000E58ABCDEF01400::urn:schemas-upnp-org:device:ZonePlayer:1',
|
||||
location: 'http://192.168.1.55:1400/xml/device_description.xml',
|
||||
}, {});
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.host).toEqual('192.168.1.55');
|
||||
expect(result.normalizedDeviceId).toEqual('RINCON_000E58ABCDEF01400');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SonosMapper } from '../../ts/integrations/sonos/index.js';
|
||||
|
||||
const snapshot = {
|
||||
speakerInfo: {
|
||||
uid: 'RINCON_000E58ABCDEF01400',
|
||||
zoneName: 'Kitchen',
|
||||
modelName: 'Sonos One',
|
||||
manufacturer: 'Sonos',
|
||||
},
|
||||
transportInfo: {
|
||||
currentTransportState: 'PLAYING',
|
||||
},
|
||||
positionInfo: {
|
||||
title: 'Example Track',
|
||||
artist: 'Example Artist',
|
||||
album: 'Example Album',
|
||||
trackUri: 'x-sonos-http:track',
|
||||
},
|
||||
volume: 35,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
tap.test('maps Sonos speaker snapshots to canonical media devices and entities', async () => {
|
||||
const devices = SonosMapper.toDevices(snapshot);
|
||||
const entities = SonosMapper.toEntities(snapshot);
|
||||
expect(devices[0].id).toEqual('sonos.player.rincon_000e58abcdef01400');
|
||||
expect(devices[0].features.some((featureArg) => featureArg.id === 'volume')).toBeTrue();
|
||||
expect(entities[0].platform).toEqual('media_player');
|
||||
expect(entities[0].state).toEqual('playing');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -115,6 +115,7 @@ export type TEntityPlatform =
|
||||
| 'climate'
|
||||
| 'cover'
|
||||
| 'fan'
|
||||
| 'media_player'
|
||||
| 'number'
|
||||
| 'select'
|
||||
| 'text'
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './integrations/index.js';
|
||||
|
||||
import { HueIntegration } from './integrations/hue/index.js';
|
||||
import { ShellyIntegration } from './integrations/shelly/index.js';
|
||||
import { SonosIntegration } from './integrations/sonos/index.js';
|
||||
import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js';
|
||||
import { generatedHomeAssistantPortIntegrations } from './integrations/generated/index.js';
|
||||
import { IntegrationRegistry } from './core/index.js';
|
||||
@@ -11,6 +12,7 @@ import { IntegrationRegistry } from './core/index.js';
|
||||
export const integrations = [
|
||||
new HueIntegration(),
|
||||
new ShellyIntegration(),
|
||||
new SonosIntegration(),
|
||||
new WolfSmartsetIntegration(),
|
||||
];
|
||||
|
||||
|
||||
@@ -1162,7 +1162,6 @@ import { HomeAssistantSomfyIntegration } from '../somfy/index.js';
|
||||
import { HomeAssistantSomfyMylinkIntegration } from '../somfy_mylink/index.js';
|
||||
import { HomeAssistantSonarrIntegration } from '../sonarr/index.js';
|
||||
import { HomeAssistantSongpalIntegration } from '../songpal/index.js';
|
||||
import { HomeAssistantSonosIntegration } from '../sonos/index.js';
|
||||
import { HomeAssistantSonyProjectorIntegration } from '../sony_projector/index.js';
|
||||
import { HomeAssistantSoundtouchIntegration } from '../soundtouch/index.js';
|
||||
import { HomeAssistantSpaceapiIntegration } from '../spaceapi/index.js';
|
||||
@@ -2620,7 +2619,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantSomfyIntegration())
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSomfyMylinkIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSonarrIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSongpalIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSonosIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSonyProjectorIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSoundtouchIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSpaceapiIntegration());
|
||||
@@ -2916,8 +2914,9 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegrati
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveJsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
||||
|
||||
export const generatedHomeAssistantPortCount = 1456;
|
||||
export const generatedHomeAssistantPortCount = 1455;
|
||||
export const handwrittenHomeAssistantPortDomains = [
|
||||
"hue",
|
||||
"shelly"
|
||||
"shelly",
|
||||
"sonos"
|
||||
];
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './sonos.classes.integration.js';
|
||||
export * from './sonos.classes.client.js';
|
||||
export * from './sonos.classes.configflow.js';
|
||||
export * from './sonos.discovery.js';
|
||||
export * from './sonos.mapper.js';
|
||||
export * from './sonos.types.js';
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { ISonosConfig, ISonosPositionInfo, ISonosSnapshot, ISonosSpeakerInfo, ISonosTransportInfo } from './sonos.types.js';
|
||||
|
||||
type TSonosServiceType = 'AVTransport' | 'RenderingControl' | 'DeviceProperties';
|
||||
|
||||
export class SonosClient {
|
||||
constructor(private readonly config: ISonosConfig) {}
|
||||
|
||||
public async getSnapshot(): Promise<ISonosSnapshot> {
|
||||
const speakerInfo = await this.getSpeakerInfo();
|
||||
const [transportInfo, positionInfo, volume, muted] = await Promise.all([
|
||||
this.getTransportInfo(),
|
||||
this.getPositionInfo(),
|
||||
this.getVolume(),
|
||||
this.getMute(),
|
||||
]);
|
||||
return { speakerInfo, transportInfo, positionInfo, volume, muted };
|
||||
}
|
||||
|
||||
public async getSpeakerInfo(): Promise<ISonosSpeakerInfo> {
|
||||
if (this.config.speakerInfo) {
|
||||
return this.config.speakerInfo;
|
||||
}
|
||||
const xml = await this.fetchText('/xml/device_description.xml');
|
||||
const uid = this.stripUuid(this.readXmlTag(xml, 'UDN') || this.config.host || 'unknown');
|
||||
const zoneName = this.readXmlTag(xml, 'roomName') || this.readXmlTag(xml, 'friendlyName') || `Sonos ${uid}`;
|
||||
return {
|
||||
uid,
|
||||
zoneName,
|
||||
modelName: this.readXmlTag(xml, 'modelName'),
|
||||
modelNumber: this.readXmlTag(xml, 'modelNumber'),
|
||||
serialNumber: this.readXmlTag(xml, 'serialNum'),
|
||||
softwareVersion: this.readXmlTag(xml, 'softwareVersion'),
|
||||
manufacturer: this.readXmlTag(xml, 'manufacturer') || 'Sonos',
|
||||
};
|
||||
}
|
||||
|
||||
public async getTransportInfo(): Promise<ISonosTransportInfo> {
|
||||
if (this.config.transportInfo) {
|
||||
return this.config.transportInfo;
|
||||
}
|
||||
const result = await this.soap('AVTransport', 'GetTransportInfo', [
|
||||
['InstanceID', 0],
|
||||
]);
|
||||
return {
|
||||
currentTransportState: result.CurrentTransportState,
|
||||
currentTransportStatus: result.CurrentTransportStatus,
|
||||
currentSpeed: result.CurrentSpeed,
|
||||
};
|
||||
}
|
||||
|
||||
public async getPositionInfo(): Promise<ISonosPositionInfo> {
|
||||
if (this.config.positionInfo) {
|
||||
return this.config.positionInfo;
|
||||
}
|
||||
const result = await this.soap('AVTransport', 'GetPositionInfo', [
|
||||
['InstanceID', 0],
|
||||
]);
|
||||
const metadata = result.TrackMetaData || '';
|
||||
return {
|
||||
track: Number(result.Track || 0),
|
||||
trackDuration: result.TrackDuration,
|
||||
trackMetaData: metadata,
|
||||
trackUri: result.TrackURI,
|
||||
relativeTime: result.RelTime,
|
||||
title: this.readXmlTag(metadata, 'dc:title') || this.readXmlTag(metadata, 'title'),
|
||||
artist: this.readXmlTag(metadata, 'dc:creator') || this.readXmlTag(metadata, 'creator'),
|
||||
album: this.readXmlTag(metadata, 'upnp:album') || this.readXmlTag(metadata, 'album'),
|
||||
albumArtUri: this.readXmlTag(metadata, 'upnp:albumArtURI') || this.readXmlTag(metadata, 'albumArtURI'),
|
||||
};
|
||||
}
|
||||
|
||||
public async getVolume(): Promise<number | undefined> {
|
||||
if (typeof this.config.volume === 'number') {
|
||||
return this.config.volume;
|
||||
}
|
||||
const result = await this.soap('RenderingControl', 'GetVolume', [
|
||||
['InstanceID', 0],
|
||||
['Channel', 'Master'],
|
||||
]);
|
||||
return Number(result.CurrentVolume);
|
||||
}
|
||||
|
||||
public async getMute(): Promise<boolean | undefined> {
|
||||
if (typeof this.config.muted === 'boolean') {
|
||||
return this.config.muted;
|
||||
}
|
||||
const result = await this.soap('RenderingControl', 'GetMute', [
|
||||
['InstanceID', 0],
|
||||
['Channel', 'Master'],
|
||||
]);
|
||||
return result.CurrentMute === '1' || result.CurrentMute?.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
public async play(): Promise<void> {
|
||||
await this.soap('AVTransport', 'Play', [
|
||||
['InstanceID', 0],
|
||||
['Speed', 1],
|
||||
]);
|
||||
}
|
||||
|
||||
public async pause(): Promise<void> {
|
||||
await this.soap('AVTransport', 'Pause', [
|
||||
['InstanceID', 0],
|
||||
['Speed', 1],
|
||||
]);
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
await this.soap('AVTransport', 'Stop', [
|
||||
['InstanceID', 0],
|
||||
['Speed', 1],
|
||||
]);
|
||||
}
|
||||
|
||||
public async next(): Promise<void> {
|
||||
await this.soap('AVTransport', 'Next', [
|
||||
['InstanceID', 0],
|
||||
['Speed', 1],
|
||||
]);
|
||||
}
|
||||
|
||||
public async previous(): Promise<void> {
|
||||
await this.soap('AVTransport', 'Previous', [
|
||||
['InstanceID', 0],
|
||||
['Speed', 1],
|
||||
]);
|
||||
}
|
||||
|
||||
public async setVolume(volumeArg: number): Promise<void> {
|
||||
const volume = Math.max(0, Math.min(100, Math.round(volumeArg)));
|
||||
await this.soap('RenderingControl', 'SetVolume', [
|
||||
['InstanceID', 0],
|
||||
['Channel', 'Master'],
|
||||
['DesiredVolume', volume],
|
||||
]);
|
||||
}
|
||||
|
||||
public async setMute(mutedArg: boolean): Promise<void> {
|
||||
await this.soap('RenderingControl', 'SetMute', [
|
||||
['InstanceID', 0],
|
||||
['Channel', 'Master'],
|
||||
['DesiredMute', mutedArg ? 1 : 0],
|
||||
]);
|
||||
}
|
||||
|
||||
public async playUri(uriArg: string, titleArg?: string): Promise<void> {
|
||||
await this.soap('AVTransport', 'SetAVTransportURI', [
|
||||
['InstanceID', 0],
|
||||
['CurrentURI', uriArg],
|
||||
['CurrentURIMetaData', titleArg ? this.createMinimalRadioMetadata(titleArg) : ''],
|
||||
]);
|
||||
await this.play();
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
|
||||
private async soap(serviceTypeArg: TSonosServiceType, actionArg: string, argsArg: Array<[string, string | number | boolean]>): Promise<Record<string, string>> {
|
||||
const path = this.servicePath(serviceTypeArg);
|
||||
const body = this.createSoapEnvelope(serviceTypeArg, actionArg, argsArg);
|
||||
const response = await globalThis.fetch(`${this.baseUrl()}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'text/xml; charset="utf-8"',
|
||||
soapaction: `urn:schemas-upnp-org:service:${serviceTypeArg}:1#${actionArg}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Sonos SOAP ${serviceTypeArg}.${actionArg} failed with HTTP ${response.status}: ${text}`);
|
||||
}
|
||||
return this.unwrapSoapResponse(text);
|
||||
}
|
||||
|
||||
private async fetchText(pathArg: string): Promise<string> {
|
||||
if (!this.config.host) {
|
||||
throw new Error('Sonos host is required when fixture data is not provided.');
|
||||
}
|
||||
const response = await globalThis.fetch(`${this.baseUrl()}${pathArg}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Sonos request ${pathArg} failed with HTTP ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
private createSoapEnvelope(serviceTypeArg: TSonosServiceType, actionArg: string, argsArg: Array<[string, string | number | boolean]>): string {
|
||||
const args = argsArg.map(([nameArg, valueArg]) => `<${nameArg}>${this.escapeXml(String(valueArg))}</${nameArg}>`).join('');
|
||||
return `<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:${actionArg} xmlns:u="urn:schemas-upnp-org:service:${serviceTypeArg}:1">${args}</u:${actionArg}></s:Body></s:Envelope>`;
|
||||
}
|
||||
|
||||
private createMinimalRadioMetadata(titleArg: string): string {
|
||||
return `<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="R:0/0/0" parentID="R:0/0" restricted="true"><dc:title>${this.escapeXml(titleArg)}</dc:title><upnp:class>object.item.audioItem.audioBroadcast</upnp:class><desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">SA_RINCON65031_</desc></item></DIDL-Lite>`;
|
||||
}
|
||||
|
||||
private unwrapSoapResponse(xmlArg: string): Record<string, string> {
|
||||
const response: Record<string, string> = {};
|
||||
const regex = /<([A-Za-z0-9_:]+)>([^<>]*)<\/\1>/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(xmlArg))) {
|
||||
response[this.localName(match[1])] = this.unescapeXml(match[2]);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private readXmlTag(xmlArg: string, tagArg: string): string | undefined {
|
||||
const escapedTag = tagArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`<(?:[A-Za-z0-9_]+:)?${escapedTag}>([\\s\\S]*?)<\\/(?:[A-Za-z0-9_]+:)?${escapedTag}>`, 'i');
|
||||
const match = regex.exec(xmlArg);
|
||||
return match?.[1] ? this.unescapeXml(match[1].trim()) : undefined;
|
||||
}
|
||||
|
||||
private servicePath(serviceTypeArg: TSonosServiceType): string {
|
||||
if (serviceTypeArg === 'RenderingControl') {
|
||||
return '/MediaRenderer/RenderingControl/Control';
|
||||
}
|
||||
if (serviceTypeArg === 'DeviceProperties') {
|
||||
return '/DeviceProperties/Control';
|
||||
}
|
||||
return '/MediaRenderer/AVTransport/Control';
|
||||
}
|
||||
|
||||
private stripUuid(valueArg: string): string {
|
||||
return valueArg.replace(/^uuid:/i, '');
|
||||
}
|
||||
|
||||
private localName(valueArg: string): string {
|
||||
return valueArg.includes(':') ? valueArg.split(':').pop() || valueArg : valueArg;
|
||||
}
|
||||
|
||||
private baseUrl(): string {
|
||||
return `http://${this.config.host}:${this.config.port || 1400}`;
|
||||
}
|
||||
|
||||
private escapeXml(valueArg: string): string {
|
||||
return valueArg.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
private unescapeXml(valueArg: string): string {
|
||||
return valueArg.replace(/'/g, "'").replace(/"/g, '"').replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { ISonosConfig } from './sonos.types.js';
|
||||
|
||||
export class SonosConfigFlow implements IConfigFlow<ISonosConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<ISonosConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect Sonos',
|
||||
description: 'Configure the local Sonos player HTTP endpoint discovered by SSDP or mDNS.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||
{ name: 'port', label: 'Port', type: 'number' },
|
||||
],
|
||||
submit: async (valuesArg) => ({
|
||||
kind: 'done',
|
||||
title: 'Sonos configured',
|
||||
config: {
|
||||
host: String(valuesArg.host || candidateArg.host || ''),
|
||||
port: typeof valuesArg.port === 'number' ? valuesArg.port : candidateArg.port || 1400,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,105 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { SonosClient } from './sonos.classes.client.js';
|
||||
import { SonosConfigFlow } from './sonos.classes.configflow.js';
|
||||
import { createSonosDiscoveryDescriptor } from './sonos.discovery.js';
|
||||
import { SonosMapper } from './sonos.mapper.js';
|
||||
import type { ISonosConfig } from './sonos.types.js';
|
||||
|
||||
export class HomeAssistantSonosIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "sonos",
|
||||
displayName: "Sonos",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/sonos",
|
||||
"upstreamDomain": "sonos",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_push",
|
||||
"qualityScale": "bronze",
|
||||
"requirements": [
|
||||
"defusedxml==0.7.1",
|
||||
"soco==0.30.15",
|
||||
"sonos-websocket==0.1.3"
|
||||
],
|
||||
"dependencies": [
|
||||
"ssdp"
|
||||
],
|
||||
"afterDependencies": [
|
||||
"plex",
|
||||
"spotify",
|
||||
"zeroconf",
|
||||
"media_source"
|
||||
],
|
||||
"codeowners": [
|
||||
"@jjlawren",
|
||||
"@peterager"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class SonosIntegration extends BaseIntegration<ISonosConfig> {
|
||||
public readonly domain = 'sonos';
|
||||
public readonly displayName = 'Sonos';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createSonosDiscoveryDescriptor();
|
||||
public readonly configFlow = new SonosConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/sonos',
|
||||
upstreamDomain: 'sonos',
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_push',
|
||||
qualityScale: 'bronze',
|
||||
};
|
||||
|
||||
public async setup(configArg: ISonosConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new SonosRuntime(new SonosClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantSonosIntegration extends SonosIntegration {}
|
||||
|
||||
class SonosRuntime implements IIntegrationRuntime {
|
||||
public domain = 'sonos';
|
||||
|
||||
constructor(private readonly client: SonosClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return SonosMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return SonosMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.domain !== 'media_player') {
|
||||
return { success: false, error: `Unsupported Sonos service domain: ${requestArg.domain}` };
|
||||
}
|
||||
|
||||
if (requestArg.service === 'play') {
|
||||
await this.client.play();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'pause') {
|
||||
await this.client.pause();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'stop') {
|
||||
await this.client.stop();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'next_track') {
|
||||
await this.client.next();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'previous_track') {
|
||||
await this.client.previous();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'volume_set') {
|
||||
const rawVolume = requestArg.data?.volume_level ?? requestArg.data?.volume;
|
||||
const volume = typeof rawVolume === 'number' && rawVolume <= 1 ? rawVolume * 100 : rawVolume;
|
||||
if (typeof volume !== 'number') {
|
||||
return { success: false, error: 'Sonos volume_set requires data.volume_level or data.volume.' };
|
||||
}
|
||||
await this.client.setVolume(volume);
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'volume_mute') {
|
||||
const muted = requestArg.data?.is_volume_muted ?? requestArg.data?.muted;
|
||||
if (typeof muted !== 'boolean') {
|
||||
return { success: false, error: 'Sonos volume_mute requires data.is_volume_muted or data.muted.' };
|
||||
}
|
||||
await this.client.setMute(muted);
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'play_media') {
|
||||
const uri = requestArg.data?.media_content_id ?? requestArg.data?.uri;
|
||||
if (typeof uri !== 'string' || !uri) {
|
||||
return { success: false, error: 'Sonos play_media requires data.media_content_id or data.uri.' };
|
||||
}
|
||||
await this.client.playUri(uri, typeof requestArg.data?.title === 'string' ? requestArg.data.title : undefined);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: false, error: `Unsupported Sonos media_player service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import type { ISonosManualEntry, ISonosMdnsRecord, ISonosSsdpRecord } from './sonos.types.js';
|
||||
|
||||
const sonosUpnpSt = 'urn:schemas-upnp-org:device:ZonePlayer:1';
|
||||
|
||||
export class SonosSsdpMatcher implements IDiscoveryMatcher<ISonosSsdpRecord> {
|
||||
public id = 'sonos-ssdp-match';
|
||||
public source = 'ssdp' as const;
|
||||
public description = 'Recognize Sonos ZonePlayer SSDP advertisements.';
|
||||
|
||||
public async matches(recordArg: ISonosSsdpRecord): Promise<IDiscoveryMatch> {
|
||||
const st = recordArg.st || recordArg.headers?.st || recordArg.headers?.ST;
|
||||
const usn = recordArg.usn || recordArg.headers?.usn || recordArg.headers?.USN;
|
||||
const location = recordArg.location || recordArg.headers?.location || recordArg.headers?.LOCATION;
|
||||
const matched = st === sonosUpnpSt || Boolean(usn?.includes('RINCON_'));
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'SSDP record is not a Sonos ZonePlayer.' };
|
||||
}
|
||||
const url = location ? new URL(location) : undefined;
|
||||
const id = usn?.match(/RINCON_[^:\s]+/)?.[0];
|
||||
return {
|
||||
matched: true,
|
||||
confidence: id ? 'certain' : 'high',
|
||||
reason: 'SSDP record matches Sonos ZonePlayer metadata.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'ssdp',
|
||||
integrationDomain: 'sonos',
|
||||
id,
|
||||
host: url?.hostname,
|
||||
port: url?.port ? Number(url.port) : 1400,
|
||||
manufacturer: 'Sonos',
|
||||
model: 'ZonePlayer',
|
||||
metadata: { st, usn, location },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SonosMdnsMatcher implements IDiscoveryMatcher<ISonosMdnsRecord> {
|
||||
public id = 'sonos-mdns-match';
|
||||
public source = 'mdns' as const;
|
||||
public description = 'Recognize Sonos mDNS advertisements.';
|
||||
|
||||
public async matches(recordArg: ISonosMdnsRecord): Promise<IDiscoveryMatch> {
|
||||
const type = recordArg.type?.toLowerCase() || '';
|
||||
const name = recordArg.name?.toLowerCase() || '';
|
||||
const matched = type === '_sonos._tcp.local.' || name.startsWith('sonos');
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'mDNS record is not a Sonos advertisement.' };
|
||||
}
|
||||
const id = recordArg.txt?.id || recordArg.txt?.uid || recordArg.name;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: id ? 'certain' : 'high',
|
||||
reason: 'mDNS record matches Sonos metadata.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'mdns',
|
||||
integrationDomain: 'sonos',
|
||||
id,
|
||||
host: recordArg.host,
|
||||
port: recordArg.port || 1400,
|
||||
manufacturer: 'Sonos',
|
||||
metadata: { mdnsName: recordArg.name, mdnsType: recordArg.type, txt: recordArg.txt },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SonosManualMatcher implements IDiscoveryMatcher<ISonosManualEntry> {
|
||||
public id = 'sonos-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Sonos setup entries.';
|
||||
|
||||
public async matches(inputArg: ISonosManualEntry): Promise<IDiscoveryMatch> {
|
||||
const matched = Boolean(inputArg.host || inputArg.model?.toLowerCase().includes('sonos') || inputArg.metadata?.sonos);
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Sonos setup hints.' };
|
||||
}
|
||||
return {
|
||||
matched: true,
|
||||
confidence: inputArg.host ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start Sonos setup.',
|
||||
normalizedDeviceId: inputArg.id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: 'sonos',
|
||||
id: inputArg.id,
|
||||
host: inputArg.host,
|
||||
port: inputArg.port || 1400,
|
||||
name: inputArg.name,
|
||||
manufacturer: 'Sonos',
|
||||
model: inputArg.model,
|
||||
metadata: inputArg.metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SonosCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'sonos-candidate-validator';
|
||||
public description = 'Validate Sonos candidate metadata.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const manufacturer = candidateArg.manufacturer?.toLowerCase() || '';
|
||||
const model = candidateArg.model?.toLowerCase() || '';
|
||||
const matched = candidateArg.integrationDomain === 'sonos' || manufacturer === 'sonos' || model.includes('zoneplayer') || model.includes('sonos');
|
||||
return {
|
||||
matched,
|
||||
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Candidate has Sonos metadata.' : 'Candidate is not Sonos.',
|
||||
candidate: matched ? candidateArg : undefined,
|
||||
normalizedDeviceId: candidateArg.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createSonosDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: 'sonos', displayName: 'Sonos' })
|
||||
.addMatcher(new SonosSsdpMatcher())
|
||||
.addMatcher(new SonosMdnsMatcher())
|
||||
.addMatcher(new SonosManualMatcher())
|
||||
.addValidator(new SonosCandidateValidator());
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity } from '../../core/types.js';
|
||||
import type { ISonosSnapshot } from './sonos.types.js';
|
||||
|
||||
export class SonosMapper {
|
||||
public static toDevices(snapshotArg: ISonosSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = new Date().toISOString();
|
||||
const deviceId = this.deviceId(snapshotArg);
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: true },
|
||||
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' },
|
||||
{ id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true },
|
||||
];
|
||||
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||
{ featureId: 'playback', value: this.playbackState(snapshotArg), updatedAt },
|
||||
{ featureId: 'volume', value: snapshotArg.volume ?? null, updatedAt },
|
||||
{ featureId: 'muted', value: snapshotArg.muted ?? null, updatedAt },
|
||||
];
|
||||
if (snapshotArg.positionInfo.title) {
|
||||
features.push({ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false });
|
||||
state.push({ featureId: 'current_title', value: snapshotArg.positionInfo.title, updatedAt });
|
||||
}
|
||||
|
||||
return [{
|
||||
id: deviceId,
|
||||
integrationDomain: 'sonos',
|
||||
name: snapshotArg.speakerInfo.zoneName,
|
||||
protocol: 'http',
|
||||
manufacturer: snapshotArg.speakerInfo.manufacturer || 'Sonos',
|
||||
model: snapshotArg.speakerInfo.modelName || snapshotArg.speakerInfo.modelNumber,
|
||||
online: true,
|
||||
features,
|
||||
state,
|
||||
metadata: {
|
||||
uid: snapshotArg.speakerInfo.uid,
|
||||
serialNumber: snapshotArg.speakerInfo.serialNumber,
|
||||
softwareVersion: snapshotArg.speakerInfo.softwareVersion,
|
||||
householdId: snapshotArg.speakerInfo.householdId,
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: ISonosSnapshot): IIntegrationEntity[] {
|
||||
return [{
|
||||
id: `media_player.${this.slug(snapshotArg.speakerInfo.zoneName)}`,
|
||||
uniqueId: `sonos_${this.slug(snapshotArg.speakerInfo.uid)}`,
|
||||
integrationDomain: 'sonos',
|
||||
deviceId: this.deviceId(snapshotArg),
|
||||
platform: 'media_player',
|
||||
name: snapshotArg.speakerInfo.zoneName,
|
||||
state: this.playbackState(snapshotArg),
|
||||
attributes: {
|
||||
volumeLevel: typeof snapshotArg.volume === 'number' ? snapshotArg.volume / 100 : undefined,
|
||||
muted: snapshotArg.muted,
|
||||
mediaTitle: snapshotArg.positionInfo.title,
|
||||
mediaArtist: snapshotArg.positionInfo.artist,
|
||||
mediaAlbum: snapshotArg.positionInfo.album,
|
||||
mediaDuration: snapshotArg.positionInfo.trackDuration,
|
||||
mediaPosition: snapshotArg.positionInfo.relativeTime,
|
||||
mediaUri: snapshotArg.positionInfo.trackUri,
|
||||
albumArtUri: snapshotArg.positionInfo.albumArtUri,
|
||||
},
|
||||
available: true,
|
||||
}];
|
||||
}
|
||||
|
||||
private static playbackState(snapshotArg: ISonosSnapshot): string {
|
||||
const state = snapshotArg.transportInfo.currentTransportState;
|
||||
if (state === 'PLAYING' || state === 'TRANSITIONING') {
|
||||
return 'playing';
|
||||
}
|
||||
if (state === 'PAUSED_PLAYBACK') {
|
||||
return snapshotArg.positionInfo.title ? 'paused' : 'idle';
|
||||
}
|
||||
if (state === 'STOPPED') {
|
||||
return snapshotArg.positionInfo.title ? 'paused' : 'idle';
|
||||
}
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
private static deviceId(snapshotArg: ISonosSnapshot): string {
|
||||
return `sonos.player.${this.slug(snapshotArg.speakerInfo.uid)}`;
|
||||
}
|
||||
|
||||
private static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'sonos';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,70 @@
|
||||
export interface IHomeAssistantSonosConfig {
|
||||
// TODO: replace with the TypeScript-native config for sonos.
|
||||
[key: string]: unknown;
|
||||
export interface ISonosConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
speakerInfo?: ISonosSpeakerInfo;
|
||||
transportInfo?: ISonosTransportInfo;
|
||||
positionInfo?: ISonosPositionInfo;
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
export interface ISonosSpeakerInfo {
|
||||
uid: string;
|
||||
zoneName: string;
|
||||
modelName?: string;
|
||||
modelNumber?: string;
|
||||
serialNumber?: string;
|
||||
softwareVersion?: string;
|
||||
manufacturer?: string;
|
||||
householdId?: string;
|
||||
}
|
||||
|
||||
export interface ISonosTransportInfo {
|
||||
currentTransportState?: string;
|
||||
currentTransportStatus?: string;
|
||||
currentSpeed?: string;
|
||||
}
|
||||
|
||||
export interface ISonosPositionInfo {
|
||||
track?: number;
|
||||
trackDuration?: string;
|
||||
trackMetaData?: string;
|
||||
trackUri?: string;
|
||||
relativeTime?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
title?: string;
|
||||
albumArtUri?: string;
|
||||
}
|
||||
|
||||
export interface ISonosSnapshot {
|
||||
speakerInfo: ISonosSpeakerInfo;
|
||||
transportInfo: ISonosTransportInfo;
|
||||
positionInfo: ISonosPositionInfo;
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
export interface ISonosSsdpRecord {
|
||||
st?: string;
|
||||
usn?: string;
|
||||
location?: string;
|
||||
headers?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface ISonosMdnsRecord {
|
||||
name?: string;
|
||||
type?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
txt?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface ISonosManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
model?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user