Files
integrations/ts/integrations/nanoleaf/nanoleaf.discovery.ts
T

209 lines
7.7 KiB
TypeScript

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<INanoleafMdnsRecord> {
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<IDiscoveryMatch> {
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<string, string | undefined> | 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<INanoleafSsdpRecord> {
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<IDiscoveryMatch> {
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<string, string | undefined> | 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<INanoleafManualEntry> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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());
};