Add native Roku integration

This commit is contained in:
2026-05-05 12:32:02 +00:00
parent 5efb2f6760
commit 1f5ab6802d
12 changed files with 631 additions and 30 deletions
+20
View File
@@ -0,0 +1,20 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createRokuDiscoveryDescriptor } from '../../ts/integrations/roku/index.js';
tap.test('matches Roku ECP SSDP records', async () => {
const descriptor = createRokuDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const result = await matcher.matches({
st: 'roku:ecp',
usn: 'uuid:roku-ecp-123456::roku:ecp',
location: 'http://192.168.1.60:8060/',
headers: {
manufacturer: 'Roku',
},
}, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.host).toEqual('192.168.1.60');
expect(result.normalizedDeviceId).toEqual('roku-ecp-123456');
});
export default tap.start();
+28
View File
@@ -0,0 +1,28 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { RokuMapper } from '../../ts/integrations/roku/index.js';
const snapshot = {
deviceInfo: {
deviceId: 'roku-ecp-123456',
vendorName: 'Roku',
modelName: 'Roku Streaming Stick',
friendlyDeviceName: 'Living Room Roku',
powerMode: 'PowerOn',
},
apps: [
{ id: '12', name: 'Netflix' },
{ id: '13', name: 'Prime Video' },
],
activeApp: { id: '12', name: 'Netflix' },
};
tap.test('maps Roku ECP snapshots to media devices and entities', async () => {
const devices = RokuMapper.toDevices(snapshot);
const entities = RokuMapper.toEntities(snapshot);
expect(devices[0].id).toEqual('roku.device.roku_ecp_123456');
expect(devices[0].state.some((stateArg) => stateArg.featureId === 'active_app' && stateArg.value === 'Netflix')).toBeTrue();
expect(entities[0].platform).toEqual('media_player');
expect(entities[0].state).toEqual('on');
});
export default tap.start();
+2
View File
@@ -3,6 +3,7 @@ export * from './protocols/index.js';
export * from './integrations/index.js'; export * from './integrations/index.js';
import { HueIntegration } from './integrations/hue/index.js'; import { HueIntegration } from './integrations/hue/index.js';
import { RokuIntegration } from './integrations/roku/index.js';
import { ShellyIntegration } from './integrations/shelly/index.js'; import { ShellyIntegration } from './integrations/shelly/index.js';
import { SonosIntegration } from './integrations/sonos/index.js'; import { SonosIntegration } from './integrations/sonos/index.js';
import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js'; import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js';
@@ -11,6 +12,7 @@ import { IntegrationRegistry } from './core/index.js';
export const integrations = [ export const integrations = [
new HueIntegration(), new HueIntegration(),
new RokuIntegration(),
new ShellyIntegration(), new ShellyIntegration(),
new SonosIntegration(), new SonosIntegration(),
new WolfSmartsetIntegration(), new WolfSmartsetIntegration(),
+2 -3
View File
@@ -1051,7 +1051,6 @@ import { HomeAssistantRitualsPerfumeGenieIntegration } from '../rituals_perfume_
import { HomeAssistantRmvtransportIntegration } from '../rmvtransport/index.js'; import { HomeAssistantRmvtransportIntegration } from '../rmvtransport/index.js';
import { HomeAssistantRoborockIntegration } from '../roborock/index.js'; import { HomeAssistantRoborockIntegration } from '../roborock/index.js';
import { HomeAssistantRocketchatIntegration } from '../rocketchat/index.js'; import { HomeAssistantRocketchatIntegration } from '../rocketchat/index.js';
import { HomeAssistantRokuIntegration } from '../roku/index.js';
import { HomeAssistantRomyIntegration } from '../romy/index.js'; import { HomeAssistantRomyIntegration } from '../romy/index.js';
import { HomeAssistantRoombaIntegration } from '../roomba/index.js'; import { HomeAssistantRoombaIntegration } from '../roomba/index.js';
import { HomeAssistantRoonIntegration } from '../roon/index.js'; import { HomeAssistantRoonIntegration } from '../roon/index.js';
@@ -2508,7 +2507,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantRitualsPerfumeGenie
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRmvtransportIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRmvtransportIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRoborockIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRoborockIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRocketchatIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRocketchatIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRokuIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRomyIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRomyIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRoombaIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRoombaIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRoonIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRoonIntegration());
@@ -2914,9 +2912,10 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegrati
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveJsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveJsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
export const generatedHomeAssistantPortCount = 1455; export const generatedHomeAssistantPortCount = 1454;
export const handwrittenHomeAssistantPortDomains = [ export const handwrittenHomeAssistantPortDomains = [
"hue", "hue",
"roku",
"shelly", "shelly",
"sonos" "sonos"
]; ];
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './roku.classes.integration.js'; 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'; export * from './roku.types.js';
+165
View File
@@ -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(/&apos;/g, "'").replace(/&quot;/g, '"').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/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,
},
}),
};
}
}
+135 -23
View File
@@ -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 { export class RokuIntegration extends BaseIntegration<IRokuConfig> {
constructor() { public readonly domain = 'roku';
super({ public readonly displayName = 'Roku';
domain: "roku", public readonly status = 'control-runtime' as const;
displayName: "Roku", public readonly discoveryDescriptor = createRokuDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new RokuConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/roku", upstreamPath: 'homeassistant/components/roku',
"upstreamDomain": "roku", upstreamDomain: 'roku',
"integrationType": "device", integrationType: 'device',
"iotClass": "local_polling", iotClass: 'local_polling',
"requirements": [ qualityScale: 'unknown',
"rokuecp==0.19.5" };
],
"dependencies": [], public async setup(configArg: IRokuConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
"afterDependencies": [], void contextArg;
"codeowners": [ return new RokuRuntime(new RokuClient(configArg));
"@ctalkington" }
]
}, 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();
} }
} }
+93
View File
@@ -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());
};
+84
View File
@@ -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';
}
}
+73 -3
View File
@@ -1,4 +1,74 @@
export interface IHomeAssistantRokuConfig { export interface IRokuConfig {
// TODO: replace with the TypeScript-native config for roku. host?: string;
[key: string]: unknown; 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';