From 5efb2f676038cd84f11fde9d658bb6d69988344d Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 5 May 2026 12:23:14 +0000 Subject: [PATCH] Add native Sonos integration --- test/sonos/test.sonos.discovery.node.ts | 17 ++ test/sonos/test.sonos.mapper.node.ts | 33 +++ ts/core/types.ts | 1 + ts/index.ts | 2 + ts/integrations/generated/index.ts | 7 +- .../sonos/.generated-by-smarthome-exchange | 1 - ts/integrations/sonos/index.ts | 4 + ts/integrations/sonos/sonos.classes.client.ts | 241 ++++++++++++++++++ .../sonos/sonos.classes.configflow.ts | 25 ++ .../sonos/sonos.classes.integration.ts | 136 +++++++--- ts/integrations/sonos/sonos.discovery.ts | 126 +++++++++ ts/integrations/sonos/sonos.mapper.ts | 88 +++++++ ts/integrations/sonos/sonos.types.ts | 72 +++++- 13 files changed, 711 insertions(+), 42 deletions(-) create mode 100644 test/sonos/test.sonos.discovery.node.ts create mode 100644 test/sonos/test.sonos.mapper.node.ts delete mode 100644 ts/integrations/sonos/.generated-by-smarthome-exchange create mode 100644 ts/integrations/sonos/sonos.classes.client.ts create mode 100644 ts/integrations/sonos/sonos.classes.configflow.ts create mode 100644 ts/integrations/sonos/sonos.discovery.ts create mode 100644 ts/integrations/sonos/sonos.mapper.ts diff --git a/test/sonos/test.sonos.discovery.node.ts b/test/sonos/test.sonos.discovery.node.ts new file mode 100644 index 0000000..c1a1901 --- /dev/null +++ b/test/sonos/test.sonos.discovery.node.ts @@ -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(); diff --git a/test/sonos/test.sonos.mapper.node.ts b/test/sonos/test.sonos.mapper.node.ts new file mode 100644 index 0000000..f90f42d --- /dev/null +++ b/test/sonos/test.sonos.mapper.node.ts @@ -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(); diff --git a/ts/core/types.ts b/ts/core/types.ts index 02bbd93..18efe3f 100644 --- a/ts/core/types.ts +++ b/ts/core/types.ts @@ -115,6 +115,7 @@ export type TEntityPlatform = | 'climate' | 'cover' | 'fan' + | 'media_player' | 'number' | 'select' | 'text' diff --git a/ts/index.ts b/ts/index.ts index 8a18a08..12d65b3 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -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(), ]; diff --git a/ts/integrations/generated/index.ts b/ts/integrations/generated/index.ts index 7aec1af..8fd3bee 100644 --- a/ts/integrations/generated/index.ts +++ b/ts/integrations/generated/index.ts @@ -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" ]; diff --git a/ts/integrations/sonos/.generated-by-smarthome-exchange b/ts/integrations/sonos/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/sonos/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/sonos/index.ts b/ts/integrations/sonos/index.ts index 0ded477..5611355 100644 --- a/ts/integrations/sonos/index.ts +++ b/ts/integrations/sonos/index.ts @@ -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'; diff --git a/ts/integrations/sonos/sonos.classes.client.ts b/ts/integrations/sonos/sonos.classes.client.ts new file mode 100644 index 0000000..5ecf792 --- /dev/null +++ b/ts/integrations/sonos/sonos.classes.client.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.soap('AVTransport', 'Play', [ + ['InstanceID', 0], + ['Speed', 1], + ]); + } + + public async pause(): Promise { + await this.soap('AVTransport', 'Pause', [ + ['InstanceID', 0], + ['Speed', 1], + ]); + } + + public async stop(): Promise { + await this.soap('AVTransport', 'Stop', [ + ['InstanceID', 0], + ['Speed', 1], + ]); + } + + public async next(): Promise { + await this.soap('AVTransport', 'Next', [ + ['InstanceID', 0], + ['Speed', 1], + ]); + } + + public async previous(): Promise { + await this.soap('AVTransport', 'Previous', [ + ['InstanceID', 0], + ['Speed', 1], + ]); + } + + public async setVolume(volumeArg: number): Promise { + 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 { + await this.soap('RenderingControl', 'SetMute', [ + ['InstanceID', 0], + ['Channel', 'Master'], + ['DesiredMute', mutedArg ? 1 : 0], + ]); + } + + public async playUri(uriArg: string, titleArg?: string): Promise { + await this.soap('AVTransport', 'SetAVTransportURI', [ + ['InstanceID', 0], + ['CurrentURI', uriArg], + ['CurrentURIMetaData', titleArg ? this.createMinimalRadioMetadata(titleArg) : ''], + ]); + await this.play(); + } + + public async destroy(): Promise {} + + private async soap(serviceTypeArg: TSonosServiceType, actionArg: string, argsArg: Array<[string, string | number | boolean]>): Promise> { + 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 { + 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))}`).join(''); + return `${args}`; + } + + private createMinimalRadioMetadata(titleArg: string): string { + return `${this.escapeXml(titleArg)}object.item.audioItem.audioBroadcastSA_RINCON65031_`; + } + + private unwrapSoapResponse(xmlArg: string): Record { + const response: Record = {}; + 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, '''); + } + + private unescapeXml(valueArg: string): string { + return valueArg.replace(/'/g, "'").replace(/"/g, '"').replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&'); + } +} diff --git a/ts/integrations/sonos/sonos.classes.configflow.ts b/ts/integrations/sonos/sonos.classes.configflow.ts new file mode 100644 index 0000000..9a637b7 --- /dev/null +++ b/ts/integrations/sonos/sonos.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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, + }, + }), + }; + } +} diff --git a/ts/integrations/sonos/sonos.classes.integration.ts b/ts/integrations/sonos/sonos.classes.integration.ts index 4605a6d..2da5bab 100644 --- a/ts/integrations/sonos/sonos.classes.integration.ts +++ b/ts/integrations/sonos/sonos.classes.integration.ts @@ -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 { + 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 { + void contextArg; + return new SonosRuntime(new SonosClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantSonosIntegration extends SonosIntegration {} + +class SonosRuntime implements IIntegrationRuntime { + public domain = 'sonos'; + + constructor(private readonly client: SonosClient) {} + + public async devices(): Promise { + return SonosMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return SonosMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + 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 { + await this.client.destroy(); } } diff --git a/ts/integrations/sonos/sonos.discovery.ts b/ts/integrations/sonos/sonos.discovery.ts new file mode 100644 index 0000000..333302a --- /dev/null +++ b/ts/integrations/sonos/sonos.discovery.ts @@ -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 { + public id = 'sonos-ssdp-match'; + public source = 'ssdp' as const; + public description = 'Recognize Sonos ZonePlayer SSDP advertisements.'; + + public async matches(recordArg: ISonosSsdpRecord): Promise { + 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 { + public id = 'sonos-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Sonos mDNS advertisements.'; + + public async matches(recordArg: ISonosMdnsRecord): Promise { + 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 { + public id = 'sonos-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Sonos setup entries.'; + + public async matches(inputArg: ISonosManualEntry): Promise { + 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 { + 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()); +}; diff --git a/ts/integrations/sonos/sonos.mapper.ts b/ts/integrations/sonos/sonos.mapper.ts new file mode 100644 index 0000000..30034f3 --- /dev/null +++ b/ts/integrations/sonos/sonos.mapper.ts @@ -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'; + } +} diff --git a/ts/integrations/sonos/sonos.types.ts b/ts/integrations/sonos/sonos.types.ts index 3b786c8..11e0f12 100644 --- a/ts/integrations/sonos/sonos.types.ts +++ b/ts/integrations/sonos/sonos.types.ts @@ -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; +} + +export interface ISonosMdnsRecord { + name?: string; + type?: string; + host?: string; + port?: number; + txt?: Record; +} + +export interface ISonosManualEntry { + host?: string; + port?: number; + id?: string; + name?: string; + model?: string; + metadata?: Record; }