Add native Roku integration
This commit is contained in:
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
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';
|
||||||
|
|||||||
Reference in New Issue
Block a user