243 lines
11 KiB
TypeScript
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;
|
|
};
|