import * as plugins from '../plugins.js'; /** * SSDP service types for device discovery */ export const SSDP_SERVICE_TYPES = { // Root device ROOT: 'upnp:rootdevice', // DLNA/UPnP AV MEDIA_RENDERER: 'urn:schemas-upnp-org:device:MediaRenderer:1', MEDIA_SERVER: 'urn:schemas-upnp-org:device:MediaServer:1', // Sonos SONOS_ZONE_PLAYER: 'urn:schemas-upnp-org:device:ZonePlayer:1', // Generic BASIC_DEVICE: 'urn:schemas-upnp-org:device:Basic:1', INTERNET_GATEWAY: 'urn:schemas-upnp-org:device:InternetGatewayDevice:1', }; /** * SSDP discovered device information */ export interface ISsdpDevice { /** Unique service name (USN) */ usn: string; /** Service type (ST) */ serviceType: string; /** Location URL of device description */ location: string; /** IP address */ address: string; /** Port number */ port: number; /** Device description (fetched from location) */ description?: ISsdpDeviceDescription; /** Raw headers from SSDP response */ headers: Record; } /** * UPnP device description (from XML) */ export interface ISsdpDeviceDescription { deviceType: string; friendlyName: string; manufacturer: string; manufacturerURL?: string; modelDescription?: string; modelName: string; modelNumber?: string; modelURL?: string; serialNumber?: string; UDN: string; services: ISsdpService[]; icons?: ISsdpIcon[]; } export interface ISsdpService { serviceType: string; serviceId: string; SCPDURL: string; controlURL: string; eventSubURL: string; } export interface ISsdpIcon { mimetype: string; width: number; height: number; depth: number; url: string; } /** * SSDP Discovery service using node-ssdp */ export class SsdpDiscovery extends plugins.events.EventEmitter { private client: InstanceType | null = null; private devices: Map = new Map(); private running = false; private searchInterval: NodeJS.Timeout | null = null; constructor() { super(); } /** * Start SSDP discovery */ public async start(serviceTypes?: string[]): Promise { if (this.running) { return; } this.running = true; this.client = new plugins.nodeSsdp.Client(); // Handle SSDP responses this.client.on('response', (headers: Record, statusCode: number, rinfo: { address: string; port: number }) => { this.handleSsdpResponse(headers, rinfo); }); // Search for devices const typesToSearch = serviceTypes ?? [ SSDP_SERVICE_TYPES.ROOT, SSDP_SERVICE_TYPES.MEDIA_RENDERER, SSDP_SERVICE_TYPES.MEDIA_SERVER, SSDP_SERVICE_TYPES.SONOS_ZONE_PLAYER, ]; // Initial search for (const st of typesToSearch) { this.client.search(st); } // Periodic re-search (every 30 seconds) this.searchInterval = setInterval(() => { if (this.client) { for (const st of typesToSearch) { this.client.search(st); } } }, 30000); this.emit('started'); } /** * Stop SSDP discovery */ public async stop(): Promise { if (!this.running) { return; } this.running = false; if (this.searchInterval) { clearInterval(this.searchInterval); this.searchInterval = null; } if (this.client) { this.client.stop(); this.client = null; } this.emit('stopped'); } /** * Check if discovery is running */ public get isRunning(): boolean { return this.running; } /** * Get all discovered devices */ public getDevices(): ISsdpDevice[] { return Array.from(this.devices.values()); } /** * Get devices by service type */ public getDevicesByType(serviceType: string): ISsdpDevice[] { return this.getDevices().filter((d) => d.serviceType === serviceType); } /** * Search for a specific service type */ public search(serviceType: string): void { if (this.client && this.running) { this.client.search(serviceType); } } /** * Handle SSDP response */ private handleSsdpResponse( headers: Record, rinfo: { address: string; port: number } ): void { const usn = headers['USN'] || headers['usn']; const location = headers['LOCATION'] || headers['location']; const st = headers['ST'] || headers['st']; if (!usn || !location) { return; } // Parse location URL let address = rinfo.address; let port = 80; try { const url = new URL(location); address = url.hostname; port = parseInt(url.port) || (url.protocol === 'https:' ? 443 : 80); } catch { // Keep rinfo address } const device: ISsdpDevice = { usn, serviceType: st || 'unknown', location, address, port, headers: { ...headers }, }; const isNew = !this.devices.has(usn); this.devices.set(usn, device); if (isNew) { // Fetch device description this.fetchDeviceDescription(device).then(() => { this.emit('device:found', device); }).catch(() => { // Still emit even without description this.emit('device:found', device); }); } else { this.emit('device:updated', device); } } /** * Fetch and parse device description XML */ private async fetchDeviceDescription(device: ISsdpDevice): Promise { try { const response = await fetch(device.location, { signal: AbortSignal.timeout(5000), }); if (!response.ok) { return; } const xml = await response.text(); device.description = this.parseDeviceDescription(xml); } catch { // Ignore fetch errors } } /** * Parse UPnP device description XML */ private parseDeviceDescription(xml: string): ISsdpDeviceDescription { const getTagContent = (tag: string, source: string = xml): string => { const regex = new RegExp(`<${tag}[^>]*>([^<]*)`, 'i'); const match = source.match(regex); return match?.[1]?.trim() ?? ''; }; const getTagBlock = (tag: string, source: string = xml): string => { const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)`, 'i'); const match = source.match(regex); return match?.[1] ?? ''; }; // Parse services const services: ISsdpService[] = []; const serviceListBlock = getTagBlock('serviceList'); const serviceMatches = serviceListBlock.match(/[\s\S]*?<\/service>/gi) || []; for (const serviceXml of serviceMatches) { services.push({ serviceType: getTagContent('serviceType', serviceXml), serviceId: getTagContent('serviceId', serviceXml), SCPDURL: getTagContent('SCPDURL', serviceXml), controlURL: getTagContent('controlURL', serviceXml), eventSubURL: getTagContent('eventSubURL', serviceXml), }); } // Parse icons const icons: ISsdpIcon[] = []; const iconListBlock = getTagBlock('iconList'); const iconMatches = iconListBlock.match(/[\s\S]*?<\/icon>/gi) || []; for (const iconXml of iconMatches) { icons.push({ mimetype: getTagContent('mimetype', iconXml), width: parseInt(getTagContent('width', iconXml)) || 0, height: parseInt(getTagContent('height', iconXml)) || 0, depth: parseInt(getTagContent('depth', iconXml)) || 0, url: getTagContent('url', iconXml), }); } return { deviceType: getTagContent('deviceType'), friendlyName: getTagContent('friendlyName'), manufacturer: getTagContent('manufacturer'), manufacturerURL: getTagContent('manufacturerURL') || undefined, modelDescription: getTagContent('modelDescription') || undefined, modelName: getTagContent('modelName'), modelNumber: getTagContent('modelNumber') || undefined, modelURL: getTagContent('modelURL') || undefined, serialNumber: getTagContent('serialNumber') || undefined, UDN: getTagContent('UDN'), services, icons: icons.length > 0 ? icons : undefined, }; } /** * Make a UPnP SOAP request */ public async soapRequest( controlUrl: string, serviceType: string, action: string, args: Record = {} ): Promise { // Build SOAP body let argsXml = ''; for (const [key, value] of Object.entries(args)) { argsXml += `<${key}>${value}`; } const soapBody = ` ${argsXml} `; const response = await fetch(controlUrl, { method: 'POST', headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPACTION': `"${serviceType}#${action}"`, }, body: soapBody, signal: AbortSignal.timeout(10000), }); if (!response.ok) { throw new Error(`SOAP request failed: ${response.status}`); } return response.text(); } }