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 +10 -0
- package/README.md +6 -1
- package/config.schema.json +10 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/hadiscovery.js +89 -0
- package/src/mqtt.js +27 -0
- package/src/xboxdevice.js +72 -1
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
|
| --- | --- | --- | --- |
|
package/config.schema.json
CHANGED
|
@@ -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
|
@@ -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}`);
|