import * as plugins from '../plugins.js'; import type { IDiscoveredDevice, IDiscoveryOptions, TDeviceType, TScannerProtocol, } from '../interfaces/index.js'; /** * Service type definitions for mDNS discovery */ const SERVICE_TYPES = { // Scanners ESCL: '_uscan._tcp', // eSCL/AirScan scanners ESCL_SECURE: '_uscans._tcp', // eSCL over TLS SANE: '_scanner._tcp', // SANE network scanners // Printers IPP: '_ipp._tcp', // IPP printers IPPS: '_ipps._tcp', // IPP over TLS PDL: '_pdl-datastream._tcp', // Raw/JetDirect printers // Speakers / Audio AIRPLAY: '_airplay._tcp', // AirPlay devices (Apple TV, HomePod, etc.) RAOP: '_raop._tcp', // Remote Audio Output Protocol (AirPlay audio) SONOS: '_sonos._tcp', // Sonos speakers GOOGLECAST: '_googlecast._tcp', // Chromecast / Google Cast devices SPOTIFY: '_spotify-connect._tcp', // Spotify Connect devices } as const; /** * Default discovery options */ const DEFAULT_OPTIONS: Required = { serviceTypes: [ // Scanners SERVICE_TYPES.ESCL, SERVICE_TYPES.ESCL_SECURE, SERVICE_TYPES.SANE, // Printers SERVICE_TYPES.IPP, SERVICE_TYPES.IPPS, // Speakers SERVICE_TYPES.AIRPLAY, SERVICE_TYPES.RAOP, SERVICE_TYPES.SONOS, SERVICE_TYPES.GOOGLECAST, ], timeout: 10000, }; /** * mDNS/Bonjour discovery service for network devices */ export class MdnsDiscovery extends plugins.events.EventEmitter { private bonjour: plugins.bonjourService.Bonjour | null = null; private browsers: plugins.bonjourService.Browser[] = []; private discoveredDevices: Map = new Map(); private options: Required; private isRunning: boolean = false; constructor(options?: IDiscoveryOptions) { super(); this.options = { ...DEFAULT_OPTIONS, ...options }; } /** * Check if discovery is currently running */ public get running(): boolean { return this.isRunning; } /** * Get all discovered devices */ public getDevices(): IDiscoveredDevice[] { return Array.from(this.discoveredDevices.values()); } /** * Get discovered scanners only */ public getScanners(): IDiscoveredDevice[] { return this.getDevices().filter((d) => d.type === 'scanner'); } /** * Get discovered printers only */ public getPrinters(): IDiscoveredDevice[] { return this.getDevices().filter((d) => d.type === 'printer'); } /** * Start mDNS discovery */ public async start(): Promise { if (this.isRunning) { return; } this.bonjour = new plugins.bonjourService.Bonjour(); this.isRunning = true; this.emit('started'); for (const serviceType of this.options.serviceTypes) { this.browseService(serviceType); } } /** * Stop mDNS discovery */ public async stop(): Promise { if (!this.isRunning) { return; } // Stop all browsers for (const browser of this.browsers) { browser.stop(); } this.browsers = []; // Destroy bonjour instance if (this.bonjour) { this.bonjour.destroy(); this.bonjour = null; } this.isRunning = false; this.emit('stopped'); } /** * Clear discovered devices */ public clear(): void { this.discoveredDevices.clear(); } /** * Browse for a specific service type */ private browseService(serviceType: string): void { if (!this.bonjour) { return; } const browser = this.bonjour.find({ type: serviceType }, (service) => { this.handleServiceFound(service, serviceType); }); browser.on('down', (service) => { this.handleServiceLost(service, serviceType); }); this.browsers.push(browser); } /** * Handle discovered service */ private handleServiceFound( service: plugins.bonjourService.Service, serviceType: string ): void { const device = this.parseService(service, serviceType); if (!device) { return; } const existingDevice = this.discoveredDevices.get(device.id); if (existingDevice) { // Update existing device this.discoveredDevices.set(device.id, device); this.emit('device:updated', device); } else { // New device found this.discoveredDevices.set(device.id, device); this.emit('device:found', device); if (device.type === 'scanner') { this.emit('scanner:found', device); } else if (device.type === 'printer') { this.emit('printer:found', device); } } } /** * Handle lost service */ private handleServiceLost( service: plugins.bonjourService.Service, serviceType: string ): void { const deviceId = this.generateDeviceId(service, serviceType); const device = this.discoveredDevices.get(deviceId); if (device) { this.discoveredDevices.delete(deviceId); this.emit('device:lost', device); if (device.type === 'scanner') { this.emit('scanner:lost', deviceId); } else if (device.type === 'printer') { this.emit('printer:lost', deviceId); } } } /** * Parse Bonjour service into device info */ private parseService( service: plugins.bonjourService.Service, serviceType: string ): IDiscoveredDevice | null { const addresses = service.addresses ?? []; // Prefer IPv4 address const address = addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host; if (!address) { return null; } const txtRecords = this.parseTxtRecords(service.txt); const deviceType = this.getDeviceType(serviceType); const protocol = this.getProtocol(serviceType); const deviceId = this.generateDeviceId(service, serviceType); return { id: deviceId, name: service.name || txtRecords['ty'] || txtRecords['product'] || 'Unknown Device', type: deviceType, protocol: protocol, address: address, port: service.port, txtRecords: txtRecords, serviceType: serviceType, }; } /** * Generate unique device ID */ private generateDeviceId( service: plugins.bonjourService.Service, serviceType: string ): string { const addresses = service.addresses ?? []; const address = addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host; return `${serviceType}:${address}:${service.port}`; } /** * Parse TXT records from service */ private parseTxtRecords( txt: Record | undefined ): Record { const records: Record = {}; if (!txt) { return records; } for (const [key, value] of Object.entries(txt)) { if (typeof value === 'string') { records[key] = value; } else if (Buffer.isBuffer(value)) { records[key] = value.toString('utf-8'); } else if (value !== undefined && value !== null) { records[key] = String(value); } } return records; } /** * Determine device type from service type */ private getDeviceType(serviceType: string): TDeviceType { switch (serviceType) { case SERVICE_TYPES.ESCL: case SERVICE_TYPES.ESCL_SECURE: case SERVICE_TYPES.SANE: return 'scanner'; case SERVICE_TYPES.IPP: case SERVICE_TYPES.IPPS: case SERVICE_TYPES.PDL: return 'printer'; case SERVICE_TYPES.AIRPLAY: case SERVICE_TYPES.RAOP: case SERVICE_TYPES.SONOS: case SERVICE_TYPES.GOOGLECAST: case SERVICE_TYPES.SPOTIFY: return 'speaker'; default: // Check if it's a scanner or printer based on service type pattern if (serviceType.includes('scan') || serviceType.includes('scanner')) { return 'scanner'; } if (serviceType.includes('airplay') || serviceType.includes('raop') || serviceType.includes('sonos') || serviceType.includes('cast') || serviceType.includes('spotify')) { return 'speaker'; } return 'printer'; } } /** * Determine protocol from service type */ private getProtocol(serviceType: string): string { switch (serviceType) { case SERVICE_TYPES.ESCL: case SERVICE_TYPES.ESCL_SECURE: return 'escl'; case SERVICE_TYPES.SANE: return 'sane'; case SERVICE_TYPES.IPP: case SERVICE_TYPES.IPPS: case SERVICE_TYPES.PDL: return 'ipp'; case SERVICE_TYPES.AIRPLAY: case SERVICE_TYPES.RAOP: return 'airplay'; case SERVICE_TYPES.SONOS: return 'sonos'; case SERVICE_TYPES.GOOGLECAST: return 'chromecast'; case SERVICE_TYPES.SPOTIFY: return 'spotify'; default: return 'unknown'; } } } export { SERVICE_TYPES };