209 lines
7.7 KiB
TypeScript
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());
|
|
};
|