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

@@ -1,8 +1,17 @@
import * as plugins from './plugins.js';
import { MdnsDiscovery, SERVICE_TYPES } from './discovery/discovery.classes.mdns.js';
import { NetworkScanner } from './discovery/discovery.classes.networkscanner.js';
import { SsdpDiscovery, SSDP_SERVICE_TYPES, type ISsdpDevice } from './discovery/discovery.classes.ssdp.js';
import { Scanner } from './scanner/scanner.classes.scanner.js';
import { Printer } from './printer/printer.classes.printer.js';
import { SnmpDevice, type ISnmpDeviceInfo } from './snmp/snmp.classes.snmpdevice.js';
import { UpsDevice, type IUpsDeviceInfo, type TUpsProtocol } from './ups/ups.classes.upsdevice.js';
import { DlnaRenderer, type IDlnaRendererInfo } from './dlna/dlna.classes.renderer.js';
import { DlnaServer, type IDlnaServerInfo } from './dlna/dlna.classes.server.js';
import { Speaker, type ISpeakerInfo } from './speaker/speaker.classes.speaker.js';
import { SonosSpeaker, type ISonosSpeakerInfo } from './speaker/speaker.classes.sonos.js';
import { AirPlaySpeaker, type IAirPlaySpeakerInfo } from './speaker/speaker.classes.airplay.js';
import { ChromecastSpeaker, type IChromecastSpeakerInfo } from './speaker/speaker.classes.chromecast.js';
import type {
IDeviceManagerOptions,
IDiscoveredDevice,
@@ -10,8 +19,14 @@ import type {
TDeviceManagerEvents,
INetworkScanOptions,
INetworkScanResult,
TDeviceType,
TFeatureType,
} from './interfaces/index.js';
// Universal Device & Features
import { UniversalDevice } from './device/device.classes.device.js';
import type { Feature } from './features/index.js';
/**
* Default device manager options
*/
@@ -27,10 +42,24 @@ const DEFAULT_OPTIONS: Required<IDeviceManagerOptions> = {
* Main Device Manager class for discovering and managing network devices
*/
export class DeviceManager extends plugins.events.EventEmitter {
private discovery: MdnsDiscovery;
private mdnsDiscovery: MdnsDiscovery;
private ssdpDiscovery: SsdpDiscovery;
private _networkScanner: NetworkScanner | null = null;
// Device collections
private scanners: Map<string, Scanner> = new Map();
private printers: Map<string, Printer> = new Map();
private snmpDevices: Map<string, SnmpDevice> = new Map();
private upsDevices: Map<string, UpsDevice> = new Map();
private dlnaRenderers: Map<string, DlnaRenderer> = new Map();
private dlnaServers: Map<string, DlnaServer> = new Map();
private sonosSpeakers: Map<string, SonosSpeaker> = new Map();
private airplaySpeakers: Map<string, AirPlaySpeaker> = new Map();
private chromecastSpeakers: Map<string, ChromecastSpeaker> = new Map();
// Universal devices (new architecture)
private universalDevices: Map<string, UniversalDevice> = new Map();
private options: Required<IDeviceManagerOptions>;
private retryOptions: IRetryOptions;
@@ -46,46 +75,58 @@ export class DeviceManager extends plugins.events.EventEmitter {
jitter: true,
};
this.discovery = new MdnsDiscovery({
this.mdnsDiscovery = new MdnsDiscovery({
timeout: this.options.discoveryTimeout,
});
this.ssdpDiscovery = new SsdpDiscovery();
this.setupDiscoveryEvents();
this.setupSsdpDiscoveryEvents();
}
/**
* Setup event forwarding from discovery service
* Setup event forwarding from mDNS discovery service
*/
private setupDiscoveryEvents(): void {
this.discovery.on('device:found', (device: IDiscoveredDevice) => {
this.handleDeviceFound(device);
this.mdnsDiscovery.on('device:found', (device: IDiscoveredDevice) => {
this.handleMdnsDeviceFound(device);
});
this.discovery.on('device:lost', (device: IDiscoveredDevice) => {
this.mdnsDiscovery.on('device:lost', (device: IDiscoveredDevice) => {
this.handleDeviceLost(device);
});
this.discovery.on('scanner:found', (device: IDiscoveredDevice) => {
// Scanner found event is emitted after device:found handling
});
this.discovery.on('printer:found', (device: IDiscoveredDevice) => {
// Printer found event is emitted after device:found handling
});
this.discovery.on('started', () => {
this.mdnsDiscovery.on('started', () => {
this.emit('discovery:started');
});
this.discovery.on('stopped', () => {
this.mdnsDiscovery.on('stopped', () => {
this.emit('discovery:stopped');
});
}
/**
* Handle newly discovered device
* Setup event forwarding from SSDP discovery service
*/
private handleDeviceFound(device: IDiscoveredDevice): void {
private setupSsdpDiscoveryEvents(): void {
this.ssdpDiscovery.on('device:found', (device: ISsdpDevice) => {
this.handleSsdpDeviceFound(device);
});
this.ssdpDiscovery.on('started', () => {
this.emit('ssdp:started');
});
this.ssdpDiscovery.on('stopped', () => {
this.emit('ssdp:stopped');
});
}
/**
* Handle newly discovered mDNS device
*/
private handleMdnsDeviceFound(device: IDiscoveredDevice): void {
if (device.type === 'scanner') {
// Create Scanner instance
const scanner = Scanner.fromDiscovery(
@@ -117,6 +158,114 @@ export class DeviceManager extends plugins.events.EventEmitter {
this.printers.set(device.id, printer);
this.emit('printer:found', printer.getPrinterInfo());
} else if (device.type === 'speaker') {
// Create appropriate speaker instance based on protocol
this.handleMdnsSpeakerFound(device);
}
}
/**
* Handle mDNS speaker discovery
*/
private handleMdnsSpeakerFound(device: IDiscoveredDevice): void {
const protocol = device.protocol;
const txt = device.txtRecords || {};
if (protocol === 'sonos') {
// Sonos speaker
const speaker = SonosSpeaker.fromDiscovery(
{
id: device.id,
name: device.name,
address: device.address,
port: device.port || 1400,
roomName: txt['room'] || device.name,
modelName: txt['model'] || txt['md'],
},
this.options.enableRetry ? this.retryOptions : undefined
);
this.sonosSpeakers.set(device.id, speaker);
this.emit('speaker:found', speaker.getSpeakerInfo());
} else if (protocol === 'airplay') {
// AirPlay speaker (HomePod, Apple TV, etc.)
const features = txt['features'] ? parseInt(txt['features'], 16) : 0;
const speaker = AirPlaySpeaker.fromDiscovery(
{
id: device.id,
name: device.name,
address: device.address,
port: device.port || 7000,
roomName: txt['room'] || device.name,
modelName: txt['model'] || txt['am'],
features,
deviceId: txt['deviceid'] || txt['pk'],
},
this.options.enableRetry ? this.retryOptions : undefined
);
this.airplaySpeakers.set(device.id, speaker);
this.emit('speaker:found', speaker.getSpeakerInfo());
} else if (protocol === 'chromecast') {
// Chromecast / Google Cast
const speaker = ChromecastSpeaker.fromDiscovery(
{
id: device.id,
name: txt['fn'] || device.name,
address: device.address,
port: device.port || 8009,
friendlyName: txt['fn'] || device.name,
modelName: txt['md'],
},
this.options.enableRetry ? this.retryOptions : undefined
);
this.chromecastSpeakers.set(device.id, speaker);
this.emit('speaker:found', speaker.getSpeakerInfo());
}
}
/**
* Handle newly discovered SSDP/UPnP device
*/
private handleSsdpDeviceFound(device: ISsdpDevice): void {
if (!device.description) {
return;
}
const serviceType = device.serviceType;
const deviceType = device.description.deviceType;
// Check for DLNA Media Renderer
if (serviceType.includes('MediaRenderer') || deviceType.includes('MediaRenderer')) {
const renderer = DlnaRenderer.fromSsdpDevice(device, this.retryOptions);
if (renderer) {
this.dlnaRenderers.set(renderer.id, renderer);
this.emit('dlna:renderer:found', renderer.getDeviceInfo());
}
}
// Check for DLNA Media Server
if (serviceType.includes('MediaServer') || deviceType.includes('MediaServer')) {
const server = DlnaServer.fromSsdpDevice(device, this.retryOptions);
if (server) {
this.dlnaServers.set(server.id, server);
this.emit('dlna:server:found', server.getDeviceInfo());
}
}
// Check for Sonos ZonePlayer
if (serviceType.includes('ZonePlayer') || deviceType.includes('ZonePlayer')) {
const speaker = SonosSpeaker.fromDiscovery(
{
id: `sonos:${device.usn}`,
name: device.description.friendlyName,
address: device.address,
port: 1400,
roomName: device.description.friendlyName,
modelName: device.description.modelName,
},
this.retryOptions
);
this.sonosSpeakers.set(speaker.id, speaker);
this.emit('speaker:found', speaker.getSpeakerInfo());
}
}
@@ -148,24 +297,30 @@ export class DeviceManager extends plugins.events.EventEmitter {
}
/**
* Start device discovery
* Start device discovery (mDNS and SSDP)
*/
public async startDiscovery(): Promise<void> {
await this.discovery.start();
await Promise.all([
this.mdnsDiscovery.start(),
this.ssdpDiscovery.start(),
]);
}
/**
* Stop device discovery
*/
public async stopDiscovery(): Promise<void> {
await this.discovery.stop();
await Promise.all([
this.mdnsDiscovery.stop(),
this.ssdpDiscovery.stop(),
]);
}
/**
* Check if discovery is running
*/
public get isDiscovering(): boolean {
return this.discovery.running;
return this.mdnsDiscovery.running || this.ssdpDiscovery.isRunning;
}
/**
@@ -182,6 +337,66 @@ export class DeviceManager extends plugins.events.EventEmitter {
return Array.from(this.printers.values());
}
/**
* Get all discovered SNMP devices
*/
public getSnmpDevices(): SnmpDevice[] {
return Array.from(this.snmpDevices.values());
}
/**
* Get all discovered UPS devices
*/
public getUpsDevices(): UpsDevice[] {
return Array.from(this.upsDevices.values());
}
/**
* Get all DLNA media renderers
*/
public getDlnaRenderers(): DlnaRenderer[] {
return Array.from(this.dlnaRenderers.values());
}
/**
* Get all DLNA media servers
*/
public getDlnaServers(): DlnaServer[] {
return Array.from(this.dlnaServers.values());
}
/**
* Get all speakers (all protocols)
*/
public getSpeakers(): Speaker[] {
return [
...this.getSonosSpeakers(),
...this.getAirPlaySpeakers(),
...this.getChromecastSpeakers(),
];
}
/**
* Get all Sonos speakers
*/
public getSonosSpeakers(): SonosSpeaker[] {
return Array.from(this.sonosSpeakers.values());
}
/**
* Get all AirPlay speakers
*/
public getAirPlaySpeakers(): AirPlaySpeaker[] {
return Array.from(this.airplaySpeakers.values());
}
/**
* Get all Chromecast speakers
*/
public getChromecastSpeakers(): ChromecastSpeaker[] {
return Array.from(this.chromecastSpeakers.values());
}
/**
* Get scanner by ID
*/
@@ -197,17 +412,92 @@ export class DeviceManager extends plugins.events.EventEmitter {
}
/**
* Get all devices (scanners and printers)
* Get SNMP device by ID
*/
public getDevices(): (Scanner | Printer)[] {
return [...this.getScanners(), ...this.getPrinters()];
public getSnmpDevice(id: string): SnmpDevice | undefined {
return this.snmpDevices.get(id);
}
/**
* Get device by ID (scanner or printer)
* Get UPS device by ID
*/
public getDevice(id: string): Scanner | Printer | undefined {
return this.scanners.get(id) ?? this.printers.get(id);
public getUpsDevice(id: string): UpsDevice | undefined {
return this.upsDevices.get(id);
}
/**
* Get DLNA renderer by ID
*/
public getDlnaRenderer(id: string): DlnaRenderer | undefined {
return this.dlnaRenderers.get(id);
}
/**
* Get DLNA server by ID
*/
public getDlnaServer(id: string): DlnaServer | undefined {
return this.dlnaServers.get(id);
}
/**
* Get speaker by ID (any protocol)
*/
public getSpeaker(id: string): Speaker | undefined {
return this.sonosSpeakers.get(id) ??
this.airplaySpeakers.get(id) ??
this.chromecastSpeakers.get(id);
}
/**
* Get all devices (all types)
*/
public getAllDevices(): (Scanner | Printer | SnmpDevice | UpsDevice | DlnaRenderer | DlnaServer | Speaker)[] {
return [
...this.getScanners(),
...this.getPrinters(),
...this.getSnmpDevices(),
...this.getUpsDevices(),
...this.getDlnaRenderers(),
...this.getDlnaServers(),
...this.getSpeakers(),
];
}
/**
* Get devices by type
*/
public getDevicesByType(type: TDeviceType): (Scanner | Printer | SnmpDevice | UpsDevice | DlnaRenderer | DlnaServer | Speaker)[] {
switch (type) {
case 'scanner':
return this.getScanners();
case 'printer':
return this.getPrinters();
case 'snmp':
return this.getSnmpDevices();
case 'ups':
return this.getUpsDevices();
case 'dlna-renderer':
return this.getDlnaRenderers();
case 'dlna-server':
return this.getDlnaServers();
case 'speaker':
return this.getSpeakers();
default:
return [];
}
}
/**
* Get device by ID (any type)
*/
public getDeviceById(id: string): Scanner | Printer | SnmpDevice | UpsDevice | DlnaRenderer | DlnaServer | Speaker | undefined {
return this.scanners.get(id) ??
this.printers.get(id) ??
this.snmpDevices.get(id) ??
this.upsDevices.get(id) ??
this.dlnaRenderers.get(id) ??
this.dlnaServers.get(id) ??
this.getSpeaker(id);
}
/**
@@ -323,10 +613,11 @@ export class DeviceManager extends plugins.events.EventEmitter {
*/
public async scanNetwork(
options: INetworkScanOptions
): Promise<{ scanners: Scanner[]; printers: Printer[] }> {
): Promise<{ scanners: Scanner[]; printers: Printer[]; speakers: Speaker[] }> {
const results = await this.networkScanner.scan(options);
const foundScanners: Scanner[] = [];
const foundPrinters: Printer[] = [];
const foundSpeakers: Speaker[] = [];
for (const result of results) {
for (const device of result.devices) {
@@ -359,6 +650,26 @@ export class DeviceManager extends plugins.events.EventEmitter {
foundPrinters.push(printer);
}
// JetDirect printers don't have a protocol handler yet
} else if (device.type === 'speaker') {
if (device.protocol === 'airplay') {
const speaker = await this.addAirPlaySpeaker(
result.address,
{ name: device.name, port: device.port }
);
foundSpeakers.push(speaker);
} else if (device.protocol === 'sonos') {
const speaker = await this.addSonosSpeaker(
result.address,
{ name: device.name, port: device.port }
);
foundSpeakers.push(speaker);
} else if (device.protocol === 'chromecast') {
const speaker = await this.addChromecastSpeaker(
result.address,
{ name: device.name, port: device.port }
);
foundSpeakers.push(speaker);
}
}
} catch (error) {
// Device could not be added (connection failed, etc.)
@@ -367,7 +678,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
}
}
return { scanners: foundScanners, printers: foundPrinters };
return { scanners: foundScanners, printers: foundPrinters, speakers: foundSpeakers };
}
/**
@@ -419,15 +730,11 @@ export class DeviceManager extends plugins.events.EventEmitter {
public async disconnectAll(): Promise<void> {
const disconnectPromises: Promise<void>[] = [];
for (const scanner of this.scanners.values()) {
if (scanner.isConnected) {
disconnectPromises.push(scanner.disconnect().catch(() => {}));
}
}
for (const printer of this.printers.values()) {
if (printer.isConnected) {
disconnectPromises.push(printer.disconnect().catch(() => {}));
// Disconnect all device types
const allDevices = this.getAllDevices();
for (const device of allDevices) {
if (device.isConnected) {
disconnectPromises.push(device.disconnect().catch(() => {}));
}
}
@@ -440,8 +747,18 @@ export class DeviceManager extends plugins.events.EventEmitter {
public async shutdown(): Promise<void> {
await this.stopDiscovery();
await this.disconnectAll();
// Clear all device collections
this.scanners.clear();
this.printers.clear();
this.snmpDevices.clear();
this.upsDevices.clear();
this.dlnaRenderers.clear();
this.dlnaServers.clear();
this.sonosSpeakers.clear();
this.airplaySpeakers.clear();
this.chromecastSpeakers.clear();
this.universalDevices.clear();
}
/**
@@ -450,20 +767,11 @@ export class DeviceManager extends plugins.events.EventEmitter {
public async refreshAllStatus(): Promise<void> {
const refreshPromises: Promise<void>[] = [];
for (const scanner of this.scanners.values()) {
if (scanner.isConnected) {
const allDevices = this.getAllDevices();
for (const device of allDevices) {
if (device.isConnected) {
refreshPromises.push(
scanner.refreshStatus().catch((error) => {
this.emit('error', error);
})
);
}
}
for (const printer of this.printers.values()) {
if (printer.isConnected) {
refreshPromises.push(
printer.refreshStatus().catch((error) => {
device.refreshStatus().catch((error) => {
this.emit('error', error);
})
);
@@ -472,6 +780,325 @@ export class DeviceManager extends plugins.events.EventEmitter {
await Promise.all(refreshPromises);
}
// ============================================================================
// Manual Device Addition Methods
// ============================================================================
/**
* Add an SNMP device manually
*/
public async addSnmpDevice(
address: string,
port: number = 161,
options?: { name?: string; community?: string }
): Promise<SnmpDevice> {
const id = `manual:snmp:${address}:${port}`;
if (this.snmpDevices.has(id)) {
return this.snmpDevices.get(id)!;
}
const device = SnmpDevice.fromDiscovery(
{
id,
name: options?.name ?? `SNMP Device at ${address}`,
address,
port,
community: options?.community ?? 'public',
},
this.retryOptions
);
await device.connect();
this.snmpDevices.set(id, device);
this.emit('snmp:found', device.getDeviceInfo());
return device;
}
/**
* Add a UPS device manually
*/
public async addUpsDevice(
address: string,
protocol: TUpsProtocol,
options?: { name?: string; port?: number; upsName?: string; community?: string }
): Promise<UpsDevice> {
const port = options?.port ?? (protocol === 'nut' ? 3493 : 161);
const id = `manual:ups:${address}:${port}`;
if (this.upsDevices.has(id)) {
return this.upsDevices.get(id)!;
}
const device = UpsDevice.fromDiscovery(
{
id,
name: options?.name ?? `UPS at ${address}`,
address,
port,
protocol,
upsName: options?.upsName,
community: options?.community,
},
this.retryOptions
);
await device.connect();
this.upsDevices.set(id, device);
this.emit('ups:found', device.getDeviceInfo());
return device;
}
/**
* Add a Sonos speaker manually
*/
public async addSonosSpeaker(
address: string,
options?: { name?: string; port?: number }
): Promise<SonosSpeaker> {
const port = options?.port ?? 1400;
const id = `manual:sonos:${address}:${port}`;
if (this.sonosSpeakers.has(id)) {
return this.sonosSpeakers.get(id)!;
}
const speaker = SonosSpeaker.fromDiscovery(
{
id,
name: options?.name ?? `Sonos at ${address}`,
address,
port,
},
this.retryOptions
);
await speaker.connect();
this.sonosSpeakers.set(id, speaker);
this.emit('speaker:found', speaker.getSpeakerInfo());
return speaker;
}
/**
* Add an AirPlay speaker manually
*/
public async addAirPlaySpeaker(
address: string,
options?: { name?: string; port?: number }
): Promise<AirPlaySpeaker> {
const port = options?.port ?? 7000;
const id = `manual:airplay:${address}:${port}`;
if (this.airplaySpeakers.has(id)) {
return this.airplaySpeakers.get(id)!;
}
const speaker = AirPlaySpeaker.fromDiscovery(
{
id,
name: options?.name ?? `AirPlay at ${address}`,
address,
port,
},
this.retryOptions
);
await speaker.connect();
this.airplaySpeakers.set(id, speaker);
this.emit('speaker:found', speaker.getSpeakerInfo());
return speaker;
}
/**
* Add a Chromecast speaker manually
*/
public async addChromecastSpeaker(
address: string,
options?: { name?: string; port?: number }
): Promise<ChromecastSpeaker> {
const port = options?.port ?? 8009;
const id = `manual:chromecast:${address}:${port}`;
if (this.chromecastSpeakers.has(id)) {
return this.chromecastSpeakers.get(id)!;
}
const speaker = ChromecastSpeaker.fromDiscovery(
{
id,
name: options?.name ?? `Chromecast at ${address}`,
address,
port,
},
this.retryOptions
);
await speaker.connect();
this.chromecastSpeakers.set(id, speaker);
this.emit('speaker:found', speaker.getSpeakerInfo());
return speaker;
}
// ============================================================================
// Universal Device & Feature-Based API (New Architecture)
// ============================================================================
/**
* Get all universal devices
*/
public getUniversalDevices(): UniversalDevice[] {
return Array.from(this.universalDevices.values());
}
/**
* Get a universal device by ID
*/
public getUniversalDevice(id: string): UniversalDevice | undefined {
return this.universalDevices.get(id);
}
/**
* Get a universal device by address
*/
public getUniversalDeviceByAddress(address: string): UniversalDevice | undefined {
for (const device of this.universalDevices.values()) {
if (device.address === address) {
return device;
}
}
return undefined;
}
/**
* Get all universal devices that have a specific feature
* @param featureType The feature type to search for
*/
public getDevicesWithFeature(featureType: TFeatureType): UniversalDevice[] {
return this.getUniversalDevices().filter(device => device.hasFeature(featureType));
}
/**
* Get all universal devices that have ALL specified features
* @param featureTypes Array of feature types - device must have ALL of them
*/
public getDevicesWithFeatures(featureTypes: TFeatureType[]): UniversalDevice[] {
return this.getUniversalDevices().filter(device => device.hasFeatures(featureTypes));
}
/**
* Get all universal devices that have ANY of the specified features
* @param featureTypes Array of feature types - device must have at least one
*/
public getDevicesWithAnyFeature(featureTypes: TFeatureType[]): UniversalDevice[] {
return this.getUniversalDevices().filter(device =>
featureTypes.some(type => device.hasFeature(type))
);
}
/**
* Add a universal device
*/
public addUniversalDevice(device: UniversalDevice): void {
const existingDevice = this.universalDevices.get(device.id);
if (existingDevice) {
// Merge features into existing device
for (const feature of device.getFeatures()) {
if (!existingDevice.hasFeature(feature.type)) {
existingDevice.addFeature(feature);
}
}
} else {
this.universalDevices.set(device.id, device);
this.emit('universal:device:found', device);
}
}
/**
* Add a feature to an existing universal device
*/
public addFeatureToDevice(deviceId: string, feature: Feature): boolean {
const device = this.universalDevices.get(deviceId);
if (device) {
device.addFeature(feature);
this.emit('feature:added', { device, feature });
return true;
}
return false;
}
/**
* Remove a feature from a universal device
*/
public async removeFeatureFromDevice(deviceId: string, featureType: TFeatureType): Promise<boolean> {
const device = this.universalDevices.get(deviceId);
if (device) {
const removed = await device.removeFeature(featureType);
if (removed) {
this.emit('feature:removed', { device, featureType });
}
return removed;
}
return false;
}
/**
* Remove a universal device
*/
public async removeUniversalDevice(id: string): Promise<boolean> {
const device = this.universalDevices.get(id);
if (device) {
await device.disconnect();
this.universalDevices.delete(id);
this.emit('universal:device:lost', id);
return true;
}
return false;
}
/**
* Get feature from a device by type
*/
public getFeatureFromDevice<T extends Feature>(deviceId: string, featureType: TFeatureType): T | undefined {
const device = this.universalDevices.get(deviceId);
return device?.getFeature<T>(featureType);
}
}
export { MdnsDiscovery, NetworkScanner, Scanner, Printer, SERVICE_TYPES };
// Export all classes and types
export {
// Discovery
MdnsDiscovery,
NetworkScanner,
SsdpDiscovery,
SERVICE_TYPES,
SSDP_SERVICE_TYPES,
// Scanner & Printer
Scanner,
Printer,
// SNMP
SnmpDevice,
// UPS
UpsDevice,
// DLNA
DlnaRenderer,
DlnaServer,
// Speakers
Speaker,
SonosSpeaker,
AirPlaySpeaker,
ChromecastSpeaker,
// Universal Device (new architecture)
UniversalDevice,
};