Add native hub protocol integrations
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
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());
|
||||
};
|
||||
Reference in New Issue
Block a user