import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; import type { INanoleafManualEntry, INanoleafMdnsRecord, INanoleafSsdpRecord } from './nanoleaf.types.js'; const DEFAULT_NANOLEAF_PORT = 16021; const NANOLEAF_MDNS_TYPES = new Set(['_nanoleafapi._tcp.local.', '_nanoleafms._tcp.local.']); const NANOLEAF_SSDP_TYPES = new Set([ 'nanoleaf_aurora:light', 'nanoleaf:nl29', 'nanoleaf:nl42', 'nanoleaf:nl52', 'nanoleaf:nl69', 'inanoleaf:nl81', ]); export class NanoleafMdnsMatcher implements IDiscoveryMatcher { public id = 'nanoleaf-mdns-match'; public source = 'mdns' as const; public description = 'Recognize Nanoleaf zeroconf records for _nanoleafapi._tcp and _nanoleafms._tcp.'; public async matches(recordArg: INanoleafMdnsRecord): Promise { const type = recordArg.type?.toLowerCase() || ''; const name = recordArg.name?.toLowerCase() || ''; const matched = NANOLEAF_MDNS_TYPES.has(type) || name.includes('nanoleaf') && (type.includes('nanoleaf') || type === '_http._tcp.local.'); if (!matched) { return { matched: false, confidence: 'low', reason: 'mDNS record is not a Nanoleaf advertisement.', }; } const deviceId = this.txtValue(recordArg.txt, 'id', 'deviceid', 'nl-deviceid') || recordArg.name; return { matched: true, confidence: deviceId ? 'certain' : 'high', reason: 'mDNS record matches Nanoleaf zeroconf metadata.', normalizedDeviceId: deviceId, candidate: { source: 'mdns', integrationDomain: 'nanoleaf', id: deviceId, host: recordArg.host, port: recordArg.port || DEFAULT_NANOLEAF_PORT, name: recordArg.name, manufacturer: 'Nanoleaf', model: this.txtValue(recordArg.txt, 'model', 'modelid', 'md'), metadata: { mdnsName: recordArg.name, mdnsType: recordArg.type, txt: recordArg.txt, }, }, }; } private txtValue(txtArg: Record | undefined, ...keysArg: string[]): string | undefined { if (!txtArg) { return undefined; } const wanted = new Set(keysArg.map((keyArg) => keyArg.toLowerCase())); for (const [key, value] of Object.entries(txtArg)) { if (value !== undefined && wanted.has(key.toLowerCase())) { return value; } } return undefined; } } export class NanoleafSsdpMatcher implements IDiscoveryMatcher { public id = 'nanoleaf-ssdp-match'; public source = 'ssdp' as const; public description = 'Recognize Nanoleaf SSDP responses by ST and nl-* headers.'; public async matches(recordArg: INanoleafSsdpRecord): Promise { const st = (recordArg.st || this.header(recordArg.headers, 'st') || '').toLowerCase(); const matched = NANOLEAF_SSDP_TYPES.has(st) || st.includes('nanoleaf') || Boolean(this.header(recordArg.headers, 'nl-deviceid')); if (!matched) { return { matched: false, confidence: 'low', reason: 'SSDP response is not a Nanoleaf advertisement.', }; } const hostPort = this.parseHostPort(this.header(recordArg.headers, '_host') || recordArg.location); const deviceId = this.header(recordArg.headers, 'nl-deviceid') || recordArg.usn; return { matched: true, confidence: deviceId ? 'certain' : 'high', reason: 'SSDP response matches Nanoleaf metadata.', normalizedDeviceId: deviceId, candidate: { source: 'ssdp', integrationDomain: 'nanoleaf', id: deviceId, host: hostPort.host, port: hostPort.port || DEFAULT_NANOLEAF_PORT, name: this.header(recordArg.headers, 'nl-devicename'), manufacturer: 'Nanoleaf', model: st.startsWith('nanoleaf:') || st.startsWith('inanoleaf:') ? st.split(':')[1]?.toUpperCase() : undefined, metadata: { st: recordArg.st, usn: recordArg.usn, location: recordArg.location, headers: recordArg.headers, }, }, }; } private header(headersArg: Record | undefined, keyArg: string): string | undefined { if (!headersArg) { return undefined; } const wanted = keyArg.toLowerCase(); for (const [key, value] of Object.entries(headersArg)) { if (key.toLowerCase() === wanted) { return value; } } return undefined; } private parseHostPort(valueArg: string | undefined): { host?: string; port?: number } { if (!valueArg) { return {}; } try { const url = new URL(valueArg.includes('://') ? valueArg : `http://${valueArg}`); return { host: url.hostname, port: url.port ? Number(url.port) : undefined, }; } catch { const [host, port] = valueArg.split(':'); return { host, port: port ? Number(port) : undefined }; } } } export class NanoleafManualMatcher implements IDiscoveryMatcher { public id = 'nanoleaf-manual-match'; public source = 'manual' as const; public description = 'Recognize manual Nanoleaf setup entries by host or Nanoleaf metadata.'; public async matches(inputArg: INanoleafManualEntry): Promise { const manufacturer = inputArg.manufacturer?.toLowerCase() || ''; const model = inputArg.model?.toLowerCase() || ''; const name = inputArg.name?.toLowerCase() || ''; const matched = Boolean(inputArg.host || manufacturer.includes('nanoleaf') || model.startsWith('nl') || model.includes('nanoleaf') || name.includes('nanoleaf') || inputArg.metadata?.nanoleaf); if (!matched) { return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Nanoleaf setup hints.', }; } return { matched: true, confidence: inputArg.host ? 'high' : 'medium', reason: 'Manual entry can start Nanoleaf setup.', normalizedDeviceId: inputArg.id, candidate: { source: 'manual', integrationDomain: 'nanoleaf', id: inputArg.id, host: inputArg.host, port: inputArg.port || DEFAULT_NANOLEAF_PORT, name: inputArg.name, manufacturer: 'Nanoleaf', model: inputArg.model, metadata: inputArg.metadata, }, }; } } export class NanoleafCandidateValidator implements IDiscoveryValidator { public id = 'nanoleaf-candidate-validator'; public description = 'Validate candidate metadata before starting Nanoleaf setup.'; public async validate(candidateArg: IDiscoveryCandidate): Promise { const manufacturer = candidateArg.manufacturer?.toLowerCase() || ''; const model = candidateArg.model?.toLowerCase() || ''; const name = candidateArg.name?.toLowerCase() || ''; const matched = candidateArg.integrationDomain === 'nanoleaf' || manufacturer.includes('nanoleaf') || model.startsWith('nl') || model.includes('nanoleaf') || name.includes('nanoleaf'); return { matched, confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', reason: matched ? 'Candidate has Nanoleaf metadata.' : 'Candidate is not Nanoleaf.', candidate: matched ? candidateArg : undefined, normalizedDeviceId: candidateArg.id, }; } } export const createNanoleafDiscoveryDescriptor = (): DiscoveryDescriptor => { return new DiscoveryDescriptor({ integrationDomain: 'nanoleaf', displayName: 'Nanoleaf', }) .addMatcher(new NanoleafMdnsMatcher()) .addMatcher(new NanoleafSsdpMatcher()) .addMatcher(new NanoleafManualMatcher()) .addValidator(new NanoleafCandidateValidator()); };