homebridge-xbox-tv
Version:
Homebridge plugin to control Xbox game consoles.
465 lines (407 loc) • 27 kB
JavaScript
import dgram from 'dgram';
import { parse as UuIdParse, v4 as UuIdv4 } from 'uuid';
import EventEmitter from 'events';
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';
import Functions from '../functions.js';
class XboxLocalApi extends EventEmitter {
constructor(config, tokensFile, devInfoFile, restFulEnabled, mqttEnabled) {
super();
this.crypto = new SGCrypto();
this.host = config.host;
this.liveId = config.xboxLiveId;
this.logSuccess = config.log?.success;
this.logWarn = config.log?.warn;
this.logError = config.log?.error;
this.logDebug = config.log?.debug;
this.tokensFile = tokensFile;
this.devInfoFile = devInfoFile;
this.restFulEnabled = restFulEnabled || false;
this.mqttEnabled = mqttEnabled || false;
this.connected = false;
this.power = false;
this.volume = 0;
this.mute = false;
this.titleId = '';
this.reference = '';
this.playState = false;
this.firstRun = false;
this.fragments = {};
this.socket = null;
this.acknowledgeInterval = null;
this.sequenceNumber = 0;
this.sourceParticipantId = 0;
this.functions = new Functions();
//create impulse generator
this.impulseGenerator = new ImpulseGenerator()
.on('connect', async () => {
try {
if (this.connected || this.connecting) return;
if (this.logDebug) this.emit('debug', `Plugin send heartbeat to console`);
const state = await this.functions.ping(this.host);
if (!state.online) {
return;
}
if (this.logDebug) this.emit('debug', `Plugin received heartbeat from console`);
this.connecting = true;
try {
await this.connect();
const discoveryRequest = new SimplePacket('discoveryRequest');
const message = discoveryRequest.pack(this.crypto);
await this.sendSocketMessage(message, 'discoveryRequest');
} catch (error) {
if (this.logError) this.emit('error', `Connection error: ${error}`);
} finally {
this.connecting = false;
}
} catch (error) {
if (this.logError) this.emit('error', `Local API heartbeat error: ${error}, will retry`);
}
})
.on('state', (state) => {
this.emit(state ? 'success' : 'warn', `Local Api monitoring ${state ? 'started' : 'stopped'}`);
});
};
async updateState() {
// reconnect and fires a spurious disconnect after 14 s of the new session.
if (this.acknowledgeInterval) {
clearInterval(this.acknowledgeInterval);
}
this.socket = null;
this.connected = false;
this.firstRun = false;
this.acknowledgeInterval = null;
this.sequenceNumber = 0;
this.fragments = {};
this.targetParticipantId = 0;
this.sourceParticipantId = 0;
this.power = false;
this.emit('stateChanged', this.power, this.titleId, this.reference, this.volume, this.mute, this.playState);
return true;
};
async getSequenceNumber() {
const seq = this.sequenceNumber;
this.sequenceNumber = (this.sequenceNumber + 1) >>> 0;
if (this.logDebug) this.emit('debug', `Sequence number set to: ${this.sequenceNumber}`);
return seq;
};
async sendSocketMessage(message, type, host = this.host) {
return new Promise((resolve, reject) => {
if (!this.socket) {
return reject(new Error(`Socket not initialized, cannot send message: ${type}`));
}
const offset = 0;
const length = message.byteLength;
this.socket.send(message, offset, length, 5050, host, (error, bytes) => {
if (error) {
return reject(new Error(`Socket send error: ${error}`));
}
if (this.logDebug) this.emit('debug', `Socket send: ${type} → ${host}, ${bytes}B`);
resolve(true);
});
});
};
async connect() {
return new Promise((resolve, reject) => {
try {
this.socket = dgram.createSocket('udp4')
.on('error', (error) => {
if (this.logError) this.emit('error', `Socket error: ${error}`);
this.socket?.close();
reject(`Socket error: ${error}`);
})
.on('close', async () => {
if (this.logDebug) this.emit('debug', 'Socket closed.');
await this.updateState();
})
.on('listening', () => {
this.socket.setBroadcast(true);
const address = this.socket.address();
if (this.logDebug) this.emit('debug', `Socket start listening: ${address.address}:${address.port}`);
resolve(true);
})
.on('message', async (data) => {
try {
// get message type in hex
const messageTypeHex = data.subarray(0, 2).toString('hex');
if (this.logDebug) this.emit('debug', `Received message type: ${messageTypeHex}`);
// check message type exists
if (!Object.keys(LocalApi.Messages.Category).includes(messageTypeHex)) {
if (this.logDebug) this.emit('debug', `Received unknown message type: ${messageTypeHex}, message: ${data}`);
return;
}
// get message type and request
const messageType = LocalApi.Messages.Category[messageTypeHex];
const messageRequest = LocalApi.Messages.CategoryTypes[messageTypeHex];
// create packet structure
let packetStructure;
switch (messageRequest) {
case 'discoveryRequest':
case 'discoveryResponse':
case 'connectRequest':
case 'connectResponse':
packetStructure = new SimplePacket(messageRequest);
break;
case 'message':
packetStructure = new MessagePacket(messageRequest);
break;
default:
if (this.logDebug) this.emit('debug', `No handler for type: ${messageTypeHex}`);
return;
}
// unpack packet
let packet;
try {
packet = packetStructure.unpack(this.crypto, data);
if (this.logDebug) this.emit('debug', `Received packet type: ${packet.type}, packet: ${JSON.stringify(packet, null, 2)}`);
} catch (error) {
if (this.logError) this.emit('error', `Failed to unpack packet type: ${messageType}, error: ${error.message}`);
return;
}
if (messageType === 'message') {
const targetId = packet.targetParticipantId;
// must pass through regardless of our sourceParticipantId.
if (targetId !== 0 && targetId !== this.sourceParticipantId) {
if (this.logDebug) this.emit('debug', `ParticipantId mismatch: ${targetId} !== ${this.sourceParticipantId}. Ignoring packet`);
return;
}
if (packet.flags.needAcknowlegde) {
try {
const acknowledge = new MessagePacket('acknowledge');
acknowledge.set('lowWatermark', packet.sequenceNumber);
acknowledge.packet.processedList.value.push({ id: packet.sequenceNumber });
const sequenceNumber1 = await this.getSequenceNumber();
const message = acknowledge.pack(this.crypto, sequenceNumber1, this.targetParticipantId, this.sourceParticipantId);
await this.sendSocketMessage(message, 'acknowledge');
} catch (error) {
if (this.logError) this.emit('error', `Heartbeat error: ${error}`);
}
}
}
// handle packet types
switch (packet.type) {
case 'json':
const fragments = this.fragments;
let jsonMessage;
try {
jsonMessage = JSON.parse(packet.payloadProtected.json);
} catch (error) {
if (this.logDebug) this.emit('debug', `Failed to parse JSON payload: ${error.message}`);
return;
}
const datagramId = jsonMessage.datagramId;
if (datagramId) {
if (!fragments[datagramId]) {
fragments[datagramId] = {
partials: {},
getValue() {
const buffers = Object.keys(this.partials)
.sort((a, b) => Number(a) - Number(b))
.map(offset => Buffer.from(this.partials[offset], 'base64'));
return Buffer.concat(buffers);
},
isValid() {
try {
JSON.parse(this.getValue().toString());
return true;
} catch {
return false;
}
}
};
}
fragments[datagramId].partials[jsonMessage.fragmentOffset] = jsonMessage.fragmentData;
if (fragments[datagramId].isValid()) {
const fullJson = fragments[datagramId].getValue().toString();
packet.payloadProtected = JSON.parse(fullJson);
if (this.logDebug) this.emit('debug', `Reassembled JSON packet: ${fullJson}`);
delete fragments[datagramId];
}
}
break;
case 'discoveryResponse':
if (this.connected) return;
const deviceType = packet.clientType;
const deviceName = packet.consoleName;
const certificate = packet.certificate;
let authorized = false;
if (this.logDebug) this.emit('debug', `Discovered device: ${LocalApi.Console.Name[deviceType] || 'Unknown'}, name: ${deviceName}`);
if (!certificate) {
if (this.logError) this.emit('error', 'Certificate missing from device packet');
return;
}
let token = null;
let userHash = null;
try {
const response = await this.functions.readData(this.tokensFile, true);
token = response?.xsts?.Token || null;
userHash = response.xsts.DisplayClaims?.xui?.[0]?.uhs;
if (token && userHash) {
authorized = true;
}
} catch (error) {
this.emit('debug', 'No valid token data found, connecting anonymously');
}
try {
const data = await this.crypto.getPublicKey(certificate);
if (this.logDebug) this.emit('debug', `Signed public key: ${data.publicKey.toString('hex')}, iv: ${data.iv.toString('hex')}`);
const connectRequest = new SimplePacket('connectRequest');
const uuidBuffer = Buffer.from(UuIdParse(UuIdv4()));
if (uuidBuffer.length !== 16) {
if (this.logError) this.emit('error', 'Invalid UUID length');
return;
}
connectRequest.set('uuid', uuidBuffer);
connectRequest.set('publicKey', data.publicKey);
connectRequest.set('iv', data.iv);
if (authorized) {
const sequenceNumber = await this.getSequenceNumber();
connectRequest.set('userHash', userHash, true);
connectRequest.set('token', token, true);
connectRequest.set('connectRequestNum', sequenceNumber);
connectRequest.set('connectRequestGroupStart', 0);
connectRequest.set('connectRequestGroupEnd', 1);
}
// Track auth state so powerOff() can detect anonymous sessions
this.authorized = authorized;
if (this.logDebug) this.emit('debug', `Client connecting using: ${authorized ? 'XSTS token' : 'Anonymous'}`);
const message = connectRequest.pack(this.crypto);
await this.sendSocketMessage(message, 'connectRequest');
} catch (error) {
if (this.logError) this.emit('error', `Sign certificate error: ${error}`);
}
break;
case 'connectResponse':
const { connectResult, pairingState, participantId } = packet.payloadProtected;
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.'
};
if (connectResult !== 0) {
if (this.logError) this.emit('error', `Connect error: ${errorTable[connectResult] || connectResult}`);
return;
}
if (this.logDebug) this.emit('debug', `Client connected, pairing state: ${LocalApi.Console.PairingState[pairingState]}`);
this.connected = true;
try {
this.sourceParticipantId = participantId;
this.targetParticipantId = packet.sourceParticipantId || this.targetParticipantId || 0;
const sequenceNumber = await this.getSequenceNumber();
const localJoin = new MessagePacket('localJoin');
const message = localJoin.pack(this.crypto, sequenceNumber, this.targetParticipantId, this.sourceParticipantId);
await this.sendSocketMessage(message, 'localJoin');
this.firstRun = true;
} catch (error) {
if (this.logError) this.emit('error', `Send local join error: ${error}`);
}
break;
case 'consoleStatus':
if (!packet.payloadProtected) return;
if (this.firstRun) {
if (this.logSuccess) this.emit('success', `Connect Success`);
const { majorVersion, minorVersion, buildNumber, locale } = packet.payloadProtected;
const firmwareRevision = `${majorVersion}.${minorVersion}.${buildNumber}`;
const info = { locale, firmwareRevision };
this.emit('deviceInfo', info);
this.firstRun = false;
}
const activeTitles = Array.isArray(packet.payloadProtected.activeTitles) ? packet.payloadProtected.activeTitles : [];
const power = activeTitles.length > 0;
if (power) {
// last entry is the foreground title
const title = activeTitles[0];
this.titleId = title.titleId;
this.reference = title.aumId;
}
this.power = power;
this.playState = false;
// (console turning off), power=false must reach HomeKit immediately.
this.emit('stateChanged', this.power, this.titleId, this.reference, this.volume, this.mute, this.playState);
if (this.logDebug) this.emit('debug', `Status changed, power: ${this.power}, app Id: ${this.titleId}, reference: ${this.reference}`);
const statusState = { power: this.power, titleId: this.titleId, reference: this.reference, volume: this.volume, mute: this.mute };
if (this.restFulEnabled) this.emit('restFul', 'state', statusState);
if (this.mqttEnabled) this.emit('mqtt', 'State', statusState);
// Inactivity watchdog — only SmartGlass packets (consoleStatus or
// acknowledge) reset the timer. Ping is diagnostic only and must NOT
// reset it: a console in standby keeps responding to pings for several
// minutes, which would otherwise prevent OFF detection.
this.heartBeatStartTime = Date.now();
if (!this.acknowledgeInterval) {
this.acknowledgeInterval = setInterval(async () => {
const elapsed = (Date.now() - this.heartBeatStartTime) / 1000;
if (this.logDebug) this.emit('debug', `Console last seen: ${elapsed.toFixed(1)}s ago`);
if (elapsed >= 14) {
clearInterval(this.acknowledgeInterval);
this.acknowledgeInterval = null;
if (this.logDebug) this.emit('debug', `Console inactivity timeout — disconnecting`);
// Close socket first so on('close') triggers updateState().
// Calling updateState() directly would null this.socket before
// close(), causing the old socket to leak in the OS.
const socketToClose = this.socket;
this.socket = null;
this.connected = false;
if (socketToClose) socketToClose.close();
return;
}
// Diagnostic ping — logged only, does not reset the watchdog.
if (Math.round(elapsed) % 5 === 0 && Math.round(elapsed) > 0) {
try {
const pingResult = await this.functions.ping(this.host);
if (this.logDebug) this.emit('debug', `Ping ${pingResult.online ? 'OK' : 'failed'} — console ${pingResult.online ? 'reachable' : 'unreachable'}`);
} catch (error) {
if (this.logError) this.emit('error', `Ping error: ${error}`);
}
}
}, 1000);
}
break;
case 'acknowledge':
this.heartBeatStartTime = Date.now();
if (!this.acknowledgeInterval) {
this.acknowledgeInterval = setInterval(async () => {
const elapsed = (Date.now() - this.heartBeatStartTime) / 1000;
if (this.logDebug) this.emit('debug', `Socket received heart beat: ${elapsed.toFixed(1)} sec ago`);
if (elapsed >= 14) {
clearInterval(this.acknowledgeInterval);
const sequenceNumber = await this.getSequenceNumber();
const disconnect = new MessagePacket('disconnect');
disconnect.set('reason', 2);
disconnect.set('errorCode', 0);
const message = disconnect.pack(this.crypto, sequenceNumber, this.targetParticipantId, this.sourceParticipantId);
await this.sendSocketMessage(message, 'disconnect');
await this.updateState();
}
}, 1000);
}
break;
case 'pairedIdentityStateChanged':
const pairingState1 = packet.payloadProtected.pairingState || 0;
if (this.logDebug) this.emit('debug', `Client pairing state: ${LocalApi.Console.PairingState[pairingState1]}`);
break;
default:
if (this.logWarn) this.emit('warn', `Received unknown packet type: ${packet.type}`);
break;
}
} catch (error) {
if (this.logError) this.emit('error', `Handle message error: ${error.message || error}`);
}
})
.bind();
} catch (error) {
reject(`Connect error: ${error.message || error}`);
};
});
};
};
export default XboxLocalApi;