homebridge-xbox-tv 4.2.2 → 4.3.1

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,19 @@ 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.3.1] - (27.09.2026)
22
+
23
+ ### Changes
24
+
25
+ - changed: the Home Assistant icons of the system menus are dark gray without a background, the same style as in the other plugins
26
+
27
+ ## [4.3.0] - (27.09.2026)
28
+
29
+ ### Changes
30
+
31
+ - added: Home Assistant app icons in the media browser and the media card. Store apps and games use their logo or box art from the public Microsoft Store catalog, Dashboard, Settings, Television and the other system menus use icons bundled with the plugin (MQTT Universal Media Player integration 0.5.0 or newer)
32
+ - added: Home Assistant media search in the apps (MQTT Universal Media Player integration 0.7.0 or newer)
33
+
21
34
  ## [4.2.2] - (24.09.2026)
22
35
 
23
36
  ### Changes
package/icons/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Input icons rendered to PNG from Material Design Icons 7.4.47 by Pictogrammers (https://pictogrammers.com/library/mdi/).
2
+
3
+ Pictogrammers Free License
4
+ --------------------------
5
+
6
+ This icon collection is released as free, open source, and GPL friendly by
7
+ the [Pictogrammers](http://pictogrammers.com/) icon group. You may use it
8
+ for commercial projects, open source projects, or anything really.
9
+
10
+ # Icons: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
11
+ Some of the icons are redistributed under the Apache 2.0 license. All other
12
+ icons are either redistributed under their respective licenses or are
13
+ distributed under the Apache 2.0 license.
14
+
15
+ # Fonts: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
16
+ All web and desktop fonts are distributed under the Apache 2.0 license. Web
17
+ and desktop fonts contain some icons that are redistributed under the Apache
18
+ 2.0 license. All other icons are either redistributed under their respective
19
+ licenses or are distributed under the Apache 2.0 license.
20
+
21
+ # Code: MIT (https://opensource.org/licenses/MIT)
22
+ The MIT license applies to all non-font and non-icon files.
Binary file
package/icons/cog.png ADDED
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "Xbox TV",
3
3
  "name": "homebridge-xbox-tv",
4
- "version": "4.2.2",
4
+ "version": "4.3.1",
5
5
  "description": "Homebridge plugin to control Xbox game consoles.",
6
6
  "license": "MIT",
7
7
  "author": "grzegorz914",
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "files": [
24
24
  "src",
25
+ "icons",
25
26
  "homebridge-ui",
26
27
  "index.js",
27
28
  "config.schema.json",
@@ -0,0 +1,77 @@
1
+ import { readFile } from 'fs/promises';
2
+ import axios from 'axios';
3
+
4
+ // Icons of the console apps for Home Assistant.
5
+ // Store apps and games: the logo or box art from the public Microsoft Store catalog, no sign in needed.
6
+ // Dashboard, Settings and the other system menus are not in the Store, they use icons bundled with the plugin,
7
+ // Material Design Icons (Apache 2.0, https://pictogrammers.com) rendered to PNG in /icons
8
+ const IconsDir = new URL('../icons/', import.meta.url);
9
+ const CatalogUrl = 'https://displaycatalog.mp.microsoft.com/v7.0/products';
10
+ const ImagePurposes = ['Logo', 'BoxArt', 'FeaturePromotionalSquareArt', 'Tile', 'Poster'];
11
+ const MaxCachedImages = 200;
12
+
13
+ const MenuIcons = {
14
+ 'Screensaver': 'monitor-shimmer',
15
+ 'Dashboard': 'view-dashboard',
16
+ 'Settings': 'cog',
17
+ 'Television': 'television-classic',
18
+ 'SettingsTv': 'tune-vertical',
19
+ 'Accessory': 'microsoft-xbox-controller',
20
+ 'NetworkTroubleshooter': 'lan-check',
21
+ 'MicrosoftStore': 'shopping',
22
+ 'XboxGuide': 'microsoft-xbox'
23
+ };
24
+
25
+ class AppIconsStore {
26
+ constructor() {
27
+ this.client = axios.create({ timeout: 10000 });
28
+ this.bundled = new Map();
29
+ // Product id to image url, null when the catalog has no image
30
+ this.urls = new Map();
31
+ this.images = new Map();
32
+ }
33
+
34
+ async bundledIcon(name) {
35
+ if (!this.bundled.has(name)) this.bundled.set(name, await readFile(new URL(`${name}.png`, IconsDir)).catch(() => null));
36
+ return this.bundled.get(name);
37
+ }
38
+
39
+ async imageUrl(productId) {
40
+ if (this.urls.has(productId)) return this.urls.get(productId);
41
+
42
+ const { data } = await this.client.get(CatalogUrl, { params: { bigIds: productId, market: 'US', languages: 'en-US' } });
43
+ const images = data?.Products?.[0]?.LocalizedProperties?.[0]?.Images ?? [];
44
+ const image = ImagePurposes.map(purpose => images.find(i => i.ImagePurpose === purpose && i.Uri)).find(Boolean);
45
+ const url = image ? `${image.Uri.startsWith('//') ? 'https:' : ''}${image.Uri}?w=256&h=256` : null;
46
+ this.urls.set(productId, url);
47
+ return url;
48
+ }
49
+
50
+ // PNG or JPEG bytes of an app (input with oneStoreProductId, isGame), the bundled icon when there is no Store image
51
+ async get(input) {
52
+ const productId = String(input?.oneStoreProductId ?? '');
53
+ if (MenuIcons[productId]) return this.bundledIcon(MenuIcons[productId]);
54
+
55
+ const fallback = () => this.bundledIcon(input?.isGame ? 'gamepad-variant' : 'application');
56
+ if (!productId) return fallback();
57
+ if (this.images.has(productId)) return this.images.get(productId) ?? fallback();
58
+
59
+ let image = null;
60
+ try {
61
+ const url = await this.imageUrl(productId);
62
+ if (url) {
63
+ const { data } = await this.client.get(url, { responseType: 'arraybuffer' });
64
+ image = Buffer.from(data);
65
+ }
66
+ } catch {
67
+ // Network error, not cached so the next request tries again
68
+ return fallback();
69
+ }
70
+
71
+ this.images.set(productId, image);
72
+ while (this.images.size > MaxCachedImages) this.images.delete(this.images.keys().next().value);
73
+ return image ?? fallback();
74
+ }
75
+ }
76
+
77
+ export default new AppIconsStore();
@@ -14,6 +14,10 @@ class HaDiscovery {
14
14
  this.uniqueId = this.objectId;
15
15
  this.name = config.name;
16
16
  this.deviceClass = config.deviceClass;
17
+ // Separate play, pause and stop buttons in the Home Assistant media card
18
+ this.assumedState = config.assumedState === true;
19
+ // Players with the same group id can be grouped in Home Assistant, e.g. the zones of one receiver
20
+ this.group = config.group?.id ? { id: String(config.group.id), leader: config.group.leader === true } : null;
17
21
  this.device = config.device ?? {};
18
22
  this.commands = config.commands ?? {};
19
23
 
@@ -21,9 +25,12 @@ class HaDiscovery {
21
25
  this.stateTopic = `${mqtt.config.prefix}/HA State`;
22
26
  this.commandTopic = `${mqtt.config.prefix}/Set`;
23
27
  this.imageTopic = config.image ? `${mqtt.config.prefix}/HA Image` : null;
28
+ // Media browser icons requested by Home Assistant with the BrowseImage key, answered by answerBrowseImage
29
+ this.browseImageTopic = config.browseImages ? `${mqtt.config.prefix}/HA Browse Image` : null;
24
30
 
25
31
  this.sources = [];
26
32
  this.soundModes = [];
33
+ this.browse = [];
27
34
  this.state = {};
28
35
  this.lastConfig = '';
29
36
  this.lastState = '';
@@ -31,9 +38,11 @@ class HaDiscovery {
31
38
  }
32
39
 
33
40
  // Publish (or republish when changed) the retained discovery message
34
- async publishConfig({ sources = this.sources, soundModes = this.soundModes } = {}) {
41
+ // browse: media browser folders [{ name, type, items: [{ id, name }] }], at most MaxBrowseItems items in total
42
+ async publishConfig({ sources = this.sources, soundModes = this.soundModes, browse = this.browse } = {}) {
35
43
  this.sources = uniqueByName(sources);
36
44
  this.soundModes = uniqueByName(soundModes);
45
+ this.browse = limitBrowse(browse);
37
46
 
38
47
  const device = { identifiers: [this.uniqueId], name: this.name };
39
48
  for (const [key, value] of Object.entries(this.device)) {
@@ -44,14 +53,18 @@ class HaDiscovery {
44
53
  platform: PLATFORM,
45
54
  unique_id: this.uniqueId,
46
55
  device_class: this.deviceClass,
56
+ ...(this.assumedState ? { assumed_state: true } : {}),
57
+ ...(this.group ? { group: this.group } : {}),
47
58
  state_topic: this.stateTopic,
48
59
  command_topic: this.commandTopic,
49
60
  availability_topic: this.mqtt.availabilityTopic,
50
61
  ...(this.imageTopic ? { image_topic: this.imageTopic } : {}),
62
+ ...(this.browseImageTopic ? { browse_image_topic: this.browseImageTopic } : {}),
51
63
  device,
52
64
  commands: this.commands,
53
65
  sources: this.sources,
54
- sound_modes: this.soundModes
66
+ sound_modes: this.soundModes,
67
+ ...(this.browse.length > 0 ? { browse: this.browse } : {})
55
68
  };
56
69
 
57
70
  const payload = JSON.stringify(config);
@@ -83,6 +96,21 @@ class HaDiscovery {
83
96
  return true;
84
97
  }
85
98
 
99
+ // Answer a media browser icon request, not retained, an empty payload when the item has no icon
100
+ async answerBrowseImage(key, fetchImage) {
101
+ // The key becomes a topic level, only the md5 hex key of the integration is accepted
102
+ if (!this.browseImageTopic || !/^[a-f0-9]{32}$/.test(String(key))) return false;
103
+
104
+ let image = null;
105
+ try {
106
+ image = await fetchImage();
107
+ } catch {
108
+ image = null;
109
+ }
110
+ await new Promise(resolve => this.mqtt.mqttClient.publish(`${this.browseImageTopic}/${key}`, image ?? '', { qos: 0 }, () => resolve()));
111
+ return true;
112
+ }
113
+
86
114
  // Merge a partial state and publish it retained when something changed
87
115
  async updateState(partial) {
88
116
  for (const [key, value] of Object.entries(partial)) {
@@ -98,6 +126,24 @@ class HaDiscovery {
98
126
  }
99
127
  }
100
128
 
129
+ // The discovery message stays small enough for the broker and Home Assistant, big bouquets are cut
130
+ const MaxBrowseItems = 2000;
131
+ function limitBrowse(folders) {
132
+ let left = MaxBrowseItems;
133
+ const result = [];
134
+ for (const folder of folders ?? []) {
135
+ const name = String(folder.name ?? '').trim();
136
+ const items = (folder.items ?? [])
137
+ .filter(item => item.id !== undefined && item.id !== null && String(item.name ?? '').trim())
138
+ .slice(0, Math.max(left, 0))
139
+ .map(item => ({ id: item.id, name: String(item.name).trim() }));
140
+ if (!name || items.length === 0) continue;
141
+ left -= items.length;
142
+ result.push({ name, ...(folder.type ? { type: folder.type } : {}), items });
143
+ }
144
+ return result;
145
+ }
146
+
101
147
  // Home Assistant needs unique names, keep the first item for a duplicated name
102
148
  function uniqueByName(items) {
103
149
  const seen = new Set();
package/src/xboxdevice.js CHANGED
@@ -3,6 +3,7 @@ import XboxWebApi from './webApi/xboxwebapi.js';
3
3
  import XboxLocalApi from './localApi/xboxlocalapi.js';
4
4
  import Functions from './functions.js';
5
5
  import HaDiscovery from './hadiscovery.js';
6
+ import AppIcons from './appicons.js';
6
7
  import { DefaultInputs, WebApi } from './constants.js';
7
8
 
8
9
  let Accessory, Characteristic, Service, Categories, Encode, AccessoryUUID;
@@ -83,6 +84,12 @@ class XboxDevice extends EventEmitter {
83
84
  }
84
85
 
85
86
  async setOverExternalIntegration(integration, key, value) {
87
+ // Media browser icon of an app, the id is the oneStoreProductId, works without the web api
88
+ if (key === 'BrowseImage') {
89
+ const input = this.inputsServices?.find(i => i.oneStoreProductId === value?.id) ?? { oneStoreProductId: value?.id };
90
+ return await this.ha?.answerBrowseImage(value?.key, () => AppIcons.get(input));
91
+ }
92
+
86
93
  if (!this.consoleAuthorized && this.logWarn) {
87
94
  this.emit('warn', `Set over external integration not possible, web api not enabled`);
88
95
  return;
@@ -1009,6 +1016,9 @@ class XboxDevice extends EventEmitter {
1009
1016
  try {
1010
1017
  this.ha = new HaDiscovery(this.mqtt1, {
1011
1018
  objectId: `xbox_${this.liveId}`,
1019
+ // App icons from the Microsoft Store, system menus with icons bundled with the plugin
1020
+ image: true,
1021
+ browseImages: true,
1012
1022
  name: this.name,
1013
1023
  deviceClass: 'receiver',
1014
1024
  device: {
@@ -1055,6 +1065,10 @@ class XboxDevice extends EventEmitter {
1055
1065
  source: input?.oneStoreProductId,
1056
1066
  app_name: input?.name ?? ''
1057
1067
  });
1068
+
1069
+ // Icon of the current app
1070
+ const productId = this.power ? input?.oneStoreProductId ?? null : null;
1071
+ this.ha.updateImage(productId, () => AppIcons.get(input)).catch(() => { });
1058
1072
  } catch (error) {
1059
1073
  if (this.logWarn) this.emit('warn', `HA Discovery state error: ${error}`);
1060
1074
  }