Files
integrations/ts/integrations/roku/roku.discovery.ts
T
2026-05-05 12:32:02 +00:00

94 lines
3.8 KiB
TypeScript

import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IRokuManualEntry, IRokuSsdpRecord } from './roku.types.js';
export class RokuSsdpMatcher implements IDiscoveryMatcher<IRokuSsdpRecord> {
public id = 'roku-ssdp-match';
public source = 'ssdp' as const;
public description = 'Recognize Roku ECP SSDP advertisements.';
public async matches(recordArg: IRokuSsdpRecord): Promise<IDiscoveryMatch> {
const st = recordArg.st || recordArg.headers?.st || recordArg.headers?.ST;
const usn = recordArg.usn || recordArg.headers?.usn || recordArg.headers?.USN;
const location = recordArg.location || recordArg.headers?.location || recordArg.headers?.LOCATION;
const manufacturer = recordArg.headers?.manufacturer || recordArg.headers?.MANUFACTURER;
const matched = st === 'roku:ecp' || manufacturer === 'Roku' || Boolean(usn?.toLowerCase().includes('roku'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'SSDP record is not Roku ECP.' };
}
const url = location ? new URL(location) : undefined;
const id = usn?.replace(/^uuid:/i, '').split('::')[0];
return {
matched: true,
confidence: id ? 'certain' : 'high',
reason: 'SSDP record matches Roku ECP metadata.',
normalizedDeviceId: id,
candidate: {
source: 'ssdp',
integrationDomain: 'roku',
id,
host: url?.hostname,
port: url?.port ? Number(url.port) : 8060,
manufacturer: 'Roku',
model: recordArg.headers?.modelName || recordArg.headers?.MODELNAME,
metadata: { st, usn, location },
},
};
}
}
export class RokuManualMatcher implements IDiscoveryMatcher<IRokuManualEntry> {
public id = 'roku-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Roku setup entries.';
public async matches(inputArg: IRokuManualEntry): Promise<IDiscoveryMatch> {
const matched = Boolean(inputArg.host || inputArg.model?.toLowerCase().includes('roku') || inputArg.metadata?.roku);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Roku setup hints.' };
}
return {
matched: true,
confidence: inputArg.host ? 'high' : 'medium',
reason: 'Manual entry can start Roku setup.',
normalizedDeviceId: inputArg.id,
candidate: {
source: 'manual',
integrationDomain: 'roku',
id: inputArg.id,
host: inputArg.host,
port: inputArg.port || 8060,
name: inputArg.name,
manufacturer: 'Roku',
model: inputArg.model,
metadata: inputArg.metadata,
},
};
}
}
export class RokuCandidateValidator implements IDiscoveryValidator {
public id = 'roku-candidate-validator';
public description = 'Validate Roku ECP candidate metadata.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const manufacturer = candidateArg.manufacturer?.toLowerCase() || '';
const model = candidateArg.model?.toLowerCase() || '';
const matched = candidateArg.integrationDomain === 'roku' || manufacturer === 'roku' || model.includes('roku');
return {
matched,
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
reason: matched ? 'Candidate has Roku metadata.' : 'Candidate is not Roku.',
candidate: matched ? candidateArg : undefined,
normalizedDeviceId: candidateArg.id,
};
}
}
export const createRokuDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: 'roku', displayName: 'Roku' })
.addMatcher(new RokuSsdpMatcher())
.addMatcher(new RokuManualMatcher())
.addValidator(new RokuCandidateValidator());
};