740 lines
28 KiB
TypeScript
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);
|
|
}
|
|
}
|