655 lines
15 KiB
TypeScript
655 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
}
|
|
}
|