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'
|
| 'climate'
|
||||||
| 'cover'
|
| 'cover'
|
||||||
| 'fan'
|
| 'fan'
|
||||||
|
| 'media_player'
|
||||||
| 'number'
|
| 'number'
|
||||||
| 'select'
|
| 'select'
|
||||||
| 'text'
|
| 'text'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export * from './integrations/index.js';
|
|||||||
|
|
||||||
import { HueIntegration } from './integrations/hue/index.js';
|
import { HueIntegration } from './integrations/hue/index.js';
|
||||||
import { ShellyIntegration } from './integrations/shelly/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 { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js';
|
||||||
import { generatedHomeAssistantPortIntegrations } from './integrations/generated/index.js';
|
import { generatedHomeAssistantPortIntegrations } from './integrations/generated/index.js';
|
||||||
import { IntegrationRegistry } from './core/index.js';
|
import { IntegrationRegistry } from './core/index.js';
|
||||||
@@ -11,6 +12,7 @@ import { IntegrationRegistry } from './core/index.js';
|
|||||||
export const integrations = [
|
export const integrations = [
|
||||||
new HueIntegration(),
|
new HueIntegration(),
|
||||||
new ShellyIntegration(),
|
new ShellyIntegration(),
|
||||||
|
new SonosIntegration(),
|
||||||
new WolfSmartsetIntegration(),
|
new WolfSmartsetIntegration(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1162,7 +1162,6 @@ import { HomeAssistantSomfyIntegration } from '../somfy/index.js';
|
|||||||
import { HomeAssistantSomfyMylinkIntegration } from '../somfy_mylink/index.js';
|
import { HomeAssistantSomfyMylinkIntegration } from '../somfy_mylink/index.js';
|
||||||
import { HomeAssistantSonarrIntegration } from '../sonarr/index.js';
|
import { HomeAssistantSonarrIntegration } from '../sonarr/index.js';
|
||||||
import { HomeAssistantSongpalIntegration } from '../songpal/index.js';
|
import { HomeAssistantSongpalIntegration } from '../songpal/index.js';
|
||||||
import { HomeAssistantSonosIntegration } from '../sonos/index.js';
|
|
||||||
import { HomeAssistantSonyProjectorIntegration } from '../sony_projector/index.js';
|
import { HomeAssistantSonyProjectorIntegration } from '../sony_projector/index.js';
|
||||||
import { HomeAssistantSoundtouchIntegration } from '../soundtouch/index.js';
|
import { HomeAssistantSoundtouchIntegration } from '../soundtouch/index.js';
|
||||||
import { HomeAssistantSpaceapiIntegration } from '../spaceapi/index.js';
|
import { HomeAssistantSpaceapiIntegration } from '../spaceapi/index.js';
|
||||||
@@ -2620,7 +2619,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantSomfyIntegration())
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSomfyMylinkIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSomfyMylinkIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSonarrIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSonarrIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSongpalIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSongpalIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSonosIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSonyProjectorIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSonyProjectorIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSoundtouchIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSoundtouchIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSpaceapiIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSpaceapiIntegration());
|
||||||
@@ -2916,8 +2914,9 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegrati
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveJsIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveJsIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
||||||
|
|
||||||
export const generatedHomeAssistantPortCount = 1456;
|
export const generatedHomeAssistantPortCount = 1455;
|
||||||
export const handwrittenHomeAssistantPortDomains = [
|
export const handwrittenHomeAssistantPortDomains = [
|
||||||
"hue",
|
"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.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';
|
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 {
|
export class SonosIntegration extends BaseIntegration<ISonosConfig> {
|
||||||
constructor() {
|
public readonly domain = 'sonos';
|
||||||
super({
|
public readonly displayName = 'Sonos';
|
||||||
domain: "sonos",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "Sonos",
|
public readonly discoveryDescriptor = createSonosDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new SonosConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/sonos",
|
upstreamPath: 'homeassistant/components/sonos',
|
||||||
"upstreamDomain": "sonos",
|
upstreamDomain: 'sonos',
|
||||||
"integrationType": "hub",
|
integrationType: 'hub',
|
||||||
"iotClass": "local_push",
|
iotClass: 'local_push',
|
||||||
"qualityScale": "bronze",
|
qualityScale: 'bronze',
|
||||||
"requirements": [
|
};
|
||||||
"defusedxml==0.7.1",
|
|
||||||
"soco==0.30.15",
|
public async setup(configArg: ISonosConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
"sonos-websocket==0.1.3"
|
void contextArg;
|
||||||
],
|
return new SonosRuntime(new SonosClient(configArg));
|
||||||
"dependencies": [
|
}
|
||||||
"ssdp"
|
|
||||||
],
|
public async destroy(): Promise<void> {}
|
||||||
"afterDependencies": [
|
}
|
||||||
"plex",
|
|
||||||
"spotify",
|
export class HomeAssistantSonosIntegration extends SonosIntegration {}
|
||||||
"zeroconf",
|
|
||||||
"media_source"
|
class SonosRuntime implements IIntegrationRuntime {
|
||||||
],
|
public domain = 'sonos';
|
||||||
"codeowners": [
|
|
||||||
"@jjlawren",
|
constructor(private readonly client: SonosClient) {}
|
||||||
"@peterager"
|
|
||||||
]
|
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 {
|
export interface ISonosConfig {
|
||||||
// TODO: replace with the TypeScript-native config for sonos.
|
host?: string;
|
||||||
[key: string]: unknown;
|
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