diff --git a/test/roku/test.roku.discovery.node.ts b/test/roku/test.roku.discovery.node.ts new file mode 100644 index 0000000..429c3e5 --- /dev/null +++ b/test/roku/test.roku.discovery.node.ts @@ -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(); diff --git a/test/roku/test.roku.mapper.node.ts b/test/roku/test.roku.mapper.node.ts new file mode 100644 index 0000000..45cb7f4 --- /dev/null +++ b/test/roku/test.roku.mapper.node.ts @@ -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(); diff --git a/ts/index.ts b/ts/index.ts index 12d65b3..73e5d36 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -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(), diff --git a/ts/integrations/generated/index.ts b/ts/integrations/generated/index.ts index 8fd3bee..6331b76 100644 --- a/ts/integrations/generated/index.ts +++ b/ts/integrations/generated/index.ts @@ -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" ]; diff --git a/ts/integrations/roku/.generated-by-smarthome-exchange b/ts/integrations/roku/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/roku/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/roku/index.ts b/ts/integrations/roku/index.ts index 8ba617d..119015e 100644 --- a/ts/integrations/roku/index.ts +++ b/ts/integrations/roku/index.ts @@ -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'; diff --git a/ts/integrations/roku/roku.classes.client.ts b/ts/integrations/roku/roku.classes.client.ts new file mode 100644 index 0000000..cd740aa --- /dev/null +++ b/ts/integrations/roku/roku.classes.client.ts @@ -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 { + const [deviceInfo, apps, activeApp] = await Promise.all([ + this.getDeviceInfo(), + this.getApps(), + this.getActiveApp(), + ]); + return { deviceInfo, apps, activeApp }; + } + + public async getDeviceInfo(): Promise { + 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 { + if (this.config.apps) { + return this.config.apps; + } + const xml = await this.requestText('/query/apps'); + return this.readApps(xml); + } + + public async getActiveApp(): Promise { + 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 { + await this.requestText(`/keypress/${encodeURIComponent(this.toEcpKey(keyArg))}`, 'POST'); + } + + public async launch(appIdArg: string, paramsArg: Record = {}): Promise { + 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 { + const params: Record = { + 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 {} + + private async requestText(pathArg: string, methodArg: 'GET' | 'POST' = 'GET'): Promise { + 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 = /]*)>([\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 { + const attrs: Record = {}; + 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 = { + 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, '&'); + } +} diff --git a/ts/integrations/roku/roku.classes.configflow.ts b/ts/integrations/roku/roku.classes.configflow.ts new file mode 100644 index 0000000..77c0ec0 --- /dev/null +++ b/ts/integrations/roku/roku.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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, + }, + }), + }; + } +} diff --git a/ts/integrations/roku/roku.classes.integration.ts b/ts/integrations/roku/roku.classes.integration.ts index 0dcd7bd..bc80c13 100644 --- a/ts/integrations/roku/roku.classes.integration.ts +++ b/ts/integrations/roku/roku.classes.integration.ts @@ -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 { + 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 { + void contextArg; + return new RokuRuntime(new RokuClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantRokuIntegration extends RokuIntegration {} + +class RokuRuntime implements IIntegrationRuntime { + public domain = 'roku'; + + constructor(private readonly client: RokuClient) {} + + public async devices(): Promise { + return RokuMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return RokuMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + 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 { + await this.client.destroy(); } } diff --git a/ts/integrations/roku/roku.discovery.ts b/ts/integrations/roku/roku.discovery.ts new file mode 100644 index 0000000..51ba295 --- /dev/null +++ b/ts/integrations/roku/roku.discovery.ts @@ -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 { + public id = 'roku-ssdp-match'; + public source = 'ssdp' as const; + public description = 'Recognize Roku ECP SSDP advertisements.'; + + public async matches(recordArg: IRokuSsdpRecord): Promise { + 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 { + public id = 'roku-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Roku setup entries.'; + + public async matches(inputArg: IRokuManualEntry): Promise { + 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 { + 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()); +}; diff --git a/ts/integrations/roku/roku.mapper.ts b/ts/integrations/roku/roku.mapper.ts new file mode 100644 index 0000000..f4f98d1 --- /dev/null +++ b/ts/integrations/roku/roku.mapper.ts @@ -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'; + } +} diff --git a/ts/integrations/roku/roku.types.ts b/ts/integrations/roku/roku.types.ts index a188a63..b8eebbf 100644 --- a/ts/integrations/roku/roku.types.ts +++ b/ts/integrations/roku/roku.types.ts @@ -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; +} + +export interface IRokuManualEntry { + host?: string; + port?: number; + id?: string; + name?: string; + model?: string; + metadata?: Record; +} + +export type TRokuKeypress = + | 'Home' + | 'Rev' + | 'Fwd' + | 'Play' + | 'Select' + | 'Left' + | 'Right' + | 'Down' + | 'Up' + | 'Back' + | 'InstantReplay' + | 'Info' + | 'Backspace' + | 'Search' + | 'Enter' + | 'PowerOn' + | 'PowerOff' + | 'Power' + | 'VolumeUp' + | 'VolumeDown' + | 'VolumeMute';