315 lines
13 KiB
TypeScript
315 lines
13 KiB
TypeScript
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
|
import type {
|
|
IDiscoveryCandidate,
|
|
IDiscoveryContext,
|
|
IDiscoveryMatch,
|
|
IDiscoveryMatcher,
|
|
IDiscoveryProbe,
|
|
IDiscoveryProbeResult,
|
|
IDiscoveryValidator,
|
|
} from '../../core/types.js';
|
|
import type { IWizManualEntry, IWizMdnsRecord, IWizUdpDiscoveryRecord, IWizUdpResponse } from './wiz.types.js';
|
|
import { wizDefaultPort } from './wiz.types.js';
|
|
|
|
const wizMacPrefixes = ['a8bb50', 'd8a011', '444f8e', '6c2990'];
|
|
const wizUdpMethods = new Set(['getPilot', 'setPilot', 'syncPilot', 'syncSystemConfig', 'getSystemConfig', 'registration']);
|
|
|
|
export class WizUdpDiscoveryProbe implements IDiscoveryProbe {
|
|
public id = 'wiz-udp-discovery-probe';
|
|
public source = 'custom' as const;
|
|
public description = 'Discover WiZ devices by UDP registration broadcast on port 38899.';
|
|
|
|
public async probe(contextArg: IDiscoveryContext): Promise<IDiscoveryProbeResult> {
|
|
if (contextArg.abortSignal?.aborted) {
|
|
return { candidates: [] };
|
|
}
|
|
return { candidates: await this.discover(1200) };
|
|
}
|
|
|
|
private async discover(timeoutMsArg: number): Promise<IDiscoveryCandidate[]> {
|
|
const { createSocket } = await import('node:dgram');
|
|
const message = Buffer.from(JSON.stringify({
|
|
method: 'registration',
|
|
params: {
|
|
phoneMac: 'AAAAAAAAAAAA',
|
|
register: false,
|
|
phoneIp: '1.2.3.4',
|
|
id: '1',
|
|
},
|
|
}));
|
|
const matcher = new WizUdpMatcher();
|
|
const candidates: IDiscoveryCandidate[] = [];
|
|
|
|
return new Promise((resolve) => {
|
|
const socket = createSocket({ type: 'udp4', reuseAddr: true });
|
|
const timer = setTimeout(() => {
|
|
try {
|
|
socket.close();
|
|
} catch {
|
|
// The discovery socket may already be closed after an OS error.
|
|
}
|
|
resolve(candidates);
|
|
}, timeoutMsArg);
|
|
|
|
socket.on('message', async (dataArg, remoteArg) => {
|
|
let response: IWizUdpResponse<Record<string, unknown>> | undefined;
|
|
try {
|
|
response = JSON.parse(dataArg.toString('utf8')) as IWizUdpResponse<Record<string, unknown>>;
|
|
} catch {
|
|
return;
|
|
}
|
|
const match = await matcher.matches({ host: remoteArg.address, port: remoteArg.port, response });
|
|
if (match.matched && match.candidate && !candidates.some((candidateArg) => candidateArg.id === match.candidate?.id || candidateArg.host === match.candidate?.host)) {
|
|
candidates.push(match.candidate);
|
|
}
|
|
});
|
|
socket.on('error', () => {
|
|
clearTimeout(timer);
|
|
try {
|
|
socket.close();
|
|
} catch {
|
|
// Ignore discovery socket close races.
|
|
}
|
|
resolve(candidates);
|
|
});
|
|
socket.bind(() => {
|
|
socket.setBroadcast(true);
|
|
socket.send(message, wizDefaultPort, '255.255.255.255');
|
|
setTimeout(() => socket.send(message, wizDefaultPort, '255.255.255.255'), 500);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
export class WizMdnsMatcher implements IDiscoveryMatcher<IWizMdnsRecord> {
|
|
public id = 'wiz-mdns-match';
|
|
public source = 'mdns' as const;
|
|
public description = 'Recognize WiZ mDNS or hostname advertisements.';
|
|
|
|
public async matches(recordArg: IWizMdnsRecord): Promise<IDiscoveryMatch> {
|
|
const txt = recordArg.txt || recordArg.properties || {};
|
|
const type = this.normalizeType(recordArg.type);
|
|
const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0];
|
|
const mac = this.normalizeMac(this.txt(txt, 'mac') || this.txt(txt, 'macaddress') || this.txt(txt, 'mac_address'));
|
|
const name = this.name(recordArg, txt);
|
|
const text = [type, recordArg.name, recordArg.hostname, host, name, this.txt(txt, 'manufacturer'), this.txt(txt, 'model')]
|
|
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
|
|
.join(' ')
|
|
.toLowerCase();
|
|
const matched = Boolean(mac && this.isWizMac(mac)) || /(^|[._-])wiz([._-]|$)/i.test(text) || text.includes('wizconnected');
|
|
if (!matched) {
|
|
return { matched: false, confidence: 'low', reason: 'mDNS record is not a WiZ advertisement.' };
|
|
}
|
|
return {
|
|
matched: true,
|
|
confidence: mac ? 'certain' : host ? 'high' : 'medium',
|
|
reason: mac ? 'mDNS record contains a WiZ MAC address.' : 'mDNS record contains WiZ hostname or TXT metadata.',
|
|
normalizedDeviceId: mac || recordArg.name || host,
|
|
candidate: {
|
|
source: 'mdns',
|
|
integrationDomain: 'wiz',
|
|
id: mac || recordArg.name || host,
|
|
host,
|
|
port: recordArg.port || wizDefaultPort,
|
|
name: name || 'WiZ',
|
|
manufacturer: 'WiZ',
|
|
model: this.txt(txt, 'model') || this.txt(txt, 'moduleName') || 'WiZ device',
|
|
macAddress: mac,
|
|
metadata: {
|
|
mdnsName: recordArg.name,
|
|
mdnsType: recordArg.type,
|
|
txt,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
private txt(txtArg: Record<string, string | undefined>, keyArg: string): string | undefined {
|
|
return txtArg[keyArg] || txtArg[keyArg.toUpperCase()];
|
|
}
|
|
|
|
private name(recordArg: IWizMdnsRecord, txtArg: Record<string, string | undefined>): string | undefined {
|
|
return this.txt(txtArg, 'name') || this.txt(txtArg, 'friendly_name') || recordArg.name?.split('._', 1)[0] || recordArg.hostname?.replace(/\.local\.?$/i, '');
|
|
}
|
|
|
|
private normalizeType(valueArg?: string): string {
|
|
return (valueArg || '').toLowerCase().replace(/\.$/, '');
|
|
}
|
|
|
|
private normalizeMac(valueArg?: string): string | undefined {
|
|
if (!valueArg) {
|
|
return undefined;
|
|
}
|
|
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
|
return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase();
|
|
}
|
|
|
|
private isWizMac(valueArg: string): boolean {
|
|
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
|
return wizMacPrefixes.some((prefixArg) => compact.startsWith(prefixArg));
|
|
}
|
|
}
|
|
|
|
export class WizUdpMatcher implements IDiscoveryMatcher<IWizUdpDiscoveryRecord> {
|
|
public id = 'wiz-udp-match';
|
|
public source = 'custom' as const;
|
|
public description = 'Recognize WiZ UDP JSON discovery responses.';
|
|
|
|
public async matches(recordArg: IWizUdpDiscoveryRecord): Promise<IDiscoveryMatch> {
|
|
const response = recordArg.response;
|
|
const result = response?.result || recordArg.result || {};
|
|
const method = response?.method || recordArg.method;
|
|
const host = recordArg.host || recordArg.address || recordArg.ip || recordArg.ip_address;
|
|
const mac = this.normalizeMac(recordArg.mac || recordArg.mac_address || this.stringValue(result.mac));
|
|
const matched = Boolean(mac) || Boolean(method && wizUdpMethods.has(method)) || recordArg.metadata?.wiz === true;
|
|
if (!matched) {
|
|
return { matched: false, confidence: 'low', reason: 'UDP record is not a WiZ JSON response.' };
|
|
}
|
|
return {
|
|
matched: true,
|
|
confidence: mac && host ? 'certain' : mac || host ? 'high' : 'medium',
|
|
reason: mac ? 'UDP response contains a WiZ MAC address.' : 'UDP response method matches WiZ JSON protocol.',
|
|
normalizedDeviceId: mac || recordArg.name || host,
|
|
candidate: {
|
|
source: 'custom',
|
|
integrationDomain: 'wiz',
|
|
id: mac || recordArg.name || host,
|
|
host,
|
|
port: recordArg.port || wizDefaultPort,
|
|
name: recordArg.name || (mac ? `WiZ ${this.shortMac(mac)}` : 'WiZ'),
|
|
manufacturer: 'WiZ',
|
|
model: 'WiZ UDP JSON device',
|
|
macAddress: mac,
|
|
metadata: {
|
|
...recordArg.metadata,
|
|
discoveryProtocol: 'udp',
|
|
method,
|
|
result,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
private stringValue(valueArg: unknown): string | undefined {
|
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
|
}
|
|
|
|
private shortMac(valueArg?: string): string {
|
|
return (valueArg || '').replace(/[^a-fA-F0-9]/g, '').slice(-6).toUpperCase();
|
|
}
|
|
|
|
private normalizeMac(valueArg?: string): string | undefined {
|
|
if (!valueArg) {
|
|
return undefined;
|
|
}
|
|
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
|
return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase();
|
|
}
|
|
}
|
|
|
|
export class WizManualMatcher implements IDiscoveryMatcher<IWizManualEntry> {
|
|
public id = 'wiz-manual-match';
|
|
public source = 'manual' as const;
|
|
public description = 'Recognize manual WiZ setup entries.';
|
|
|
|
public async matches(inputArg: IWizManualEntry): Promise<IDiscoveryMatch> {
|
|
const mac = this.normalizeMac(inputArg.mac || inputArg.macAddress || inputArg.deviceInfo?.mac);
|
|
const text = [inputArg.name, inputArg.manufacturer, inputArg.model, inputArg.deviceInfo?.manufacturer, inputArg.deviceInfo?.model, inputArg.deviceInfo?.moduleName]
|
|
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
|
|
.join(' ')
|
|
.toLowerCase();
|
|
const matched = Boolean(inputArg.host || mac || inputArg.metadata?.wiz || text.includes('wiz'));
|
|
if (!matched) {
|
|
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain WiZ setup hints.' };
|
|
}
|
|
return {
|
|
matched: true,
|
|
confidence: inputArg.host && mac ? 'certain' : inputArg.host || mac ? 'high' : 'medium',
|
|
reason: 'Manual entry can start WiZ setup.',
|
|
normalizedDeviceId: mac || inputArg.id || inputArg.host,
|
|
candidate: {
|
|
source: 'manual',
|
|
integrationDomain: 'wiz',
|
|
id: inputArg.id || mac || inputArg.host,
|
|
host: inputArg.host,
|
|
port: inputArg.port || wizDefaultPort,
|
|
name: inputArg.name || inputArg.deviceInfo?.name || (mac ? `WiZ ${this.shortMac(mac)}` : 'WiZ'),
|
|
manufacturer: inputArg.manufacturer || inputArg.deviceInfo?.manufacturer || 'WiZ',
|
|
model: inputArg.model || inputArg.deviceInfo?.model || inputArg.deviceInfo?.moduleName || 'WiZ device',
|
|
macAddress: mac,
|
|
metadata: {
|
|
...inputArg.metadata,
|
|
discoveryProtocol: 'manual',
|
|
deviceInfo: inputArg.deviceInfo,
|
|
pilot: inputArg.pilot,
|
|
snapshot: inputArg.snapshot,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
private shortMac(valueArg?: string): string {
|
|
return (valueArg || '').replace(/[^a-fA-F0-9]/g, '').slice(-6).toUpperCase();
|
|
}
|
|
|
|
private normalizeMac(valueArg?: string): string | undefined {
|
|
if (!valueArg) {
|
|
return undefined;
|
|
}
|
|
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
|
return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase();
|
|
}
|
|
}
|
|
|
|
export class WizCandidateValidator implements IDiscoveryValidator {
|
|
public id = 'wiz-candidate-validator';
|
|
public description = 'Validate WiZ candidates from mDNS, UDP, DHCP, and manual setup.';
|
|
|
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
|
const metadata = candidateArg.metadata || {};
|
|
const mac = this.normalizeMac(candidateArg.macAddress);
|
|
const compactMac = (mac || '').replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
|
const name = (candidateArg.name || '').toLowerCase();
|
|
const model = (candidateArg.model || '').toLowerCase();
|
|
const manufacturer = (candidateArg.manufacturer || '').toLowerCase();
|
|
const mdnsType = typeof metadata.mdnsType === 'string' ? metadata.mdnsType.toLowerCase() : '';
|
|
const discoveryProtocol = typeof metadata.discoveryProtocol === 'string' ? metadata.discoveryProtocol : undefined;
|
|
const macMatched = wizMacPrefixes.some((prefixArg) => compactMac.startsWith(prefixArg));
|
|
const hostMatched = Boolean(candidateArg.host && /^wiz[_-]/i.test(candidateArg.host));
|
|
const textMatched = name.includes('wiz') || model.includes('wiz') || manufacturer.includes('wiz') || mdnsType.includes('wiz');
|
|
const matched = candidateArg.integrationDomain === 'wiz'
|
|
|| macMatched
|
|
|| hostMatched
|
|
|| textMatched
|
|
|| candidateArg.port === wizDefaultPort
|
|
|| metadata.wiz === true
|
|
|| discoveryProtocol === 'udp'
|
|
|| discoveryProtocol === 'manual';
|
|
return {
|
|
matched,
|
|
confidence: matched && (candidateArg.integrationDomain === 'wiz' || macMatched) && candidateArg.host ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
|
reason: matched ? 'Candidate has WiZ metadata or local UDP port information.' : 'Candidate is not WiZ.',
|
|
candidate: matched ? candidateArg : undefined,
|
|
normalizedDeviceId: mac || candidateArg.id || candidateArg.host,
|
|
metadata: matched ? { macMatched, hostMatched, discoveryProtocol } : undefined,
|
|
};
|
|
}
|
|
|
|
private normalizeMac(valueArg?: string): string | undefined {
|
|
if (!valueArg) {
|
|
return undefined;
|
|
}
|
|
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
|
return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase();
|
|
}
|
|
}
|
|
|
|
export const createWizDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
|
return new DiscoveryDescriptor({ integrationDomain: 'wiz', displayName: 'WiZ' })
|
|
.addProbe(new WizUdpDiscoveryProbe())
|
|
.addMatcher(new WizMdnsMatcher())
|
|
.addMatcher(new WizUdpMatcher())
|
|
.addMatcher(new WizManualMatcher())
|
|
.addValidator(new WizCandidateValidator());
|
|
};
|