Add native Roku integration
This commit is contained in:
@@ -3,6 +3,7 @@ export * from './protocols/index.js';
|
||||
export * from './integrations/index.js';
|
||||
|
||||
import { HueIntegration } from './integrations/hue/index.js';
|
||||
import { RokuIntegration } from './integrations/roku/index.js';
|
||||
import { ShellyIntegration } from './integrations/shelly/index.js';
|
||||
import { SonosIntegration } from './integrations/sonos/index.js';
|
||||
import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js';
|
||||
@@ -11,6 +12,7 @@ import { IntegrationRegistry } from './core/index.js';
|
||||
|
||||
export const integrations = [
|
||||
new HueIntegration(),
|
||||
new RokuIntegration(),
|
||||
new ShellyIntegration(),
|
||||
new SonosIntegration(),
|
||||
new WolfSmartsetIntegration(),
|
||||
|
||||
@@ -1051,7 +1051,6 @@ import { HomeAssistantRitualsPerfumeGenieIntegration } from '../rituals_perfume_
|
||||
import { HomeAssistantRmvtransportIntegration } from '../rmvtransport/index.js';
|
||||
import { HomeAssistantRoborockIntegration } from '../roborock/index.js';
|
||||
import { HomeAssistantRocketchatIntegration } from '../rocketchat/index.js';
|
||||
import { HomeAssistantRokuIntegration } from '../roku/index.js';
|
||||
import { HomeAssistantRomyIntegration } from '../romy/index.js';
|
||||
import { HomeAssistantRoombaIntegration } from '../roomba/index.js';
|
||||
import { HomeAssistantRoonIntegration } from '../roon/index.js';
|
||||
@@ -2508,7 +2507,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantRitualsPerfumeGenie
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRmvtransportIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRoborockIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRocketchatIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRokuIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRomyIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRoombaIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRoonIntegration());
|
||||
@@ -2914,9 +2912,10 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegrati
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveJsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
||||
|
||||
export const generatedHomeAssistantPortCount = 1455;
|
||||
export const generatedHomeAssistantPortCount = 1454;
|
||||
export const handwrittenHomeAssistantPortDomains = [
|
||||
"hue",
|
||||
"roku",
|
||||
"shelly",
|
||||
"sonos"
|
||||
];
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './roku.classes.integration.js';
|
||||
export * from './roku.classes.client.js';
|
||||
export * from './roku.classes.configflow.js';
|
||||
export * from './roku.discovery.js';
|
||||
export * from './roku.mapper.js';
|
||||
export * from './roku.types.js';
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { IRokuApp, IRokuConfig, IRokuDeviceInfo, IRokuSnapshot, TRokuKeypress } from './roku.types.js';
|
||||
|
||||
export class RokuClient {
|
||||
constructor(private readonly config: IRokuConfig) {}
|
||||
|
||||
public async getSnapshot(): Promise<IRokuSnapshot> {
|
||||
const [deviceInfo, apps, activeApp] = await Promise.all([
|
||||
this.getDeviceInfo(),
|
||||
this.getApps(),
|
||||
this.getActiveApp(),
|
||||
]);
|
||||
return { deviceInfo, apps, activeApp };
|
||||
}
|
||||
|
||||
public async getDeviceInfo(): Promise<IRokuDeviceInfo> {
|
||||
if (this.config.deviceInfo) {
|
||||
return this.config.deviceInfo;
|
||||
}
|
||||
const xml = await this.requestText('/query/device-info');
|
||||
return {
|
||||
serialNumber: this.readXmlTag(xml, 'serial-number'),
|
||||
deviceId: this.readXmlTag(xml, 'device-id'),
|
||||
vendorName: this.readXmlTag(xml, 'vendor-name'),
|
||||
modelName: this.readXmlTag(xml, 'model-name'),
|
||||
modelNumber: this.readXmlTag(xml, 'model-number'),
|
||||
friendlyDeviceName: this.readXmlTag(xml, 'friendly-device-name'),
|
||||
userDeviceName: this.readXmlTag(xml, 'user-device-name'),
|
||||
deviceType: this.readXmlTag(xml, 'device-type'),
|
||||
softwareVersion: this.readXmlTag(xml, 'software-version'),
|
||||
powerMode: this.readXmlTag(xml, 'power-mode'),
|
||||
supportsSuspend: this.readXmlTag(xml, 'supports-suspend') === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
public async getApps(): Promise<IRokuApp[]> {
|
||||
if (this.config.apps) {
|
||||
return this.config.apps;
|
||||
}
|
||||
const xml = await this.requestText('/query/apps');
|
||||
return this.readApps(xml);
|
||||
}
|
||||
|
||||
public async getActiveApp(): Promise<IRokuApp | undefined> {
|
||||
if (this.config.activeApp) {
|
||||
return this.config.activeApp;
|
||||
}
|
||||
const xml = await this.requestText('/query/active-app');
|
||||
return this.readApps(xml)[0];
|
||||
}
|
||||
|
||||
public async keypress(keyArg: TRokuKeypress | string): Promise<void> {
|
||||
await this.requestText(`/keypress/${encodeURIComponent(this.toEcpKey(keyArg))}`, 'POST');
|
||||
}
|
||||
|
||||
public async launch(appIdArg: string, paramsArg: Record<string, string | number | boolean> = {}): Promise<void> {
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(paramsArg)) {
|
||||
searchParams.set(key, String(value));
|
||||
}
|
||||
const suffix = searchParams.size ? `?${searchParams.toString()}` : '';
|
||||
await this.requestText(`/launch/${encodeURIComponent(appIdArg)}${suffix}`, 'POST');
|
||||
}
|
||||
|
||||
public async playUrl(urlArg: string, mediaTypeArg: 'video' | 'audio' = 'video', titleArg?: string): Promise<void> {
|
||||
const params: Record<string, string> = {
|
||||
u: urlArg,
|
||||
t: mediaTypeArg === 'audio' ? 'a' : 'v',
|
||||
};
|
||||
if (titleArg) {
|
||||
params[mediaTypeArg === 'audio' ? 'songName' : 'videoName'] = titleArg;
|
||||
}
|
||||
await this.launch(this.config.playMediaAppId || '15985', params);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
|
||||
private async requestText(pathArg: string, methodArg: 'GET' | 'POST' = 'GET'): Promise<string> {
|
||||
if (!this.config.host) {
|
||||
throw new Error('Roku host is required when fixture data is not provided.');
|
||||
}
|
||||
const response = await globalThis.fetch(`${this.baseUrl()}${pathArg}`, { method: methodArg });
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Roku request ${pathArg} failed with HTTP ${response.status}: ${text}`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private readApps(xmlArg: string): IRokuApp[] {
|
||||
const apps: IRokuApp[] = [];
|
||||
const regex = /<app\s+([^>]*)>([\s\S]*?)<\/app>/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(xmlArg))) {
|
||||
const attrs = this.readAttributes(match[1]);
|
||||
const id = attrs.id;
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
apps.push({
|
||||
id,
|
||||
name: this.unescapeXml(match[2].trim()),
|
||||
type: attrs.type,
|
||||
version: attrs.version,
|
||||
});
|
||||
}
|
||||
return apps;
|
||||
}
|
||||
|
||||
private readAttributes(valueArg: string): Record<string, string> {
|
||||
const attrs: Record<string, string> = {};
|
||||
const regex = /([a-zA-Z0-9_-]+)="([^"]*)"/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(valueArg))) {
|
||||
attrs[match[1]] = this.unescapeXml(match[2]);
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
private readXmlTag(xmlArg: string, tagArg: string): string | undefined {
|
||||
const escapedTag = tagArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`<${escapedTag}>([\\s\\S]*?)<\\/${escapedTag}>`, 'i');
|
||||
const match = regex.exec(xmlArg);
|
||||
return match?.[1] ? this.unescapeXml(match[1].trim()) : undefined;
|
||||
}
|
||||
|
||||
private toEcpKey(keyArg: string): string {
|
||||
const normalized = keyArg.replace(/[_\s-]+/g, '').toLowerCase();
|
||||
const keys: Record<string, TRokuKeypress> = {
|
||||
home: 'Home',
|
||||
rev: 'Rev',
|
||||
reverse: 'Rev',
|
||||
fwd: 'Fwd',
|
||||
forward: 'Fwd',
|
||||
play: 'Play',
|
||||
pause: 'Play',
|
||||
select: 'Select',
|
||||
left: 'Left',
|
||||
right: 'Right',
|
||||
down: 'Down',
|
||||
up: 'Up',
|
||||
back: 'Back',
|
||||
instantreplay: 'InstantReplay',
|
||||
info: 'Info',
|
||||
backspace: 'Backspace',
|
||||
search: 'Search',
|
||||
enter: 'Enter',
|
||||
poweron: 'PowerOn',
|
||||
poweroff: 'PowerOff',
|
||||
power: 'Power',
|
||||
volumeup: 'VolumeUp',
|
||||
volumedown: 'VolumeDown',
|
||||
volumemute: 'VolumeMute',
|
||||
mute: 'VolumeMute',
|
||||
};
|
||||
return keys[normalized] || keyArg;
|
||||
}
|
||||
|
||||
private baseUrl(): string {
|
||||
return `http://${this.config.host}:${this.config.port || 8060}`;
|
||||
}
|
||||
|
||||
private unescapeXml(valueArg: string): string {
|
||||
return valueArg.replace(/'/g, "'").replace(/"/g, '"').replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { IRokuConfig } from './roku.types.js';
|
||||
|
||||
export class RokuConfigFlow implements IConfigFlow<IRokuConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IRokuConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect Roku',
|
||||
description: 'Configure the local Roku External Control Protocol endpoint.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||
{ name: 'port', label: 'Port', type: 'number' },
|
||||
],
|
||||
submit: async (valuesArg) => ({
|
||||
kind: 'done',
|
||||
title: 'Roku configured',
|
||||
config: {
|
||||
host: String(valuesArg.host || candidateArg.host || ''),
|
||||
port: typeof valuesArg.port === 'number' ? valuesArg.port : candidateArg.port || 8060,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,138 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { RokuClient } from './roku.classes.client.js';
|
||||
import { RokuConfigFlow } from './roku.classes.configflow.js';
|
||||
import { createRokuDiscoveryDescriptor } from './roku.discovery.js';
|
||||
import { RokuMapper } from './roku.mapper.js';
|
||||
import type { IRokuConfig } from './roku.types.js';
|
||||
|
||||
export class HomeAssistantRokuIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "roku",
|
||||
displayName: "Roku",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/roku",
|
||||
"upstreamDomain": "roku",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"rokuecp==0.19.5"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@ctalkington"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class RokuIntegration extends BaseIntegration<IRokuConfig> {
|
||||
public readonly domain = 'roku';
|
||||
public readonly displayName = 'Roku';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createRokuDiscoveryDescriptor();
|
||||
public readonly configFlow = new RokuConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/roku',
|
||||
upstreamDomain: 'roku',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
qualityScale: 'unknown',
|
||||
};
|
||||
|
||||
public async setup(configArg: IRokuConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new RokuRuntime(new RokuClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantRokuIntegration extends RokuIntegration {}
|
||||
|
||||
class RokuRuntime implements IIntegrationRuntime {
|
||||
public domain = 'roku';
|
||||
|
||||
constructor(private readonly client: RokuClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return RokuMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return RokuMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.domain === 'remote') {
|
||||
const command = requestArg.data?.command;
|
||||
if (typeof command === 'string') {
|
||||
await this.client.keypress(command);
|
||||
return { success: true };
|
||||
}
|
||||
if (Array.isArray(command)) {
|
||||
for (const item of command) {
|
||||
if (typeof item === 'string') {
|
||||
await this.client.keypress(item);
|
||||
}
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: 'Roku remote service requires data.command.' };
|
||||
}
|
||||
|
||||
if (requestArg.domain !== 'media_player') {
|
||||
return { success: false, error: `Unsupported Roku service domain: ${requestArg.domain}` };
|
||||
}
|
||||
|
||||
if (requestArg.service === 'turn_on') {
|
||||
await this.client.keypress('PowerOn');
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
await this.client.keypress('PowerOff');
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'play' || requestArg.service === 'pause' || requestArg.service === 'play_pause') {
|
||||
await this.client.keypress('Play');
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'next_track') {
|
||||
await this.client.keypress('Fwd');
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'previous_track') {
|
||||
await this.client.keypress('Rev');
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'volume_up') {
|
||||
await this.client.keypress('VolumeUp');
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'volume_down') {
|
||||
await this.client.keypress('VolumeDown');
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'volume_mute') {
|
||||
await this.client.keypress('VolumeMute');
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'select_source') {
|
||||
const source = requestArg.data?.source;
|
||||
if (source === 'Home') {
|
||||
await this.client.keypress('Home');
|
||||
return { success: true };
|
||||
}
|
||||
if (typeof source === 'string') {
|
||||
const apps = await this.client.getApps();
|
||||
const app = apps.find((appArg) => source === appArg.id || source === appArg.name);
|
||||
if (app) {
|
||||
await this.client.launch(app.id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
return { success: false, error: 'Roku select_source requires a known source.' };
|
||||
}
|
||||
if (requestArg.service === 'play_media') {
|
||||
const mediaId = requestArg.data?.media_content_id ?? requestArg.data?.uri;
|
||||
const mediaType = requestArg.data?.media_content_type ?? requestArg.data?.media_type;
|
||||
if (typeof mediaId !== 'string' || !mediaId) {
|
||||
return { success: false, error: 'Roku play_media requires data.media_content_id or data.uri.' };
|
||||
}
|
||||
if (mediaType === 'app') {
|
||||
await this.client.launch(mediaId);
|
||||
return { success: true };
|
||||
}
|
||||
await this.client.playUrl(mediaId, mediaType === 'music' || mediaType === 'audio' ? 'audio' : 'video', typeof requestArg.data?.title === 'string' ? requestArg.data.title : undefined);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: false, error: `Unsupported Roku media_player service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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());
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity } from '../../core/types.js';
|
||||
import type { IRokuSnapshot } from './roku.types.js';
|
||||
|
||||
export class RokuMapper {
|
||||
public static toDevices(snapshotArg: IRokuSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = new Date().toISOString();
|
||||
return [{
|
||||
id: this.deviceId(snapshotArg),
|
||||
integrationDomain: 'roku',
|
||||
name: this.deviceName(snapshotArg),
|
||||
protocol: 'http',
|
||||
manufacturer: snapshotArg.deviceInfo.vendorName || 'Roku',
|
||||
model: snapshotArg.deviceInfo.modelName || snapshotArg.deviceInfo.modelNumber,
|
||||
online: true,
|
||||
features: [
|
||||
{ id: 'power', capability: 'media', name: 'Power', readable: true, writable: true },
|
||||
{ id: 'active_app', capability: 'media', name: 'Active app', readable: true, writable: true },
|
||||
{ id: 'remote_key', capability: 'media', name: 'Remote key', readable: false, writable: true },
|
||||
],
|
||||
state: [
|
||||
{ featureId: 'power', value: this.powerState(snapshotArg), updatedAt },
|
||||
{ featureId: 'active_app', value: snapshotArg.activeApp?.name || null, updatedAt },
|
||||
],
|
||||
metadata: {
|
||||
serialNumber: snapshotArg.deviceInfo.serialNumber,
|
||||
deviceId: snapshotArg.deviceInfo.deviceId,
|
||||
deviceType: snapshotArg.deviceInfo.deviceType,
|
||||
softwareVersion: snapshotArg.deviceInfo.softwareVersion,
|
||||
apps: snapshotArg.apps.map((appArg) => ({ id: appArg.id, name: appArg.name })),
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IRokuSnapshot): IIntegrationEntity[] {
|
||||
return [{
|
||||
id: `media_player.${this.slug(this.deviceName(snapshotArg))}`,
|
||||
uniqueId: `roku_${this.slug(snapshotArg.deviceInfo.deviceId || snapshotArg.deviceInfo.serialNumber || this.deviceName(snapshotArg))}`,
|
||||
integrationDomain: 'roku',
|
||||
deviceId: this.deviceId(snapshotArg),
|
||||
platform: 'media_player',
|
||||
name: this.deviceName(snapshotArg),
|
||||
state: this.mediaState(snapshotArg),
|
||||
attributes: {
|
||||
source: snapshotArg.activeApp?.name,
|
||||
appId: snapshotArg.activeApp?.id,
|
||||
sourceList: ['Home', ...snapshotArg.apps.map((appArg) => appArg.name)],
|
||||
powerMode: snapshotArg.deviceInfo.powerMode,
|
||||
},
|
||||
available: true,
|
||||
}];
|
||||
}
|
||||
|
||||
private static mediaState(snapshotArg: IRokuSnapshot): string {
|
||||
if (this.powerState(snapshotArg) === 'off') {
|
||||
return 'off';
|
||||
}
|
||||
const activeName = snapshotArg.activeApp?.name;
|
||||
if (!activeName || activeName === 'Power Saver' || activeName === 'Roku') {
|
||||
return 'idle';
|
||||
}
|
||||
return 'on';
|
||||
}
|
||||
|
||||
private static powerState(snapshotArg: IRokuSnapshot): string {
|
||||
const powerMode = snapshotArg.deviceInfo.powerMode?.toLowerCase() || '';
|
||||
if (powerMode.includes('off') || powerMode.includes('standby') || powerMode.includes('suspend')) {
|
||||
return 'off';
|
||||
}
|
||||
return 'on';
|
||||
}
|
||||
|
||||
private static deviceId(snapshotArg: IRokuSnapshot): string {
|
||||
return `roku.device.${this.slug(snapshotArg.deviceInfo.deviceId || snapshotArg.deviceInfo.serialNumber || this.deviceName(snapshotArg))}`;
|
||||
}
|
||||
|
||||
private static deviceName(snapshotArg: IRokuSnapshot): string {
|
||||
return snapshotArg.deviceInfo.userDeviceName || snapshotArg.deviceInfo.friendlyDeviceName || snapshotArg.deviceInfo.modelName || 'Roku';
|
||||
}
|
||||
|
||||
private static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'roku';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,74 @@
|
||||
export interface IHomeAssistantRokuConfig {
|
||||
// TODO: replace with the TypeScript-native config for roku.
|
||||
[key: string]: unknown;
|
||||
export interface IRokuConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
deviceInfo?: IRokuDeviceInfo;
|
||||
apps?: IRokuApp[];
|
||||
activeApp?: IRokuApp;
|
||||
playMediaAppId?: string;
|
||||
}
|
||||
|
||||
export interface IRokuDeviceInfo {
|
||||
serialNumber?: string;
|
||||
deviceId?: string;
|
||||
vendorName?: string;
|
||||
modelName?: string;
|
||||
modelNumber?: string;
|
||||
friendlyDeviceName?: string;
|
||||
userDeviceName?: string;
|
||||
deviceType?: string;
|
||||
softwareVersion?: string;
|
||||
powerMode?: string;
|
||||
supportsSuspend?: boolean;
|
||||
}
|
||||
|
||||
export interface IRokuApp {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface IRokuSnapshot {
|
||||
deviceInfo: IRokuDeviceInfo;
|
||||
apps: IRokuApp[];
|
||||
activeApp?: IRokuApp;
|
||||
}
|
||||
|
||||
export interface IRokuSsdpRecord {
|
||||
st?: string;
|
||||
usn?: string;
|
||||
location?: string;
|
||||
headers?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface IRokuManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
model?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TRokuKeypress =
|
||||
| 'Home'
|
||||
| 'Rev'
|
||||
| 'Fwd'
|
||||
| 'Play'
|
||||
| 'Select'
|
||||
| 'Left'
|
||||
| 'Right'
|
||||
| 'Down'
|
||||
| 'Up'
|
||||
| 'Back'
|
||||
| 'InstantReplay'
|
||||
| 'Info'
|
||||
| 'Backspace'
|
||||
| 'Search'
|
||||
| 'Enter'
|
||||
| 'PowerOn'
|
||||
| 'PowerOff'
|
||||
| 'Power'
|
||||
| 'VolumeUp'
|
||||
| 'VolumeDown'
|
||||
| 'VolumeMute';
|
||||
|
||||
Reference in New Issue
Block a user