import * as plugins from '../plugins.js'; import { Speaker, type ITrackInfo, type IPlaybackStatus, type TPlaybackState, type ISpeakerInfo } from './speaker.classes.speaker.js'; import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js'; /** * Sonos zone (room) information */ export interface ISonosZoneInfo { name: string; uuid: string; coordinator: boolean; groupId: string; members: string[]; } /** * Sonos speaker device info */ export interface ISonosSpeakerInfo extends ISpeakerInfo { protocol: 'sonos'; zoneName: string; zoneUuid: string; isCoordinator: boolean; groupId?: string; } /** * Sonos Speaker device */ export class SonosSpeaker extends Speaker { private device: InstanceType | null = null; private _zoneName: string = ''; private _zoneUuid: string = ''; private _isCoordinator: boolean = false; private _groupId?: string; constructor( info: IDeviceInfo, options?: { roomName?: string; modelName?: string; }, retryOptions?: IRetryOptions ) { super(info, 'sonos', options, retryOptions); } // Getters public get zoneName(): string { return this._zoneName; } public get zoneUuid(): string { return this._zoneUuid; } public get isCoordinator(): boolean { return this._isCoordinator; } public get groupId(): string | undefined { return this._groupId; } /** * Connect to Sonos device */ protected async doConnect(): Promise { this.device = new plugins.sonos.Sonos(this.address, this.port); // Get device info try { const zoneInfo = await this.device.getZoneInfo(); this._zoneName = zoneInfo.ZoneName || ''; this._roomName = this._zoneName; const attrs = await this.device.getZoneAttrs(); this._zoneUuid = attrs.CurrentZoneName || ''; } catch (error) { // Some info may not be available } // Get device description try { const desc = await this.device.deviceDescription(); this._modelName = desc.modelName; this.model = desc.modelName; this.manufacturer = desc.manufacturer; this.serialNumber = desc.serialNum; } catch { // Optional info } // Get current state await this.refreshStatus(); } /** * Disconnect */ protected async doDisconnect(): Promise { this.device = null; } /** * Refresh status */ public async refreshStatus(): Promise { if (!this.device) { throw new Error('Not connected'); } try { const [volume, muted, state] = await Promise.all([ this.device.getVolume(), this.device.getMuted(), this.device.getCurrentState(), ]); this._volume = volume; this._muted = muted; this._playbackState = this.mapSonosState(state); } catch { // Status refresh failed } this.emit('status:updated', this.getSpeakerInfo()); } /** * Map Sonos state to our state */ private mapSonosState(state: string): TPlaybackState { switch (state.toLowerCase()) { case 'playing': return 'playing'; case 'paused': case 'paused_playback': return 'paused'; case 'stopped': return 'stopped'; case 'transitioning': return 'transitioning'; default: return 'unknown'; } } // ============================================================================ // Playback Control // ============================================================================ /** * Play */ public async play(uri?: string): Promise { if (!this.device) { throw new Error('Not connected'); } if (uri) { await this.device.play(uri); } else { await this.device.play(); } this._playbackState = 'playing'; this.emit('playback:started'); } /** * Pause */ public async pause(): Promise { if (!this.device) { throw new Error('Not connected'); } await this.device.pause(); this._playbackState = 'paused'; this.emit('playback:paused'); } /** * Stop */ public async stop(): Promise { if (!this.device) { throw new Error('Not connected'); } await this.device.stop(); this._playbackState = 'stopped'; this.emit('playback:stopped'); } /** * Next track */ public async next(): Promise { if (!this.device) { throw new Error('Not connected'); } await this.device.next(); this.emit('playback:next'); } /** * Previous track */ public async previous(): Promise { if (!this.device) { throw new Error('Not connected'); } await this.device.previous(); this.emit('playback:previous'); } /** * Seek to position */ public async seek(seconds: number): Promise { if (!this.device) { throw new Error('Not connected'); } await this.device.seek(seconds); this.emit('playback:seeked', { position: seconds }); } // ============================================================================ // Volume Control // ============================================================================ /** * Get volume */ public async getVolume(): Promise { if (!this.device) { throw new Error('Not connected'); } const volume = await this.device.getVolume(); this._volume = volume; return volume; } /** * Set volume */ public async setVolume(level: number): Promise { if (!this.device) { throw new Error('Not connected'); } const clamped = Math.max(0, Math.min(100, level)); await this.device.setVolume(clamped); this._volume = clamped; this.emit('volume:changed', { volume: clamped }); } /** * Get mute state */ public async getMute(): Promise { if (!this.device) { throw new Error('Not connected'); } const muted = await this.device.getMuted(); this._muted = muted; return muted; } /** * Set mute state */ public async setMute(muted: boolean): Promise { if (!this.device) { throw new Error('Not connected'); } await this.device.setMuted(muted); this._muted = muted; this.emit('mute:changed', { muted }); } // ============================================================================ // Track Information // ============================================================================ /** * Get current track */ public async getCurrentTrack(): Promise { if (!this.device) { throw new Error('Not connected'); } try { const track = await this.device.currentTrack(); if (!track) return null; return { title: track.title || 'Unknown', artist: track.artist, album: track.album, duration: track.duration || 0, position: track.position || 0, albumArtUri: track.albumArtURI || track.albumArtURL, uri: track.uri, }; } catch { return null; } } /** * Get playback status */ public async getPlaybackStatus(): Promise { if (!this.device) { throw new Error('Not connected'); } const [state, volume, muted, track] = await Promise.all([ this.device.getCurrentState(), this.device.getVolume(), this.device.getMuted(), this.getCurrentTrack(), ]); return { state: this.mapSonosState(state), volume, muted, track: track || undefined, }; } // ============================================================================ // Sonos-specific Features // ============================================================================ /** * Play from queue */ public async playFromQueue(index: number): Promise { if (!this.device) { throw new Error('Not connected'); } await this.device.selectQueue(); await this.device.selectTrack(index); await this.device.play(); } /** * Add URI to queue */ public async addToQueue(uri: string, positionInQueue?: number): Promise { if (!this.device) { throw new Error('Not connected'); } await this.device.queue(uri, positionInQueue); this.emit('queue:added', { uri, position: positionInQueue }); } /** * Clear queue */ public async clearQueue(): Promise { if (!this.device) { throw new Error('Not connected'); } await this.device.flush(); this.emit('queue:cleared'); } /** * Get queue contents */ public async getQueue(): Promise { if (!this.device) { throw new Error('Not connected'); } const queue = await this.device.getQueue(); if (!queue || !queue.items) { return []; } return queue.items.map((item: { title?: string; artist?: string; album?: string; albumArtURI?: string; uri?: string }) => ({ title: item.title || 'Unknown', artist: item.artist, album: item.album, duration: 0, position: 0, albumArtUri: item.albumArtURI, uri: item.uri, })); } /** * Play a Sonos playlist */ public async playPlaylist(playlistName: string): Promise { if (!this.device) { throw new Error('Not connected'); } const playlists = await this.device.getMusicLibrary('sonos_playlists'); const playlist = playlists.items?.find((p: { title?: string }) => p.title?.toLowerCase().includes(playlistName.toLowerCase()) ); if (playlist && playlist.uri) { await this.device.play(playlist.uri); } else { throw new Error(`Playlist "${playlistName}" not found`); } } /** * Play favorite by name */ public async playFavorite(favoriteName: string): Promise { if (!this.device) { throw new Error('Not connected'); } const favorites = await this.device.getFavorites(); const favorite = favorites.items?.find((f: { title?: string }) => f.title?.toLowerCase().includes(favoriteName.toLowerCase()) ); if (favorite && favorite.uri) { await this.device.play(favorite.uri); } else { throw new Error(`Favorite "${favoriteName}" not found`); } } /** * Get favorites */ public async getFavorites(): Promise<{ title: string; uri: string; albumArtUri?: string }[]> { if (!this.device) { throw new Error('Not connected'); } const favorites = await this.device.getFavorites(); if (!favorites.items) { return []; } return favorites.items.map((f: { title?: string; uri?: string; albumArtURI?: string }) => ({ title: f.title || 'Unknown', uri: f.uri || '', albumArtUri: f.albumArtURI, })); } /** * Play TuneIn radio station by ID */ public async playTuneInRadio(stationId: string): Promise { if (!this.device) { throw new Error('Not connected'); } await this.device.playTuneinRadio(stationId); } /** * Play Spotify URI */ public async playSpotify(spotifyUri: string): Promise { if (!this.device) { throw new Error('Not connected'); } await this.device.play(spotifyUri); } // ============================================================================ // Grouping // ============================================================================ /** * Join another speaker's group */ public async joinGroup(coordinatorAddress: string): Promise { if (!this.device) { throw new Error('Not connected'); } const coordinator = new plugins.sonos.Sonos(coordinatorAddress); await this.device.joinGroup(await coordinator.getName()); this.emit('group:joined', { coordinator: coordinatorAddress }); } /** * Leave current group */ public async leaveGroup(): Promise { if (!this.device) { throw new Error('Not connected'); } await this.device.leaveGroup(); this.emit('group:left'); } /** * Get group information */ public async getGroupInfo(): Promise { if (!this.device) { throw new Error('Not connected'); } try { const groups = await this.device.getAllGroups(); // Find our group for (const group of groups) { const members = group.ZoneGroupMember || []; const memberArray = Array.isArray(members) ? members : [members]; for (const member of memberArray) { if (member.Location?.includes(this.address)) { const coordinator = memberArray.find((m: { UUID?: string }) => m.UUID === group.Coordinator); return { name: group.Name || 'Group', uuid: group.Coordinator || '', coordinator: member.UUID === group.Coordinator, groupId: group.ID || '', members: memberArray.map((m: { ZoneName?: string }) => m.ZoneName || ''), }; } } } } catch { return null; } return null; } // ============================================================================ // Device Info // ============================================================================ /** * Get speaker info */ public getSpeakerInfo(): ISonosSpeakerInfo { return { id: this.id, name: this.name, type: 'speaker', address: this.address, port: this.port, status: this.status, protocol: 'sonos', roomName: this._roomName, modelName: this._modelName, zoneName: this._zoneName, zoneUuid: this._zoneUuid, isCoordinator: this._isCoordinator, groupId: this._groupId, supportsGrouping: true, isGroupCoordinator: this._isCoordinator, }; } /** * Create from discovery */ public static fromDiscovery( data: { id: string; name: string; address: string; port?: number; roomName?: string; modelName?: string; }, retryOptions?: IRetryOptions ): SonosSpeaker { const info: IDeviceInfo = { id: data.id, name: data.name, type: 'speaker', address: data.address, port: data.port ?? 1400, status: 'unknown', }; return new SonosSpeaker( info, { roomName: data.roomName, modelName: data.modelName, }, retryOptions ); } /** * Discover Sonos devices on the network */ public static async discover(timeout: number = 5000): Promise { return new Promise((resolve) => { const speakers: SonosSpeaker[] = []; const discovery = new plugins.sonos.AsyncDeviceDiscovery(); const timer = setTimeout(() => { resolve(speakers); }, timeout); discovery.discover().then((device: { host: string; port: number }) => { clearTimeout(timer); const speaker = new SonosSpeaker( { id: `sonos:${device.host}`, name: `Sonos ${device.host}`, type: 'speaker', address: device.host, port: device.port || 1400, status: 'unknown', } ); speakers.push(speaker); resolve(speakers); }).catch(() => { clearTimeout(timer); resolve(speakers); }); }); } }