Files
devicemanager/ts/speaker/speaker.classes.sonos.ts

655 lines
15 KiB
TypeScript
Raw Permalink Normal View History

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<typeof plugins.sonos.Sonos> | 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<void> {
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<void> {
this.device = null;
}
/**
* Refresh status
*/
public async refreshStatus(): Promise<void> {
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<void> {
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<void> {
if (!this.device) {
throw new Error('Not connected');
}
await this.device.pause();
this._playbackState = 'paused';
this.emit('playback:paused');
}
/**
* Stop
*/
public async stop(): Promise<void> {
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<void> {
if (!this.device) {
throw new Error('Not connected');
}
await this.device.next();
this.emit('playback:next');
}
/**
* Previous track
*/
public async previous(): Promise<void> {
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<void> {
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<number> {
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<void> {
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<boolean> {
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<void> {
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<ITrackInfo | null> {
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<IPlaybackStatus> {
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<void> {
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<void> {
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<void> {
if (!this.device) {
throw new Error('Not connected');
}
await this.device.flush();
this.emit('queue:cleared');
}
/**
* Get queue contents
*/
public async getQueue(): Promise<ITrackInfo[]> {
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<void> {
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<void> {
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<void> {
if (!this.device) {
throw new Error('Not connected');
}
await this.device.playTuneinRadio(stationId);
}
/**
* Play Spotify URI
*/
public async playSpotify(spotifyUri: string): Promise<void> {
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<void> {
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<void> {
if (!this.device) {
throw new Error('Not connected');
}
await this.device.leaveGroup();
this.emit('group:left');
}
/**
* Get group information
*/
public async getGroupInfo(): Promise<ISonosZoneInfo | null> {
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<SonosSpeaker[]> {
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);
});
});
}
}