feat(devicemanager): Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors

This commit is contained in:
2026-01-09 09:03:42 +00:00
parent 05e1f94c79
commit 206b4b5ae0
33 changed files with 8254 additions and 87 deletions

View File

@@ -0,0 +1,548 @@
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';
/**
* AirPlay features bitmask
*/
export const AIRPLAY_FEATURES = {
Video: 1 << 0,
Photo: 1 << 1,
VideoFairPlay: 1 << 2,
VideoVolumeControl: 1 << 3,
VideoHTTPLiveStreams: 1 << 4,
Slideshow: 1 << 5,
Screen: 1 << 7,
ScreenRotate: 1 << 8,
Audio: 1 << 9,
AudioRedundant: 1 << 11,
FPSAPv2pt5_AES_GCM: 1 << 12,
PhotoCaching: 1 << 13,
Authentication4: 1 << 14,
MetadataFeatures: 1 << 15,
AudioFormats: 1 << 16,
Authentication1: 1 << 17,
};
/**
* AirPlay device info
*/
export interface IAirPlaySpeakerInfo extends ISpeakerInfo {
protocol: 'airplay';
features: number;
supportsVideo: boolean;
supportsAudio: boolean;
supportsScreen: boolean;
deviceId?: string;
}
/**
* AirPlay playback info
*/
export interface IAirPlayPlaybackInfo {
duration: number;
position: number;
rate: number;
readyToPlay: boolean;
playbackBufferEmpty: boolean;
playbackBufferFull: boolean;
playbackLikelyToKeepUp: boolean;
}
/**
* AirPlay Speaker device
* Basic implementation for AirPlay-compatible devices
*/
export class AirPlaySpeaker extends Speaker {
private _features: number = 0;
private _deviceId?: string;
private _supportsVideo: boolean = false;
private _supportsAudio: boolean = true;
private _supportsScreen: boolean = false;
private _currentUri?: string;
private _currentPosition: number = 0;
private _currentDuration: number = 0;
private _isPlaying: boolean = false;
constructor(
info: IDeviceInfo,
options?: {
roomName?: string;
modelName?: string;
features?: number;
deviceId?: string;
},
retryOptions?: IRetryOptions
) {
super(info, 'airplay', options, retryOptions);
this._features = options?.features || 0;
this._deviceId = options?.deviceId;
// Parse features
if (this._features) {
this._supportsVideo = !!(this._features & AIRPLAY_FEATURES.Video);
this._supportsAudio = !!(this._features & AIRPLAY_FEATURES.Audio);
this._supportsScreen = !!(this._features & AIRPLAY_FEATURES.Screen);
}
}
// Getters
public get features(): number {
return this._features;
}
public get deviceId(): string | undefined {
return this._deviceId;
}
public get supportsVideo(): boolean {
return this._supportsVideo;
}
public get supportsAudio(): boolean {
return this._supportsAudio;
}
public get supportsScreen(): boolean {
return this._supportsScreen;
}
/**
* Connect to AirPlay device
* AirPlay 2 devices (HomePods) may not respond to /server-info,
* so we consider them connected even if we can't get device info.
*/
protected async doConnect(): Promise<void> {
// Try /server-info endpoint (works for older AirPlay devices)
const url = `http://${this.address}:${this.port}/server-info`;
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(3000),
});
if (response.ok) {
// Parse server info (plist format)
const text = await response.text();
// Extract features if available
const featuresMatch = text.match(/<key>features<\/key>\s*<integer>(\d+)<\/integer>/);
if (featuresMatch) {
this._features = parseInt(featuresMatch[1]);
this._supportsVideo = !!(this._features & AIRPLAY_FEATURES.Video);
this._supportsAudio = !!(this._features & AIRPLAY_FEATURES.Audio);
this._supportsScreen = !!(this._features & AIRPLAY_FEATURES.Screen);
}
// Extract device ID
const deviceIdMatch = text.match(/<key>deviceid<\/key>\s*<string>([^<]+)<\/string>/);
if (deviceIdMatch) {
this._deviceId = deviceIdMatch[1];
}
// Extract model
const modelMatch = text.match(/<key>model<\/key>\s*<string>([^<]+)<\/string>/);
if (modelMatch) {
this._modelName = modelMatch[1];
this.model = modelMatch[1];
}
return;
}
// Non-OK response - might be AirPlay 2, continue below
} catch {
// /server-info failed, might be AirPlay 2 device
}
// For AirPlay 2 devices (HomePods), /server-info doesn't work
// Try a simple port check - if the port responds, consider it connected
// HomePods will respond to proper AirPlay 2 protocol even if HTTP endpoints fail
// We'll assume it's an AirPlay 2 audio device
this._supportsAudio = true;
this._supportsVideo = false;
this._supportsScreen = false;
}
/**
* Disconnect
*/
protected async doDisconnect(): Promise<void> {
try {
await this.stop();
} catch {
// Ignore stop errors
}
}
/**
* Refresh status
*/
public async refreshStatus(): Promise<void> {
try {
const info = await this.getAirPlayPlaybackInfo();
this._isPlaying = info.rate > 0;
this._currentPosition = info.position;
this._currentDuration = info.duration;
this._playbackState = this._isPlaying ? 'playing' : 'paused';
} catch {
this._playbackState = 'stopped';
}
this.emit('status:updated', this.getSpeakerInfo());
}
// ============================================================================
// Playback Control
// ============================================================================
/**
* Play media URL
*/
public async play(uri?: string): Promise<void> {
if (uri) {
this._currentUri = uri;
const body = `Content-Location: ${uri}\nStart-Position: 0\n`;
const response = await fetch(`http://${this.address}:${this.port}/play`, {
method: 'POST',
headers: {
'Content-Type': 'text/parameters',
},
body,
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
throw new Error(`Play failed: ${response.status}`);
}
} else {
// Resume playback
await this.setRate(1);
}
this._isPlaying = true;
this._playbackState = 'playing';
this.emit('playback:started');
}
/**
* Pause playback
*/
public async pause(): Promise<void> {
await this.setRate(0);
this._isPlaying = false;
this._playbackState = 'paused';
this.emit('playback:paused');
}
/**
* Stop playback
*/
public async stop(): Promise<void> {
const response = await fetch(`http://${this.address}:${this.port}/stop`, {
method: 'POST',
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
throw new Error(`Stop failed: ${response.status}`);
}
this._isPlaying = false;
this._playbackState = 'stopped';
this._currentUri = undefined;
this.emit('playback:stopped');
}
/**
* Next track (not supported on basic AirPlay)
*/
public async next(): Promise<void> {
throw new Error('Next track not supported on AirPlay');
}
/**
* Previous track (not supported on basic AirPlay)
*/
public async previous(): Promise<void> {
throw new Error('Previous track not supported on AirPlay');
}
/**
* Seek to position
*/
public async seek(seconds: number): Promise<void> {
const body = `position: ${seconds}\n`;
const response = await fetch(`http://${this.address}:${this.port}/scrub`, {
method: 'POST',
headers: {
'Content-Type': 'text/parameters',
},
body,
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
throw new Error(`Seek failed: ${response.status}`);
}
this._currentPosition = seconds;
this.emit('playback:seeked', { position: seconds });
}
// ============================================================================
// Volume Control (limited support)
// ============================================================================
/**
* Get volume (not always supported)
*/
public async getVolume(): Promise<number> {
// AirPlay volume control varies by device
return this._volume;
}
/**
* Set volume (not always supported)
*/
public async setVolume(level: number): Promise<void> {
const clamped = Math.max(0, Math.min(100, level));
try {
const body = `volume: ${clamped / 100}\n`;
const response = await fetch(`http://${this.address}:${this.port}/volume`, {
method: 'POST',
headers: {
'Content-Type': 'text/parameters',
},
body,
signal: AbortSignal.timeout(5000),
});
if (response.ok) {
this._volume = clamped;
this.emit('volume:changed', { volume: clamped });
}
} catch {
// Volume control may not be supported
throw new Error('Volume control not supported on this device');
}
}
/**
* Get mute state (not always supported)
*/
public async getMute(): Promise<boolean> {
return this._muted;
}
/**
* Set mute state (not always supported)
*/
public async setMute(muted: boolean): Promise<void> {
// Mute by setting volume to 0
if (muted) {
await this.setVolume(0);
} else {
await this.setVolume(this._volume || 50);
}
this._muted = muted;
this.emit('mute:changed', { muted });
}
// ============================================================================
// Track Information
// ============================================================================
/**
* Get current track
*/
public async getCurrentTrack(): Promise<ITrackInfo | null> {
if (!this._currentUri) {
return null;
}
return {
title: this._currentUri.split('/').pop() || 'Unknown',
duration: this._currentDuration,
position: this._currentPosition,
uri: this._currentUri,
};
}
/**
* Get playback status
*/
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
await this.refreshStatus();
return {
state: this._playbackState,
volume: this._volume,
muted: this._muted,
track: await this.getCurrentTrack() || undefined,
};
}
// ============================================================================
// AirPlay-specific Methods
// ============================================================================
/**
* Set playback rate
*/
private async setRate(rate: number): Promise<void> {
const body = `value: ${rate}\n`;
const response = await fetch(`http://${this.address}:${this.port}/rate`, {
method: 'POST',
headers: {
'Content-Type': 'text/parameters',
},
body,
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
throw new Error(`Set rate failed: ${response.status}`);
}
}
/**
* Get AirPlay playback info
*/
public async getAirPlayPlaybackInfo(): Promise<IAirPlayPlaybackInfo> {
const response = await fetch(`http://${this.address}:${this.port}/playback-info`, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
throw new Error(`Get playback info failed: ${response.status}`);
}
const text = await response.text();
// Parse plist response
const extractReal = (key: string): number => {
const match = text.match(new RegExp(`<key>${key}</key>\\s*<real>([\\d.]+)</real>`));
return match ? parseFloat(match[1]) : 0;
};
const extractBool = (key: string): boolean => {
const match = text.match(new RegExp(`<key>${key}</key>\\s*<(true|false)/>`));
return match?.[1] === 'true';
};
return {
duration: extractReal('duration'),
position: extractReal('position'),
rate: extractReal('rate'),
readyToPlay: extractBool('readyToPlay'),
playbackBufferEmpty: extractBool('playbackBufferEmpty'),
playbackBufferFull: extractBool('playbackBufferFull'),
playbackLikelyToKeepUp: extractBool('playbackLikelyToKeepUp'),
};
}
/**
* Get scrub position
*/
public async getScrubPosition(): Promise<{ position: number; duration: number }> {
const response = await fetch(`http://${this.address}:${this.port}/scrub`, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
throw new Error(`Get scrub position failed: ${response.status}`);
}
const text = await response.text();
const durationMatch = text.match(/duration:\s*([\d.]+)/);
const positionMatch = text.match(/position:\s*([\d.]+)/);
return {
duration: durationMatch ? parseFloat(durationMatch[1]) : 0,
position: positionMatch ? parseFloat(positionMatch[1]) : 0,
};
}
// ============================================================================
// Device Info
// ============================================================================
/**
* Get speaker info
*/
public getSpeakerInfo(): IAirPlaySpeakerInfo {
return {
id: this.id,
name: this.name,
type: 'speaker',
address: this.address,
port: this.port,
status: this.status,
protocol: 'airplay',
roomName: this._roomName,
modelName: this._modelName,
features: this._features,
supportsVideo: this._supportsVideo,
supportsAudio: this._supportsAudio,
supportsScreen: this._supportsScreen,
deviceId: this._deviceId,
};
}
/**
* Create from mDNS discovery
*/
public static fromDiscovery(
data: {
id: string;
name: string;
address: string;
port?: number;
roomName?: string;
modelName?: string;
features?: number;
deviceId?: string;
},
retryOptions?: IRetryOptions
): AirPlaySpeaker {
const info: IDeviceInfo = {
id: data.id,
name: data.name,
type: 'speaker',
address: data.address,
port: data.port ?? 7000,
status: 'unknown',
};
return new AirPlaySpeaker(
info,
{
roomName: data.roomName,
modelName: data.modelName,
features: data.features,
deviceId: data.deviceId,
},
retryOptions
);
}
/**
* Probe for AirPlay device
*/
public static async probe(address: string, port: number = 7000, timeout: number = 3000): Promise<boolean> {
try {
const response = await fetch(`http://${address}:${port}/server-info`, {
signal: AbortSignal.timeout(timeout),
});
return response.ok;
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,725 @@
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';
/**
* Chromecast device types
*/
export type TChromecastType = 'audio' | 'video' | 'group';
/**
* Chromecast application IDs
*/
export const CHROMECAST_APPS = {
DEFAULT_MEDIA_RECEIVER: 'CC1AD845',
BACKDROP: 'E8C28D3C',
YOUTUBE: '233637DE',
NETFLIX: 'CA5E8412',
PLEX: '9AC194DC',
};
/**
* Chromecast device info
*/
export interface IChromecastSpeakerInfo extends ISpeakerInfo {
protocol: 'chromecast';
friendlyName: string;
deviceType: TChromecastType;
capabilities: string[];
currentAppId?: string;
currentAppName?: string;
}
/**
* Chromecast media metadata
*/
export interface IChromecastMediaMetadata {
metadataType?: number;
title?: string;
subtitle?: string;
artist?: string;
albumName?: string;
albumArtist?: string;
trackNumber?: number;
discNumber?: number;
images?: { url: string; width?: number; height?: number }[];
releaseDate?: string;
studio?: string;
seriesTitle?: string;
season?: number;
episode?: number;
}
/**
* Chromecast media status
*/
export interface IChromecastMediaStatus {
mediaSessionId: number;
playbackRate: number;
playerState: 'IDLE' | 'PLAYING' | 'PAUSED' | 'BUFFERING';
currentTime: number;
idleReason?: 'CANCELLED' | 'INTERRUPTED' | 'FINISHED' | 'ERROR';
media?: {
contentId: string;
contentType: string;
duration: number;
metadata?: IChromecastMediaMetadata;
};
volume: {
level: number;
muted: boolean;
};
}
/**
* Chromecast Speaker device
*/
export class ChromecastSpeaker extends Speaker {
private client: InstanceType<typeof plugins.castv2Client.Client> | null = null;
private player: unknown = null;
private _friendlyName: string = '';
private _deviceType: TChromecastType = 'audio';
private _capabilities: string[] = [];
private _currentAppId?: string;
private _currentAppName?: string;
private _mediaSessionId?: number;
constructor(
info: IDeviceInfo,
options?: {
roomName?: string;
modelName?: string;
friendlyName?: string;
deviceType?: TChromecastType;
capabilities?: string[];
},
retryOptions?: IRetryOptions
) {
super(info, 'chromecast', options, retryOptions);
this._friendlyName = options?.friendlyName || info.name;
this._deviceType = options?.deviceType || 'audio';
this._capabilities = options?.capabilities || [];
}
// Getters
public get friendlyName(): string {
return this._friendlyName;
}
public get deviceType(): TChromecastType {
return this._deviceType;
}
public get capabilities(): string[] {
return this._capabilities;
}
public get currentAppId(): string | undefined {
return this._currentAppId;
}
public get currentAppName(): string | undefined {
return this._currentAppName;
}
/**
* Connect to Chromecast
*/
protected async doConnect(): Promise<void> {
return new Promise((resolve, reject) => {
this.client = new plugins.castv2Client.Client();
const timeout = setTimeout(() => {
if (this.client) {
this.client.close();
this.client = null;
}
reject(new Error('Connection timeout'));
}, 10000);
this.client.on('error', (err: Error) => {
clearTimeout(timeout);
if (this.client) {
this.client.close();
this.client = null;
}
reject(err);
});
this.client.connect(this.address, () => {
clearTimeout(timeout);
// Get receiver status
this.client!.getStatus((err: Error | null, status: { applications?: Array<{ appId: string; displayName: string }> }) => {
if (err) {
reject(err);
return;
}
if (status && status.applications && status.applications.length > 0) {
const app = status.applications[0];
this._currentAppId = app.appId;
this._currentAppName = app.displayName;
}
resolve();
});
});
});
}
/**
* Disconnect
*/
protected async doDisconnect(): Promise<void> {
if (this.client) {
this.client.close();
this.client = null;
}
this.player = null;
}
/**
* Refresh status
*/
public async refreshStatus(): Promise<void> {
if (!this.client) {
throw new Error('Not connected');
}
return new Promise((resolve) => {
this.client!.getStatus((err: Error | null, status: {
applications?: Array<{ appId: string; displayName: string }>;
volume?: { level: number; muted: boolean };
}) => {
if (!err && status) {
if (status.applications && status.applications.length > 0) {
const app = status.applications[0];
this._currentAppId = app.appId;
this._currentAppName = app.displayName;
}
if (status.volume) {
this._volume = Math.round(status.volume.level * 100);
this._muted = status.volume.muted;
}
}
this.emit('status:updated', this.getSpeakerInfo());
resolve();
});
});
}
/**
* Launch media receiver and get player
*/
private async getMediaPlayer(): Promise<InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>> {
if (!this.client) {
throw new Error('Not connected');
}
return new Promise((resolve, reject) => {
this.client!.launch(plugins.castv2Client.DefaultMediaReceiver, (err: Error | null, player: InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>) => {
if (err) {
reject(err);
return;
}
this.player = player;
player.on('status', (status: IChromecastMediaStatus) => {
this.handleMediaStatus(status);
});
resolve(player);
});
});
}
/**
* Handle media status update
*/
private handleMediaStatus(status: IChromecastMediaStatus): void {
if (!status) return;
this._mediaSessionId = status.mediaSessionId;
// Update playback state
switch (status.playerState) {
case 'PLAYING':
this._playbackState = 'playing';
break;
case 'PAUSED':
this._playbackState = 'paused';
break;
case 'BUFFERING':
this._playbackState = 'transitioning';
break;
case 'IDLE':
default:
this._playbackState = 'stopped';
break;
}
// Update volume
if (status.volume) {
this._volume = Math.round(status.volume.level * 100);
this._muted = status.volume.muted;
}
this.emit('playback:status', status);
}
// ============================================================================
// Playback Control
// ============================================================================
/**
* Play media URL
*/
public async play(uri?: string): Promise<void> {
if (!this.client) {
throw new Error('Not connected');
}
const player = await this.getMediaPlayer() as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>;
if (uri) {
// Determine content type
const contentType = this.guessContentType(uri);
const media = {
contentId: uri,
contentType,
streamType: 'BUFFERED' as const,
metadata: {
type: 0,
metadataType: 0,
title: uri.split('/').pop() || 'Media',
},
};
return new Promise((resolve, reject) => {
player.load(media, { autoplay: true }, (err: Error | null) => {
if (err) {
reject(err);
return;
}
this._playbackState = 'playing';
this.emit('playback:started');
resolve();
});
});
} else {
// Resume playback
return new Promise((resolve, reject) => {
player.play((err: Error | null) => {
if (err) {
reject(err);
return;
}
this._playbackState = 'playing';
this.emit('playback:started');
resolve();
});
});
}
}
/**
* Pause playback
*/
public async pause(): Promise<void> {
if (!this.player) {
throw new Error('No active media session');
}
return new Promise((resolve, reject) => {
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).pause((err: Error | null) => {
if (err) {
reject(err);
return;
}
this._playbackState = 'paused';
this.emit('playback:paused');
resolve();
});
});
}
/**
* Stop playback
*/
public async stop(): Promise<void> {
if (!this.player) {
throw new Error('No active media session');
}
return new Promise((resolve, reject) => {
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).stop((err: Error | null) => {
if (err) {
reject(err);
return;
}
this._playbackState = 'stopped';
this.emit('playback:stopped');
resolve();
});
});
}
/**
* Next track (not supported)
*/
public async next(): Promise<void> {
throw new Error('Next track not supported on basic Chromecast');
}
/**
* Previous track (not supported)
*/
public async previous(): Promise<void> {
throw new Error('Previous track not supported on basic Chromecast');
}
/**
* Seek to position
*/
public async seek(seconds: number): Promise<void> {
if (!this.player) {
throw new Error('No active media session');
}
return new Promise((resolve, reject) => {
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).seek(seconds, (err: Error | null) => {
if (err) {
reject(err);
return;
}
this.emit('playback:seeked', { position: seconds });
resolve();
});
});
}
// ============================================================================
// Volume Control
// ============================================================================
/**
* Get volume
*/
public async getVolume(): Promise<number> {
await this.refreshStatus();
return this._volume;
}
/**
* Set volume
*/
public async setVolume(level: number): Promise<void> {
if (!this.client) {
throw new Error('Not connected');
}
const clamped = Math.max(0, Math.min(100, level));
return new Promise((resolve, reject) => {
this.client!.setVolume({ level: clamped / 100 }, (err: Error | null) => {
if (err) {
reject(err);
return;
}
this._volume = clamped;
this.emit('volume:changed', { volume: clamped });
resolve();
});
});
}
/**
* Get mute state
*/
public async getMute(): Promise<boolean> {
await this.refreshStatus();
return this._muted;
}
/**
* Set mute state
*/
public async setMute(muted: boolean): Promise<void> {
if (!this.client) {
throw new Error('Not connected');
}
return new Promise((resolve, reject) => {
this.client!.setVolume({ muted }, (err: Error | null) => {
if (err) {
reject(err);
return;
}
this._muted = muted;
this.emit('mute:changed', { muted });
resolve();
});
});
}
// ============================================================================
// Track Information
// ============================================================================
/**
* Get current track
*/
public async getCurrentTrack(): Promise<ITrackInfo | null> {
if (!this.player) {
return null;
}
return new Promise((resolve) => {
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).getStatus((err: Error | null, status: IChromecastMediaStatus) => {
if (err || !status || !status.media) {
resolve(null);
return;
}
const media = status.media;
const metadata = media.metadata;
resolve({
title: metadata?.title || 'Unknown',
artist: metadata?.artist,
album: metadata?.albumName,
duration: media.duration || 0,
position: status.currentTime || 0,
albumArtUri: metadata?.images?.[0]?.url,
uri: media.contentId,
});
});
});
}
/**
* Get playback status
*/
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
await this.refreshStatus();
return {
state: this._playbackState,
volume: this._volume,
muted: this._muted,
track: await this.getCurrentTrack() || undefined,
};
}
// ============================================================================
// Chromecast-specific Methods
// ============================================================================
/**
* Launch an application
*/
public async launchApp(appId: string): Promise<void> {
if (!this.client) {
throw new Error('Not connected');
}
return new Promise((resolve, reject) => {
this.client!.launch({ id: appId } as Parameters<typeof plugins.castv2Client.Client.prototype.launch>[0], (err: Error | null) => {
if (err) {
reject(err);
return;
}
this._currentAppId = appId;
this.emit('app:launched', { appId });
resolve();
});
});
}
/**
* Stop current application
*/
public async stopApp(): Promise<void> {
if (!this.client) {
throw new Error('Not connected');
}
return new Promise((resolve, reject) => {
this.client!.stop(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>, (err: Error | null) => {
if (err) {
reject(err);
return;
}
this._currentAppId = undefined;
this._currentAppName = undefined;
this.player = null;
this.emit('app:stopped');
resolve();
});
});
}
/**
* Get receiver status
*/
public async getReceiverStatus(): Promise<{
applications?: Array<{ appId: string; displayName: string }>;
volume: { level: number; muted: boolean };
}> {
if (!this.client) {
throw new Error('Not connected');
}
return new Promise((resolve, reject) => {
this.client!.getStatus((err: Error | null, status: {
applications?: Array<{ appId: string; displayName: string }>;
volume: { level: number; muted: boolean };
}) => {
if (err) {
reject(err);
return;
}
resolve(status);
});
});
}
/**
* Guess content type from URL
*/
private guessContentType(url: string): string {
const ext = url.split('.').pop()?.toLowerCase();
switch (ext) {
case 'mp3':
return 'audio/mpeg';
case 'mp4':
case 'm4v':
return 'video/mp4';
case 'webm':
return 'video/webm';
case 'mkv':
return 'video/x-matroska';
case 'ogg':
return 'audio/ogg';
case 'flac':
return 'audio/flac';
case 'wav':
return 'audio/wav';
case 'm3u8':
return 'application/x-mpegURL';
case 'mpd':
return 'application/dash+xml';
default:
return 'video/mp4';
}
}
// ============================================================================
// Device Info
// ============================================================================
/**
* Get speaker info
*/
public getSpeakerInfo(): IChromecastSpeakerInfo {
return {
id: this.id,
name: this.name,
type: 'speaker',
address: this.address,
port: this.port,
status: this.status,
protocol: 'chromecast',
roomName: this._roomName,
modelName: this._modelName,
friendlyName: this._friendlyName,
deviceType: this._deviceType,
capabilities: this._capabilities,
currentAppId: this._currentAppId,
currentAppName: this._currentAppName,
};
}
/**
* Create from mDNS discovery
*/
public static fromDiscovery(
data: {
id: string;
name: string;
address: string;
port?: number;
roomName?: string;
modelName?: string;
friendlyName?: string;
deviceType?: TChromecastType;
capabilities?: string[];
},
retryOptions?: IRetryOptions
): ChromecastSpeaker {
const info: IDeviceInfo = {
id: data.id,
name: data.name,
type: 'speaker',
address: data.address,
port: data.port ?? 8009,
status: 'unknown',
};
return new ChromecastSpeaker(
info,
{
roomName: data.roomName,
modelName: data.modelName,
friendlyName: data.friendlyName,
deviceType: data.deviceType,
capabilities: data.capabilities,
},
retryOptions
);
}
/**
* Probe for Chromecast device
*/
public static async probe(address: string, port: number = 8009, timeout: number = 5000): Promise<boolean> {
return new Promise((resolve) => {
const client = new plugins.castv2Client.Client();
const timer = setTimeout(() => {
client.close();
resolve(false);
}, timeout);
client.on('error', () => {
clearTimeout(timer);
client.close();
resolve(false);
});
client.connect(address, () => {
clearTimeout(timer);
client.close();
resolve(true);
});
});
}
}

