UNPKG

homebridge-xbox-tv

Version:

Homebridge plugin to control Xbox game consoles.

308 lines (265 loc) • 12.5 kB
import EventEmitter from 'events'; import { v4 as UuIdv4 } from 'uuid'; import axios from 'axios'; import Authentication from './authentication.js'; import ImpulseGenerator from '../impulsegenerator.js'; import Functions from '../functions.js'; import { WebApi, DefaultInputs } from '../constants.js'; class XboxWebApi extends EventEmitter { constructor(config, authTokenFile, inputsFile, restFulEnabled, mqttEnabled) { super(); this.liveId = config.xboxLiveId; this.getInputsFromDevice = config.inputs?.getFromDevice; this.logWarn = config.log?.warn; this.logError = config.log?.error; this.logDebug = config.log?.debug; this.inputsFile = inputsFile; this.restFulEnabled = restFulEnabled || false; this.mqttEnabled = mqttEnabled || false; // Variables this.consoleAuthorized = false; this.rmEnabled = false; this.functions = new Functions(); const authConfig = { clientId: config.webApi?.clientId, clientSecret: config.webApi?.clientSecret, tokensFile: authTokenFile } this.authentication = new Authentication(authConfig); // Impulse generator this.call = false; this.impulseGenerator = new ImpulseGenerator() .on('checkAuthorization', async () => { if (this.call) return; try { this.call = true; await this.checkAuthorization(); } catch (error) { if (this.logError) this.emit('error', `Web Api generator error: ${error}`); } finally { this.call = false; } }) .on('state', (state) => { this.emit(state ? 'success' : 'warn', `Web Api monitoring ${state ? 'started' : 'stopped'}`); }); } async checkAuthorization() { try { const data = await this.authentication.checkAuthorization(); if (this.logDebug) this.emit('debug', `Authorization headers: ${JSON.stringify(data.headers, null, 2)}`); const authorized = data.tokens?.xsts?.Token?.trim() || false; if (!authorized) { if (this.logWarn) this.emit('warn', `Not authorized`); return false; } this.consoleAuthorized = true; // Axios instance with global timeout and retry this.axiosInstance = axios.create({ baseURL: WebApi.Url.Xccs, timeout: 5000, headers: { 'Authorization': data.headers, 'Accept-Language': 'en-US', 'x-xbl-contract-version': '4', 'x-xbl-client-name': 'XboxApp', 'x-xbl-client-type': 'UWA', 'x-xbl-client-version': '39.39.22001.0', 'skillplatform': 'RemoteManagement', 'Content-Type': 'application/json' } }); this.axiosInstance.interceptors.response.use(null, async (error) => { const config = error.config; if (!config || !config.retryCount) config.retryCount = 0; if (config.retryCount < 2 && (error.code === 'ECONNABORTED' || error.response?.status === 429)) { config.retryCount += 1; if (this.logDebug) this.emit('debug', `Retry ${config.retryCount} for ${config.url}`); await new Promise(res => setTimeout(res, 1000)); return this.axiosInstance(config); } return Promise.reject(error); }); // Check console data const consoleExist = await this.consolesList(); if (!consoleExist) return false; await this.consoleStatus(); await this.installedApps(); return true; } catch (error) { throw new Error(`Check authorization error: ${error.message}`, { cause: error }); } } async consolesList() { try { const { data } = await this.axiosInstance.get('/lists/devices?queryCurrentDevice=false&includeStorageDevices=true'); if (this.logDebug) this.emit('debug', `Consoles list data: ${JSON.stringify(data, null, 2)}`); const status = data.status?.errorCode === 'OK'; const error = data.status?.errorMessage; if (!status) { if (this.logDebug) this.emit('debug', `Console list data error: ${error}`); return false; } const console = data.result.find(c => c.id === this.liveId); if (!console) { if (this.logWarn) this.emit('warn', `Console with Live ID ${this.liveId} not found on server`); return false; } const obj = { id: console.id, name: console.name, locale: console.locale, region: console.region, consoleType: WebApi.Console.Name[console.consoleType], powerState: WebApi.Console.PowerState[console.powerState], digitalAssistantRemoteControlEnabled: !!console.digitalAssistantRemoteControlEnabled, remoteManagementEnabled: !!console.remoteManagementEnabled, consoleStreamingEnabled: !!console.consoleStreamingEnabled, wirelessWarning: !!console.wirelessWarning, outOfHomeWarning: !!console.outOfHomeWarning, storageDevices: console.storageDevices.map(s => ({ id: s.storageDeviceId, name: s.storageDeviceName, isDefault: s.isDefault, freeSpaceBytes: s.freeSpaceBytes, totalSpaceBytes: s.totalSpaceBytes, isGen9Compatible: s.isGen9Compatible })) }; if (!obj.remoteManagementEnabled && this.logWarn) this.emit('warn', `Console with Live ID ${this.liveId} remote management not enabled`); this.rmEnabled = obj.remoteManagementEnabled; if (this.restFulEnabled) this.emit('restFul', 'consoleslist', data); if (this.mqttEnabled) this.emit('mqtt', 'Consoles List', data); return true; } catch (error) { throw new Error(`Consoles list error: ${error.message}`, { cause: error }); } } async consoleStatus() { try { const url = `/consoles/${this.liveId}`; const { data } = await this.axiosInstance.get(url); if (this.logDebug) this.emit('debug', `Console status data: ${JSON.stringify(data, null, 2)}`); const status = { id: data.id, name: data.name, locale: data.locale, region: data.region, consoleType: WebApi.Console.Name[data.consoleType], powerState: WebApi.Console.PowerState[data.powerState], playbackState: data.playbackState, loginState: data.loginState, focusAppAumid: data.focusAppAumid, isTvConfigured: !!data.isTvConfigured, digitalAssistantRemoteControlEnabled: !!data.digitalAssistantRemoteControlEnabled, consoleStreamingEnabled: !!data.consoleStreamingEnabled, remoteManagementEnabled: !!data.remoteManagementEnabled, status: data.status?.errorCode === 'OK', error: data.status?.errorMessage }; if (!status.status) { if (this.logDebug) this.emit('debug', `Console status error: ${status.error}`); return; } this.emit('consoleStatus', status); if (this.restFulEnabled) this.emit('restFul', 'status', data); if (this.mqttEnabled) this.emit('mqtt', 'Status', data); return true; } catch (error) { throw new Error(`Console status error: ${error.message}`, { cause: error }); } } async installedApps() { if (!this.getInputsFromDevice) return true; try { const url = `/lists/installedApps?deviceId=${this.liveId}`; const { data } = await this.axiosInstance.get(url); if (this.logDebug) this.emit('debug', `Installed apps data: ${JSON.stringify(data, null, 2)}`); const status = data.status?.errorCode === 'OK'; const error = data.status?.errorMessage; if (!status) { if (this.logDebug) this.emit('debug', `Installed apps data error: ${error}`); return false; } const apps = data.result.filter(a => a.name && a.aumid).map(a => ({ name: a.name, oneStoreProductId: a.oneStoreProductId, reference: a.aumid, titleId: a.titleId, isGame: a.isGame, contentType: a.contentType, mode: 0, })); if (this.restFulEnabled) this.emit('restFul', 'apps', data); if (this.mqttEnabled) this.emit('mqtt', 'Apps', data); const inputs = [...DefaultInputs, ...apps]; await this.functions.saveData(this.inputsFile, inputs); this.emit('installedApps', inputs, false); return true; } catch (error) { throw new Error(`Installed apps error: ${error.message}`, { cause: error }); } } async mediaState(tokens) { try { const url = `/users/xuid(${tokens.xsts.DisplayClaims.xui[0].xid})/devices/${this.liveId}/media`; const { data } = await this.axiosInstance.get(url); if (this.logDebug) this.emit('debug', `Media state data: ${JSON.stringify(data, null, 2)}`); const status = data.status?.errorCode === 'OK'; const error = data.status?.errorMessage; if (!status) { if (this.logDebug) this.emit('debug', `Media state data error: ${error}`); return false; } const state = { state: data.state, title: data.title, artist: data.artist, album: data.album, position: data.position, duration: data.duration, canSeek: !!data.canSeek, volume: data.volume, muted: !!data.muted, }; this.emit('mediaState', state); if (this.restFulEnabled) this.emit('restFul', 'mediastate', data); if (this.mqttEnabled) this.emit('mqtt', 'Media State', data); return true; } catch (error) { throw new Error(`Media state error: ${error.message}`, { cause: error }); } } async send(commandType, command, payload) { if (!this.consoleAuthorized || !this.rmEnabled) { if (this.logWarn) this.emit('warn', `Not authorized or remote management not enabled`); return; } const postParams = { destination: 'Xbox', type: commandType, command, sessionId: UuIdv4(), sourceId: 'com.microsoft.smartglass', parameters: payload ?? [], linkedXboxId: this.liveId }; try { const response = await this.axiosInstance.post('/commands', postParams); if (this.logDebug) this.emit('debug', `Command ${command} result: ${JSON.stringify(response.data)}`); return true; } catch (error) { await new Promise(resolve => setTimeout(resolve, 1000)); if (command === 'WakeUp') this.emit('stateChanged', false); if (command === 'TurnOff') this.emit('stateChanged', true); throw new Error(`Failed to send command: type=${commandType}, command=${command}, error=${error.message}`, { cause: error }); } } // Media / shell helpers async next() { return this.send('Media', 'Next'); } async previous() { return this.send('Media', 'Previous'); } async pause() { return this.send('Media', 'Pause'); } async play() { return this.send('Media', 'Play'); } async goBack() { return this.send('Shell', 'GoBack'); } } export default XboxWebApi;