/** * Device Manager * Unified device discovery and management using UniversalDevice with Features */ 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 { UniversalDevice } from './device/device.classes.device.js'; import { ScanFeature, PrintFeature, PlaybackFeature, VolumeFeature, PowerFeature, SnmpFeature, } from './features/index.js'; import type { Feature } from './features/index.js'; import { createScanner, createPrinter, createSnmpDevice, createUpsDevice, createSpeaker, createDlnaRenderer, type IScannerDiscoveryInfo, type IPrinterDiscoveryInfo, type ISnmpDiscoveryInfo, type IUpsDiscoveryInfo, type ISpeakerDiscoveryInfo, type IDlnaRendererDiscoveryInfo, } from './factories/index.js'; import type { IDeviceManagerOptions, IDiscoveredDevice, IRetryOptions, INetworkScanOptions, INetworkScanResult, TFeatureType, } from './interfaces/index.js'; /** * Default device manager options */ const DEFAULT_OPTIONS: Required = { autoDiscovery: true, discoveryTimeout: 10000, enableRetry: true, maxRetries: 5, retryBaseDelay: 1000, }; /** * Name source priority - higher number = higher priority * Used to determine which discovery method's name should be preferred */ type TNameSource = 'generic' | 'manual' | 'airplay' | 'chromecast' | 'mdns' | 'dlna' | 'sonos'; const NAME_SOURCE_PRIORITY: Record = { generic: 0, // IP-based placeholder names manual: 1, // Manually added devices airplay: 2, // AirPlay can have generic names chromecast: 3, // Chromecast has decent names mdns: 4, // mDNS for scanners/printers (usually good) dlna: 5, // SSDP/UPnP MediaRenderer (good friendly names) sonos: 6, // Sonos SSDP ZonePlayer (most authoritative) }; /** * Check if a name is a "real" name vs a generic placeholder */ function isRealName(name: string | undefined): boolean { if (!name) return false; const lower = name.toLowerCase(); // Generic names that indicate no real name was discovered return !( lower.includes(' at ') || lower.startsWith('device ') || lower.startsWith('scanner ') || lower.startsWith('printer ') || lower.startsWith('speaker ') || lower.startsWith('ups ') || lower.startsWith('snmp ') || /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(name) // Just an IP address ); } /** * Choose the best name based on source priority */ function shouldUpdateName( existingSource: TNameSource | undefined, newSource: TNameSource, existingName: string | undefined, newName: string | undefined ): boolean { // If new name isn't real, don't update if (!isRealName(newName)) return false; // If no existing name or source, use new if (!existingName || !existingSource) return true; // If existing name isn't real but new is, use new if (!isRealName(existingName)) return true; // Both are real names - use priority const existingPriority = NAME_SOURCE_PRIORITY[existingSource] ?? 0; const newPriority = NAME_SOURCE_PRIORITY[newSource] ?? 0; return newPriority > existingPriority; } /** * Create a device reference for feature construction */ function makeDeviceRef(address: string, port: number, name: string): { id: string; address: string; port: number; name: string } { return { id: `device:${address}`, address, port, name, }; } /** * Main Device Manager class * Discovers and manages network devices using the UniversalDevice architecture * * Devices are deduplicated by IP address - if the same device is discovered * via multiple methods (mDNS, SSDP, IP scan), features are merged into a * single UniversalDevice instance. */ export class DeviceManager extends plugins.events.EventEmitter { private mdnsDiscovery: MdnsDiscovery; private ssdpDiscovery: SsdpDiscovery; private _networkScanner: NetworkScanner | null = null; // Devices keyed by IP address for deduplication private devicesByIp: Map = new Map(); // Track name source for priority-based naming private nameSourceByIp: Map = new Map(); private options: Required; private retryOptions: IRetryOptions; constructor(options?: IDeviceManagerOptions) { super(); this.options = { ...DEFAULT_OPTIONS, ...options }; this.retryOptions = { maxRetries: this.options.maxRetries, baseDelay: this.options.retryBaseDelay, maxDelay: 16000, multiplier: 2, jitter: true, }; this.mdnsDiscovery = new MdnsDiscovery({ timeout: this.options.discoveryTimeout, }); this.ssdpDiscovery = new SsdpDiscovery(); this.setupDiscoveryEvents(); this.setupSsdpDiscoveryEvents(); } // ============================================================================ // Device Registration (with deduplication by IP) // ============================================================================ /** * Register or merge a device by IP address * Returns the device (existing or new) and whether it's newly created */ private registerDevice( address: string, port: number, name: string | undefined, manufacturer: string | undefined, model: string | undefined, feature: Feature, nameSource: TNameSource ): { device: UniversalDevice; isNew: boolean; featureAdded: boolean } { const existing = this.devicesByIp.get(address); if (existing) { // Update name if new source has higher priority const existingSource = this.nameSourceByIp.get(address); if (shouldUpdateName(existingSource, nameSource, existing.name, name)) { (existing as { name: string }).name = name!; this.nameSourceByIp.set(address, nameSource); } // Update manufacturer/model if we have better info if (manufacturer && !existing.manufacturer) { (existing as { manufacturer: string | undefined }).manufacturer = manufacturer; } if (model && !existing.model) { (existing as { model: string | undefined }).model = model; } // Add feature if not already present if (!existing.hasFeature(feature.type)) { existing.addFeature(feature); return { device: existing, isNew: false, featureAdded: true }; } return { device: existing, isNew: false, featureAdded: false }; } // Create new device const device = new UniversalDevice(address, port, { name: name || `Device at ${address}`, manufacturer, model, retryOptions: this.options.enableRetry ? this.retryOptions : undefined, }); device.addFeature(feature); this.devicesByIp.set(address, device); this.nameSourceByIp.set(address, isRealName(name) ? nameSource : 'generic'); return { device, isNew: true, featureAdded: true }; } // ============================================================================ // Discovery Event Handlers // ============================================================================ private setupDiscoveryEvents(): void { this.mdnsDiscovery.on('device:found', (device: IDiscoveredDevice) => { this.handleMdnsDeviceFound(device); }); this.mdnsDiscovery.on('device:lost', (device: IDiscoveredDevice) => { this.handleDeviceLost(device.address); }); this.mdnsDiscovery.on('started', () => { this.emit('discovery:started'); }); this.mdnsDiscovery.on('stopped', () => { this.emit('discovery:stopped'); }); } 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'); }); } private handleMdnsDeviceFound(discovered: IDiscoveredDevice): void { const manufacturer = discovered.txtRecords['usb_MFG'] || discovered.txtRecords['mfg']; const model = discovered.txtRecords['usb_MDL'] || discovered.txtRecords['mdl'] || discovered.txtRecords['ty']; if (discovered.type === 'scanner') { const protocol = discovered.protocol === 'ipp' ? 'escl' : discovered.protocol as 'escl' | 'sane'; const isSecure = discovered.txtRecords['TLS'] === '1' || (protocol === 'escl' && discovered.port === 443); const feature = new ScanFeature( makeDeviceRef(discovered.address, discovered.port, discovered.name), discovered.port, { protocol, secure: isSecure, supportedFormats: this.parseScanFormats(discovered.txtRecords), supportedResolutions: this.parseScanResolutions(discovered.txtRecords), supportedColorModes: this.parseScanColorModes(discovered.txtRecords), supportedSources: this.parseScanSources(discovered.txtRecords), hasAdf: discovered.txtRecords['is']?.includes('adf') || false, hasDuplex: discovered.txtRecords['is']?.includes('duplex') || false, } ); const { device, isNew, featureAdded } = this.registerDevice( discovered.address, discovered.port, discovered.name, manufacturer, model, feature, 'mdns' ); if (isNew || featureAdded) { this.emit('device:found', { device, featureType: 'scan' }); } } else if (discovered.type === 'printer') { const ippPath = discovered.txtRecords['rp'] || discovered.txtRecords['rfo'] || '/ipp/print'; const uri = `ipp://${discovered.address}:${discovered.port}${ippPath.startsWith('/') ? '' : '/'}${ippPath}`; const feature = new PrintFeature( makeDeviceRef(discovered.address, discovered.port, discovered.name), discovered.port, { protocol: 'ipp', uri, supportsColor: discovered.txtRecords['Color'] === 'T' || discovered.txtRecords['color'] === 'true', supportsDuplex: discovered.txtRecords['Duplex'] === 'T' || discovered.txtRecords['duplex'] === 'true', } ); const { device, isNew, featureAdded } = this.registerDevice( discovered.address, discovered.port, discovered.name, manufacturer, model, feature, 'mdns' ); if (isNew || featureAdded) { this.emit('device:found', { device, featureType: 'print' }); } } else if (discovered.type === 'speaker') { const protocol = discovered.protocol as 'sonos' | 'airplay' | 'chromecast' | 'dlna'; const playbackFeature = new PlaybackFeature( makeDeviceRef(discovered.address, discovered.port, discovered.name), discovered.port, { protocol, supportsQueue: protocol === 'sonos', supportsSeek: protocol !== 'airplay', } ); const volumeFeature = new VolumeFeature( makeDeviceRef(discovered.address, discovered.port, discovered.name), discovered.port, { volumeProtocol: protocol, minVolume: 0, maxVolume: 100, supportsMute: true, } ); const modelName = discovered.txtRecords['model'] || discovered.txtRecords['md']; // Map protocol to name source - mDNS speaker protocols const speakerNameSource: TNameSource = protocol === 'sonos' ? 'sonos' : protocol === 'airplay' ? 'airplay' : protocol === 'chromecast' ? 'chromecast' : protocol === 'dlna' ? 'dlna' : 'mdns'; const { device, isNew } = this.registerDevice( discovered.address, discovered.port, discovered.name, undefined, modelName, playbackFeature, speakerNameSource ); // Also add volume feature if (!device.hasFeature('volume')) { device.addFeature(volumeFeature); } if (isNew) { this.emit('device:found', { device, featureType: 'playback' }); } } } private handleSsdpDeviceFound(ssdpDevice: ISsdpDevice): void { if (!ssdpDevice.description) { return; } const serviceType = ssdpDevice.serviceType; const deviceType = ssdpDevice.description.deviceType; const desc = ssdpDevice.description; // Check for DLNA Media Renderer if (serviceType.includes('MediaRenderer') || deviceType.includes('MediaRenderer')) { const port = parseInt(new URL(ssdpDevice.location).port) || 80; const playbackFeature = new PlaybackFeature( makeDeviceRef(ssdpDevice.address, port, desc.friendlyName), port, { protocol: 'dlna', supportsQueue: false, supportsSeek: true, } ); const volumeFeature = new VolumeFeature( makeDeviceRef(ssdpDevice.address, port, desc.friendlyName), port, { volumeProtocol: 'dlna', minVolume: 0, maxVolume: 100, supportsMute: true, } ); const { device, isNew } = this.registerDevice( ssdpDevice.address, port, desc.friendlyName, desc.manufacturer, desc.modelName, playbackFeature, 'dlna' ); if (!device.hasFeature('volume')) { device.addFeature(volumeFeature); } if (isNew) { this.emit('device:found', { device, featureType: 'playback' }); } } // Check for Sonos ZonePlayer if (serviceType.includes('ZonePlayer') || deviceType.includes('ZonePlayer')) { const playbackFeature = new PlaybackFeature( makeDeviceRef(ssdpDevice.address, 1400, desc.friendlyName), 1400, { protocol: 'sonos', supportsQueue: true, supportsSeek: true, } ); const volumeFeature = new VolumeFeature( makeDeviceRef(ssdpDevice.address, 1400, desc.friendlyName), 1400, { volumeProtocol: 'sonos', minVolume: 0, maxVolume: 100, supportsMute: true, } ); const { device, isNew } = this.registerDevice( ssdpDevice.address, 1400, desc.friendlyName, desc.manufacturer, desc.modelName, playbackFeature, 'sonos' // Sonos has highest naming priority ); if (!device.hasFeature('volume')) { device.addFeature(volumeFeature); } if (isNew) { this.emit('device:found', { device, featureType: 'playback' }); } } } private async handleDeviceLost(address: string): Promise { const device = this.devicesByIp.get(address); if (device) { try { await device.disconnect(); } catch { // Ignore disconnect errors } this.devicesByIp.delete(address); this.nameSourceByIp.delete(address); this.emit('device:lost', address); } } // ============================================================================ // Parsing Helpers // ============================================================================ private parseScanFormats(txtRecords: Record): ('jpeg' | 'png' | 'pdf' | 'tiff')[] { const formats: ('jpeg' | 'png' | 'pdf' | 'tiff')[] = []; const pdl = txtRecords['pdl'] || txtRecords['DocumentFormat'] || ''; if (pdl.includes('jpeg') || pdl.includes('jpg')) formats.push('jpeg'); if (pdl.includes('png')) formats.push('png'); if (pdl.includes('pdf')) formats.push('pdf'); if (pdl.includes('tiff')) formats.push('tiff'); return formats.length > 0 ? formats : ['jpeg', 'png']; } private parseScanResolutions(txtRecords: Record): number[] { const rs = txtRecords['rs'] || ''; const parts = rs.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n > 0); return parts.length > 0 ? parts : [75, 150, 300, 600]; } private parseScanColorModes(txtRecords: Record): ('color' | 'grayscale' | 'blackwhite')[] { const cs = txtRecords['cs'] || txtRecords['ColorSpace'] || ''; const modes: ('color' | 'grayscale' | 'blackwhite')[] = []; if (cs.includes('color') || cs.includes('RGB')) modes.push('color'); if (cs.includes('gray') || cs.includes('grayscale')) modes.push('grayscale'); if (cs.includes('binary') || cs.includes('bw')) modes.push('blackwhite'); return modes.length > 0 ? modes : ['color', 'grayscale']; } private parseScanSources(txtRecords: Record): ('flatbed' | 'adf' | 'adf-duplex')[] { const is = txtRecords['is'] || txtRecords['InputSource'] || ''; const sources: ('flatbed' | 'adf' | 'adf-duplex')[] = []; if (is.includes('platen') || is.includes('flatbed') || is === '') { sources.push('flatbed'); } if (is.includes('adf') || is.includes('feeder')) { sources.push('adf'); } if (is.includes('duplex')) { sources.push('adf-duplex'); } return sources.length > 0 ? sources : ['flatbed']; } // ============================================================================ // Discovery Control // ============================================================================ public async startDiscovery(): Promise { await Promise.all([ this.mdnsDiscovery.start(), this.ssdpDiscovery.start(), ]); } public async stopDiscovery(): Promise { await Promise.all([ this.mdnsDiscovery.stop(), this.ssdpDiscovery.stop(), ]); } public get isDiscovering(): boolean { return this.mdnsDiscovery.running || this.ssdpDiscovery.isRunning; } // ============================================================================ // Device Access - All Devices // ============================================================================ /** * Get all devices */ public getDevices(): UniversalDevice[] { return Array.from(this.devicesByIp.values()); } /** * Get device by ID */ public getDevice(id: string): UniversalDevice | undefined { for (const device of this.devicesByIp.values()) { if (device.id === id) { return device; } } return undefined; } /** * Get device by address */ public getDeviceByAddress(address: string): UniversalDevice | undefined { return this.devicesByIp.get(address); } // ============================================================================ // Device Access - By Feature Type // ============================================================================ /** * Get all devices that have a specific feature */ public getDevicesWithFeature(featureType: TFeatureType): UniversalDevice[] { return this.getDevices().filter(device => device.hasFeature(featureType)); } /** * Get all devices that have ALL specified features */ public getDevicesWithFeatures(featureTypes: TFeatureType[]): UniversalDevice[] { return this.getDevices().filter(device => device.hasFeatures(featureTypes)); } /** * Get all devices that have ANY of the specified features */ public getDevicesWithAnyFeature(featureTypes: TFeatureType[]): UniversalDevice[] { return this.getDevices().filter(device => featureTypes.some(type => device.hasFeature(type)) ); } // ============================================================================ // Convenience Accessors (by feature type) // ============================================================================ /** * Get all scanner devices */ public getScanners(): UniversalDevice[] { return this.getDevicesWithFeature('scan'); } /** * Get all printer devices */ public getPrinters(): UniversalDevice[] { return this.getDevicesWithFeature('print'); } /** * Get all speaker devices (playback feature) */ public getSpeakers(): UniversalDevice[] { return this.getDevicesWithFeature('playback'); } /** * Get all UPS/power devices */ public getUpsDevices(): UniversalDevice[] { return this.getDevicesWithFeature('power'); } /** * Get all SNMP devices */ public getSnmpDevices(): UniversalDevice[] { return this.getDevicesWithFeature('snmp'); } // ============================================================================ // Manual Device Addition // ============================================================================ /** * Add a device */ public addDevice(device: UniversalDevice): void { const existing = this.devicesByIp.get(device.address); if (existing) { // Merge features into existing device for (const feature of device.getFeatures()) { if (!existing.hasFeature(feature.type)) { existing.addFeature(feature); } } // Update name if better using priority system const existingSource = this.nameSourceByIp.get(device.address); if (shouldUpdateName(existingSource, 'manual', existing.name, device.name)) { (existing as { name: string }).name = device.name; this.nameSourceByIp.set(device.address, 'manual'); } } else { this.devicesByIp.set(device.address, device); this.nameSourceByIp.set(device.address, isRealName(device.name) ? 'manual' : 'generic'); this.emit('device:added', device); } } /** * Add a scanner manually */ public async addScanner( address: string, port: number, protocol: 'escl' | 'sane' = 'escl', name?: string ): Promise { const feature = new ScanFeature( makeDeviceRef(address, port, name || `Scanner at ${address}`), port, { protocol } ); const { device } = this.registerDevice( address, port, name, undefined, undefined, feature, 'manual' ); await device.connect(); return device; } /** * Add a printer manually */ public async addPrinter( address: string, port: number = 631, name?: string, ippPath?: string ): Promise { const path = ippPath || '/ipp/print'; const uri = `ipp://${address}:${port}${path.startsWith('/') ? '' : '/'}${path}`; const feature = new PrintFeature( makeDeviceRef(address, port, name || `Printer at ${address}`), port, { protocol: 'ipp', uri } ); const { device } = this.registerDevice( address, port, name, undefined, undefined, feature, 'manual' ); await device.connect(); return device; } /** * Add an SNMP device manually */ public async addSnmpDevice( address: string, port: number = 161, options?: { name?: string; community?: string } ): Promise { const feature = new SnmpFeature( makeDeviceRef(address, port, options?.name || `SNMP Device at ${address}`), port, { community: options?.community ?? 'public' } ); const { device } = this.registerDevice( address, port, options?.name, undefined, undefined, feature, 'manual' ); await device.connect(); return device; } /** * Add a UPS device manually */ public async addUpsDevice( address: string, protocol: 'nut' | 'snmp', options?: { name?: string; port?: number; upsName?: string; community?: string } ): Promise { const port = options?.port ?? (protocol === 'nut' ? 3493 : 161); const feature = new PowerFeature( makeDeviceRef(address, port, options?.name || `UPS at ${address}`), port, { protocol, upsName: options?.upsName, community: options?.community, } ); const { device } = this.registerDevice( address, port, options?.name, undefined, undefined, feature, 'manual' ); await device.connect(); return device; } /** * Add a speaker manually */ public async addSpeaker( address: string, protocol: 'sonos' | 'airplay' | 'chromecast' | 'dlna', options?: { name?: string; port?: number } ): Promise { const defaultPort = protocol === 'sonos' ? 1400 : protocol === 'airplay' ? 7000 : protocol === 'chromecast' ? 8009 : 80; const port = options?.port ?? defaultPort; const deviceName = options?.name || `${protocol} at ${address}`; const playbackFeature = new PlaybackFeature( makeDeviceRef(address, port, deviceName), port, { protocol, supportsQueue: protocol === 'sonos', supportsSeek: protocol !== 'airplay', } ); const volumeFeature = new VolumeFeature( makeDeviceRef(address, port, deviceName), port, { volumeProtocol: protocol, minVolume: 0, maxVolume: 100, supportsMute: true, } ); // Use protocol as name source for speakers const speakerNameSource: TNameSource = protocol === 'sonos' ? 'sonos' : protocol === 'airplay' ? 'airplay' : protocol === 'chromecast' ? 'chromecast' : 'dlna'; const { device } = this.registerDevice( address, port, options?.name, undefined, undefined, playbackFeature, speakerNameSource ); if (!device.hasFeature('volume')) { device.addFeature(volumeFeature); } await device.connect(); return device; } // ============================================================================ // Feature Access // ============================================================================ /** * Get a feature from a device */ public getFeature(deviceId: string, featureType: TFeatureType): T | undefined { const device = this.getDevice(deviceId); return device?.getFeature(featureType); } /** * Add a feature to a device */ public addFeature(deviceId: string, feature: Feature): boolean { const device = this.getDevice(deviceId); if (device) { device.addFeature(feature); this.emit('feature:added', { device, feature }); return true; } return false; } /** * Remove a feature from a device */ public async removeFeature(deviceId: string, featureType: TFeatureType): Promise { const device = this.getDevice(deviceId); if (device) { const removed = await device.removeFeature(featureType); if (removed) { this.emit('feature:removed', { device, featureType }); } return removed; } return false; } // ============================================================================ // Network Scanning // ============================================================================ public get networkScanner(): NetworkScanner { if (!this._networkScanner) { this._networkScanner = new NetworkScanner(); this.setupNetworkScannerEvents(); } return this._networkScanner; } private setupNetworkScannerEvents(): void { if (!this._networkScanner) return; this._networkScanner.on('device:found', (result: INetworkScanResult) => { this.emit('network:device:found', result); }); this._networkScanner.on('progress', (progress) => { this.emit('network:progress', progress); }); this._networkScanner.on('complete', (results) => { this.emit('network:complete', results); }); this._networkScanner.on('error', (error) => { this.emit('error', error); }); } public async scanNetwork(options: INetworkScanOptions): Promise { const results = await this.networkScanner.scan(options); const foundDevices: UniversalDevice[] = []; const seenAddresses = new Set(); for (const result of results) { // Skip if we already processed this IP in this scan if (seenAddresses.has(result.address)) continue; seenAddresses.add(result.address); for (const deviceInfo of result.devices) { try { let device: UniversalDevice | null = null; if (deviceInfo.type === 'scanner') { device = await this.addScanner( result.address, deviceInfo.port, deviceInfo.protocol as 'escl' | 'sane', deviceInfo.name ); } else if (deviceInfo.type === 'printer' && deviceInfo.protocol === 'ipp') { device = await this.addPrinter(result.address, deviceInfo.port, deviceInfo.name); } else if (deviceInfo.type === 'speaker') { device = await this.addSpeaker( result.address, deviceInfo.protocol as 'sonos' | 'airplay' | 'chromecast' | 'dlna', { name: deviceInfo.name, port: deviceInfo.port } ); } if (device && !foundDevices.includes(device)) { foundDevices.push(device); } } catch (error) { this.emit('error', error instanceof Error ? error : new Error(String(error))); } } } return foundDevices; } public async cancelNetworkScan(): Promise { if (this._networkScanner) { await this._networkScanner.cancel(); } } public get isNetworkScanning(): boolean { return this._networkScanner?.isScanning ?? false; } // ============================================================================ // Device Removal and Cleanup // ============================================================================ /** * Remove a device by ID */ public async removeDevice(id: string): Promise { const device = this.getDevice(id); if (device) { await device.disconnect(); this.devicesByIp.delete(device.address); this.nameSourceByIp.delete(device.address); this.emit('device:removed', id); return true; } return false; } /** * Remove a device by address */ public async removeDeviceByAddress(address: string): Promise { const device = this.devicesByIp.get(address); if (device) { await device.disconnect(); this.devicesByIp.delete(address); this.nameSourceByIp.delete(address); this.emit('device:removed', device.id); return true; } return false; } /** * Disconnect all devices */ public async disconnectAll(): Promise { const promises: Promise[] = []; for (const device of this.devicesByIp.values()) { promises.push(device.disconnect().catch(() => {})); } await Promise.all(promises); } /** * Stop discovery and disconnect all devices */ public async shutdown(): Promise { await this.stopDiscovery(); await this.disconnectAll(); this.devicesByIp.clear(); this.nameSourceByIp.clear(); } } // ============================================================================ // Exports // ============================================================================ export { // Discovery MdnsDiscovery, NetworkScanner, SsdpDiscovery, SERVICE_TYPES, SSDP_SERVICE_TYPES, // Universal Device UniversalDevice, // Features ScanFeature, PrintFeature, PlaybackFeature, VolumeFeature, PowerFeature, SnmpFeature, // Factories createScanner, createPrinter, createSnmpDevice, createUpsDevice, createSpeaker, createDlnaRenderer, };