import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; import type { ISqueezeboxDhcpRecord, ISqueezeboxManualEntry, ISqueezeboxMdnsRecord } from './squeezebox.types.js'; import { squeezeboxDefaultHttpPort } from './squeezebox.types.js'; const squeezeboxDomain = 'squeezebox'; const lmsNames = ['squeezebox', 'lyrion', 'logitech media server', 'lms', 'slimserver']; const lmsMdnsTypes = new Set([ '_squeezebox._tcp', '_squeezebox-jsonrpc._tcp', '_squeezebox-server._tcp', '_lms._tcp', '_slimserver._tcp', ]); export class SqueezeboxMdnsMatcher implements IDiscoveryMatcher { public id = 'squeezebox-mdns-match'; public source = 'mdns' as const; public description = 'Recognize local Lyrion/Logitech Media Server mDNS advertisements.'; public async matches(recordArg: ISqueezeboxMdnsRecord): Promise { const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || ''); const properties = { ...recordArg.txt, ...recordArg.properties }; const name = cleanName(recordArg.name || recordArg.hostname || valueForKey(properties, 'name')) || 'Lyrion Music Server'; const haystack = `${name} ${type} ${valueForKey(properties, 'model') || ''} ${valueForKey(properties, 'server') || ''}`.toLowerCase(); const serviceMatch = lmsMdnsTypes.has(type); const nameMatch = lmsNames.some((needleArg) => haystack.includes(needleArg)); if (!serviceMatch && !nameMatch) { return { matched: false, confidence: 'low', reason: 'mDNS record is not an LMS/Squeezebox service.' }; } const host = recordArg.host || recordArg.addresses?.[0]; const port = recordArg.port || numberString(valueForKey(properties, 'port')) || squeezeboxDefaultHttpPort; const id = valueForKey(properties, 'uuid') || valueForKey(properties, 'id') || valueForKey(properties, 'mac') || (host ? `${host}:${port}` : name); return { matched: true, confidence: serviceMatch && host ? 'certain' : serviceMatch ? 'high' : 'medium', reason: serviceMatch ? `mDNS service ${type} is an LMS service.` : 'mDNS metadata contains LMS/Squeezebox hints.', normalizedDeviceId: id, candidate: { source: 'mdns', integrationDomain: squeezeboxDomain, id, host, port, name, manufacturer: 'Lyrion', model: 'Lyrion Music Server', metadata: { mdnsType: type, txt: properties, uuid: valueForKey(properties, 'uuid'), }, }, metadata: { mdnsType: type }, }; } } export class SqueezeboxDhcpMatcher implements IDiscoveryMatcher { public id = 'squeezebox-dhcp-match'; public source = 'dhcp' as const; public description = 'Recognize Squeezebox player DHCP hints that can start LMS setup.'; public async matches(recordArg: ISqueezeboxDhcpRecord): Promise { const hostname = recordArg.hostname || recordArg.name || ''; const mac = recordArg.macaddress || recordArg.macAddress || ''; const matched = hostname.toLowerCase().startsWith('squeezebox') || normalizeMac(mac).startsWith('000420'); if (!matched) { return { matched: false, confidence: 'low', reason: 'DHCP record is not a known Squeezebox player hint.' }; } const host = recordArg.host || recordArg.ipAddress; const id = normalizeMac(mac) || host || hostname; return { matched: true, confidence: normalizeMac(mac).startsWith('000420') ? 'high' : 'medium', reason: 'DHCP record matches the Home Assistant Squeezebox player discovery hints.', normalizedDeviceId: id, candidate: { source: 'dhcp', integrationDomain: squeezeboxDomain, id, host, port: squeezeboxDefaultHttpPort, name: hostname || 'Squeezebox player', manufacturer: 'Logitech', model: 'Squeezebox Player', macAddress: mac || undefined, metadata: { ...recordArg.metadata, playerDiscovery: true, }, }, }; } } export class SqueezeboxManualMatcher implements IDiscoveryMatcher { public id = 'squeezebox-manual-match'; public source = 'manual' as const; public description = 'Recognize manual Lyrion Music Server setup entries.'; public async matches(inputArg: ISqueezeboxManualEntry): Promise { const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase(); const matched = Boolean(inputArg.host || inputArg.metadata?.squeezebox || inputArg.metadata?.lms || lmsNames.some((needleArg) => haystack.includes(needleArg))); if (!matched) { return { matched: false, confidence: 'low', reason: 'Manual entry does not contain LMS/Squeezebox setup hints.' }; } const port = inputArg.port || squeezeboxDefaultHttpPort; const id = inputArg.id || (inputArg.host ? `${inputArg.host}:${port}` : undefined); return { matched: true, confidence: inputArg.host ? 'high' : 'medium', reason: 'Manual entry can start Lyrion Music Server setup.', normalizedDeviceId: id, candidate: { source: 'manual', integrationDomain: squeezeboxDomain, id, host: inputArg.host, port, name: inputArg.name, manufacturer: inputArg.manufacturer || 'Lyrion', model: inputArg.model || 'Lyrion Music Server', metadata: { ...inputArg.metadata, cliPort: inputArg.cliPort, https: inputArg.https, username: inputArg.username ? true : undefined, password: inputArg.password ? true : undefined, }, }, }; } } export class SqueezeboxCandidateValidator implements IDiscoveryValidator { public id = 'squeezebox-candidate-validator'; public description = 'Validate LMS/Squeezebox discovery candidates have local setup metadata.'; public async validate(candidateArg: IDiscoveryCandidate): Promise { const metadata = candidateArg.metadata || {}; const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''}`.toLowerCase(); const mdnsType = typeof metadata.mdnsType === 'string' ? normalizeMdnsType(metadata.mdnsType) : ''; const matched = candidateArg.integrationDomain === squeezeboxDomain || Boolean(metadata.squeezebox || metadata.lms || metadata.playerDiscovery) || lmsMdnsTypes.has(mdnsType) || lmsNames.some((needleArg) => haystack.includes(needleArg)); return { matched, confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', reason: matched ? 'Candidate has LMS/Squeezebox metadata.' : 'Candidate is not LMS/Squeezebox.', normalizedDeviceId: candidateArg.id || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || squeezeboxDefaultHttpPort}` : undefined), candidate: matched ? { ...candidateArg, port: candidateArg.port || squeezeboxDefaultHttpPort } : undefined, }; } } export const createSqueezeboxDiscoveryDescriptor = (): DiscoveryDescriptor => { return new DiscoveryDescriptor({ integrationDomain: squeezeboxDomain, displayName: 'Squeezebox (Lyrion Music Server)' }) .addMatcher(new SqueezeboxMdnsMatcher()) .addMatcher(new SqueezeboxDhcpMatcher()) .addMatcher(new SqueezeboxManualMatcher()) .addValidator(new SqueezeboxCandidateValidator()); }; const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.local\.?$/, '').replace(/\.$/, ''); const cleanName = (valueArg: string | undefined): string | undefined => { return valueArg ?.replace(/\._squeezebox(?:-jsonrpc|-server)?\._tcp\.local\.?$/i, '') .replace(/\._lms\._tcp\.local\.?$/i, '') .replace(/\._slimserver\._tcp\.local\.?$/i, '') .replace(/\.local\.?$/i, '') .trim() || undefined; }; const valueForKey = (recordArg: Record | undefined, keyArg: string): string | undefined => { if (!recordArg) { return undefined; } const lowerKey = keyArg.toLowerCase(); for (const [key, value] of Object.entries(recordArg)) { if (key.toLowerCase() === lowerKey) { return value; } } return undefined; }; const numberString = (valueArg: string | undefined): number | undefined => { if (!valueArg) { return undefined; } const value = Number(valueArg); return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined; }; const normalizeMac = (valueArg: string | undefined): string => (valueArg || '').toLowerCase().replace(/[^a-f0-9]/g, '');