Add native Sonos integration

This commit is contained in:
2026-05-05 12:23:14 +00:00
parent e91176fb9b
commit 5efb2f6760
13 changed files with 711 additions and 42 deletions
+17
View File
@@ -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();
+33
View File
@@ -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();
+1
View File
@@ -115,6 +115,7 @@ export type TEntityPlatform =
| 'climate'
| 'cover'
| 'fan'
| 'media_player'
| 'number'
| 'select'
| 'text'
+2
View File
@@ -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(),
];
+3 -4
View File
@@ -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.
+4
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
private unescapeXml(valueArg: string): string {
return valueArg.replace(/&apos;/g, "'").replace(/&quot;/g, '"').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/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();
}
}
+126
View File
@@ -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());
};
+88
View File
@@ -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';
}
}
+69 -3
View File
@@ -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>;
}