View 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);
});
});
}
}

View File

@@ -0,0 +1,216 @@
import * as plugins from '../plugins.js';
import { Device } from '../abstract/device.abstract.js';
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
/**
* Speaker protocol types
*/
export type TSpeakerProtocol = 'sonos' | 'airplay' | 'chromecast' | 'dlna';
/**
* Playback state
*/
export type TPlaybackState = 'playing' | 'paused' | 'stopped' | 'transitioning' | 'unknown';
/**
* Track information
*/
export interface ITrackInfo {
title: string;
artist?: string;
album?: string;
duration: number; // seconds
position: number; // seconds
albumArtUri?: string;
uri?: string;
}
/**
* Speaker playback status
*/
export interface IPlaybackStatus {
state: TPlaybackState;
volume: number; // 0-100
muted: boolean;
track?: ITrackInfo;
}
/**
* Speaker device info
*/
export interface ISpeakerInfo extends IDeviceInfo {
type: 'speaker';
protocol: TSpeakerProtocol;
roomName?: string;
modelName?: string;
supportsGrouping?: boolean;
groupId?: string;
isGroupCoordinator?: boolean;
}
/**
* Abstract Speaker base class
* Common interface for all speaker types (Sonos, AirPlay, Chromecast)
*/
export abstract class Speaker extends Device {
protected _protocol: TSpeakerProtocol;
protected _roomName?: string;
protected _modelName?: string;
protected _volume: number = 0;
protected _muted: boolean = false;
protected _playbackState: TPlaybackState = 'unknown';
constructor(
info: IDeviceInfo,
protocol: TSpeakerProtocol,
options?: {
roomName?: string;
modelName?: string;
},
retryOptions?: IRetryOptions
) {
super(info, retryOptions);
this._protocol = protocol;
this._roomName = options?.roomName;
this._modelName = options?.modelName;
}
// Getters
public get protocol(): TSpeakerProtocol {
return this._protocol;
}
public get roomName(): string | undefined {
return this._roomName;
}
public get speakerModelName(): string | undefined {
return this._modelName;
}
public get volume(): number {
return this._volume;
}
public get muted(): boolean {
return this._muted;
}
public get playbackState(): TPlaybackState {
return this._playbackState;
}
// ============================================================================
// Abstract Methods - Must be implemented by subclasses
// ============================================================================
/**
* Play media from URI
*/
public abstract play(uri?: string): Promise<void>;
/**
* Pause playback
*/
public abstract pause(): Promise<void>;
/**
* Stop playback
*/
public abstract stop(): Promise<void>;
/**
* Next track
*/
public abstract next(): Promise<void>;
/**
* Previous track
*/
public abstract previous(): Promise<void>;
/**
* Seek to position
*/
public abstract seek(seconds: number): Promise<void>;
/**
* Get volume level (0-100)
*/
public abstract getVolume(): Promise<number>;
/**
* Set volume level (0-100)
*/
public abstract setVolume(level: number): Promise<void>;
/**
* Get mute state
*/
public abstract getMute(): Promise<boolean>;
/**
* Set mute state
*/
public abstract setMute(muted: boolean): Promise<void>;
/**
* Get current track info
*/
public abstract getCurrentTrack(): Promise<ITrackInfo | null>;
/**
* Get playback status
*/
public abstract getPlaybackStatus(): Promise<IPlaybackStatus>;
// ============================================================================
// Common Methods
// ============================================================================
/**
* Toggle mute
*/
public async toggleMute(): Promise<boolean> {
const currentMute = await this.getMute();
await this.setMute(!currentMute);
return !currentMute;
}
/**
* Volume up
*/
public async volumeUp(step: number = 5): Promise<number> {
const current = await this.getVolume();
const newVolume = Math.min(100, current + step);
await this.setVolume(newVolume);
return newVolume;
}
/**
* Volume down
*/
public async volumeDown(step: number = 5): Promise<number> {
const current = await this.getVolume();
const newVolume = Math.max(0, current - step);
await this.setVolume(newVolume);
return newVolume;
}
/**
* Get speaker info
*/
public getSpeakerInfo(): ISpeakerInfo {
return {
id: this.id,
name: this.name,
type: 'speaker',
address: this.address,
port: this.port,
status: this.status,
protocol: this._protocol,
roomName: this._roomName,
modelName: this._modelName,
};
}
}