Add native hub protocol integrations

This commit is contained in:
2026-05-05 14:57:06 +00:00
parent 2823a1c718
commit 1eebd71e7d
102 changed files with 16316 additions and 330 deletions
+314
View File
@@ -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());
};