homebridge-xbox-tv 4.1.25 → 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,16 @@ 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
+
21
31
  ## [4.1.25] - (23.09.2026)
22
32
 
23
33
  ### 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>
@@ -138,6 +138,7 @@
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
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. |
141
142
  | `mqtt.auth{}` | MQTT authorization object. |
142
143
  | `mqtt.auth.enable` | Here enable authorization for MQTT Broker. |
143
144
  | `mqtt.auth.user` | Here set the MQTT Broker user. |
@@ -178,6 +179,10 @@
178
179
  ### MQTT Integration
179
180
 
180
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).
181
186
 
182
187
  | Method | Topic | Message | Type |
183
188
  | --- | --- | --- | --- |
@@ -1238,6 +1238,15 @@
1238
1238
  "functionBody": "return model.devices[arrayIndices].mqtt.enable === true;"
1239
1239
  }
1240
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
+ },
1241
1250
  "auth": {
1242
1251
  "title": "Authorization",
1243
1252
  "type": "object",
@@ -1492,6 +1501,7 @@
1492
1501
  "devices[].mqtt.clientId",
1493
1502
  "devices[].mqtt.prefix",
1494
1503
  "devices[].mqtt.protocolVersion",
1504
+ "devices[].mqtt.haDiscovery",
1495
1505
  {
1496
1506
  "key": "devices[].mqtt.auth",
1497
1507
  "title": "Authorization",
package/index.js CHANGED
@@ -157,6 +157,7 @@ class XboxPlatform {
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
159
  protocolVersion: device.mqtt.protocolVersion,
160
+ haDiscovery: device.mqtt.haDiscovery,
160
161
  user: device.mqtt.auth?.user,
161
162
  passwd: device.mqtt.auth?.passwd,
162
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.25",
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
@@ -12,12 +12,20 @@ class Mqtt extends EventEmitter {
12
12
  const isV5 = protocolVersion === 5;
13
13
  this.isV5 = isV5;
14
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`;
19
+
15
20
  const options = {
16
21
  clientId: config.clientId,
17
22
  username: config.user,
18
23
  password: config.passwd,
19
24
  protocolVersion,
20
25
  clean: !isV5,
26
+ ...(this.haDiscovery ? {
27
+ will: { topic: this.availabilityTopic, payload: 'offline', qos: 1, retain: true }
28
+ } : {}),
21
29
  ...(isV5 ? {
22
30
  properties: {
23
31
  sessionExpiryInterval: 60 * 60,
@@ -36,6 +44,7 @@ class Mqtt extends EventEmitter {
36
44
  .on('connect', async () => {
37
45
  hasConnected = true;
38
46
  this.emit('connected', `MQTT v${protocolVersion} connected.`);
47
+ if (this.haDiscovery) this.publishRetained(this.availabilityTopic, 'online').catch(() => { });
39
48
 
40
49
  try {
41
50
  await new Promise((resolve, reject) => {
@@ -95,6 +104,24 @@ class Mqtt extends EventEmitter {
95
104
  });
96
105
  }
97
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
+
98
125
  publish(topic, message) {
99
126
  return new Promise((resolve, reject) => {
100
127
  const fullTopic = `${this.config.prefix}/${topic}`;
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}`);