homebridge-xbox-tv 4.1.24 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -18,6 +18,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
18
18
  - For plugin < v4.1.0 use Homebridge UI <= v5.5.0
19
19
  - For plugin >= v4.1.0 use Homebridge UI >= v5.13.0
20
20
 
21
+ ## [4.2.0] - (23.09.2026)
22
+
23
+ ### Changes
24
+
25
+ - added: Home Assistant discovery (`HA Discovery` option in the MQTT section). The device appears in Home Assistant as one media player (class `receiver`) with power, volume, mute, source selection and more. Requires the [MQTT Universal Media Player](https://github.com/grzegorz914/homeassistant-mqtt-media-player) integration
26
+ - added: MQTT availability topic (`<prefix>/Availability`, retained `online` / `offline` last will) when HA Discovery is enabled
27
+ - fix: MQTT/RESTFul `App` command now switches built-in inputs (Dashboard, Settings, Television, Xbox Guide...) the same way as HomeKit
28
+ - readme update
29
+ - sample config update
30
+
31
+ ## [4.1.25] - (23.09.2026)
32
+
33
+ ### Changes
34
+
35
+ - fix: MQTT client had `protocolVersion: 5` hardcoded, so brokers that only support MQTT 3.1.1 (e.g. the ioBroker MQTT adapter) accepted the TCP connection, failed the handshake, and got disconnected every second forever, with nothing published and no error logged. Added a `Protocol Version` option (5.0 / 3.1.1) in the MQTT section, and a one-time warning if no successful connection is established within 30 seconds
36
+ - readme update
37
+ - sample config update
38
+
21
39
  ## [4.1.24] - (23.09.2026)
22
40
 
23
41
  ### Added
@@ -25,6 +43,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
25
43
  - optional RESTful access `Token`. When set, every request (GET and POST) must send the header `Authorization: Bearer <token>`, otherwise the server responds `401`. Leaving it empty keeps the previous behaviour, so existing configs keep working
26
44
  - readme update
27
45
 
46
+ ## [4.1.23] - (13.09.2026)
47
+
48
+ ### Changes
49
+
50
+ - bump dependencies
51
+
28
52
  ## [4.1.22] - (18.07.2026)
29
53
 
30
54
  ## Changes
package/README.md CHANGED
@@ -41,7 +41,7 @@
41
41
  * Apps, Inputs, Games can be switched if `webApiControl` is enabled and console is authorized.
42
42
  * Siri can be used for all functions, some times need to be created legacy buttons/switches/sensors.
43
43
  * Automations can be used for all functions, some times need to be created legacy buttons/switches/sensors.
44
- * Support external integrations, [RESTFul](https://github.com/grzegorz914/homebridge-xbox-tv?tab=readme-ov-file#restful-integration), [MQTT](https://github.com/grzegorz914/homebridge-xbox-tv?tab=readme-ov-file#mqtt-integration).
44
+ * Support external integrations, [RESTFul](https://github.com/grzegorz914/homebridge-xbox-tv?tab=readme-ov-file#restful-integration), [MQTT](https://github.com/grzegorz914/homebridge-xbox-tv?tab=readme-ov-file#mqtt-integration), [HA Discovery](https://github.com/grzegorz914/homeassistant-mqtt-media-player).
45
45
 
46
46
  <p align="center">
47
47
  <a href="https://github.com/grzegorz914/homebridge-xbox-tv"><img alt="Accessory tile in the HomeKit app" src="https://raw.githubusercontent.com/grzegorz914/homebridge-xbox-tv/master/graphics/homekit.png" width="382" /></a>
@@ -137,6 +137,8 @@
137
137
  | `mqtt.port` | Here set the `Port` for MQTT Broker, default 1883. |
138
138
  | `mqtt.clientId` | Here optional set the `Client Id` of MQTT Broker. |
139
139
  | `mqtt.prefix` | Here set the `Prefix` for `Topic` or leave empty. |
140
+ | `mqtt.protocolVersion` | Here select the MQTT protocol version, `5.0` (default) or `3.1.1` for brokers that only support 3.1.1 (e.g. ioBroker MQTT adapter). |
141
+ | `mqtt.haDiscovery` | If enabled, the device is published to Home Assistant as one media player (class `receiver`) with power, volume, mute, source selection and more. Requires the [MQTT Universal Media Player](https://github.com/grzegorz914/homeassistant-mqtt-media-player) integration. |
140
142
  | `mqtt.auth{}` | MQTT authorization object. |
141
143
  | `mqtt.auth.enable` | Here enable authorization for MQTT Broker. |
142
144
  | `mqtt.auth.user` | Here set the MQTT Broker user. |
@@ -177,6 +179,10 @@
177
179
  ### MQTT Integration
178
180
 
179
181
  * Subscribe data as a JSON Object `{App: "Xbox.Dashboard_8wekyb3d8bbwe!Xbox.Dashboard.Application"}`
182
+ * HA Discovery - if enabled, the device is published to Home Assistant as one media player (class `receiver`) with power, volume, mute, source selection and more. Games and apps are available as sources, volume can be changed only up and down. Requires the [MQTT Universal Media Player](https://github.com/grzegorz914/homeassistant-mqtt-media-player) integration. Additional retained topics:
183
+ * `homeassistant/media_player/<id>/config` - discovery message.
184
+ * `HA State` - `{"power": true, "state": "playing", "muted": false, "source": "9WZDNCRFJ3TJ", "app_name": "Netflix"}`.
185
+ * `Availability` - `online`, `offline` (last will).
180
186
 
181
187
  | Method | Topic | Message | Type |
182
188
  | --- | --- | --- | --- |
@@ -1219,6 +1219,34 @@
1219
1219
  "functionBody": "return model.devices[arrayIndices].mqtt.enable === true;"
1220
1220
  }
1221
1221
  },
1222
+ "protocolVersion": {
1223
+ "title": "Protocol Version",
1224
+ "type": "integer",
1225
+ "default": 5,
1226
+ "oneOf": [
1227
+ {
1228
+ "title": "5.0",
1229
+ "enum": [5]
1230
+ },
1231
+ {
1232
+ "title": "3.1.1",
1233
+ "enum": [4]
1234
+ }
1235
+ ],
1236
+ "description": "MQTT protocol version used to connect to the broker. Some brokers (e.g. the ioBroker MQTT adapter) only support 3.1.1 and will silently reject 5.0 connections.",
1237
+ "condition": {
1238
+ "functionBody": "return model.devices[arrayIndices].mqtt.enable === true;"
1239
+ }
1240
+ },
1241
+ "haDiscovery": {
1242
+ "title": "HA Discovery",
1243
+ "type": "boolean",
1244
+ "default": false,
1245
+ "description": "Publish a Home Assistant MQTT discovery message so the device appears as one media player (class receiver). Requires the MQTT Universal Media Player integration in Home Assistant: https://github.com/grzegorz914/homeassistant-mqtt-media-player",
1246
+ "condition": {
1247
+ "functionBody": "return model.devices[arrayIndices].mqtt.enable === true;"
1248
+ }
1249
+ },
1222
1250
  "auth": {
1223
1251
  "title": "Authorization",
1224
1252
  "type": "object",
@@ -1472,6 +1500,8 @@
1472
1500
  "devices[].mqtt.port",
1473
1501
  "devices[].mqtt.clientId",
1474
1502
  "devices[].mqtt.prefix",
1503
+ "devices[].mqtt.protocolVersion",
1504
+ "devices[].mqtt.haDiscovery",
1475
1505
  {
1476
1506
  "key": "devices[].mqtt.auth",
1477
1507
  "title": "Authorization",
package/index.js CHANGED
@@ -156,6 +156,8 @@ class XboxPlatform {
156
156
  port: device.mqtt.port || 1883,
157
157
  clientId: device.mqtt.clientId ? `microsoft_${device.mqtt.clientId}_${Math.random().toString(16).slice(3)}` : `microsoft_${Math.random().toString(16).slice(3)}`,
158
158
  prefix: device.mqtt.prefix ? `microsoft/${device.mqtt.prefix}/${name}` : `microsoft/${name}`,
159
+ protocolVersion: device.mqtt.protocolVersion,
160
+ haDiscovery: device.mqtt.haDiscovery,
159
161
  user: device.mqtt.auth?.user,
160
162
  passwd: device.mqtt.auth?.passwd,
161
163
  logWarn: logLevel.warn,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "Xbox TV",
3
3
  "name": "homebridge-xbox-tv",
4
- "version": "4.1.24",
4
+ "version": "4.2.0",
5
5
  "description": "Homebridge plugin to control Xbox game consoles.",
6
6
  "license": "MIT",
7
7
  "author": "grzegorz914",
@@ -0,0 +1,89 @@
1
+ // Home Assistant discovery for the MQTT Universal Media Player integration
2
+ // https://github.com/grzegorz914/homeassistant-mqtt-media-player
3
+ const PLATFORM = 'mqtt_universal_media_player';
4
+
5
+ class HaDiscovery {
6
+ constructor(mqtt, config) {
7
+ // + and # are MQTT wildcards, Home Assistant would subscribe to a pattern instead of the device topic
8
+ if (/[+#]/.test(mqtt.config.prefix)) {
9
+ throw new Error(`MQTT prefix ${mqtt.config.prefix} must not contain + or #, rename the device or set a prefix`);
10
+ }
11
+
12
+ this.mqtt = mqtt;
13
+ this.objectId = String(config.objectId).replace(/[^a-zA-Z0-9_-]/g, '_');
14
+ this.uniqueId = this.objectId;
15
+ this.name = config.name;
16
+ this.deviceClass = config.deviceClass;
17
+ this.device = config.device ?? {};
18
+ this.commands = config.commands ?? {};
19
+
20
+ this.configTopic = `${mqtt.haPrefix}/media_player/${this.objectId}/config`;
21
+ this.stateTopic = `${mqtt.config.prefix}/HA State`;
22
+ this.commandTopic = `${mqtt.config.prefix}/Set`;
23
+
24
+ this.sources = [];
25
+ this.soundModes = [];
26
+ this.state = {};
27
+ this.lastConfig = '';
28
+ this.lastState = '';
29
+ }
30
+
31
+ // Publish (or republish when changed) the retained discovery message
32
+ async publishConfig({ sources = this.sources, soundModes = this.soundModes } = {}) {
33
+ this.sources = uniqueByName(sources);
34
+ this.soundModes = uniqueByName(soundModes);
35
+
36
+ const device = { identifiers: [this.uniqueId], name: this.name };
37
+ for (const [key, value] of Object.entries(this.device)) {
38
+ if (value !== undefined && value !== null && value !== '') device[key] = String(value);
39
+ }
40
+
41
+ const config = {
42
+ platform: PLATFORM,
43
+ unique_id: this.uniqueId,
44
+ device_class: this.deviceClass,
45
+ state_topic: this.stateTopic,
46
+ command_topic: this.commandTopic,
47
+ availability_topic: this.mqtt.availabilityTopic,
48
+ device,
49
+ commands: this.commands,
50
+ sources: this.sources,
51
+ sound_modes: this.soundModes
52
+ };
53
+
54
+ const payload = JSON.stringify(config);
55
+ if (payload === this.lastConfig) return false;
56
+ this.lastConfig = payload;
57
+ await this.mqtt.publishRetained(this.configTopic, payload);
58
+ return true;
59
+ }
60
+
61
+ // Merge a partial state and publish it retained when something changed
62
+ async updateState(partial) {
63
+ for (const [key, value] of Object.entries(partial)) {
64
+ if (value === undefined) continue;
65
+ this.state[key] = value;
66
+ }
67
+
68
+ const payload = JSON.stringify(this.state);
69
+ if (payload === this.lastState) return false;
70
+ this.lastState = payload;
71
+ await this.mqtt.publishRetained(this.stateTopic, payload);
72
+ return true;
73
+ }
74
+ }
75
+
76
+ // Home Assistant needs unique names, keep the first item for a duplicated name
77
+ function uniqueByName(items) {
78
+ const seen = new Set();
79
+ const result = [];
80
+ for (const item of items) {
81
+ const name = String(item.name ?? '').trim();
82
+ if (!name || item.id === undefined || item.id === null || seen.has(name)) continue;
83
+ seen.add(name);
84
+ result.push({ ...item, name });
85
+ }
86
+ return result;
87
+ }
88
+
89
+ export default HaDiscovery;
package/src/mqtt.js CHANGED
@@ -8,35 +8,56 @@ class Mqtt extends EventEmitter {
8
8
 
9
9
  const url = `mqtt://${config.host}:${config.port}`;
10
10
  const subscribeTopic = `${config.prefix}/Set`;
11
+ const protocolVersion = config.protocolVersion === 4 ? 4 : 5;
12
+ const isV5 = protocolVersion === 5;
13
+ this.isV5 = isV5;
14
+
15
+ // Home Assistant discovery, availability is reported with a retained last will
16
+ this.haDiscovery = config.haDiscovery === true;
17
+ this.haPrefix = config.haPrefix || 'homeassistant';
18
+ this.availabilityTopic = `${config.prefix}/Availability`;
11
19
 
12
20
  const options = {
13
21
  clientId: config.clientId,
14
22
  username: config.user,
15
23
  password: config.passwd,
16
- protocolVersion: 5,
17
- clean: false,
18
- properties: {
19
- sessionExpiryInterval: 60 * 60,
20
- userProperties: {
21
- source: 'node-client'
24
+ protocolVersion,
25
+ clean: !isV5,
26
+ ...(this.haDiscovery ? {
27
+ will: { topic: this.availabilityTopic, payload: 'offline', qos: 1, retain: true }
28
+ } : {}),
29
+ ...(isV5 ? {
30
+ properties: {
31
+ sessionExpiryInterval: 60 * 60,
32
+ userProperties: {
33
+ source: 'node-client'
34
+ }
22
35
  }
23
- }
36
+ } : {})
24
37
  };
25
38
 
39
+ const startTime = Date.now();
40
+ let hasConnected = false;
41
+ let warnedStalled = false;
42
+
26
43
  this.mqttClient = connect(url, options)
27
44
  .on('connect', async () => {
28
- this.emit('connected', 'MQTT v5 connected.');
45
+ hasConnected = true;
46
+ this.emit('connected', `MQTT v${protocolVersion} connected.`);
47
+ if (this.haDiscovery) this.publishRetained(this.availabilityTopic, 'online').catch(() => { });
29
48
 
30
49
  try {
31
50
  await new Promise((resolve, reject) => {
32
51
  this.mqttClient.subscribe(subscribeTopic,
33
52
  {
34
53
  qos: 1,
35
- properties: {
36
- userProperties: {
37
- type: 'subscription'
54
+ ...(isV5 ? {
55
+ properties: {
56
+ userProperties: {
57
+ type: 'subscription'
58
+ }
38
59
  }
39
- }
60
+ } : {})
40
61
  },
41
62
  (error) => {
42
63
  if (error) return reject(error);
@@ -69,6 +90,10 @@ class Mqtt extends EventEmitter {
69
90
  if (config.logDebug) this.emit('debug', 'MQTT Reconnecting...');
70
91
  })
71
92
  .on('close', () => {
93
+ if (!hasConnected && !warnedStalled && Date.now() - startTime > 30000) {
94
+ warnedStalled = true;
95
+ this.emit('warn', `MQTT has not connected after 30s of retries using protocol v${protocolVersion}. Check broker address, credentials, and whether the broker supports this MQTT protocol version.`);
96
+ }
72
97
  if (config.logDebug) this.emit('debug', 'MQTT Connection closed.');
73
98
  });
74
99
  }
@@ -79,6 +104,24 @@ class Mqtt extends EventEmitter {
79
104
  });
80
105
  }
81
106
 
107
+ publishRetained(fullTopic, payload) {
108
+ return new Promise((resolve, reject) => {
109
+ // + and # are wildcards, the client would never complete such a publish
110
+ if (/[+#]/.test(fullTopic)) {
111
+ return reject(new Error(`Invalid topic ${fullTopic}, the name or prefix must not contain + or #`));
112
+ }
113
+
114
+ this.mqttClient.publish(fullTopic, payload, { qos: 1, retain: true }, (error) => {
115
+ if (error) {
116
+ if (this.config.logWarn) this.emit('warn', `MQTT Publish error: ${error.message}`);
117
+ return reject(error);
118
+ }
119
+ if (this.config.logDebug) this.emit('debug', `MQTT Publish retained Topic: ${fullTopic}, Payload: ${payload}`);
120
+ resolve();
121
+ });
122
+ });
123
+ }
124
+
82
125
  publish(topic, message) {
83
126
  return new Promise((resolve, reject) => {
84
127
  const fullTopic = `${this.config.prefix}/${topic}`;
@@ -87,13 +130,15 @@ class Mqtt extends EventEmitter {
87
130
  this.mqttClient.publish(fullTopic, publishMessage,
88
131
  {
89
132
  qos: 1,
90
- properties: {
91
- contentType: 'application/json',
92
- userProperties: {
93
- source: 'node',
94
- action: 'set'
133
+ ...(this.isV5 ? {
134
+ properties: {
135
+ contentType: 'application/json',
136
+ userProperties: {
137
+ source: 'node',
138
+ action: 'set'
139
+ }
95
140
  }
96
- }
141
+ } : {})
97
142
  },
98
143
  (error) => {
99
144
  if (error) {
package/src/xboxdevice.js CHANGED
@@ -2,6 +2,7 @@ import EventEmitter from 'events';
2
2
  import XboxWebApi from './webApi/xboxwebapi.js';
3
3
  import XboxLocalApi from './localApi/xboxlocalapi.js';
4
4
  import Functions from './functions.js';
5
+ import HaDiscovery from './hadiscovery.js';
5
6
  import { DefaultInputs, WebApi } from './constants.js';
6
7
 
7
8
  let Accessory, Characteristic, Service, Categories, Encode, AccessoryUUID;
@@ -100,10 +101,18 @@ class XboxDevice extends EventEmitter {
100
101
  break;
101
102
  }
102
103
  break;
103
- case 'App':
104
+ case 'App': {
105
+ // Known inputs (also Dashboard, Settings, Television...) are switched the same way as from HomeKit
106
+ const input = this.inputsServices?.find(i => i.oneStoreProductId === value);
107
+ if (input) {
108
+ await this.setInput(input);
109
+ set = true;
110
+ break;
111
+ }
104
112
  const payload = [{ 'oneStoreProductId': value }];
105
113
  set = await this.xboxWebApi.send('Shell', 'ActivateApplicationWithOneStoreProductId', payload);
106
114
  break;
115
+ }
107
116
  case 'Volume':
108
117
  switch (value) {
109
118
  case 'up':
@@ -316,6 +325,7 @@ class XboxDevice extends EventEmitter {
316
325
 
317
326
  // Only one time run
318
327
  if (updated) await this.displayOrder();
328
+ if (updated) this.haPublishConfig();
319
329
 
320
330
  return true;
321
331
  } catch (error) {
@@ -991,6 +1001,64 @@ class XboxDevice extends EventEmitter {
991
1001
  };
992
1002
  }
993
1003
 
1004
+ //home assistant discovery
1005
+ async setupHaDiscovery() {
1006
+ if (!this.mqttConnected || !this.mqtt.haDiscovery) return;
1007
+
1008
+ try {
1009
+ this.ha = new HaDiscovery(this.mqtt1, {
1010
+ objectId: `xbox_${this.liveId}`,
1011
+ name: this.name,
1012
+ deviceClass: 'receiver',
1013
+ device: {
1014
+ manufacturer: 'Microsoft',
1015
+ model: this.modelName ?? this.savedInfo?.modelName,
1016
+ sw_version: this.savedInfo?.firmwareRevision
1017
+ },
1018
+ commands: {
1019
+ power: { key: 'Power' },
1020
+ // The console can only step the volume
1021
+ volume_step: { key: 'Volume', up: 'up', down: 'down' },
1022
+ mute: { key: 'Mute' },
1023
+ source: { key: 'App' },
1024
+ play_pause: { key: 'RcControl', value: 'playPause' }
1025
+ }
1026
+ });
1027
+ await this.haPublishConfig();
1028
+ } catch (error) {
1029
+ if (this.logWarn) this.emit('warn', `HA Discovery setup error: ${error}`);
1030
+ }
1031
+ }
1032
+
1033
+ async haPublishConfig() {
1034
+ if (!this.ha) return;
1035
+
1036
+ try {
1037
+ const sources = (this.inputsServices ?? []).map(input => ({ id: input.oneStoreProductId, name: input.name }));
1038
+ await this.ha.publishConfig({ sources });
1039
+ await this.haUpdateState();
1040
+ } catch (error) {
1041
+ if (this.logWarn) this.emit('warn', `HA Discovery publish error: ${error}`);
1042
+ }
1043
+ }
1044
+
1045
+ async haUpdateState() {
1046
+ if (!this.ha) return;
1047
+
1048
+ try {
1049
+ const input = this.inputsServices?.find(input => input.identifier === this.inputIdentifier);
1050
+ await this.ha.updateState({
1051
+ power: this.power,
1052
+ state: this.power ? (this.playState ? 'playing' : 'on') : 'off',
1053
+ muted: typeof this.mute === 'boolean' ? this.mute : undefined,
1054
+ source: input?.oneStoreProductId,
1055
+ app_name: input?.name ?? ''
1056
+ });
1057
+ } catch (error) {
1058
+ if (this.logWarn) this.emit('warn', `HA Discovery state error: ${error}`);
1059
+ }
1060
+ }
1061
+
994
1062
  //start
995
1063
  async start() {
996
1064
  try {
@@ -1022,6 +1090,7 @@ class XboxDevice extends EventEmitter {
1022
1090
  })
1023
1091
  .on('stateChanged', (power) => {
1024
1092
  this.power = power;
1093
+ this.haUpdateState();
1025
1094
 
1026
1095
  this.televisionService?.updateCharacteristic(Characteristic.Active, power);
1027
1096
 
@@ -1179,6 +1248,7 @@ class XboxDevice extends EventEmitter {
1179
1248
  this.mute = mute;
1180
1249
  this.screenSaver = screenSaver;
1181
1250
  this.playState = playState;
1251
+ this.haUpdateState();
1182
1252
  if (this.logInfo) {
1183
1253
  const name = input ? input.name : reference;
1184
1254
  const productId = input ? input.oneStoreProductId : reference;
@@ -1213,6 +1283,7 @@ class XboxDevice extends EventEmitter {
1213
1283
 
1214
1284
  // Prepare accessory
1215
1285
  const accessory = await this.prepareAccessory();
1286
+ this.setupHaDiscovery();
1216
1287
  return accessory;
1217
1288
  } catch (error) {
1218
1289
  throw new Error(`Start error: ${error}`);