feat(devicemanager): Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors
This commit is contained in:
654
ts/speaker/speaker.classes.sonos.ts
Normal file
654
ts/speaker/speaker.classes.sonos.ts
Normal file
@@ -0,0 +1,654 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user