homebridge-xbox-tv
Version:
Homebridge plugin to control Xbox game consoles.
658 lines (581 loc) • 32.9 kB
JavaScript
import { promises as fsPromises } from 'fs';
import Dgram from 'dgram';
import Net from 'net';
import { parse as UuIdParse, v4 as UuIdv4 } from 'uuid';
import EventEmitter from 'events';
import Ping from 'ping';
import SimplePacket from './simple.js';
import MessagePacket from './message.js';
import SGCrypto from './sgcrypto.js';
import { LocalApi } from '../constants.js';
import ImpulseGenerator from '../impulsegenerator.js';
class XboxLocalApi extends EventEmitter {
constructor(config) {
super();
this.crypto = new SGCrypto();
this.udpType = Net.isIPv6(config.host) ? 'udp6' : 'udp4';
this.host = config.host;
this.xboxLiveId = config.xboxLiveId;
this.tokensFile = config.tokensFile;
this.devInfoFile = config.devInfoFile;
this.disableLogInfo = config.disableLogInfo;
this.enableDebugMode = config.enableDebugMode;
this.isConnected = false;
this.isAuthorized = false;
this.sequenceNumber = 0;
this.sourceParticipantId = 0;
this.channels = [];
this.channelRequestId = 0;
this.mediaRequestId = 0;
//create impulse generator
this.impulseGenerator = new ImpulseGenerator();
this.impulseGenerator.on('heartBeat', async () => {
try {
if (!this.isConnected) {
const debug = this.enableDebugMode ? this.emit('debug', `Plugin send heartbeat to console`) : false;
const state = await Ping.promise.probe(this.host, { timeout: 3 });
if (!state.alive || this.isConnected) {
return;
}
const debug1 = this.enableDebugMode ? this.emit('debug', `Plugin received heartbeat from console`) : false
const discoveryRequest = new SimplePacket('discoveryRequest');
const message = discoveryRequest.pack(this.crypto);
await this.sendSocketMessage(message, 'discoveryRequest');
}
if (this.isConnected) {
const debug = this.enableDebugMode ? this.emit('debug', `Socket send heartbeat`) : false;
const elapse = (Date.now() - this.heartBeatStartTime) / 1000;
const debug1 = this.enableDebugMode && elapse >= 12 ? this.emit('debug', `Last message was: ${elapse} sec ago`) : false;
const disconnect = elapse >= 12 ? await this.disconnect() : false;
};
} catch (error) {
this.emit('error', `Impulse generatotr error: ${error}, trying again`);
};
}).on('state', (state) => {
const emit = state ? this.emit('success', `Local Api heartbeat started`) : this.emit('warn', `Local Api heartbeat stopped`);
});
};
async reconnect() {
try {
await this.connect();
return true;
} catch (error) {
throw new Error(`Reconnect error: ${error.message || error}`);
}
};
async readData(path) {
try {
const data = await fsPromises.readFile(path);
return data;
} catch (error) {
throw new Error(`Read data error: ${error.message || error}`);
}
}
async saveData(path, data) {
try {
data = JSON.stringify(data, null, 2);
await fsPromises.writeFile(path, data);
const debug = this.enableDebugMode ? this.emit('debug', `Saved data: ${data}`) : false;
return true;
} catch (error) {
throw new Error(`Save data error: ${error.message || error}`);
};
};
async acknowledge(sequenceNumber) {
try {
const acknowledge = new MessagePacket('acknowledge');
acknowledge.set('lowWatermark', sequenceNumber);
acknowledge.packet.processedList.value.push({
id: sequenceNumber
});
const sequenceNumber1 = await this.getSequenceNumber();
const message = acknowledge.pack(this.crypto, sequenceNumber1, this.sourceParticipantId);
await this.sendSocketMessage(message, 'acknowledge');
return true;
} catch (error) {
throw new Error(error);
};
}
async channelOpen(channelRequestId, service) {
const debug = this.enableDebugMode ? this.emit('debug', `Received channel Id: ${channelRequestId}, request open`) : false;
this.channelRequestId = channelRequestId;
try {
const channelStartRequest = new MessagePacket('channelStartRequest');
channelStartRequest.set('channelRequestId', channelRequestId);
channelStartRequest.set('titleId', 0);
channelStartRequest.set('service', Buffer.from(service, 'hex'));
channelStartRequest.set('activityId', 0);
const sequenceNumber = await this.getSequenceNumber();
const message = channelStartRequest.pack(this.crypto, sequenceNumber, this.sourceParticipantId);
await this.sendSocketMessage(message, 'channelStartRequest');
return true;
} catch (error) {
throw new Error(error);
}
}
async powerOn() {
if (this.isConnected) {
this.emit('warn', 'Console already On.');
return;
};
const info = this.disableLogInfo ? false : this.emit('info', 'Send power On.');
try {
for (let i = 0; i < 15; i++) {
if (this.isConnected) {
return true;
}
const powerOn = new SimplePacket('powerOn');
powerOn.set('liveId', this.xboxLiveId);
const message = powerOn.pack(this.crypto);
await this.sendSocketMessage(message, 'powerOn');
await new Promise(resolve => setTimeout(resolve, 1000));
return true;
}
this.emit('info', 'Power On failed, please try again.');
} catch (error) {
throw new Error(error);
};
};
async powerOff() {
if (!this.isConnected) {
this.emit('warn', 'Console already Off.');
return;
};
const info = this.disableLogInfo ? false : this.emit('info', 'Send power Off.');
try {
const powerOff = new MessagePacket('powerOff');
powerOff.set('liveId', this.xboxLiveId);
const sequenceNumber = await this.getSequenceNumber();
const message = powerOff.pack(this.crypto, sequenceNumber, this.sourceParticipantId);
await this.sendSocketMessage(message, 'powerOff');
await new Promise(resolve => setTimeout(resolve, 3500));
await this.disconnect();
return true;
} catch (error) {
throw new Error(error);
};
};
async recordGameDvr() {
if (!this.isConnected || !this.isAuthorized) {
this.emit('warn', `Send record game ignored, connection state: ${this.isConnected}, authorization state: ${this.isAuthorized}`);
return;
};
const info = this.disableLogInfo ? false : this.emit('info', 'Send record game.');
try {
const recordGameDvr = new MessagePacket('recordGameDvr');
recordGameDvr.set('startTimeDelta', -60);
recordGameDvr.set('endTimeDelta', 0);
const sequenceNumber = await this.getSequenceNumber();
const message = recordGameDvr.pack(this.crypto, sequenceNumber, this.sourceParticipantId);
await this.sendSocketMessage(message, 'recordGameDvr');
return true;
} catch (error) {
throw new Error(error);
};
};
async sendButtonPress(channelName, command) {
if (!this.isConnected) {
this.emit('waarn', `Send command ignored, connection state: ${this.isConnected ? 'Not Connected' : 'Connected'}`);
return;
};
const channelRequestId = LocalApi.Channels.System[channelName].Id
const channelCommunicationId = this.channels[requestId].id;
const channelOpen = this.channels[requestId].open;
if (channelCommunicationId === -1 || !channelOpen) {
this.emit('warn', `Channel Id: ${channelCommunicationId}, state: ${channelOpen ? 'Open' : 'Closed'}, trying to open it`);
};
command = LocalApi.Channels.System[channelName][command];
const debug = this.enableDebugMode ? this.emit('debug', `Channel communication Id: ${channelCommunicationId}, name: ${channelName} opened, send command; ${command}`) : false;
switch (channelRequestId) {
case 0:
try {
const gamepad = new MessagePacket('gamepad');
gamepad.set('timestamp', Buffer.from(`000${new Date().getTime().toString()}`, 'hex'));
gamepad.set('buttons', command);
const sequenceNumber = await this.getSequenceNumber();
const message = gamepad.pack(this.crypto, sequenceNumber, this.sourceParticipantId, channelCommunicationId);
await this.sendSocketMessage(message, 'gamepad');
setTimeout(async () => {
const gamepadUnpress = new MessagePacket('gamepad');
gamepadUnpress.set('timestamp', Buffer.from(`000${new Date().getTime().toString()}`, 'hex'));
gamepadUnpress.set('buttons', LocalApi.Channels.System.Input.Commands.unpress);
const sequenceNumber1 = await this.getSequenceNumber();
const message = gamepadUnpress.pack(this.crypto, sequenceNumber1, this.sourceParticipantId, channelCommunicationId);
await this.sendSocketMessage(message, 'gamepadUnpress');
}, 150)
} catch (error) {
this.emit('warn', `Send system input command error: ${error}`)
};
break;
case 1:
try {
const sequenceNumber = await this.getSequenceNumber();
const jsonRequest = JSON.stringify({
msgid: `2ed6c0fd.${sequenceNumber}`,
request: LocalApi.Channels.System.TvRemote.MessageType.SendKey,
params: {
button_id: command,
device_id: null
}
});
const json = new MessagePacket('json');
json.set('json', jsonRequest);
const sequenceNumber1 = await this.getSequenceNumber();
const message = json.pack(this.crypto, sequenceNumber1, this.sourceParticipantId, channelCommunicationId);
this.sendSocketMessage(message, 'json');
} catch (error) {
this.emit('warn', `Send tv remote command error: ${error}`)
};
break;
case 2:
try {
let requestId = '0000000000000000';
requestId = (`${requestId}${this.mediaRequestId}`).slice(-requestId.length);
const mediaCommand = new MessagePacket('mediaCommand');
mediaCommand.set('requestId', Buffer.from(requestId, 'hex'));
mediaCommand.set('titleId', 0);
mediaCommand.set('command', command);
const sequenceNumber = await this.getSequenceNumber();
const message = mediaCommand.pack(this.crypto, sequenceNumber, this.sourceParticipantId, channelCommunicationId);
this.sendSocketMessage(message, 'mediaCommand');
this.mediaRequestId++;
} catch (error) {
this.emit('warn', `Send system media command error: ${error}`)
};
break;
}
return true;
};
async disconnect() {
const debug = this.enableDebugMode ? this.emit('debug', 'Disconnecting...') : false;
try {
const disconnect = new MessagePacket('disconnect');
disconnect.set('reason', 2);
disconnect.set('errorCode', 0);
const sequenceNumber = await this.getSequenceNumber();
const message = disconnect.pack(this.crypto, sequenceNumber, this.sourceParticipantId);
await this.sendSocketMessage(message, 'disconnect');
this.isConnected = false;
this.sequenceNumber = 0;
this.sourceParticipantId = 0;
this.channels = [];
this.channelRequestId = 0;
this.mediaRequestId = 0;
await new Promise(resolve => setTimeout(resolve, 3000));
this.emit('stateChanged', false, 0, true, 0, -1, -1);
this.emit('info', 'Disconnected.');
return true;
} catch (error) {
throw new Error(error);
};
};
async getSequenceNumber() {
this.sequenceNumber++;
const debug = this.enableDebugMode ? this.emit('debug', `Sqquence number set to: ${this.sequenceNumber}`) : false;
return this.sequenceNumber;
};
async sendSocketMessage(message, type) {
const offset = 0;
const length = message.byteLength;
try {
this.socket.send(message, offset, length, 5050, this.host, (error, bytes) => {
if (error) {
throw new Error(error);
}
const debug = this.enableDebugMode ? this.emit('debug', `Socket send: ${type}, ${bytes}B`) : false;
return true;
});
} catch (error) {
throw new Error(`Socket send error: ${error}`);
};
};
//connect
async connect() {
try {
const socket = Dgram.createSocket(this.udpType);
socket.on('error', (error) => {
this.emit('error', `Socket error: ${error}`);
socket.close();
}).on('close', async () => {
const debug = this.enableDebugMode ? this.emit('debug', 'Socket closed.') : false;
try {
this.isConnected = false;
await new Promise(resolve => setTimeout(resolve, 5000));
await this.reconnect();
} catch (error) {
this.emit('error', error)
};
}).on('message', async (message, remote) => {
const debug = this.enableDebugMode ? this.emit('debug', `Received message from: ${remote.address}:${remote.port}`) : false;
this.heartBeatStartTime = Date.now();
//get message type in hex
const copiedSlice = Uint8Array.prototype.slice.call(message, 0, 2);
const messageTypeHex = Buffer.from(copiedSlice).toString('hex');
const debug1 = this.enableDebugMode ? this.emit('debug', `Received message type hex: ${messageTypeHex}`) : false;
//check message type exist in types
const keysTypes = Object.keys(LocalApi.Messages.Types);
const keysTypesExist = keysTypes.includes(messageTypeHex);
if (!keysTypesExist) {
const debug = this.enableDebugMode ? this.emit('debug', `Received unknown message type: ${messageTypeHex}`) : false;
return;
};
//get message type and request
const messageType = LocalApi.Messages.Types[messageTypeHex];
const messageRequest = LocalApi.Messages.Requests[messageTypeHex];
const debug2 = this.enableDebugMode ? this.emit('debug', `Received message type: ${messageType}, request: ${messageRequest}`) : false;
let packeStructure;
switch (messageType) {
case 'simple':
packeStructure = new SimplePacket(messageRequest);
break;
case 'message':
packeStructure = new MessagePacket(messageRequest);
break;
};
let packet = packeStructure.unpack(this.crypto, message);
const type = packet.type;
const debug3 = this.enableDebugMode ? this.emit('debug', `Received type: ${type}`) : false;
const debug4 = this.enableDebugMode ? this.emit('debug', `Received packet: ${JSON.stringify(packet, null, 2)}`) : false;
if (messageType === 'message' && packet.targetParticipantId !== this.sourceParticipantId) {
const debug = this.enableDebugMode ? this.emit('debug', `Participant Id: ${packet.targetParticipantId} !== ${this.sourceParticipantId}. Ignoring packet`) : false;
return;
};
switch (type) {
case 'json':
// Object to hold fragments
const fragments = {};
const jsonMessage = JSON.parse(packet.payloadProtected.json);
const datagramId = jsonMessage.datagramId;
// Check if JSON is fragmented
if (datagramId) {
const debug = this.enableDebugMode ? this.emit('debug', `JSON message datagram Id: ${datagramId}`) : false;
// Initialize fragment store if not present
if (!fragments[datagramId]) {
fragments[datagramId] = {
partials: {},
getValue() {
const buffers = Object.keys(this.partials)
.sort((a, b) => a - b) // Ensure correct ordering
.map(offset => Buffer.from(this.partials[offset], 'base64'));
return Buffer.concat(buffers);
},
isValid() {
try {
JSON.parse(this.getValue().toString()); // Ensure valid JSON
return true;
} catch (error) {
this.emit('error', `Invalid fragments: ${error.message}`);
return false;
}
}
};
}
// Store fragment
fragments[datagramId].partials[jsonMessage.fragmentOffset] = jsonMessage.fragmentData;
// If all fragments are valid, reconstruct and replace the original JSON
if (fragments[datagramId].isValid()) {
packet.payloadProtected.json = fragments[datagramId].getValue().toString();
const debug = this.enableDebugMode ? this.emit('debug', `Reassembled JSON: ${packet.payloadProtected.json}`) : false;
// Delete after reconstruction
delete fragments[datagramId];
}
}
break;
case 'discoveryResponse':
const deviceType = packet.clientType;
const deviceName = packet.name;
const certificate = packet.certificate;
const debug = this.enableDebugMode ? this.emit('debug', `Discovered device: ${LocalApi.Console.Name[deviceType]}, name: ${deviceName}`) : false;
try {
// Sign public key
const data = await this.crypto.getPublicKey(certificate);
const debug = this.enableDebugMode ? this.emit('debug', `Signed public key: ${data.publicKey}, iv: ${data.iv}`) : false;
// Connect request
if (!this.isConnected) {
try {
const connectRequest = new SimplePacket('connectRequest');
connectRequest.set('uuid', Buffer.from(UuIdParse(UuIdv4())));
connectRequest.set('publicKey', data.publicKey);
connectRequest.set('iv', data.iv);
const response = await this.readData(this.tokensFile);
const parseTokenData = JSON.parse(response);
const tokenData = parseTokenData?.xsts?.Token?.trim() || null;
if (tokenData) {
connectRequest.set('userHash', parseTokenData.xsts.DisplayClaims?.xui?.[0]?.uhs || '', true);
connectRequest.set('jwt', parseTokenData.xsts.Token, true);
this.isAuthorized = true;
}
const debug = this.enableDebugMode ? this.emit('debug', `Client connecting using: ${this.isAuthorized ? 'XSTS token' : 'Anonymous'}`) : false;
const message = connectRequest.pack(this.crypto);
await this.sendSocketMessage(message, 'connectRequest');
} catch (error) {
this.emit('error', `Send connect request error: ${error}`)
};
};
} catch (error) {
this.emit('error', `Sign certificate error: ${error}`)
};
break;
case 'connectResponse':
const connectionResult = packet.payloadProtected.connectResult;
const pairingState = packet.payloadProtected.pairingState;
const sourceParticipantId = packet.payloadProtected.participantId;
if (connectionResult !== 0) {
const errorTable = {
0: 'Success.',
1: 'Pending login. Reconnect to complete.',
2: 'Unknown.',
3: 'Anonymous connections disabled.',
4: 'Device limit exceeded.',
5: 'Remote connect is disabled on the console.',
6: 'User authentication failed.',
7: 'User Sign-In failed.',
8: 'User Sign-In timeout.',
9: 'User Sign-In required.'
};
this.emit('error', `Connect error: ${errorTable[connectionResult]}`);
return;
}
this.isConnected = true;
this.sourceParticipantId = sourceParticipantId;
const debug1 = this.enableDebugMode ? this.emit('debug', `Client connect state: ${this.isConnected ? 'Connected' : 'Not Connected'}`) : false;
const debug2 = this.enableDebugMode ? this.emit('debug', `Client pairing state: ${LocalApi.Console.PairingState[pairingState]}`) : false;
try {
const localJoin = new MessagePacket('localJoin');
const sequenceNumber = await this.getSequenceNumber();
const localJointMessage = localJoin.pack(this.crypto, sequenceNumber, sourceParticipantId);
await this.sendSocketMessage(localJointMessage, 'localJoin');
// Open channels
try {
const channelNames = ['Input', 'TvRemote', 'Media'];
for (const channelName of channelNames) {
const channelRequestId = LocalApi.Channels.System[channelName].Id
const service = LocalApi.Channels.System[channelName].Uuid
//await this.channelOpen(channelRequestId, service);
};
} catch (error) {
this.emit('error', `Channel open error: ${error}`)
};
} catch (error) {
this.emit('error', `Send local join error: ${error}`)
};
break;
case 'acknowledge':
const needAck = packet.flags.needAck;
if (!needAck) {
return;
};
try {
const sequenceNumber = packet.sequenceNumber;
const acknowledge = new MessagePacket('acknowledge');
acknowledge.set('lowWatermark', sequenceNumber);
acknowledge.packet.processedList.value.push({ id: sequenceNumber });
const sequenceNumber1 = await this.getSequenceNumber();
const message = acknowledge.pack(this.crypto, sequenceNumber1, this.sourceParticipantId);
await this.sendSocketMessage(message, 'acknowledge');
} catch (error) {
this.emit('error', `Send acknowledge error: ${error}`)
};
break;
case 'consoleStatus':
if (!packet.payloadProtected) {
return;
};
if (this.emitDevInfo) {
//connect to deice success
this.emit('success', `Connect Success`)
const majorVersion = packet.payloadProtected.majorVersion;
const minorVersion = packet.payloadProtected.minorVersion;
const buildNumber = packet.payloadProtected.buildNumber;
const locale = packet.payloadProtected.locale;
const firmwareRevision = `${majorVersion}.${minorVersion}.${buildNumber}`;
const obj = {
manufacturer: 'Microsoft',
modelName: 'Xbox',
serialNumber: this.xboxLiveId,
firmwareRevision: firmwareRevision,
locale: locale
};
//save device info to the file
await this.saveData(this.devInfoFile, obj)
//emit device info
this.emit('deviceInfo', firmwareRevision, locale);
this.emitDevInfo = false;
};
const appsCount = Array.isArray(packet.payloadProtected.activeTitles) ? packet.payloadProtected.activeTitles.length : 0;
if (appsCount > 0) {
const power = true;
const volume = 0;
const mute = power ? power : true;
const mediaState = 2;
const titleId = appsCount === 2 ? packet.payloadProtected.activeTitles[0].titleId : packet.payloadProtected.activeTitles[0].titleId;
const reference = appsCount === 2 ? packet.payloadProtected.activeTitles[0].aumId : packet.payloadProtected.activeTitles[0].aumId;
this.emit('stateChanged', power, volume, mute, mediaState, titleId, reference);
const debug = this.enableDebugMode ? this.emit('debug', `Status changed, app Id: ${titleId}, reference: ${reference}`) : false;
//emit restFul and mqtt
const obj = {
'power': power,
'titleId': titleId,
'app': reference,
'volume': volume,
'mute': mute,
'mediaState': mediaState,
};
this.emit('restFul', 'state', obj);
this.emit('mqtt', 'State', obj);
};
//acknowledge
try {
const acknowledge = packet.flags.needAck ? await this.acknowledge(packet.sequenceNumber) : false;
} catch (error) {
this.emit('error', `Acknowledge error: ${error}`)
};
break;
case 'mediaState':
break;
case 'mediaCommandResult':
break;
case 'channelStartResponse':
const requestIdMatch = this.channelRequestId === packet.payloadProtected.channelRequestId;
const channelState = requestIdMatch ? packet.payloadProtected.result === 0 : false;
const debug5 = this.enableDebugMode ? this.emit('debug', `channelState: ${channelState}`) : false;
const channelCommunicationId = channelState && packet.payloadProtected.channelTargetId ? packet.payloadProtected.channelTargetId : -1;
const debug6 = this.enableDebugMode ? this.emit('debug', `channelCommunicationId: ${packet.payloadProtected.channelTargetId}`) : false;
const channel = {
id: channelCommunicationId,
open: channelState
}
const pushChannel = channelCommunicationId !== -1 ? this.channels.push(channel) : false;
const debug3 = this.enableDebugMode ? this.emit('debug', `Channel communication Id: ${channelCommunicationId}, state: ${channelState ? 'Open' : 'Closed'}`) : false;
break;
case 'pairedIdentityStateChanged':
const pairingState1 = packet.payloadProtected.pairingState || 0;
const debug4 = this.enableDebugMode ? this.emit('debug', `Client pairing state: ${LocalApi.Console.PairingState[pairingState1]}`) : false;
break;
};
}).on('listening', async () => {
//socket.setBroadcast(true);
const address = socket.address();
const debug = this.enableDebugMode ? this.emit('debug', `Server start listening: ${address.address}:${address.port}`) : false;
this.socket = socket;
this.emitDevInfo = true;
if (!this.isConnected) {
const debug = this.enableDebugMode ? this.emit('debug', `Plugin send heartbeat to console`) : false;
const state = await Ping.promise.probe(this.host, { timeout: 3 });
if (!state.alive || this.isConnected) {
return;
}
const debug1 = this.enableDebugMode ? this.emit('debug', `Plugin received heartbeat from console`) : false
const discoveryRequest = new SimplePacket('discoveryRequest');
const message = discoveryRequest.pack(this.crypto);
await this.sendSocketMessage(message, 'discoveryRequest');
}
}).bind();
await new Promise(resolve => setTimeout(resolve, 1500));
return true;
} catch (error) {
throw new Error(`Connect error: ${error.message || error}`);
};
};
};
export default XboxLocalApi;