Files
integrations/ts/integrations/braviatv/braviatv.classes.client.ts
T

740 lines
28 KiB
TypeScript

import type {
IBraviatvApp,
IBraviatvChannel,
IBraviatvConfig,
IBraviatvPlayingInfo,
IBraviatvSnapshot,
IBraviatvSource,
IBraviatvState,
IBraviatvSystemInfo,
IBraviatvVolumeInfo,
TBraviatvRestService,
TBraviatvSourceType,
} from './braviatv.types.js';
interface IBraviatvRestResponse {
result?: unknown[];
error?: [number, string] | unknown[];
id?: number;
}
const defaultTimeoutMs = 10000;
const defaultAudioTarget = 'speaker';
const defaultRestVersion = '1.0';
const powerOnCode = 'AAAAAQAAAAEAAAAuAw==';
const fallbackCommands: Record<string, string> = {
Power: 'AAAAAQAAAAEAAAAVAw==',
PowerOn: powerOnCode,
Input: 'AAAAAQAAAAEAAAAlAw==',
SyncMenu: 'AAAAAgAAABoAAABYAw==',
Hdmi1: 'AAAAAgAAABoAAABaAw==',
Hdmi2: 'AAAAAgAAABoAAABbAw==',
Hdmi3: 'AAAAAgAAABoAAABcAw==',
Hdmi4: 'AAAAAgAAABoAAABdAw==',
Num1: 'AAAAAQAAAAEAAAAAAw==',
Num2: 'AAAAAQAAAAEAAAABAw==',
Num3: 'AAAAAQAAAAEAAAACAw==',
Num4: 'AAAAAQAAAAEAAAADAw==',
Num5: 'AAAAAQAAAAEAAAAEAw==',
Num6: 'AAAAAQAAAAEAAAAFAw==',
Num7: 'AAAAAQAAAAEAAAAGAw==',
Num8: 'AAAAAQAAAAEAAAAHAw==',
Num9: 'AAAAAQAAAAEAAAAIAw==',
Num0: 'AAAAAQAAAAEAAAAJAw==',
Dot: 'AAAAAgAAAJcAAAAdAw==',
CC: 'AAAAAgAAAJcAAAAoAw==',
Red: 'AAAAAgAAAJcAAAAlAw==',
Green: 'AAAAAgAAAJcAAAAmAw==',
Yellow: 'AAAAAgAAAJcAAAAnAw==',
Blue: 'AAAAAgAAAJcAAAAkAw==',
Up: 'AAAAAQAAAAEAAAB0Aw==',
Down: 'AAAAAQAAAAEAAAB1Aw==',
Right: 'AAAAAQAAAAEAAAAzAw==',
Left: 'AAAAAQAAAAEAAAA0Aw==',
Confirm: 'AAAAAQAAAAEAAABlAw==',
Help: 'AAAAAgAAAMQAAABNAw==',
Display: 'AAAAAQAAAAEAAAA6Aw==',
Options: 'AAAAAgAAAJcAAAA2Aw==',
Back: 'AAAAAgAAAJcAAAAjAw==',
Home: 'AAAAAQAAAAEAAABgAw==',
VolumeUp: 'AAAAAQAAAAEAAAASAw==',
VolumeDown: 'AAAAAQAAAAEAAAATAw==',
Mute: 'AAAAAQAAAAEAAAAUAw==',
Audio: 'AAAAAQAAAAEAAAAXAw==',
ChannelUp: 'AAAAAQAAAAEAAAAQAw==',
ChannelDown: 'AAAAAQAAAAEAAAARAw==',
Play: 'AAAAAgAAAJcAAAAaAw==',
Pause: 'AAAAAgAAAJcAAAAZAw==',
Stop: 'AAAAAgAAAJcAAAAYAw==',
FlashPlus: 'AAAAAgAAAJcAAAB4Aw==',
FlashMinus: 'AAAAAgAAAJcAAAB5Aw==',
Prev: 'AAAAAgAAAJcAAAA8Aw==',
Next: 'AAAAAgAAAJcAAAA9Aw==',
};
export class BraviatvAuthError extends Error {
constructor(messageArg: string) {
super(messageArg);
this.name = 'BraviatvAuthError';
}
}
export class BraviatvLiveControlError extends Error {
constructor(messageArg: string) {
super(messageArg);
this.name = 'BraviatvLiveControlError';
}
}
export class BraviatvClient {
private commandCache?: Record<string, string>;
private irccEndpoint: string;
constructor(private readonly config: IBraviatvConfig) {
this.irccEndpoint = config.irccEndpoint || 'ircc';
this.commandCache = config.commands ? { ...fallbackCommands, ...config.commands } : undefined;
}
public async getSnapshot(): Promise<IBraviatvSnapshot> {
if (this.config.snapshot) {
return this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot));
}
if (this.hasLivePskConfig()) {
try {
return await this.getLiveSnapshot();
} catch (errorArg) {
if (errorArg instanceof BraviatvAuthError) {
throw errorArg;
}
return this.normalizeSnapshot(this.snapshotFromManualConfig(this.errorMessage(errorArg)));
}
}
return this.normalizeSnapshot(this.snapshotFromManualConfig());
}
public async turnOn(): Promise<void> {
this.assertLivePskSupported('turn_on');
try {
await this.sendRestQuick('system', 'setPowerStatus', { status: true });
} catch {
await this.sendIrcc(powerOnCode);
}
this.updateLocalState({ powerStatus: 'active', power: 'on', available: true });
}
public async turnOff(): Promise<void> {
await this.sendRestQuick('system', 'setPowerStatus', { status: false });
this.updateLocalState({ powerStatus: 'standby', power: 'off', playback: 'off' });
}
public async setVolumeLevel(volumeLevelArg: number): Promise<void> {
const volume = Math.max(0, Math.min(100, Math.round(volumeLevelArg * 100)));
await this.sendRestQuick('audio', 'setAudioVolume', { target: defaultAudioTarget, volume: String(volume) });
this.updateLocalState({ volumeLevel: volume / 100, volumePercent: volume });
}
public async volumeUp(): Promise<void> {
await this.stepVolume(1);
}
public async volumeDown(): Promise<void> {
await this.stepVolume(-1);
}
public async muteVolume(mutedArg: boolean): Promise<void> {
await this.sendRestQuick('audio', 'setAudioMute', { status: mutedArg });
this.updateLocalState({ muted: mutedArg });
}
public async mediaPlay(): Promise<void> {
await this.sendCommand('Play');
this.updateLocalState({ playback: 'playing' });
}
public async mediaPause(): Promise<void> {
await this.sendCommand('Pause');
this.updateLocalState({ playback: 'paused' });
}
public async mediaStop(): Promise<void> {
await this.sendCommand('Stop');
this.updateLocalState({ playback: 'idle' });
}
public async nextTrack(): Promise<void> {
await this.sendCommand('Next');
}
public async previousTrack(): Promise<void> {
await this.sendCommand('Prev');
}
public async selectSource(sourceArg: string): Promise<void> {
const source = sourceArg.trim();
if (!source) {
throw new Error('Sony Bravia TV select_source requires a source name or URI.');
}
const item = await this.findSource(source);
await this.startSource(item.uri, item.type);
this.updateLocalState({ powerStatus: 'active', power: 'on', source: item.title, sourceUri: item.uri });
}
public async playMedia(mediaTypeArg: string, mediaIdArg: string): Promise<void> {
const normalizedType = mediaTypeArg.toLowerCase();
if (normalizedType !== 'app' && normalizedType !== 'channel') {
throw new Error(`Sony Bravia TV play_media supports app or channel media only, not ${mediaTypeArg}.`);
}
const source = await this.findSource(mediaIdArg, normalizedType as TBraviatvSourceType);
await this.startSource(source.uri, source.type);
}
public async sendCommand(commandArg: string): Promise<void> {
const code = await this.commandCode(commandArg);
if (!code) {
throw new Error(`Unsupported Sony Bravia TV IRCC command: ${commandArg}`);
}
await this.sendIrcc(code);
}
public async sendCommands(commandsArg: string[], repeatsArg = 1): Promise<void> {
const repeats = Math.max(1, Math.floor(repeatsArg));
for (let repeat = 0; repeat < repeats; repeat += 1) {
for (const command of commandsArg) {
await this.sendCommand(command);
}
}
}
public async reboot(): Promise<void> {
await this.sendRestQuick('system', 'requestReboot');
}
public async terminateApps(): Promise<void> {
await this.sendRestQuick('appControl', 'terminateApps');
}
public async destroy(): Promise<void> {}
private async getLiveSnapshot(): Promise<IBraviatvSnapshot> {
const powerStatus = await this.getPowerStatus();
const systemInfo = await this.getSystemInfo().catch(() => this.systemInfoFromConfig());
const state: IBraviatvState = {
...this.config.state,
powerStatus,
power: this.powerFromStatus(powerStatus),
available: true,
};
let sources = this.config.sources || [];
let apps = this.config.apps || [];
let channels = this.config.channels || [];
let commands = this.config.commands;
if (state.power === 'on') {
const [volumeInfo, playingInfo, liveSources, liveApps, liveChannels, liveCommands] = await Promise.all([
this.getVolumeInfo().catch(() => undefined),
this.getPlayingInfo().catch(() => undefined),
this.getExternalInputsStatus().catch(() => sources),
this.getAppList().catch(() => apps),
this.config.fetchChannels ? this.getContentListAll('tv').catch(() => channels) : Promise.resolve(channels),
this.getCommandList().catch(() => commands),
]);
sources = liveSources;
apps = liveApps;
channels = liveChannels;
commands = liveCommands;
this.applyVolumeInfo(state, volumeInfo);
this.applyPlayingInfo(state, playingInfo);
}
return this.normalizeSnapshot({
systemInfo,
state,
sources,
apps,
channels,
commands,
updatedAt: new Date().toISOString(),
});
}
private async getPowerStatus(): Promise<string> {
const response = await this.sendRest('system', 'getPowerStatus', undefined, defaultRestVersion, 5000);
return this.firstResult<{ status?: string }>(response)?.status || 'unknown';
}
private async getSystemInfo(): Promise<IBraviatvSystemInfo> {
const response = await this.sendRest('system', 'getSystemInformation');
const info = this.firstResult<IBraviatvSystemInfo>(response) || {};
this.config.macAddress = info.macAddr || this.config.macAddress;
this.config.cid = info.cid || this.config.cid;
return info;
}
private async getVolumeInfo(targetArg = defaultAudioTarget): Promise<IBraviatvVolumeInfo | undefined> {
const response = await this.sendRest('audio', 'getVolumeInformation');
const outputs = this.firstResult<IBraviatvVolumeInfo[]>(response) || [];
return outputs.find((outputArg) => outputArg.target === targetArg) || outputs[0];
}
private async getPlayingInfo(): Promise<IBraviatvPlayingInfo | undefined> {
const response = await this.sendRest('avContent', 'getPlayingContentInfo');
return this.firstResult<IBraviatvPlayingInfo>(response);
}
private async getExternalInputsStatus(): Promise<IBraviatvSource[]> {
const response = await this.sendRest('avContent', 'getCurrentExternalInputsStatus');
return (this.firstResult<IBraviatvSource[]>(response) || []).map((sourceArg) => ({ ...sourceArg, type: 'input' }));
}
private async getAppList(): Promise<IBraviatvApp[]> {
const response = await this.sendRest('appControl', 'getApplicationList');
return (this.firstResult<IBraviatvApp[]>(response) || []).map((appArg) => ({ ...appArg, type: 'app' }));
}
private async getSourceList(schemeArg: string): Promise<string[]> {
const response = await this.sendRest('avContent', 'getSourceList', { scheme: schemeArg });
return (this.firstResult<Array<{ source?: string }>>(response) || []).map((itemArg) => itemArg.source).filter((itemArg): itemArg is string => Boolean(itemArg));
}
private async getContentCount(sourceArg: string): Promise<number> {
const response = await this.sendRest('avContent', 'getContentCount', { source: sourceArg }, defaultRestVersion, 20000);
return this.firstResult<{ count?: number }>(response)?.count || 0;
}
private async getContentList(sourceArg: string, indexArg: number, countArg: number): Promise<IBraviatvChannel[]> {
const response = await this.sendRest('avContent', 'getContentList', { source: sourceArg, stIdx: indexArg, cnt: countArg }, defaultRestVersion, 20000);
return (this.firstResult<IBraviatvChannel[]>(response) || []).map((channelArg) => ({ ...channelArg, type: 'channel' }));
}
private async getContentListAll(schemeArg: string): Promise<IBraviatvChannel[]> {
const channels: IBraviatvChannel[] = [];
for (const source of await this.getSourceList(schemeArg)) {
const total = await this.getContentCount(source);
for (let index = 0; index < total; index += 50) {
channels.push(...await this.getContentList(source, index, Math.min(50, total - index)));
}
}
return channels;
}
private async getCommandList(): Promise<Record<string, string>> {
if (this.commandCache) {
return this.commandCache;
}
const response = await this.sendRest('system', 'getRemoteControllerInfo');
const remoteCommands = Array.isArray(response.result?.[1]) ? response.result[1] as Array<{ name?: string; value?: string }> : [];
this.commandCache = {
...fallbackCommands,
...Object.fromEntries(remoteCommands.filter((itemArg) => itemArg.name && itemArg.value).map((itemArg) => [itemArg.name as string, itemArg.value as string])),
};
return this.commandCache;
}
private async stepVolume(stepArg: number): Promise<void> {
const prefix = stepArg > 0 ? '+' : '';
await this.sendRestQuick('audio', 'setAudioVolume', { target: defaultAudioTarget, volume: `${prefix}${stepArg}` });
}
private async findSource(queryArg: string, sourceTypeArg?: TBraviatvSourceType): Promise<Required<Pick<IBraviatvSource, 'title' | 'uri' | 'type'>>> {
const direct = this.directSource(queryArg, sourceTypeArg);
if (direct) {
return direct;
}
const snapshot = await this.getSnapshot();
const items = [...snapshot.sources, ...snapshot.apps, ...(snapshot.channels || [])].map((itemArg) => ({
...itemArg,
type: itemArg.type || this.inferSourceType(itemArg.uri),
}));
const exact = items.find((itemArg) => (!sourceTypeArg || itemArg.type === sourceTypeArg) && (itemArg.uri === queryArg || itemArg.title.toLowerCase() === queryArg.toLowerCase()));
if (exact) {
return { title: exact.title, uri: exact.uri, type: exact.type };
}
const coarse = items.find((itemArg) => (!sourceTypeArg || itemArg.type === sourceTypeArg) && itemArg.title.toLowerCase().includes(queryArg.toLowerCase()));
if (coarse) {
return { title: coarse.title, uri: coarse.uri, type: coarse.type };
}
throw new Error(`Sony Bravia TV source is not known: ${queryArg}`);
}
private directSource(sourceArg: string, sourceTypeArg?: TBraviatvSourceType): Required<Pick<IBraviatvSource, 'title' | 'uri' | 'type'>> | undefined {
if (sourceArg.startsWith('extInput:') || sourceArg.startsWith('tv:')) {
return { title: sourceArg, uri: sourceArg, type: sourceArg.startsWith('tv:') ? 'channel' : 'input' };
}
if (sourceArg.startsWith('com.sony.dtv.') || sourceArg.startsWith('localapp:')) {
return { title: sourceArg, uri: sourceArg, type: sourceTypeArg || 'app' };
}
return undefined;
}
private async startSource(uriArg: string, sourceTypeArg: TBraviatvSourceType): Promise<void> {
if (sourceTypeArg === 'app') {
await this.sendRestQuick('appControl', 'setActiveApp', { uri: uriArg });
return;
}
await this.sendRestQuick('avContent', 'setPlayContent', { uri: uriArg });
}
private async sendRestQuick(serviceArg: TBraviatvRestService, methodArg: string, paramsArg?: unknown, versionArg = defaultRestVersion): Promise<boolean> {
const response = await this.sendRest(serviceArg, methodArg, paramsArg, versionArg);
return Boolean(response.result);
}
private async sendRest(serviceArg: TBraviatvRestService, methodArg: string, paramsArg?: unknown, versionArg = defaultRestVersion, timeoutMsArg?: number): Promise<IBraviatvRestResponse> {
this.assertLivePskSupported(`${serviceArg}.${methodArg}`);
const params = paramsArg === undefined ? [] : Array.isArray(paramsArg) ? paramsArg : [paramsArg];
const response = await this.postJson(this.serviceUrl(serviceArg), {
method: methodArg,
params,
id: 1,
version: versionArg,
}, timeoutMsArg);
if (response.error) {
const [code, message] = response.error;
const text = String(message || code || 'Unknown REST API error');
if (Number(code) === 401) {
throw new BraviatvAuthError(`Sony Bravia TV authentication failed for ${methodArg}: ${text}`);
}
if (text.includes('not power-on')) {
throw new BraviatvLiveControlError(`Sony Bravia TV is turned off and rejected ${methodArg}.`);
}
throw new BraviatvLiveControlError(`Sony Bravia TV REST ${methodArg} failed: ${text}`);
}
return response;
}
private async sendIrcc(codeArg: string): Promise<boolean> {
this.assertLivePskSupported('IRCC command');
try {
return await this.postIrcc(this.irccEndpoint, codeArg);
} catch (errorArg) {
if (this.errorMessage(errorArg).includes('HTTP 404')) {
this.irccEndpoint = this.irccEndpoint === 'ircc' ? 'IRCC' : 'ircc';
return this.postIrcc(this.irccEndpoint, codeArg);
}
throw errorArg;
}
}
private async postIrcc(endpointArg: string, codeArg: string): Promise<boolean> {
const response = await this.postText(this.serviceUrl(endpointArg), this.irccBody(codeArg), {
SOAPACTION: '"urn:schemas-sony-com:service:IRCC:1#X_SendIRCC"',
'Content-Type': 'text/xml; charset=UTF-8',
});
return response;
}
private async postJson(urlArg: string, bodyArg: unknown, timeoutMsArg?: number): Promise<IBraviatvRestResponse> {
const text = await this.request(urlArg, {
'Content-Type': 'application/json; charset=UTF-8',
}, JSON.stringify(bodyArg), timeoutMsArg);
try {
return (text ? JSON.parse(text) : {}) as IBraviatvRestResponse;
} catch (errorArg) {
throw new BraviatvLiveControlError(`Sony Bravia TV returned invalid JSON: ${this.errorMessage(errorArg)}`);
}
}
private async postText(urlArg: string, bodyArg: string, headersArg: Record<string, string>): Promise<boolean> {
await this.request(urlArg, headersArg, bodyArg);
return true;
}
private async request(urlArg: string, headersArg: Record<string, string>, bodyArg: string, timeoutMsArg?: number): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMsArg || this.config.timeoutMs || defaultTimeoutMs);
try {
const response = await globalThis.fetch(urlArg, {
method: 'POST',
headers: {
...headersArg,
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Auth-PSK': this.config.psk as string,
},
body: bodyArg,
signal: controller.signal,
});
const text = await response.text();
if (response.status === 401 || response.status === 403) {
throw new BraviatvAuthError(`Sony Bravia TV authentication failed with HTTP ${response.status}.`);
}
if (!response.ok) {
throw new BraviatvLiveControlError(`Sony Bravia TV request failed with HTTP ${response.status}: ${text}`);
}
return text;
} catch (errorArg) {
if (errorArg instanceof BraviatvAuthError || errorArg instanceof BraviatvLiveControlError) {
throw errorArg;
}
if (errorArg instanceof Error && errorArg.name === 'AbortError') {
throw new BraviatvLiveControlError(`Sony Bravia TV request timed out after ${timeoutMsArg || this.config.timeoutMs || defaultTimeoutMs}ms.`);
}
throw new BraviatvLiveControlError(`Sony Bravia TV request failed: ${this.errorMessage(errorArg)}`);
} finally {
clearTimeout(timeout);
}
}
private assertLivePskSupported(commandArg: string): void {
if (!this.config.host) {
throw new BraviatvLiveControlError(`Sony Bravia TV host is required for live ${commandArg}.`);
}
if (!this.config.psk) {
if (this.config.pin || this.config.usePsk === false) {
throw new BraviatvLiveControlError(`Sony Bravia TV PIN pairing/cookie authentication is not implemented in this native TypeScript port. Configure a PSK to use live ${commandArg}.`);
}
throw new BraviatvLiveControlError(`Sony Bravia TV PSK is required for live ${commandArg}.`);
}
}
private hasLivePskConfig(): boolean {
return Boolean(this.config.host && this.config.psk);
}
private serviceUrl(serviceArg: string): string {
const protocol = this.config.useSsl ? 'https' : 'http';
const port = this.config.port ? `:${this.config.port}` : '';
return `${protocol}://${this.config.host}${port}/sony/${serviceArg}`;
}
private irccBody(codeArg: string): string {
return `<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:X_SendIRCC xmlns:u="urn:schemas-sony-com:service:IRCC:1"><IRCCCode>${codeArg}</IRCCCode></u:X_SendIRCC></s:Body></s:Envelope>`;
}
private firstResult<TResult>(responseArg: IBraviatvRestResponse): TResult | undefined {
return responseArg.result?.[0] as TResult | undefined;
}
private async commandCode(commandArg: string): Promise<string | undefined> {
const command = commandArg.trim();
if (this.looksLikeIrccCode(command)) {
return command;
}
const commands = await this.getCommandList().catch(() => ({ ...fallbackCommands, ...(this.config.commands || {}) }));
const candidates = [command, this.normalizeCommand(command)].filter(Boolean);
for (const candidate of candidates) {
if (commands[candidate]) {
return commands[candidate];
}
const lowerCandidate = candidate.toLowerCase();
const entry = Object.entries(commands).find(([name]) => name.toLowerCase() === lowerCandidate);
if (entry) {
return entry[1];
}
}
return undefined;
}
private normalizeCommand(commandArg: string): string {
const normalized = commandArg.replace(/[_\s-]+/g, '').toLowerCase();
const aliases: Record<string, string> = {
power: 'Power',
poweron: 'PowerOn',
input: 'Input',
source: 'Input',
hdmi1: 'Hdmi1',
hdmi2: 'Hdmi2',
hdmi3: 'Hdmi3',
hdmi4: 'Hdmi4',
up: 'Up',
down: 'Down',
left: 'Left',
right: 'Right',
ok: 'Confirm',
enter: 'Confirm',
select: 'Confirm',
confirm: 'Confirm',
back: 'Back',
return: 'Back',
home: 'Home',
menu: 'Home',
options: 'Options',
info: 'Display',
display: 'Display',
volumeup: 'VolumeUp',
volup: 'VolumeUp',
volumedown: 'VolumeDown',
voldown: 'VolumeDown',
mute: 'Mute',
channelup: 'ChannelUp',
chup: 'ChannelUp',
channeldown: 'ChannelDown',
chdown: 'ChannelDown',
play: 'Play',
pause: 'Pause',
stop: 'Stop',
next: 'Next',
previous: 'Prev',
prev: 'Prev',
};
return aliases[normalized] || commandArg;
}
private looksLikeIrccCode(valueArg: string): boolean {
return /^[A-Za-z0-9+/]+={0,2}$/.test(valueArg) && valueArg.length >= 16 && valueArg.includes('Aw');
}
private snapshotFromManualConfig(errorMessageArg?: string): IBraviatvSnapshot {
const systemInfo = this.systemInfoFromConfig();
const state: IBraviatvState = {
powerStatus: this.config.state?.powerStatus || (this.config.state?.power === 'on' ? 'active' : this.config.state?.power === 'off' ? 'standby' : 'unknown'),
available: Boolean(this.config.snapshot || this.config.state || this.config.systemInfo || this.config.host),
...this.config.state,
lastError: errorMessageArg || this.config.state?.lastError,
};
return {
systemInfo,
state,
sources: [...(this.config.sources || [])],
apps: [...(this.config.apps || [])],
channels: [...(this.config.channels || [])],
commands: this.config.commands,
updatedAt: new Date().toISOString(),
};
}
private systemInfoFromConfig(): IBraviatvSystemInfo {
return {
...this.config.systemInfo,
name: this.config.systemInfo?.name || this.config.name || this.config.host || 'Sony Bravia TV',
model: this.config.systemInfo?.model || this.config.model,
serial: this.config.systemInfo?.serial || this.config.serialNumber,
macAddr: this.config.systemInfo?.macAddr || this.config.macAddress,
cid: this.config.systemInfo?.cid || this.config.cid || this.config.macAddress || this.config.host,
product: this.config.systemInfo?.product || 'TV',
};
}
private normalizeSnapshot(snapshotArg: IBraviatvSnapshot): IBraviatvSnapshot {
const systemInfo = {
...this.systemInfoFromConfig(),
...snapshotArg.systemInfo,
};
const state = this.normalizeState(snapshotArg.state);
return {
...snapshotArg,
systemInfo,
state,
sources: this.normalizeSources(snapshotArg.sources, 'input'),
apps: this.normalizeSources(snapshotArg.apps, 'app') as IBraviatvApp[],
channels: this.normalizeSources(snapshotArg.channels || [], 'channel') as IBraviatvChannel[],
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
};
}
private normalizeState(stateArg: IBraviatvState): IBraviatvState {
const powerStatus = stateArg.powerStatus || (stateArg.power === 'on' ? 'active' : stateArg.power === 'off' ? 'standby' : 'unknown');
const power = stateArg.power || this.powerFromStatus(powerStatus);
let volumeLevel = stateArg.volumeLevel;
if (typeof volumeLevel === 'number' && volumeLevel > 1) {
volumeLevel /= 100;
}
if (typeof volumeLevel !== 'number' && typeof stateArg.volumePercent === 'number') {
volumeLevel = stateArg.volumePercent / 100;
}
return {
...stateArg,
powerStatus,
power,
playback: stateArg.playback || (power === 'off' ? 'off' : 'idle'),
volumeLevel,
volumePercent: typeof stateArg.volumePercent === 'number' ? stateArg.volumePercent : typeof volumeLevel === 'number' ? Math.round(volumeLevel * 100) : undefined,
};
}
private normalizeSources(sourcesArg: IBraviatvSource[], typeArg: TBraviatvSourceType): IBraviatvSource[] {
return sourcesArg.filter((sourceArg) => sourceArg.title && sourceArg.uri).map((sourceArg) => ({
...sourceArg,
type: sourceArg.type || typeArg,
}));
}
private applyVolumeInfo(stateArg: IBraviatvState, volumeInfoArg: IBraviatvVolumeInfo | undefined): void {
if (!volumeInfoArg) {
return;
}
const volume = typeof volumeInfoArg.volume === 'number' ? volumeInfoArg.volume : Number(volumeInfoArg.volume);
if (Number.isFinite(volume)) {
stateArg.volumePercent = volume;
stateArg.volumeLevel = volume / 100;
}
stateArg.muted = volumeInfoArg.mute;
}
private applyPlayingInfo(stateArg: IBraviatvState, playingInfoArg: IBraviatvPlayingInfo | undefined): void {
if (!playingInfoArg) {
stateArg.mediaTitle = stateArg.mediaTitle || 'Smart TV';
return;
}
stateArg.mediaTitle = playingInfoArg.programTitle || playingInfoArg.title || stateArg.mediaTitle;
stateArg.mediaContentId = playingInfoArg.uri || playingInfoArg.dispNum || stateArg.mediaContentId;
stateArg.sourceUri = playingInfoArg.uri || stateArg.sourceUri;
stateArg.mediaDuration = playingInfoArg.durationSec;
if (playingInfoArg.uri?.startsWith('extInput:')) {
stateArg.source = playingInfoArg.title || stateArg.source;
}
if (playingInfoArg.uri?.startsWith('tv:')) {
stateArg.mediaChannel = playingInfoArg.title || playingInfoArg.dispNum;
stateArg.mediaContentType = 'channel';
} else if (playingInfoArg.uri) {
stateArg.mediaContentType = this.inferSourceType(playingInfoArg.uri);
}
if (playingInfoArg.startDateTime) {
const startTime = Date.parse(playingInfoArg.startDateTime);
if (Number.isFinite(startTime)) {
stateArg.mediaPosition = Math.max(0, Math.floor((Date.now() - startTime) / 1000));
stateArg.mediaPositionUpdatedAt = new Date().toISOString();
}
}
}
private inferSourceType(uriArg: string): TBraviatvSourceType {
if (uriArg.startsWith('extInput:')) {
return 'input';
}
if (uriArg.startsWith('tv:')) {
return 'channel';
}
return 'app';
}
private powerFromStatus(statusArg: string): 'on' | 'off' | 'unknown' {
const status = statusArg.toLowerCase();
if (status === 'active') {
return 'on';
}
if (status === 'standby' || status === 'off') {
return 'off';
}
return 'unknown';
}
private updateLocalState(stateArg: Partial<IBraviatvState>): void {
const state = this.config.snapshot?.state || this.config.state || {};
Object.assign(state, stateArg);
if (this.config.snapshot) {
this.config.snapshot.state = state;
return;
}
this.config.state = state;
}
private cloneSnapshot(snapshotArg: IBraviatvSnapshot): IBraviatvSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IBraviatvSnapshot;
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
}