Files
integrations/ts/integrations/broadlink/broadlink.discovery.ts
T

243 lines
11 KiB
TypeScript

import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import {
broadlinkDefaultPort,
broadlinkDefaultTimeoutSeconds,
broadlinkDomain,
broadlinkMacPrefixes,
broadlinkSupportedTypes,
} from './broadlink.constants.js';
import type {
IBroadlinkCandidateMetadata,
IBroadlinkDhcpRecord,
IBroadlinkLocalDiscoveryRecord,
IBroadlinkManualEntry,
} from './broadlink.types.js';
export class BroadlinkDhcpMatcher implements IDiscoveryMatcher<IBroadlinkDhcpRecord> {
public id = 'broadlink-dhcp-match';
public source = 'dhcp' as const;
public description = 'Recognize Broadlink DHCP leases using Home Assistant manifest MAC prefixes.';
public async matches(recordArg: IBroadlinkDhcpRecord): Promise<IDiscoveryMatch> {
const metadata = recordArg.metadata || {};
const host = recordArg.host || recordArg.ipAddress || recordArg.address || recordArg.ip;
const hostname = recordArg.hostname || recordArg.hostName;
const macAddress = normalizeMac(recordArg.macAddress || recordArg.mac || stringValue(metadata.macAddress));
const model = recordArg.model || stringValue(metadata.model) || stringValue(metadata.deviceType);
const text = [recordArg.integrationDomain, recordArg.manufacturer, model, hostname, metadata.brand, metadata.manufacturer]
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
.join(' ')
.toLowerCase();
const macMatched = isBroadlinkMac(macAddress);
const textMatched = text.includes('broadlink');
const matched = recordArg.integrationDomain === broadlinkDomain || metadata.broadlink === true || macMatched || textMatched;
if (!matched) {
return { matched: false, confidence: 'low', reason: 'DHCP record does not match Broadlink metadata.' };
}
const id = macAddress || stringValue(metadata.deviceId) || hostname || host;
return {
matched: true,
confidence: macMatched && host ? 'certain' : host ? 'high' : 'medium',
reason: macMatched ? 'DHCP MAC prefix matches Home Assistant Broadlink manifest rules.' : 'DHCP metadata identifies a Broadlink device.',
normalizedDeviceId: id,
candidate: {
source: 'dhcp',
integrationDomain: broadlinkDomain,
id,
host,
port: broadlinkDefaultPort,
name: hostname || model || 'Broadlink device',
manufacturer: recordArg.manufacturer || 'Broadlink',
model,
macAddress,
metadata: {
...metadata,
broadlink: true,
hostname,
macMatched,
discoveryProtocol: 'dhcp',
timeout: broadlinkDefaultTimeoutSeconds,
},
},
metadata: { macMatched, model },
};
}
}
export class BroadlinkLocalDiscoveryMatcher implements IDiscoveryMatcher<IBroadlinkLocalDiscoveryRecord> {
public id = 'broadlink-local-discovery-match';
public source = 'custom' as const;
public description = 'Recognize normalized Broadlink UDP hello responses supplied by a host discovery layer.';
public async matches(recordArg: IBroadlinkLocalDiscoveryRecord): Promise<IDiscoveryMatch> {
const metadata = recordArg.metadata || {};
const macAddress = normalizeMac(recordArg.macAddress || recordArg.mac || stringValue(metadata.macAddress));
const deviceType = recordArg.type || stringValue(metadata.deviceType);
const supportedType = isSupportedType(deviceType);
const matched = Boolean(recordArg.host && (metadata.broadlink === true || macAddress || supportedType || recordArg.devtype || recordArg.model));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Local discovery record is not a Broadlink hello response.' };
}
const id = macAddress || recordArg.host;
return {
matched: true,
confidence: recordArg.host && macAddress && supportedType ? 'certain' : recordArg.host ? 'high' : 'medium',
reason: supportedType ? 'Broadlink hello response contains a supported device type.' : 'Broadlink hello response contains local host or MAC data.',
normalizedDeviceId: id,
candidate: {
source: 'custom',
integrationDomain: broadlinkDomain,
id,
host: recordArg.host,
port: recordArg.port || broadlinkDefaultPort,
name: recordArg.name || recordArg.model || 'Broadlink device',
manufacturer: recordArg.manufacturer || 'Broadlink',
model: recordArg.model || deviceType,
macAddress,
metadata: {
...metadata,
broadlink: true,
discoveryProtocol: 'broadlink-hello',
deviceType,
devtype: recordArg.devtype,
isLocked: recordArg.isLocked ?? recordArg.locked,
timeout: broadlinkDefaultTimeoutSeconds,
} satisfies IBroadlinkCandidateMetadata,
},
};
}
}
export class BroadlinkManualMatcher implements IDiscoveryMatcher<IBroadlinkManualEntry> {
public id = 'broadlink-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Broadlink host, snapshot, learned-code, and custom-switch setup entries.';
public async matches(inputArg: IBroadlinkManualEntry): Promise<IDiscoveryMatch> {
const metadata = inputArg.metadata || {};
const macAddress = normalizeMac(inputArg.macAddress || inputArg.mac || stringValue(metadata.macAddress));
const deviceType = inputArg.type || stringValue(metadata.deviceType);
const text = [inputArg.integrationDomain, inputArg.manufacturer, inputArg.model, inputArg.name, deviceType, metadata.brand, metadata.manufacturer]
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
.join(' ')
.toLowerCase();
const snapshot = inputArg.snapshot || metadata.snapshot as IBroadlinkCandidateMetadata['snapshot'];
const hasManualData = Boolean(inputArg.host || inputArg.device || inputArg.devices?.length || inputArg.codes || inputArg.switches?.length || inputArg.state || inputArg.sensors);
const matched = inputArg.integrationDomain === broadlinkDomain
|| metadata.broadlink === true
|| Boolean(snapshot)
|| Boolean(macAddress && isBroadlinkMac(macAddress))
|| isSupportedType(deviceType)
|| text.includes('broadlink')
|| hasManualData && Boolean(inputArg.host || macAddress || deviceType || inputArg.codes || inputArg.switches?.length);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Broadlink setup data.' };
}
const id = inputArg.id || inputArg.deviceId || macAddress || inputArg.host;
return {
matched: true,
confidence: snapshot ? 'certain' : inputArg.host && (macAddress || deviceType) ? 'high' : inputArg.host ? 'medium' : 'low',
reason: snapshot ? 'Manual entry includes a Broadlink snapshot.' : 'Manual entry can start Broadlink setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: broadlinkDomain,
id,
host: inputArg.host,
port: inputArg.port || broadlinkDefaultPort,
name: inputArg.name || inputArg.model || 'Broadlink device',
manufacturer: inputArg.manufacturer || 'Broadlink',
model: inputArg.model || deviceType,
macAddress,
metadata: {
...metadata,
broadlink: true,
manual: true,
deviceType,
devtype: inputArg.devtype,
timeout: inputArg.timeout || broadlinkDefaultTimeoutSeconds,
snapshot,
device: inputArg.device,
devices: inputArg.devices,
state: inputArg.state,
sensors: inputArg.sensors,
codesConfigured: Boolean(inputArg.codes),
customSwitchesConfigured: Boolean(inputArg.switches?.length),
} satisfies IBroadlinkCandidateMetadata,
},
metadata: { snapshotConfigured: Boolean(snapshot), codesConfigured: Boolean(inputArg.codes) },
};
}
}
export class BroadlinkCandidateValidator implements IDiscoveryValidator {
public id = 'broadlink-candidate-validator';
public description = 'Validate Broadlink candidates from DHCP, local hello, and manual setup.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const macAddress = normalizeMac(candidateArg.macAddress || stringValue(metadata.macAddress));
const deviceType = stringValue(metadata.deviceType) || candidateArg.model;
const text = [candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.brand, metadata.manufacturer]
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
.join(' ')
.toLowerCase();
const macMatched = isBroadlinkMac(macAddress);
const snapshotConfigured = metadata.snapshot !== undefined;
const matched = candidateArg.integrationDomain === broadlinkDomain
|| metadata.broadlink === true
|| snapshotConfigured
|| macMatched
|| isSupportedType(deviceType)
|| text.includes('broadlink')
|| candidateArg.source === 'manual' && Boolean(candidateArg.host);
return {
matched,
confidence: matched && (macMatched || snapshotConfigured || candidateArg.integrationDomain === broadlinkDomain) && candidateArg.host ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
reason: matched ? 'Candidate has Broadlink metadata or manual setup data.' : 'Candidate is not Broadlink.',
candidate: matched ? { ...candidateArg, port: candidateArg.port || broadlinkDefaultPort } : undefined,
normalizedDeviceId: macAddress || candidateArg.id || candidateArg.host,
metadata: matched ? { macMatched, snapshotConfigured, deviceType } : undefined,
};
}
}
export const createBroadlinkDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: broadlinkDomain, displayName: 'Broadlink' })
.addMatcher(new BroadlinkDhcpMatcher())
.addMatcher(new BroadlinkLocalDiscoveryMatcher())
.addMatcher(new BroadlinkManualMatcher())
.addValidator(new BroadlinkCandidateValidator());
};
export const normalizeBroadlinkMac = (valueArg?: string): string | undefined => normalizeMac(valueArg);
const normalizeMac = (valueArg?: string): string | undefined => {
if (!valueArg) {
return undefined;
}
const compact = valueArg.replace(/[^0-9a-f]/gi, '').toLowerCase();
return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : undefined;
};
const isBroadlinkMac = (valueArg?: string): boolean => {
const compact = (valueArg || '').replace(/[^0-9a-f]/gi, '').toLowerCase();
return broadlinkMacPrefixes.some((prefixArg) => compact.startsWith(prefixArg));
};
const isSupportedType = (valueArg?: string): boolean => {
return Boolean(valueArg && broadlinkSupportedTypes.has(valueArg.toUpperCase()));
};
const stringValue = (valueArg: unknown): string | undefined => {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
};