Add native local network integrations
This commit is contained in:
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
androidtvRemoteApiPort,
|
||||
androidtvRemoteKeyAliases,
|
||||
androidtvRemoteKnownApps,
|
||||
androidtvRemotePairPort,
|
||||
} from './androidtv_remote.constants.js';
|
||||
import type {
|
||||
IAndroidtvRemoteApp,
|
||||
IAndroidtvRemoteCommand,
|
||||
IAndroidtvRemoteCommandContext,
|
||||
IAndroidtvRemoteConfig,
|
||||
IAndroidtvRemoteConfiguredApp,
|
||||
IAndroidtvRemoteDeviceInfo,
|
||||
IAndroidtvRemoteDeviceState,
|
||||
IAndroidtvRemoteKeyPress,
|
||||
IAndroidtvRemoteSnapshot,
|
||||
IAndroidtvRemoteVolumeInfo,
|
||||
TAndroidtvRemoteCommandDirection,
|
||||
TAndroidtvRemoteCommandExecutor,
|
||||
TAndroidtvRemoteCommandReason,
|
||||
TAndroidtvRemoteKeyCode,
|
||||
} from './androidtv_remote.types.js';
|
||||
|
||||
export class AndroidtvRemoteUnsupportedProtocolError extends Error {
|
||||
constructor(commandArg: IAndroidtvRemoteCommand) {
|
||||
super(`Android TV Remote protocol action "${commandArg.action}" requires an injected executor. This TypeScript port does not implement pairing or live androidtvremote2 transport.`);
|
||||
this.name = 'AndroidtvRemoteUnsupportedProtocolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AndroidtvRemoteClient {
|
||||
private readonly snapshot?: IAndroidtvRemoteSnapshot;
|
||||
|
||||
constructor(private readonly config: IAndroidtvRemoteConfig) {
|
||||
this.snapshot = config.snapshot ? this.cloneSnapshot(config.snapshot) : undefined;
|
||||
}
|
||||
|
||||
public async getSnapshot(): Promise<IAndroidtvRemoteSnapshot> {
|
||||
return this.normalizeSnapshot(this.snapshot || this.snapshotFromManualConfig());
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
await this.execute({ action: 'connect', reason: 'connect' });
|
||||
}
|
||||
|
||||
public async startPairing(): Promise<void> {
|
||||
await this.execute({ action: 'start_pairing', reason: 'start_pairing' });
|
||||
}
|
||||
|
||||
public async finishPairing(pinArg: string): Promise<void> {
|
||||
await this.execute({ action: 'finish_pairing', reason: 'finish_pairing', pin: pinArg });
|
||||
}
|
||||
|
||||
public async turnOn(): Promise<void> {
|
||||
await this.sendKeyCommand('POWER', 'SHORT', 'turn_on');
|
||||
}
|
||||
|
||||
public async turnOff(): Promise<void> {
|
||||
await this.sendKeyCommand('POWER', 'SHORT', 'turn_off');
|
||||
}
|
||||
|
||||
public async volumeUp(): Promise<void> {
|
||||
await this.sendKeyCommand('VOLUME_UP', 'SHORT', 'volume_up');
|
||||
}
|
||||
|
||||
public async volumeDown(): Promise<void> {
|
||||
await this.sendKeyCommand('VOLUME_DOWN', 'SHORT', 'volume_down');
|
||||
}
|
||||
|
||||
public async muteVolume(mutedArg: boolean): Promise<void> {
|
||||
await this.sendKeyCommand('VOLUME_MUTE', 'SHORT', 'volume_mute', { muted: mutedArg });
|
||||
}
|
||||
|
||||
public async setVolumeLevel(volumeLevelArg: number): Promise<void> {
|
||||
const volumeLevel = Math.max(0, Math.min(1, volumeLevelArg));
|
||||
await this.execute({ action: 'volume_set', reason: 'volume_set', volumeLevel });
|
||||
}
|
||||
|
||||
public async mediaPlay(): Promise<void> {
|
||||
await this.sendKeyCommand('MEDIA_PLAY', 'SHORT', 'media_play');
|
||||
}
|
||||
|
||||
public async mediaPause(): Promise<void> {
|
||||
await this.sendKeyCommand('MEDIA_PAUSE', 'SHORT', 'media_pause');
|
||||
}
|
||||
|
||||
public async mediaPlayPause(): Promise<void> {
|
||||
await this.sendKeyCommand('MEDIA_PLAY_PAUSE', 'SHORT', 'media_play_pause');
|
||||
}
|
||||
|
||||
public async mediaStop(): Promise<void> {
|
||||
await this.sendKeyCommand('MEDIA_STOP', 'SHORT', 'media_stop');
|
||||
}
|
||||
|
||||
public async mediaPreviousTrack(): Promise<void> {
|
||||
await this.sendKeyCommand('MEDIA_PREVIOUS', 'SHORT', 'media_previous_track');
|
||||
}
|
||||
|
||||
public async mediaNextTrack(): Promise<void> {
|
||||
await this.sendKeyCommand('MEDIA_NEXT', 'SHORT', 'media_next_track');
|
||||
}
|
||||
|
||||
public async playChannel(channelArg: string): Promise<void> {
|
||||
if (!/^\d+$/.test(channelArg)) {
|
||||
throw new Error(`Android TV Remote channel media_id must be numeric: ${channelArg}`);
|
||||
}
|
||||
await this.sendCommand(channelArg.split(''), { reason: 'play_channel' });
|
||||
}
|
||||
|
||||
public async launchApp(appLinkOrAppIdArg: string, reasonArg: TAndroidtvRemoteCommandReason = 'launch_app'): Promise<void> {
|
||||
const app = await this.appForActivity(appLinkOrAppIdArg);
|
||||
const appId = app?.id || (this.hasUrlScheme(appLinkOrAppIdArg) ? undefined : appLinkOrAppIdArg);
|
||||
const appLink = app?.link || (this.hasUrlScheme(appLinkOrAppIdArg) ? appLinkOrAppIdArg : `market://launch?id=${appLinkOrAppIdArg}`);
|
||||
await this.execute({
|
||||
action: 'launch_app',
|
||||
reason: reasonArg,
|
||||
appId,
|
||||
appLink,
|
||||
appName: app?.name || (appId ? androidtvRemoteKnownApps[appId] : undefined),
|
||||
});
|
||||
}
|
||||
|
||||
public async selectActivity(activityArg: string): Promise<void> {
|
||||
const app = await this.appForActivity(activityArg);
|
||||
await this.launchApp(app?.id || activityArg, 'select_activity');
|
||||
}
|
||||
|
||||
public async sendText(textArg: string): Promise<void> {
|
||||
await this.execute({ action: 'send_text', reason: 'send_text', text: textArg });
|
||||
}
|
||||
|
||||
public async sendKeyCommand(
|
||||
keyCodeArg: TAndroidtvRemoteKeyCode | string,
|
||||
directionArg: TAndroidtvRemoteCommandDirection = 'SHORT',
|
||||
reasonArg: TAndroidtvRemoteCommandReason = 'remote_send_command',
|
||||
extraArg: Partial<IAndroidtvRemoteCommand> = {}
|
||||
): Promise<void> {
|
||||
await this.execute({
|
||||
action: 'key_command',
|
||||
reason: reasonArg,
|
||||
keyCode: this.normalizeKeyCode(keyCodeArg),
|
||||
direction: directionArg,
|
||||
...extraArg,
|
||||
});
|
||||
}
|
||||
|
||||
public async sendCommand(
|
||||
commandsArg: Array<TAndroidtvRemoteKeyCode | string>,
|
||||
optionsArg: {
|
||||
repeats?: number;
|
||||
delaySecs?: number;
|
||||
holdSecs?: number;
|
||||
reason?: TAndroidtvRemoteCommandReason;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const keys: IAndroidtvRemoteKeyPress[] = commandsArg.flatMap((keyArg): IAndroidtvRemoteKeyPress[] => {
|
||||
const keyCode = this.normalizeKeyCode(keyArg);
|
||||
if (optionsArg.holdSecs) {
|
||||
return [
|
||||
{ keyCode, direction: 'START_LONG' },
|
||||
{ keyCode, direction: 'END_LONG' },
|
||||
];
|
||||
}
|
||||
return [{ keyCode, direction: 'SHORT' }];
|
||||
});
|
||||
await this.execute({
|
||||
action: 'remote_send_command',
|
||||
reason: optionsArg.reason || 'remote_send_command',
|
||||
keys,
|
||||
repeats: this.repeats(optionsArg.repeats),
|
||||
delaySecs: optionsArg.delaySecs,
|
||||
holdSecs: optionsArg.holdSecs,
|
||||
});
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
|
||||
private async execute(commandArg: IAndroidtvRemoteCommand): Promise<void> {
|
||||
const executor = this.config.executor;
|
||||
if (!executor) {
|
||||
throw new AndroidtvRemoteUnsupportedProtocolError(commandArg);
|
||||
}
|
||||
const context: IAndroidtvRemoteCommandContext = {
|
||||
config: this.config,
|
||||
snapshot: await this.getSnapshot(),
|
||||
};
|
||||
if (typeof executor === 'function') {
|
||||
await executor(commandArg, context);
|
||||
return;
|
||||
}
|
||||
await executor.execute(commandArg, context);
|
||||
}
|
||||
|
||||
private snapshotFromManualConfig(): IAndroidtvRemoteSnapshot {
|
||||
const deviceInfo: IAndroidtvRemoteDeviceInfo = {
|
||||
...this.config.deviceInfo,
|
||||
host: this.config.deviceInfo?.host || this.config.host,
|
||||
apiPort: this.config.deviceInfo?.apiPort || this.config.apiPort || androidtvRemoteApiPort,
|
||||
pairPort: this.config.deviceInfo?.pairPort || this.config.pairPort || androidtvRemotePairPort,
|
||||
name: this.config.deviceInfo?.name || this.config.deviceName || this.config.host || 'Android TV Remote',
|
||||
macAddress: this.config.deviceInfo?.macAddress || this.config.macAddress,
|
||||
manufacturer: this.config.deviceInfo?.manufacturer || this.config.manufacturer,
|
||||
model: this.config.deviceInfo?.model || this.config.model,
|
||||
};
|
||||
const state: IAndroidtvRemoteDeviceState = {
|
||||
mediaState: 'unknown',
|
||||
available: false,
|
||||
...this.config.state,
|
||||
volumeInfo: this.config.state?.volumeInfo || this.config.volumeInfo,
|
||||
};
|
||||
return {
|
||||
deviceInfo,
|
||||
state,
|
||||
apps: this.normalizeApps(this.config.apps),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSnapshot(snapshotArg: IAndroidtvRemoteSnapshot): IAndroidtvRemoteSnapshot {
|
||||
const deviceInfo: IAndroidtvRemoteDeviceInfo = {
|
||||
...snapshotArg.deviceInfo,
|
||||
host: snapshotArg.deviceInfo.host || this.config.host,
|
||||
apiPort: snapshotArg.deviceInfo.apiPort || this.config.apiPort || androidtvRemoteApiPort,
|
||||
pairPort: snapshotArg.deviceInfo.pairPort || this.config.pairPort || androidtvRemotePairPort,
|
||||
macAddress: snapshotArg.deviceInfo.macAddress || this.config.macAddress,
|
||||
};
|
||||
if (!deviceInfo.name) {
|
||||
deviceInfo.name = this.config.deviceName || deviceInfo.host || 'Android TV Remote';
|
||||
}
|
||||
const apps = this.normalizeApps(snapshotArg.apps.length ? snapshotArg.apps : this.config.apps);
|
||||
const volumeInfo = this.normalizeVolumeInfo(snapshotArg.state.volumeInfo || this.config.volumeInfo);
|
||||
const state: IAndroidtvRemoteDeviceState = {
|
||||
...snapshotArg.state,
|
||||
volumeInfo,
|
||||
};
|
||||
if (state.available === undefined) {
|
||||
state.available = state.isOn !== undefined || Boolean(state.currentApp || volumeInfo);
|
||||
}
|
||||
if (!state.currentAppName && state.currentApp) {
|
||||
state.currentAppName = this.appName(apps, state.currentApp);
|
||||
}
|
||||
if (!state.currentActivity) {
|
||||
state.currentActivity = state.currentAppName || state.currentApp;
|
||||
}
|
||||
if (state.isVolumeMuted === undefined && volumeInfo?.muted !== undefined) {
|
||||
state.isVolumeMuted = volumeInfo.muted;
|
||||
}
|
||||
if (state.volumeLevel === undefined) {
|
||||
state.volumeLevel = this.volumeLevel(volumeInfo);
|
||||
}
|
||||
return {
|
||||
deviceInfo,
|
||||
state,
|
||||
apps,
|
||||
updatedAt: snapshotArg.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeApps(appsArg: IAndroidtvRemoteConfig['apps']): IAndroidtvRemoteApp[] {
|
||||
if (!appsArg) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(appsArg)) {
|
||||
return appsArg.map((appArg) => ({
|
||||
...appArg,
|
||||
name: appArg.name || androidtvRemoteKnownApps[appArg.id],
|
||||
}));
|
||||
}
|
||||
return Object.entries(appsArg).map(([id, appArg]) => this.normalizeConfiguredApp(id, appArg));
|
||||
}
|
||||
|
||||
private normalizeConfiguredApp(idArg: string, appArg: IAndroidtvRemoteConfiguredApp): IAndroidtvRemoteApp {
|
||||
return {
|
||||
id: idArg,
|
||||
name: appArg.name || appArg.appName || appArg.app_name || androidtvRemoteKnownApps[idArg],
|
||||
icon: appArg.icon || appArg.appIcon || appArg.app_icon,
|
||||
link: appArg.link,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeVolumeInfo(volumeInfoArg?: IAndroidtvRemoteVolumeInfo): IAndroidtvRemoteVolumeInfo | undefined {
|
||||
return volumeInfoArg ? { ...volumeInfoArg } : undefined;
|
||||
}
|
||||
|
||||
private volumeLevel(volumeInfoArg?: IAndroidtvRemoteVolumeInfo): number | undefined {
|
||||
if (!volumeInfoArg) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof volumeInfoArg.max === 'number' && volumeInfoArg.max > 0 && typeof volumeInfoArg.level === 'number') {
|
||||
return volumeInfoArg.level / volumeInfoArg.max;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async appForActivity(activityArg: string): Promise<IAndroidtvRemoteApp | undefined> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
return snapshot.apps.find((appArg) => activityArg === appArg.id || activityArg === appArg.name || activityArg === appArg.link);
|
||||
}
|
||||
|
||||
private appName(appsArg: IAndroidtvRemoteApp[], appIdArg: string): string | undefined {
|
||||
return appsArg.find((appArg) => appArg.id === appIdArg)?.name || androidtvRemoteKnownApps[appIdArg];
|
||||
}
|
||||
|
||||
private normalizeKeyCode(keyCodeArg: TAndroidtvRemoteKeyCode | string): TAndroidtvRemoteKeyCode | string {
|
||||
const raw = String(keyCodeArg).trim();
|
||||
if (!raw) {
|
||||
return raw;
|
||||
}
|
||||
const withoutPrefix = raw.toUpperCase().replace(/^KEYCODE_/, '').replace(/[\s-]+/g, '_');
|
||||
return androidtvRemoteKeyAliases[withoutPrefix] || withoutPrefix;
|
||||
}
|
||||
|
||||
private repeats(repeatsArg?: number): number {
|
||||
return typeof repeatsArg === 'number' && Number.isFinite(repeatsArg) ? Math.max(1, Math.floor(repeatsArg)) : 1;
|
||||
}
|
||||
|
||||
private hasUrlScheme(valueArg: string): boolean {
|
||||
return /^[a-z][a-z0-9+.-]*:/i.test(valueArg);
|
||||
}
|
||||
|
||||
private cloneSnapshot(snapshotArg: IAndroidtvRemoteSnapshot): IAndroidtvRemoteSnapshot {
|
||||
return {
|
||||
deviceInfo: { ...snapshotArg.deviceInfo },
|
||||
state: {
|
||||
...snapshotArg.state,
|
||||
volumeInfo: snapshotArg.state.volumeInfo ? { ...snapshotArg.state.volumeInfo } : undefined,
|
||||
},
|
||||
apps: snapshotArg.apps.map((appArg) => ({ ...appArg })),
|
||||
updatedAt: snapshotArg.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import { androidtvRemoteApiPort, androidtvRemotePairPort } from './androidtv_remote.constants.js';
|
||||
import type { IAndroidtvRemoteConfig } from './androidtv_remote.types.js';
|
||||
|
||||
export class AndroidtvRemoteConfigFlow implements IConfigFlow<IAndroidtvRemoteConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IAndroidtvRemoteConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect Android TV Remote',
|
||||
description: 'Configure an Android TV Remote protocol v2 host. Pairing and live protocol transport require an injected executor.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||
{ name: 'apiPort', label: 'API port', type: 'number' },
|
||||
{ name: 'pairPort', label: 'Pairing port', type: 'number' },
|
||||
{ name: 'deviceName', label: 'Device name', type: 'text' },
|
||||
{ name: 'macAddress', label: 'MAC address', type: 'text' },
|
||||
{ name: 'enableIme', label: 'Enable IME updates', type: 'boolean' },
|
||||
],
|
||||
submit: async (valuesArg) => {
|
||||
const host = this.stringValue(valuesArg.host) || candidateArg.host;
|
||||
if (!host) {
|
||||
return { kind: 'error', title: 'Android TV Remote configuration failed', error: 'Host is required.' };
|
||||
}
|
||||
const apiPort = this.numberValue(valuesArg.apiPort) || this.numberValue(candidateArg.metadata?.apiPort) || candidateArg.port || androidtvRemoteApiPort;
|
||||
const pairPort = this.numberValue(valuesArg.pairPort) || this.numberValue(candidateArg.metadata?.pairPort) || androidtvRemotePairPort;
|
||||
const deviceName = this.stringValue(valuesArg.deviceName) || candidateArg.name;
|
||||
const macAddress = this.stringValue(valuesArg.macAddress) || candidateArg.macAddress || this.stringValue(candidateArg.metadata?.macAddress);
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'Android TV Remote configured',
|
||||
config: {
|
||||
host,
|
||||
apiPort,
|
||||
pairPort,
|
||||
deviceName,
|
||||
macAddress,
|
||||
manufacturer: candidateArg.manufacturer,
|
||||
model: candidateArg.model,
|
||||
enableIme: typeof valuesArg.enableIme === 'boolean' ? valuesArg.enableIme : true,
|
||||
deviceInfo: {
|
||||
id: candidateArg.id || macAddress,
|
||||
name: deviceName,
|
||||
host,
|
||||
apiPort,
|
||||
pairPort,
|
||||
macAddress,
|
||||
manufacturer: candidateArg.manufacturer,
|
||||
model: candidateArg.model,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0 ? valueArg : undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,236 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { AndroidtvRemoteClient } from './androidtv_remote.classes.client.js';
|
||||
import { AndroidtvRemoteConfigFlow } from './androidtv_remote.classes.configflow.js';
|
||||
import { createAndroidtvRemoteDiscoveryDescriptor } from './androidtv_remote.discovery.js';
|
||||
import { AndroidtvRemoteMapper } from './androidtv_remote.mapper.js';
|
||||
import type { IAndroidtvRemoteConfig } from './androidtv_remote.types.js';
|
||||
|
||||
export class HomeAssistantAndroidtvRemoteIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "androidtv_remote",
|
||||
displayName: "Android TV Remote",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/androidtv_remote",
|
||||
"upstreamDomain": "androidtv_remote",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_push",
|
||||
"qualityScale": "platinum",
|
||||
"requirements": [
|
||||
"androidtvremote2==0.3.1"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@tronikos",
|
||||
"@Drafteed"
|
||||
]
|
||||
},
|
||||
export class AndroidtvRemoteIntegration extends BaseIntegration<IAndroidtvRemoteConfig> {
|
||||
public readonly domain = 'androidtv_remote';
|
||||
public readonly displayName = 'Android TV Remote';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createAndroidtvRemoteDiscoveryDescriptor();
|
||||
public readonly configFlow = new AndroidtvRemoteConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/androidtv_remote',
|
||||
upstreamDomain: 'androidtv_remote',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_push',
|
||||
qualityScale: 'platinum',
|
||||
requirements: ['androidtvremote2==0.3.1'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@tronikos', '@Drafteed'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/androidtv_remote',
|
||||
};
|
||||
|
||||
public async setup(configArg: IAndroidtvRemoteConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new AndroidtvRemoteRuntime(new AndroidtvRemoteClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantAndroidtvRemoteIntegration extends AndroidtvRemoteIntegration {}
|
||||
|
||||
class AndroidtvRemoteRuntime implements IIntegrationRuntime {
|
||||
public domain = 'androidtv_remote';
|
||||
|
||||
constructor(private readonly client: AndroidtvRemoteClient) {}
|
||||
|
||||
public async devices(): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return AndroidtvRemoteMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return AndroidtvRemoteMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain === 'remote') {
|
||||
return await this.callRemoteService(requestArg);
|
||||
}
|
||||
if (requestArg.domain === 'androidtv_remote') {
|
||||
return await this.callAndroidtvRemoteService(requestArg);
|
||||
}
|
||||
if (requestArg.domain !== 'media_player') {
|
||||
return { success: false, error: `Unsupported Android TV Remote service domain: ${requestArg.domain}` };
|
||||
}
|
||||
return await this.callMediaPlayerService(requestArg);
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
|
||||
private async callRemoteService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'turn_on') {
|
||||
await this.client.turnOn();
|
||||
const activity = this.stringValue(requestArg.data?.activity);
|
||||
if (activity) {
|
||||
await this.client.selectActivity(activity);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
await this.client.turnOff();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service !== 'send_command') {
|
||||
return { success: false, error: `Unsupported Android TV Remote remote service: ${requestArg.service}` };
|
||||
}
|
||||
const command = requestArg.data?.command;
|
||||
const commands = typeof command === 'string' ? [command] : Array.isArray(command) ? command.filter((itemArg): itemArg is string => typeof itemArg === 'string' && Boolean(itemArg)) : [];
|
||||
if (!commands.length) {
|
||||
return { success: false, error: 'Android TV Remote remote.send_command requires data.command.' };
|
||||
}
|
||||
await this.client.sendCommand(commands, {
|
||||
repeats: this.numberValue(requestArg.data?.num_repeats ?? requestArg.data?.numRepeats),
|
||||
delaySecs: this.numberValue(requestArg.data?.delay_secs ?? requestArg.data?.delaySecs),
|
||||
holdSecs: this.numberValue(requestArg.data?.hold_secs ?? requestArg.data?.holdSecs),
|
||||
reason: 'remote_send_command',
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async callAndroidtvRemoteService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'connect') {
|
||||
await this.client.connect();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'start_pairing') {
|
||||
await this.client.startPairing();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'finish_pairing') {
|
||||
const pin = this.stringValue(requestArg.data?.pin);
|
||||
if (!pin) {
|
||||
return { success: false, error: 'Android TV Remote finish_pairing requires data.pin.' };
|
||||
}
|
||||
await this.client.finishPairing(pin);
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'launch_app') {
|
||||
const app = this.stringValue(requestArg.data?.app_id ?? requestArg.data?.appId ?? requestArg.data?.app_link ?? requestArg.data?.appLink);
|
||||
if (!app) {
|
||||
return { success: false, error: 'Android TV Remote launch_app requires data.app_id or data.app_link.' };
|
||||
}
|
||||
await this.client.launchApp(app);
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'send_text') {
|
||||
const text = this.stringValue(requestArg.data?.text);
|
||||
if (!text) {
|
||||
return { success: false, error: 'Android TV Remote send_text requires data.text.' };
|
||||
}
|
||||
await this.client.sendText(text);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: `Unsupported Android TV Remote service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'turn_on') {
|
||||
await this.client.turnOn();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
await this.client.turnOff();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'play' || requestArg.service === 'media_play') {
|
||||
await this.client.mediaPlay();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'pause' || requestArg.service === 'media_pause') {
|
||||
await this.client.mediaPause();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'play_pause' || requestArg.service === 'media_play_pause') {
|
||||
await this.client.mediaPlayPause();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'stop' || requestArg.service === 'media_stop') {
|
||||
await this.client.mediaStop();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'next_track' || requestArg.service === 'media_next_track') {
|
||||
await this.client.mediaNextTrack();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'previous_track' || requestArg.service === 'media_previous_track') {
|
||||
await this.client.mediaPreviousTrack();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'volume_up') {
|
||||
await this.client.volumeUp();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'volume_down') {
|
||||
await this.client.volumeDown();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'volume_mute' || requestArg.service === 'mute') {
|
||||
const muted = requestArg.data?.is_volume_muted ?? requestArg.data?.muted;
|
||||
if (typeof muted !== 'boolean') {
|
||||
return { success: false, error: 'Android TV Remote volume_mute requires data.is_volume_muted.' };
|
||||
}
|
||||
await this.client.muteVolume(muted);
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'volume_set') {
|
||||
const level = requestArg.data?.volume_level;
|
||||
if (typeof level !== 'number') {
|
||||
return { success: false, error: 'Android TV Remote volume_set requires data.volume_level.' };
|
||||
}
|
||||
await this.client.setVolumeLevel(level);
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'select_source') {
|
||||
const source = this.stringValue(requestArg.data?.source);
|
||||
if (!source) {
|
||||
return { success: false, error: 'Android TV Remote select_source requires data.source.' };
|
||||
}
|
||||
await this.client.selectActivity(source);
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'play_media') {
|
||||
return await this.callPlayMediaService(requestArg);
|
||||
}
|
||||
return { success: false, error: `Unsupported Android TV Remote media_player service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
private async callPlayMediaService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
const mediaId = this.stringValue(requestArg.data?.media_content_id ?? requestArg.data?.media_id ?? requestArg.data?.uri);
|
||||
const mediaType = this.stringValue(requestArg.data?.media_content_type ?? requestArg.data?.media_type);
|
||||
if (!mediaId) {
|
||||
return { success: false, error: 'Android TV Remote play_media requires data.media_content_id.' };
|
||||
}
|
||||
if (mediaType === 'channel') {
|
||||
await this.client.playChannel(mediaId);
|
||||
return { success: true };
|
||||
}
|
||||
if (mediaType === 'app' || mediaType === 'url') {
|
||||
await this.client.launchApp(mediaId);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: `Unsupported Android TV Remote media type: ${mediaType || 'unknown'}` };
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { TAndroidtvRemoteKeyCode } from './androidtv_remote.types.js';
|
||||
|
||||
export const androidtvRemoteApiPort = 6466;
|
||||
export const androidtvRemotePairPort = 6467;
|
||||
export const androidtvRemoteMdnsService = '_androidtvremote2._tcp.local.';
|
||||
|
||||
export const androidtvRemoteKnownApps: Record<string, string> = {
|
||||
'com.android.tv.settings': 'Settings',
|
||||
'com.disney.disneyplus': 'Disney+',
|
||||
'com.google.android.apps.tv.launcherx': 'Google TV Launcher',
|
||||
'com.google.android.tvlauncher': 'Android TV Launcher',
|
||||
'com.google.android.youtube.tv': 'YouTube',
|
||||
'com.google.android.youtube.tvkids': 'YouTube Kids',
|
||||
'com.google.android.youtube.tvmusic': 'YouTube Music',
|
||||
'com.hbo.hbonow': 'HBO Max',
|
||||
'com.hulu.plus': 'Hulu',
|
||||
'com.netflix.ninja': 'Netflix',
|
||||
'com.plexapp.android': 'Plex',
|
||||
'com.spotify.tv.android': 'Spotify',
|
||||
'org.jellyfin.androidtv': 'Jellyfin',
|
||||
'org.videolan.vlc': 'VLC',
|
||||
'org.xbmc.kodi': 'Kodi',
|
||||
'tv.twitch.android.app': 'Twitch',
|
||||
};
|
||||
|
||||
export const androidtvRemoteKeyAliases: Record<string, TAndroidtvRemoteKeyCode> = {
|
||||
BLUE: 'PROG_BLUE',
|
||||
CENTER: 'DPAD_CENTER',
|
||||
CH_DOWN: 'CHANNEL_DOWN',
|
||||
CH_UP: 'CHANNEL_UP',
|
||||
CHANNELDOWN: 'CHANNEL_DOWN',
|
||||
CHANNELUP: 'CHANNEL_UP',
|
||||
DOWN: 'DPAD_DOWN',
|
||||
FAST_FORWARD: 'MEDIA_FAST_FORWARD',
|
||||
FORWARD: 'MEDIA_FAST_FORWARD',
|
||||
GREEN: 'PROG_GREEN',
|
||||
INFO: 'INFO',
|
||||
LEFT: 'DPAD_LEFT',
|
||||
NEXT: 'MEDIA_NEXT',
|
||||
PAUSE: 'MEDIA_PAUSE',
|
||||
PLAY: 'MEDIA_PLAY',
|
||||
PLAY_PAUSE: 'MEDIA_PLAY_PAUSE',
|
||||
PREVIOUS: 'MEDIA_PREVIOUS',
|
||||
RED: 'PROG_RED',
|
||||
REWIND: 'MEDIA_REWIND',
|
||||
RIGHT: 'DPAD_RIGHT',
|
||||
SELECT: 'DPAD_CENTER',
|
||||
STOP: 'MEDIA_STOP',
|
||||
UP: 'DPAD_UP',
|
||||
VOL_DOWN: 'VOLUME_DOWN',
|
||||
VOL_UP: 'VOLUME_UP',
|
||||
VOLUMEDOWN: 'VOLUME_DOWN',
|
||||
VOLUMEMUTE: 'VOLUME_MUTE',
|
||||
VOLUMEUP: 'VOLUME_UP',
|
||||
YELLOW: 'PROG_YELLOW',
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import { androidtvRemoteApiPort, androidtvRemoteMdnsService, androidtvRemotePairPort } from './androidtv_remote.constants.js';
|
||||
import type { IAndroidtvRemoteManualEntry, IAndroidtvRemoteMdnsRecord } from './androidtv_remote.types.js';
|
||||
|
||||
export class AndroidtvRemoteMdnsMatcher implements IDiscoveryMatcher<IAndroidtvRemoteMdnsRecord> {
|
||||
public id = 'androidtv-remote-mdns-match';
|
||||
public source = 'mdns' as const;
|
||||
public description = 'Recognize Android TV Remote protocol v2 mDNS advertisements.';
|
||||
|
||||
public async matches(recordArg: IAndroidtvRemoteMdnsRecord): Promise<IDiscoveryMatch> {
|
||||
const type = this.stringValue(recordArg.type || recordArg.metadata?.type).toLowerCase();
|
||||
const name = this.stringValue(recordArg.name || recordArg.metadata?.name);
|
||||
const matched = type.includes('androidtvremote2') || name.toLowerCase().includes(androidtvRemoteMdnsService);
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'mDNS record is not an Android TV Remote advertisement.' };
|
||||
}
|
||||
const properties = { ...(recordArg.txt || {}), ...(recordArg.properties || {}) };
|
||||
const macAddress = this.stringValue(properties.bt || properties.mac || properties.macAddress || recordArg.metadata?.macAddress);
|
||||
const id = this.stringValue(properties.id || macAddress);
|
||||
const displayName = this.displayName(name);
|
||||
const apiPort = this.numberValue(recordArg.port) || androidtvRemoteApiPort;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: recordArg.host && macAddress ? 'certain' : recordArg.host ? 'high' : 'medium',
|
||||
reason: 'mDNS record matches Android TV Remote protocol v2.',
|
||||
normalizedDeviceId: id || recordArg.host,
|
||||
candidate: {
|
||||
source: 'mdns',
|
||||
integrationDomain: 'androidtv_remote',
|
||||
id,
|
||||
host: recordArg.host,
|
||||
port: apiPort,
|
||||
name: displayName,
|
||||
macAddress,
|
||||
metadata: {
|
||||
type: recordArg.type,
|
||||
txt: properties,
|
||||
apiPort,
|
||||
pairPort: androidtvRemotePairPort,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private displayName(nameArg: string): string | undefined {
|
||||
const name = nameArg.replace(androidtvRemoteMdnsService, '').replace(/\.$/, '').trim();
|
||||
return name || undefined;
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : '';
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0 ? valueArg : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class AndroidtvRemoteManualMatcher implements IDiscoveryMatcher<IAndroidtvRemoteManualEntry> {
|
||||
public id = 'androidtv-remote-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Android TV Remote host entries.';
|
||||
|
||||
public async matches(inputArg: IAndroidtvRemoteManualEntry): Promise<IDiscoveryMatch> {
|
||||
const matched = Boolean(inputArg.host || inputArg.metadata?.androidtvRemote || inputArg.metadata?.androidtv_remote);
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not include an Android TV Remote host.' };
|
||||
}
|
||||
const apiPort = this.numberValue(inputArg.apiPort || inputArg.port || inputArg.metadata?.apiPort) || androidtvRemoteApiPort;
|
||||
const pairPort = this.numberValue(inputArg.pairPort || inputArg.metadata?.pairPort) || androidtvRemotePairPort;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: inputArg.host ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start Android TV Remote setup.',
|
||||
normalizedDeviceId: inputArg.id || inputArg.macAddress || inputArg.host,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: 'androidtv_remote',
|
||||
id: inputArg.id || inputArg.macAddress,
|
||||
host: inputArg.host,
|
||||
port: apiPort,
|
||||
name: inputArg.deviceName || inputArg.name,
|
||||
manufacturer: inputArg.manufacturer,
|
||||
model: inputArg.model,
|
||||
macAddress: inputArg.macAddress,
|
||||
metadata: {
|
||||
...inputArg.metadata,
|
||||
apiPort,
|
||||
pairPort,
|
||||
enableIme: inputArg.enableIme,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0 ? valueArg : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class AndroidtvRemoteCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'androidtv-remote-candidate-validator';
|
||||
public description = 'Validate Android TV Remote candidates.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const matched = candidateArg.integrationDomain === 'androidtv_remote' || Boolean(candidateArg.metadata?.androidtvRemote || candidateArg.metadata?.androidtv_remote);
|
||||
return {
|
||||
matched,
|
||||
confidence: matched && candidateArg.host && (candidateArg.id || candidateArg.macAddress) ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Candidate has Android TV Remote metadata.' : 'Candidate is not Android TV Remote.',
|
||||
candidate: matched ? candidateArg : undefined,
|
||||
normalizedDeviceId: candidateArg.id || candidateArg.macAddress || candidateArg.host,
|
||||
metadata: matched ? {
|
||||
apiPort: candidateArg.port || candidateArg.metadata?.apiPort || androidtvRemoteApiPort,
|
||||
pairPort: candidateArg.metadata?.pairPort || androidtvRemotePairPort,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createAndroidtvRemoteDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: 'androidtv_remote', displayName: 'Android TV Remote' })
|
||||
.addMatcher(new AndroidtvRemoteMdnsMatcher())
|
||||
.addMatcher(new AndroidtvRemoteManualMatcher())
|
||||
.addValidator(new AndroidtvRemoteCandidateValidator());
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity } from '../../core/types.js';
|
||||
import { androidtvRemoteKnownApps } from './androidtv_remote.constants.js';
|
||||
import type { IAndroidtvRemoteApp, IAndroidtvRemoteSnapshot, IAndroidtvRemoteVolumeInfo } from './androidtv_remote.types.js';
|
||||
|
||||
export class AndroidtvRemoteMapper {
|
||||
public static toDevices(snapshotArg: IAndroidtvRemoteSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
return [{
|
||||
id: this.deviceId(snapshotArg),
|
||||
integrationDomain: 'androidtv_remote',
|
||||
name: this.deviceName(snapshotArg),
|
||||
protocol: 'unknown',
|
||||
manufacturer: snapshotArg.deviceInfo.manufacturer || 'Android',
|
||||
model: snapshotArg.deviceInfo.model || 'Android TV Remote',
|
||||
online: this.available(snapshotArg),
|
||||
features: [
|
||||
{ id: 'power', capability: 'media', name: 'Power', readable: true, writable: true },
|
||||
{ id: 'media_state', capability: 'media', name: 'Media state', readable: true, writable: false },
|
||||
{ id: 'activity', capability: 'media', name: 'Activity', readable: true, writable: true },
|
||||
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true },
|
||||
{ id: 'mute', capability: 'media', name: 'Mute', 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: 'media_state', value: this.mediaState(snapshotArg), updatedAt },
|
||||
{ featureId: 'activity', value: this.activity(snapshotArg) || null, updatedAt },
|
||||
{ featureId: 'volume', value: this.volumePercent(snapshotArg), updatedAt },
|
||||
{ featureId: 'mute', value: this.muted(snapshotArg), updatedAt },
|
||||
],
|
||||
metadata: {
|
||||
host: snapshotArg.deviceInfo.host,
|
||||
protocol: 'androidtvremote2',
|
||||
apiPort: snapshotArg.deviceInfo.apiPort,
|
||||
pairPort: snapshotArg.deviceInfo.pairPort,
|
||||
macAddress: snapshotArg.deviceInfo.macAddress,
|
||||
softwareVersion: snapshotArg.deviceInfo.softwareVersion,
|
||||
appVersion: snapshotArg.deviceInfo.appVersion,
|
||||
currentApp: snapshotArg.state.currentApp,
|
||||
voiceEnabled: snapshotArg.state.voiceEnabled,
|
||||
apps: snapshotArg.apps.map((appArg) => ({ id: appArg.id, name: this.appName(appArg), icon: appArg.icon, link: appArg.link })),
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IAndroidtvRemoteSnapshot): IIntegrationEntity[] {
|
||||
return [{
|
||||
id: `media_player.${this.slug(this.deviceName(snapshotArg))}`,
|
||||
uniqueId: `androidtv_remote_${this.slug(this.stableDeviceKey(snapshotArg))}`,
|
||||
integrationDomain: 'androidtv_remote',
|
||||
deviceId: this.deviceId(snapshotArg),
|
||||
platform: 'media_player',
|
||||
name: this.deviceName(snapshotArg),
|
||||
state: this.mediaState(snapshotArg),
|
||||
attributes: {
|
||||
appId: snapshotArg.state.currentApp,
|
||||
appName: snapshotArg.state.currentAppName || (snapshotArg.state.currentApp ? this.appNameById(snapshotArg, snapshotArg.state.currentApp) : undefined),
|
||||
currentActivity: this.activity(snapshotArg),
|
||||
activityList: this.activityList(snapshotArg),
|
||||
source: this.activity(snapshotArg),
|
||||
sourceList: this.activityList(snapshotArg),
|
||||
volumeLevel: this.normalizedVolumeLevel(snapshotArg),
|
||||
isVolumeMuted: this.muted(snapshotArg),
|
||||
assumedState: true,
|
||||
voiceEnabled: snapshotArg.state.voiceEnabled,
|
||||
rawState: snapshotArg.state.rawState,
|
||||
},
|
||||
available: this.available(snapshotArg),
|
||||
}];
|
||||
}
|
||||
|
||||
public static mediaState(snapshotArg: IAndroidtvRemoteSnapshot): string {
|
||||
if (!this.available(snapshotArg)) {
|
||||
return 'unavailable';
|
||||
}
|
||||
const rawState = String(snapshotArg.state.mediaState || snapshotArg.state.rawState || '').toLowerCase();
|
||||
if (snapshotArg.state.isOn === false || snapshotArg.state.powerState === 'off' || rawState === 'off') {
|
||||
return 'off';
|
||||
}
|
||||
if (['playing', 'paused', 'stopped', 'idle', 'buffering', 'on'].includes(rawState)) {
|
||||
return rawState;
|
||||
}
|
||||
if (snapshotArg.state.isOn === true || snapshotArg.state.currentApp) {
|
||||
return 'on';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
public static powerState(snapshotArg: IAndroidtvRemoteSnapshot): string {
|
||||
if (snapshotArg.state.isOn === true || snapshotArg.state.powerState === 'on') {
|
||||
return 'on';
|
||||
}
|
||||
if (snapshotArg.state.isOn === false || snapshotArg.state.powerState === 'off' || String(snapshotArg.state.mediaState || '').toLowerCase() === 'off') {
|
||||
return 'off';
|
||||
}
|
||||
return this.available(snapshotArg) && snapshotArg.state.currentApp ? 'on' : 'unknown';
|
||||
}
|
||||
|
||||
private static available(snapshotArg: IAndroidtvRemoteSnapshot): boolean {
|
||||
return snapshotArg.state.available !== false;
|
||||
}
|
||||
|
||||
private static activity(snapshotArg: IAndroidtvRemoteSnapshot): string | undefined {
|
||||
return snapshotArg.state.currentActivity || snapshotArg.state.currentAppName || (snapshotArg.state.currentApp ? this.appNameById(snapshotArg, snapshotArg.state.currentApp) || snapshotArg.state.currentApp : undefined);
|
||||
}
|
||||
|
||||
private static activityList(snapshotArg: IAndroidtvRemoteSnapshot): string[] {
|
||||
const activities = new Set<string>();
|
||||
for (const appArg of snapshotArg.apps) {
|
||||
const name = this.appName(appArg);
|
||||
if (name) {
|
||||
activities.add(name);
|
||||
}
|
||||
}
|
||||
return [...activities];
|
||||
}
|
||||
|
||||
private static appNameById(snapshotArg: IAndroidtvRemoteSnapshot, appIdArg: string): string | undefined {
|
||||
return snapshotArg.apps.find((appArg) => appArg.id === appIdArg)?.name || androidtvRemoteKnownApps[appIdArg];
|
||||
}
|
||||
|
||||
private static appName(appArg: IAndroidtvRemoteApp): string {
|
||||
return appArg.name || androidtvRemoteKnownApps[appArg.id] || appArg.id;
|
||||
}
|
||||
|
||||
private static normalizedVolumeLevel(snapshotArg: IAndroidtvRemoteSnapshot): number | undefined {
|
||||
if (typeof snapshotArg.state.volumeLevel === 'number') {
|
||||
return Math.max(0, Math.min(1, snapshotArg.state.volumeLevel));
|
||||
}
|
||||
return this.volumeLevelFromInfo(snapshotArg.state.volumeInfo);
|
||||
}
|
||||
|
||||
private static volumePercent(snapshotArg: IAndroidtvRemoteSnapshot): number | null {
|
||||
const level = this.normalizedVolumeLevel(snapshotArg);
|
||||
return typeof level === 'number' ? Math.round(level * 100) : null;
|
||||
}
|
||||
|
||||
private static volumeLevelFromInfo(volumeInfoArg?: IAndroidtvRemoteVolumeInfo): number | undefined {
|
||||
if (!volumeInfoArg || typeof volumeInfoArg.level !== 'number' || typeof volumeInfoArg.max !== 'number' || volumeInfoArg.max <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(0, Math.min(1, volumeInfoArg.level / volumeInfoArg.max));
|
||||
}
|
||||
|
||||
private static muted(snapshotArg: IAndroidtvRemoteSnapshot): boolean | null {
|
||||
return snapshotArg.state.isVolumeMuted ?? snapshotArg.state.volumeInfo?.muted ?? null;
|
||||
}
|
||||
|
||||
private static deviceId(snapshotArg: IAndroidtvRemoteSnapshot): string {
|
||||
return `androidtv_remote.device.${this.slug(this.stableDeviceKey(snapshotArg))}`;
|
||||
}
|
||||
|
||||
private static stableDeviceKey(snapshotArg: IAndroidtvRemoteSnapshot): string {
|
||||
return snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.macAddress || snapshotArg.deviceInfo.host || this.deviceName(snapshotArg);
|
||||
}
|
||||
|
||||
private static deviceName(snapshotArg: IAndroidtvRemoteSnapshot): string {
|
||||
return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.model || snapshotArg.deviceInfo.host || 'Android TV Remote';
|
||||
}
|
||||
|
||||
private static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'androidtv_remote';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,254 @@
|
||||
export interface IHomeAssistantAndroidtvRemoteConfig {
|
||||
// TODO: replace with the TypeScript-native config for androidtv_remote.
|
||||
[key: string]: unknown;
|
||||
export type TAndroidtvRemoteMediaState =
|
||||
| 'off'
|
||||
| 'on'
|
||||
| 'idle'
|
||||
| 'playing'
|
||||
| 'paused'
|
||||
| 'stopped'
|
||||
| 'buffering'
|
||||
| 'unknown';
|
||||
|
||||
export type TAndroidtvRemotePowerState = 'on' | 'off' | 'unknown';
|
||||
|
||||
export type TAndroidtvRemoteCommandDirection = 'SHORT' | 'START_LONG' | 'END_LONG';
|
||||
|
||||
export type TAndroidtvRemoteKeyCode =
|
||||
| '0'
|
||||
| '1'
|
||||
| '2'
|
||||
| '3'
|
||||
| '4'
|
||||
| '5'
|
||||
| '6'
|
||||
| '7'
|
||||
| '8'
|
||||
| '9'
|
||||
| 'ASSIST'
|
||||
| 'BACK'
|
||||
| 'BUTTON_A'
|
||||
| 'BUTTON_B'
|
||||
| 'BUTTON_MODE'
|
||||
| 'BUTTON_X'
|
||||
| 'BUTTON_Y'
|
||||
| 'CAPTIONS'
|
||||
| 'CHANNEL_DOWN'
|
||||
| 'CHANNEL_UP'
|
||||
| 'DEL'
|
||||
| 'DPAD_CENTER'
|
||||
| 'DPAD_DOWN'
|
||||
| 'DPAD_LEFT'
|
||||
| 'DPAD_RIGHT'
|
||||
| 'DPAD_UP'
|
||||
| 'DVR'
|
||||
| 'ENTER'
|
||||
| 'EXPLORER'
|
||||
| 'F1'
|
||||
| 'F2'
|
||||
| 'F3'
|
||||
| 'F4'
|
||||
| 'F5'
|
||||
| 'F6'
|
||||
| 'F7'
|
||||
| 'F8'
|
||||
| 'F9'
|
||||
| 'F10'
|
||||
| 'F11'
|
||||
| 'F12'
|
||||
| 'GUIDE'
|
||||
| 'HOME'
|
||||
| 'INFO'
|
||||
| 'MEDIA_AUDIO_TRACK'
|
||||
| 'MEDIA_FAST_FORWARD'
|
||||
| 'MEDIA_NEXT'
|
||||
| 'MEDIA_PAUSE'
|
||||
| 'MEDIA_PLAY'
|
||||
| 'MEDIA_PLAY_PAUSE'
|
||||
| 'MEDIA_PREVIOUS'
|
||||
| 'MEDIA_RECORD'
|
||||
| 'MEDIA_REWIND'
|
||||
| 'MEDIA_STOP'
|
||||
| 'MENU'
|
||||
| 'MUTE'
|
||||
| 'POWER'
|
||||
| 'PROG_BLUE'
|
||||
| 'PROG_GREEN'
|
||||
| 'PROG_RED'
|
||||
| 'PROG_YELLOW'
|
||||
| 'SEARCH'
|
||||
| 'SETTINGS'
|
||||
| 'TV'
|
||||
| 'TV_TELETEXT'
|
||||
| 'VOLUME_DOWN'
|
||||
| 'VOLUME_MUTE'
|
||||
| 'VOLUME_UP';
|
||||
|
||||
export type TAndroidtvRemoteCommandReason =
|
||||
| 'connect'
|
||||
| 'finish_pairing'
|
||||
| 'launch_app'
|
||||
| 'media_next_track'
|
||||
| 'media_pause'
|
||||
| 'media_play'
|
||||
| 'media_play_pause'
|
||||
| 'media_previous_track'
|
||||
| 'media_stop'
|
||||
| 'play_channel'
|
||||
| 'remote_send_command'
|
||||
| 'select_activity'
|
||||
| 'send_text'
|
||||
| 'start_pairing'
|
||||
| 'turn_off'
|
||||
| 'turn_on'
|
||||
| 'volume_down'
|
||||
| 'volume_mute'
|
||||
| 'volume_set'
|
||||
| 'volume_up';
|
||||
|
||||
export type TAndroidtvRemoteCommandAction =
|
||||
| 'connect'
|
||||
| 'finish_pairing'
|
||||
| 'key_command'
|
||||
| 'launch_app'
|
||||
| 'remote_send_command'
|
||||
| 'send_text'
|
||||
| 'start_pairing'
|
||||
| 'volume_set';
|
||||
|
||||
export interface IAndroidtvRemoteVolumeInfo {
|
||||
level?: number;
|
||||
max?: number;
|
||||
muted?: boolean;
|
||||
playerModel?: string;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteDeviceInfo {
|
||||
id?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
apiPort?: number;
|
||||
pairPort?: number;
|
||||
macAddress?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
softwareVersion?: string;
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteDeviceState {
|
||||
available?: boolean;
|
||||
isOn?: boolean | null;
|
||||
powerState?: TAndroidtvRemotePowerState;
|
||||
mediaState?: TAndroidtvRemoteMediaState | string;
|
||||
rawState?: string;
|
||||
currentApp?: string;
|
||||
currentAppName?: string;
|
||||
currentActivity?: string;
|
||||
volumeInfo?: IAndroidtvRemoteVolumeInfo;
|
||||
volumeLevel?: number;
|
||||
isVolumeMuted?: boolean;
|
||||
voiceEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteApp {
|
||||
id: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteConfiguredApp {
|
||||
appName?: string;
|
||||
appIcon?: string;
|
||||
app_name?: string;
|
||||
app_icon?: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteSnapshot {
|
||||
deviceInfo: IAndroidtvRemoteDeviceInfo;
|
||||
state: IAndroidtvRemoteDeviceState;
|
||||
apps: IAndroidtvRemoteApp[];
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteKeyPress {
|
||||
keyCode: TAndroidtvRemoteKeyCode | string;
|
||||
direction?: TAndroidtvRemoteCommandDirection;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteCommand {
|
||||
action: TAndroidtvRemoteCommandAction;
|
||||
reason?: TAndroidtvRemoteCommandReason;
|
||||
keyCode?: TAndroidtvRemoteKeyCode | string;
|
||||
direction?: TAndroidtvRemoteCommandDirection;
|
||||
keys?: IAndroidtvRemoteKeyPress[];
|
||||
appId?: string;
|
||||
appLink?: string;
|
||||
appName?: string;
|
||||
text?: string;
|
||||
pin?: string;
|
||||
volumeLevel?: number;
|
||||
muted?: boolean;
|
||||
repeats?: number;
|
||||
delaySecs?: number;
|
||||
holdSecs?: number;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteCommandContext {
|
||||
config: IAndroidtvRemoteConfig;
|
||||
snapshot: IAndroidtvRemoteSnapshot;
|
||||
}
|
||||
|
||||
export type TAndroidtvRemoteCommandExecutor =
|
||||
| ((commandArg: IAndroidtvRemoteCommand, contextArg: IAndroidtvRemoteCommandContext) => Promise<void> | void)
|
||||
| {
|
||||
execute(commandArg: IAndroidtvRemoteCommand, contextArg: IAndroidtvRemoteCommandContext): Promise<void> | void;
|
||||
};
|
||||
|
||||
export interface IAndroidtvRemoteConfig {
|
||||
host?: string;
|
||||
apiPort?: number;
|
||||
pairPort?: number;
|
||||
deviceName?: string;
|
||||
macAddress?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
enableIme?: boolean;
|
||||
deviceInfo?: IAndroidtvRemoteDeviceInfo;
|
||||
state?: IAndroidtvRemoteDeviceState;
|
||||
volumeInfo?: IAndroidtvRemoteVolumeInfo;
|
||||
apps?: IAndroidtvRemoteApp[] | Record<string, IAndroidtvRemoteConfiguredApp>;
|
||||
snapshot?: IAndroidtvRemoteSnapshot;
|
||||
executor?: TAndroidtvRemoteCommandExecutor;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteMdnsRecord {
|
||||
type?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
txt?: Record<string, unknown>;
|
||||
properties?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteManualEntry {
|
||||
host?: string;
|
||||
apiPort?: number;
|
||||
pairPort?: number;
|
||||
port?: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
deviceName?: string;
|
||||
macAddress?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
enableIme?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TAndroidtvRemoteDiscoveryRecord = IAndroidtvRemoteMdnsRecord | IAndroidtvRemoteManualEntry;
|
||||
|
||||
export type IHomeAssistantAndroidtvRemoteConfig = IAndroidtvRemoteConfig;
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
export * from './androidtv_remote.classes.client.js';
|
||||
export * from './androidtv_remote.classes.configflow.js';
|
||||
export * from './androidtv_remote.classes.integration.js';
|
||||
export * from './androidtv_remote.constants.js';
|
||||
export * from './androidtv_remote.discovery.js';
|
||||
export * from './androidtv_remote.mapper.js';
|
||||
export * from './androidtv_remote.types.js';
|
||||
|
||||
Reference in New Issue
Block a user