Repository: grzegorz914/homebridge-xbox-tv Branch: main Commit: 8532599aea6d Files: 37 Total size: 283.1 KB Directory structure: gitextract_t1gbyqj_/ ├── .github/ │ └── workflows/ │ └── stale.yml ├── .gitignore ├── CHANGELOG.md ├── CLAUDE.md ├── LICENSE ├── README.md ├── config.schema.json ├── homebridge-ui/ │ ├── public/ │ │ └── index.html │ └── server.js ├── index.js ├── package.json ├── sample-config.json └── src/ ├── constants.js ├── functions.js ├── impulsegenerator.js ├── localApi/ │ ├── message.js │ ├── packets.js │ ├── sgcrypto.js │ ├── simple.js │ ├── structure.js │ └── xboxlocalapi.js ├── mqtt.js ├── restful.js ├── webApi/ │ ├── authentication.js │ ├── providers/ │ │ ├── achievements.js │ │ ├── catalog.js │ │ ├── gameclips.js │ │ ├── messages.js │ │ ├── people.js │ │ ├── pins.js │ │ ├── screenshots.js │ │ ├── social.js │ │ ├── titlehub.js │ │ ├── userpresence.js │ │ └── userstats.js │ └── xboxwebapi.js └── xboxdevice.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/stale.yml ================================================ name: Mark stale issues and pull requests on: schedule: - cron: '0 0 * * *' # Runs daily at midnight (UTC) workflow_dispatch: # Allows you to manually trigger the workflow jobs: stale: runs-on: ubuntu-latest permissions: contents: write issues: write pull-requests: write steps: - uses: actions/stale@v8 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.' days-before-stale: 30 days-before-close: 7 stale-pr-message: 'This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.' stale-issue-label: 'stale' exempt-issue-labels: 'pinned,security' close-issue-message: 'Closing this issue due to inactivity.' close-pr-message: 'Closing this pull request due to inactivity.' ================================================ FILE: .gitignore ================================================ node_modules npm-debug.log .DS_Store .vscode ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### NOTE - After update to 2.x.x the plugin settings (xboxLiveId) need to be updated - After update to v3.0.0 RESTFull and MQTT config settings need to be updated - After update to v3.4.0 all buttons in config need to be updated - After update to v3.9.0 plugin need to be reconfigured and console reauthorized - After update to v4.0.0 sensors need to be reconfigured!!! ## Warning - For plugin < v4.1.0 use Homebridge UI <= v5.5.0 - For plugin >= v4.1.0 use Homebridge UI >= v5.13.0 ## [4.1.13] - (06.04.2026) ## Changes - refactor inputs switch - bump dependencies - cleanup ## [4.1.4] - (27.01.2026) ## Changes - bump dependencies - cleanup ## [4.1.1] - (03.01.2026) ## Changes - bump dependencies - warn if console not exist on server - cleanup ## [4.1.0] - (01.01.2026) ## Changes - added support for Homebridge UI >= v5.13.0 - config schema updated - readme updated ## [4.0.0] - (12.12.2025) ## Changes - after update to v4.0.0 sensors need to be reconfigured!!! - full refactor the sensor section code, now is possible to create multiple sensors of differrent types - config schema updated - redme updated - cleanup ## [3.9.16] - (09.12.2025) ## Changes - moved to MQTT v5 ## [3.9.11] - (08.11.2025) ## Changes - stability and performance improvements - bump deependencies - config schema updated - redme updated - cleanup ## [3.9.1] - (30.09.2025) ## Changes - added possibility to change accessory display type in Home app - stability improvements - redme updated - cleanup ## [3.9.0] - (24.09.2025) ## Changes - config schema and json refactor - plugin need to be reconfigured - console need to be reauthorized - fix [#234](https://github.com/grzegorz914/homebridge-xbox-tv/issues/234) - stability improvements - bump deependencies - redme updated - cleanup ## [3.8.1] - (13.09.2025) ## Changes - fix [#232](https://github.com/grzegorz914/homebridge-xbox-tv/issues/232) - bump dependencies - cleanup ## [3.8.0] - (10.09.2025) ## Changes - stability and performance improvement - added dynamic firmware info update if changed - bump dependencies - cleanup ## [3.7.0] - (17.08.2025) ## Changes - added dynamic add/update/remove input/channel if load from device - added dynamic firmware info update if changed - cleanup ## [3.6.0] - (30.05.2025) ## Changes - added speaker option to volume control (for feature use) - now if volume control option is set to disable/nonethe also TV Speakers (hardware control) is disabled - stability improvements - cleanup ## [3.5.1] - (15.03.2025) ## Changes - fix [#224](https://github.com/grzegorz914/homebridge-xbox-tv/issues/224) - cleanup ## [3.5.0] - (13.03.2025) ## Changes - added possibility to disable indyvidual accessory - bump dependencies - config schema updated - redme updated - cleanup ## [3.4.0] - (05.03.2025) ## Changes - after update to this version all buttons in config need to be updated - refactor code of buttons - bump dependencies - config schema updated - cleanup ## [3.3.11] - (20.02.2025) ## Changes - stability and improvements - fix deprected method - cleanup ## [3.3.9] - (07.02.2025) ## Changes - stability and improvements ## [3.3.8] - (07.02.2025) ## Changes - fix inputs display order ## [3.3.7] - (06.02.2025) ## Changes - cleanup and optimizations of web api control ## [3.3.6] - (06.02.2025) ## Changes - fix HAP-NodeJS WARNING: The accessory has an invalid 'Name' characteristic 'configuredName' - Please use only alphanumeric, space, and apostrophe characters - Ensure it starts and ends with an alphabetic or numeric character, and avoid emojis ## [3.3.4] - (05.02.2025) ## Changes - Web Api improvements of authoriztion check and remote management - cleanup ## [3.3.3] - (04.02.2025) ## Changes - fix [#221](https://github.com/grzegorz914/homebridge-xbox-tv/issues/221) ## [3.3.2] - (04.02.2025) ## Changes - update RESTFul - bump dependencies ## [3.3.0] - (19.01.2025) ## Changes - added possibility to disable/enable log success, info, warn, error - refactor cnnect code - bump dependencies - config schema updated - redme updated - cleanup ## [3.2.0] - (30.11.2024) ## Changes - move from commonJS to esm module - moved constants.json to constants.js - cleanup ## [3.1.12] - (27.09.2024) ## Changes - fix restFul start [#212](https://github.com/grzegorz914/homebridge-xbox-tv/issues/212) - cleanup ## [3.1.6] - (06.09.2024) ## Changes - Authorization manager layout improvements - cleanup ## [3.1.5] - (06.09.2024) ## Changes - cleanup ## [3.1.4] - (06.09.2024) ## Changes - fix display duplicated dev info - cleanup ## [3.1.3] - (06.09.2024) ## Changes - refactor web and local api connect code - cleanup ## [3.1.0] - (23.08.2024) ## Changes - add control over RESTFul POST JSON Object - cleanup ## [3.0.2] - (18.08.2024) ## Changes - fixed authorization manager [#204](https://github.com/grzegorz914/homebridge-xbox-tv/issues/204) - cleanup ## [3.0.0] - (14.08.2024) ## Changes ### After update to v3.0.0 RESTFull and MQTT config settings need to be updated - hide passwords by typing and display in Config UI - remove return duplicate promises from whole code - bump dependencies - cleanup ## [2.14.0] - (04.08.2024) ## Changes - added possiblity to set own volume control name and enable/disable prefix - config schema updated - bump dependencies - cleanup ## [2.13.0] - (05.03.2024) ## Changes - added support to subscribe MQTT and control device - config schema updated - cleanup ## [2.12.0] - (02.01.2024) ## Changes - added possibility to disable prefix name for buttons and sensors - config schema updated - cleanup ## [2.11.0] - (29.12.2023) ## Changes - added possibility to select display inputs order, possible by `None`, `Alphabetically Name`, `Alphabetically Reference` - config schema updated - cleanup ## [2.10.0] - (26.12.2023) ## After update to this version the plugin properties are changed and console must be authorized and settings need to be corrected ## Changes - full code refactor - added possibility toggle Power control between local/web api - fixed disconnect problem on first run - performance and stability improvements - config.schema updated - readme updated - cleanup ## [2.9.0] - (29.07.2023) ## Changes - added RESTFul server - use JWT token for lokal api if console authorizen - code refactor and cleanup - config.schema updated - fixed some minor issues - prepare for next release and features ## [2.8.0] - (20.02.2023) ## Changes - fix load plugin gui on first start after install - authorization manager updated - added possibility to set IP Address and Xbox Live ID from Authorization Manager. - added possibility to enable Web Api Control from Authorization Manager after authorization successfull done. - cleanup ## [2.7.0] - (13.02.2023) ## Changes - standarize function of display type and volume control, now volume control -1 None/Disabled, 0 Slider, 1 Fan, please see in readme - config.schema updated - fix expose extra input tile in homekit app - other small fixes and improvements - cleanup ## [2.6.0] - (12.02.2023) ## Changes - integrate web api library in to the plugin - simplify the authorization manager process(reduced 1 step, correct some words) - bump dependencies - stability improvements - config.schema updated - cleanup ## [2.5.0] - (29.01.2023) ## Changes - update logging - added new mqtt topics *Consoles List*, *Profile*, *Apps*, *Storages*, *Status* - bump dependencies - stability improwements - config.schema updated - cleanup ## [2.4.0] - (24.01.2023) ## Changes - added Power Sensor for use with automations (active if power is ON) - added Input Sensor for use with automations (activ on every Input change) - added Screen Saver Sensor for use with automations (active on change to Screen Saver) - added custom Inputs Sensor based on reference for use with automations (active on change to Input) - config.schema updated - cleanup ## [2.3.16] - (04.01.2023) ## Changes - fix wrong state after power Off - fix display current app - fix save target visibility - fix save custom names ## [2.3.15] - (04.01.2023) ## Changes - fix #147 #148 ## [2.3.14] - (03.01.2023) ## Changes - code refactor - stability improwements ## [2.3.13] - (31.12.2022) ## Changes - dynamic update accessory information ## [2.3.12] - (24.12.2022) ## Changed - fix #145 ## [2.3.11] - (18.12.2022) ## Changed - fix buttons and switch services ## [2.3.10] - (02.12.2022) ## Changed - fix [#143](https://github.com/grzegorz914/homebridge-xbox-tv/issues/143) ## [2.3.9] - (28.11.2022) ## Changed - fix [#143](https://github.com/grzegorz914/homebridge-xbox-tv/issues/143) - update dependencies ## [2.3.8] - (02.11.2022) ## Changed - fix error with 2.3.7 ## [2.3.7] - (02.11.2022) ## Changed - code refactor ## [2.3.6] - (10.09.2022) ## Changed - cleanup - added content type properties to inputs - bump dependencies ## [2.3.3] - (29.08.2022) ## Changed - cleanup - rebuild mqtt topics ## [2.3.2] - (28.08.2022) ## Changed - fix publish MQTT message ## [2.3.0] - (24.08.2022) ## Changed - fix MQTT device info - refactor debug and info log - refactor send mqtt message - bump dependencies - code cleanup - added Xbox Guide as default input - fix [#137](https://github.com/grzegorz914/homebridge-xbox-tv/issues/137) ## [2.2.2] - (09.03.2022) ## Changed - MQTT Client connection process ## Fixed - webApiControl switch state ## [2.2.0] - (27.02.2022) ## Added - MQTT Client, publish all device data - possibility to set custom command for Info button in RC ## Changes - update dependencies - code refactor ## [2.1.3] - (28.01.2022) ### Fixed - offset out of range - code refactor ## [2.1.2] - (21.01.2022) ### Fixed - [#136](https://github.com/grzegorz914/homebridge-xbox-tv/issues/136) ## [2.1.1] - (21.01.2022) ### Changed - refactor debug message logging - update readme ### Fixed - wrong variables - removed unnecessary async - report unknown message if power on fail ## [2.1.0] - (21.01.2022) ### Added - check authorization state of console every 10 min. if powered ON and web api control enabled - check cosole data and installed apps every 10 min. if powered ON and web api control enabled ### Changed - send status message data only if changed - debug message logging - code refactor - code cleanup - stability and performance improvements ### Fixed - unexpected set authorization to true however the console is not authorized - data offset out of range [#133](https://github.com/grzegorz914/homebridge-xbox-tv/issues/133) - incorrect client authorization on console ## [2.0.13] - (15.01.2022) ### Added - Network Troubleshooter as defaul input ### Changed - removed manual authorization method - code cleanup - redme update ### Fixed - services calculation count ## [2.0.12] - (09.01.2022) ### Changed - code cleanup ## [2.0.10/11] - (08.01.2022) ### Changed - rebuild device info read and write ## [2.0.9] - (03.01.2022) ### Added - ability to disable log device info by every connections device to the network (Advanced Section) ### Fixed - unexpected power on after power off ## [2.0.8] - 2021-12-29 ### Added - prevent load plugin if host or xboxLiveId not set - prepare directory and files synchronously ## [2.0.6] - 2021-12-28 ### Added - better handle clientId if not defined in config ## [2.0.3] - 2021-12-28 ### Added - Selectable display type of buttons in HomeKit app ## [2.0.2] - 2021-12-28 ### Changed - Changed switches to buttons appear in HomeKit accessory ## [2.0.1] - 2021-12-26 ### Fixed - RC Control ## [2.0.0] - 2021-12-25 ### Added - Screensaver and Settings TV input as default - Smartglass library (based on @unknownskl code) as standalone packet, completelly rebuilded - Debug mode - TV Remote control (buttons) - Media control (buttons) - Game Pad control (buttons) - Clear web api token from plugin config menu ### Changes - full code rebuild - config.schema updated - dependencies updated - authorizatin manager updated - removed bramnding ### Fixed - memmory leak on some scenerious - protocol disconnect if send multiple command at once - authorization manager ## [1.8.13] - 2021-12-01 ### Fixed - fix authorization UI Manager open URI ## [1.8.12] - 2021-12-01 ### Fixed - fix authorization UI Manager ## [1.8.8] - 2021-11-04 ### Fixed - fix some connect/disconnect case - fix some remote command not send ## [1.8.7] - 2021-11-01 ### Changes - performance improvement ## [1.8.6] - 2021-10-30 ### Fixed - fix powerOn ## [1.8.3] - 2021-10-30 ### Fixed - fix graphic in settings ## [1.8.2] - 2021-10-30 ### Changes - code optimize - config.schema update - redme update ## [1.8.1] - 2021-10-26 ### Fixed - fixed callback issue ([#105](https://github.com/grzegorz914/homebridge-xbox-tv/issues/105)) ## [1.8.0] - 2021-10-26 ### Changes - added possibility Record Game DVR - rebuild connection proces to console - fixed Authorization Manager error on first run - removed 'Undefined Input', not nedded any more - code cleanup ## [1.7.9] - 2021-09-26 ### Changes - config.schema update ## [1.7.8] - 2021-09-24 ### Changes - update authorization manager - code cleanup ## [1.7.5] - 2021-09-05 ### Changes - update config schema - extend fiter possibility - code cleanup ## [1.7.3] - 2021-09-03 ### Changes - update config schema ## [1.7.2] - 2021-09-03 ### Changes - added filter for Games, Apps, Dlc ## [1.7.1] - 2021-08-31 ### Changes - code refactorin - added default inputs TV, Settings, Dashboard, Accessory, no need to create it in config - many small changes and stability improvements ## [1.6.3] - 2021-08-05 ### Changes - added alternative check current running app if reference app is missing - removed unnecessary reference property from buttons in config.json ## [1.6.2] - 2021-08-05 ### Changes - added possibility reboot console - added possibility switch to Television input - code and config reconfigured - update config schema ## [1.6.0] - 2021-08-04 ### Changes - fixes ## [1.6.0] - 2021-08-04 ### Changes - added possibility load inputs list direct from device - chenged config properties, please adapted config to latest one - changed stored files names, may be need authenticate console again or just copy authentication Token to the new created file(authToken_xxxx) - update dependencies - code rebuild ## [1.5.0] - 2021-04-11 ### Changes - added control over Web Api - code rebuild ## [1.4.0] - 2021-02-19 ### Changes - code rebuild, use Characteristic.onSet - require Homebridge 1.3.x or above ## [1.3.10] - 2021-02-15 ### Added and Fixed - Add possibility disable log info, options available in config - Fix memory leak ## [1.3.2] - 2021-01-18 ### Fixed - Fix log info regarding Input references ([#63](https://github.com/grzegorz914/homebridge-xbox-tv/issues/63)) ## [1.3.1] - 2021-01-06 ### Fixed - Fix `getAppChannelLineups` data error. ## [1.3.0] - 2020-11-20 ### Fixed - Dependency bump ([#55](https://github.com/grzegorz914/homebridge-xbox-tv/issues/55)) ## [1.2.41] - 2020-11-20 ### Fixed - Fix slow response on RC control. ## [1.2.1] - 2020-09-18 ### Changes - Updated device category to `TV_SET_TOP_BOX` ([#47](https://github.com/grzegorz914/homebridge-xbox-tv/pull/47)) ## [1.2.0] - 2020-09-17 ### Changes - Fix send power on until successful ([#38](https://github.com/grzegorz914/homebridge-xbox-tv/issues/38)) - Fix remote control function ([#28](https://github.com/grzegorz914/homebridge-xbox-tv/issues/28)) - Add `refreshInterval` with a default of five seconds. - Updated config layout. ## [1.1.0] - 2020-09-06 ### Changes - Completely reconfigured layout of the configuration schema. ## [1.0.0] - 2020-06-28 ### Added - Release version. ## [0.9.0] - 2020-05-23 ### Added - Add possibility to select what a type of extra volume control you want to use. None, Slider, Fan. ## [0.8.21] - 2020-05-23 ### Changes - Output app reference to log when opening app ([#22](https://github.com/grzegorz914/homebridge-xbox-tv/issues/22), [#26](https://github.com/grzegorz914/homebridge-xbox-tv/issues/26)) - Used for discovering the value to use for `reference` when adding new inputs. ## [0.8.0] - 2020-05-20 ### Added - Add mute ON/OFF to the slider volume. ## [0.7.60] - 2020-05-18 ### Fixed - Fix bug in RC control. ## [0.7.35] - 2020-05-17 ### Added - Add read console configuration after Homebridge restart and save to `/homebridge_folder/xboxTv/` file. ## [0.7.2] - 2020-05-14 ### Added - Add descriptions in `config.schema.json`. ## [0.7.0] - 2020-05-14 ### Added - Revert back with defaults inputs. - Add input type to inputs. - Add other fixes in code to prevent app crash without configured inputs. ## [0.6.0] - 2020-05-14 ### Breaking Changes - Update your config.json: Add types to the inputs. ### Default Inputs ```json "inputs": [ { "name": "TV", "reference": "Microsoft.Xbox.LiveTV_8wekyb3d8bbwe!Microsoft.Xbox.LiveTV.Application", "type": "HDMI" }, { "name": "Dashboard", "reference": "Xbox.Dashboard_8wekyb3d8bbwe!Xbox.Dashboard.Application", "type": "HOME_SCREEN" }, { "name": "Settings", "reference": "Microsoft.Xbox.Settings_8wekyb3d8bbwe!Xbox.Settings.Application", "type": "OTHER" }, { "name": "Accessory", "reference": "Microsoft.XboxDevices_8wekyb3d8bbwe!App", "type": "OTHER" } ] ``` ## [0.5.0] - 2020-05-10 ### Changes - Code cleanup. - Miscellaneous fixes and performance improvements. ## [0.4.0] - 2020-05-06 ### Changes - Adapted to HAP-Node JS lib. ## [0.3.12] - 2020-05-05 ### Changes - Cleanup code. ### Breaking Changes - Update your config.json: replace `apps` with `inputs`. ## [0.3.12] - 2020-05-05 ### Changes - Fix and performance improvements. - Corrected logging state. ## [0.3.9] - 2020-05-05 ### Added - Add real time read and write data for lightbulb slider volume value. ## [0.2.3] - 2020-04-27 ### Added - Add switch ON/OFF volume control. ## [0.2.1] - 2020-04-27 ### Added - Add Siri volume control. - Add slider or Brightness volume control. ## [0.1.39] - 2020-04-21 - Different fixes. ## [0.1.12] - 2020-04-13 - Fix memory leak. ## [0.1.9] - 2020-04-07 - Fix store of position in HomeKit favorites. ## [0.1.6] - 2020-04-06 - Test 2. ## [0.1.5] - 2020-04-05 - Test 1. ## [0.1.2] - 2020-04-05 - Some improvements. ## [0.1.1] - 2020-04-05 - Update `README.md`. - Update `sample-config.json`. ## [0.1.0] - 2020-03-29 - Fix crash if no device name defined. - Fix `config.schema.json`. - Fix store file inside the Homebridge directory. ## [0.0.118] - 2020-03-29 - Small fixes. ## [0.0.115] - 2020-03-21 - Corrections for Homebridge git. - Performance improvements. ## [0.0.1] - 2020-02-05 - Initial release. ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Commands ```bash # Install dependencies npm install # Run the plugin locally (requires a working Homebridge setup) homebridge # No test suite exists — the test script exits with an error by design npm test ``` There is no lint or build step. The project uses native ES modules (`"type": "module"` in package.json) and runs directly in Node.js without transpilation. ## Architecture This is a Homebridge platform plugin that exposes Xbox consoles as HomeKit TV accessories. The entry point is [index.js](index.js), which registers `XboxPlatform` under the `XboxTv` platform name. ### Startup flow `XboxPlatform` (index.js) reads `config.devices[]` and, for each device, runs a startup `ImpulseGenerator` that retries every 120 s until `XboxDevice.start()` succeeds. Once a device is up, it is published via `api.publishExternalAccessories()` and the startup generator stops, handing off to `XboxDevice`'s own connect generator (6 s interval). ### Core classes - **[src/xboxdevice.js](src/xboxdevice.js)** — Central device class (`EventEmitter`). Orchestrates both APIs, builds all HAP services/characteristics, handles HomeKit gets/sets, and drives external integrations. All log events bubble up via `emit('info'|'warn'|'error'|'debug'|'success')`. - **[src/impulsegenerator.js](src/impulsegenerator.js)** — Lightweight interval scheduler. `state(true, [{name, sampling}])` starts named intervals that fire events; `state(false)` clears them. Used as a polling/heartbeat mechanism throughout the codebase. - **[src/functions.js](src/functions.js)** — Shared utilities: file I/O (`readData`/`saveData`), network ping, diacritics normalization. - **[src/constants.js](src/constants.js)** — All protocol constants: HAP `InputSourceTypes`, Web API URLs/scopes, Local API channel UUIDs, SmartGlass message type/flag maps, default built-in inputs (Dashboard, Settings, etc.), `DiacriticsMap`. ### Two control paths **Local API** (`src/localApi/`) — UDP-based SmartGlass protocol over the LAN: - `xboxlocalapi.js` — Manages a UDP socket, discovery/connect handshake, heartbeat ping, and inbound message dispatch. - `sgcrypto.js` — ECDH key exchange (P-256) + AES-128 + HMAC-SHA256 for the SmartGlass session. - `simple.js` / `message.js` / `packets.js` / `structure.js` — Packet construction and parsing for the two SmartGlass packet families (simple pre-auth, encrypted post-auth). **Web API** (`src/webApi/`) — Microsoft Xbox cloud REST API: - `xboxwebapi.js` — Polls authorization every 15 min, sends commands via `axios` to `xccs.xboxlive.com`, retrieves console status and installed app list. - `authentication.js` — OAuth 2.0 flow (OAuth → User token → XSTS token) with token persistence to `authToken_` file. - `providers/` — Individual REST endpoint wrappers (achievements, catalog, people, etc.). These are used by the web API to fetch app/game metadata. ### External integrations - **[src/restful.js](src/restful.js)** — Express HTTP server. GET routes expose device state; POST `/` accepts `{key: value}` JSON to send commands back to the device. - **[src/mqtt.js](src/mqtt.js)** — MQTT v5 client. Publishes state updates to `microsoft///` topics; subscribes to `…/Set` for inbound commands. ### Persistent storage All per-device state files live under `/xboxTv/` with a host-IP-derived suffix (dots stripped): - `authToken_` — OAuth + XSTS tokens (JSON) - `devInfo_` — Console hardware info - `inputs_` — Installed app/game list from Web API - `inputsNames_` — User-customized input display names (from HomeKit) - `inputsTargetVisibility_` — Per-input visibility state (from HomeKit) ### Homebridge UI `homebridge-ui/server.js` runs as a `HomebridgePluginUiServer` and exposes two custom endpoints used by the Config UI: - `/clearToken` — Wipes the auth token file so the OAuth flow can restart. - `/startAuthorization` — Drives the OAuth token exchange when the user pastes a code. ### HomeKit accessory model Each Xbox is published as an external accessory (`Categories.TELEVISION`). Services created in `XboxDevice.start()`: - `Television` — primary, with `ActiveIdentifier` mapped to inputs - `TelevisionSpeaker` — volume/mute - Up to 85 `InputSource` services (built-in defaults + Web API app list + user-configured static inputs) - Optional `Switch`/`Outlet` services for each configured button - Optional `MotionSensor`/`OccupancySensor`/`ContactSensor` services for each configured sensor - Optional `Lightbulb` service for volume control Input identifiers are 1-based integers assigned at build time; display order can be sorted by name or reference via `inputs.displayOrder` config. ================================================ FILE: LICENSE ================================================ # MIT License Copyright (c) 2022 Grzegorz Kaczor Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE ================================================ FILE: README.md ================================================

Xbox and controller

# Homebridge Xbox TV [![verified-by-homebridge](https://img.shields.io/badge/homebridge-verified-purple)](https://github.com/homebridge/homebridge/wiki/Verified-Plugins) [![npm](https://shields.io/npm/dt/homebridge-xbox-tv?color=purple)](https://www.npmjs.com/package/homebridge-xbox-tv) [![npm](https://shields.io/npm/v/homebridge-xbox-tv?color=purple)](https://www.npmjs.com/package/homebridge-xbox-tv) [![npm](https://img.shields.io/npm/v/homebridge-xbox-tv/beta.svg?style=flat-square)](https://www.npmjs.com/package/homebridge-xbox-tv) [![GitHub pull requests](https://img.shields.io/github/issues-pr/grzegorz914/homebridge-xbox-tv.svg)](https://github.com/grzegorz914/homebridge-xbox-tv/pulls) [![GitHub issues](https://img.shields.io/github/issues/grzegorz914/homebridge-xbox-tv.svg)](https://github.com/grzegorz914/homebridge-xbox-tv/issues) Supports My Work ## Package Requirements | Package | Installation | Role | Required | | --- | --- | --- | --- | | [Homebridge](https://github.com/homebridge/homebridge) | [Homebridge Wiki](https://github.com/homebridge/homebridge/wiki) | HomeKit Bridge | Required | | [Homebridge UI](https://github.com/homebridge/homebridge-config-ui-x) | [Homebridge UI Wiki](https://github.com/homebridge/homebridge-config-ui-x/wiki) | Homebridge Web User Interface | Required | | [Xbox TV](https://www.npmjs.com/package/homebridge-xbox-tv) | [Plug-In Wiki](https://github.com/grzegorz914/homebridge-xbox-tv/wiki) | Homebridge Plug-In | Required | ## Warning * For plugin < v4.1.0 use Homebridge UI <= v5.5.0. * For plugin >= v4.1.0 use Homebridge UI >= v5.13.0. ## About The Plugin * Power ON/OFF short press tile in HomeKit app. * Reboot Console with button, rquired `webApiControl` enabled. * Record Game DVR with button, rquired `webApiControl` enabled. * RC/Media/Volume control from RC app on iPhone/iPad and possible over web/local api. * Speaker control from RC app on iPhone/iPad `Speaker Service`. * Legacy Volume/Mute control is possible throught extra `lightbulb`/`fan` (slider). * Apps, Inputs, Games can be switched if `webApiControl` is enabled and console is authorized. * Siri can be used for all functions, some times need to be created legacy buttons/switches/sensors. * Automations can be used for all functions, some times need to be created legacy buttons/switches/sensors. * 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).

Accessory tile in the HomeKit app Changing the accessory input Arrow pointing to the remote control icon in the control center Remote control interface

## Authorization Manager * First of all please use built in Authorization Manager. * Start new authorization need remove old token first, to clear token use Authorization Manager GUI. * Make sure Your web browser do not block pop-up window, if Yes allow pop-up window for this app.

Authentication Manager

## Configuration * [Device must have Instant-on power mode enabled](https://support.xbox.com/help/hardware-network/power/learn-about-power-modes) * Profile & System > Settings > General > Power mode & startup * Console need to allow connect from any 3rd app. *Allow Connections from any device* should be enabled. * Profile & System > Settings > Devices & Connections > Remote features > Xbox app preferences. * Run this plugin as a [Child Bridge](https://github.com/homebridge/homebridge/wiki/Child-Bridges) (Highly Recommended), this prevent crash Homebridge if plugin crashes. * Install and use [Homebridge UI](https://github.com/homebridge/homebridge-config-ui-x/wiki) to configure this plugin. * The `sample-config.json` can be edited and used as an alternative (advanced users).

| Key | Description | | --- | --- | | `name` | Here set the accessory `Name` to be displayed in `Homebridge/HomeKit`. | | `host` | Here set the `Hsostname or Address IP` of Console.| | `xboxLiveId` | On your console select Profile > Settings > System > Console info, listed as ``Xbox network device ID``. `You can only find the Xbox network device ID in Settings on your console, this is different from your console serial number`. | | `displayType` | Accessory type to be displayed in Home app: `0 - None/Disabled`, `1 - Television` , `2 - TV Set Top Box`, `3 - TV Streaming Stick`, `4 - Audio Receiver`. | | `webApi{}` | Web Api object. | | `webApi.enable` | This enable console control over Web Api. Additional functions are available in `Advanced Settings` section. | | `webApi.token` | Required if `webApiControl` enabled, use Authorization Manager to get it. | | `webApi.clientId` | Here set your own Client Id from Azure AD or leave empty if you do not have own account. | | `webApi.clientSecret` | Here set your Client Secret from Azure AD or leave empty if you do not have own account. | | `inputs{}` | Inputs object. | | `inputs.getFromDevice`| If enabled, apps will be loaded from device, only available if `webApiControl` enabled. | | `inputs.filterGames` | If enabled, Games will be hidden and not displayed in the inputs list, only available if `webApiControl` enabled. | | `inputs.filterApps` | If enabled, Apps will be hidden and not displayed in the inputs list, only available if `webApiControl` enabled. | | `inputs.filterSystemApps` | If enabled, System Apps (Accessory, Microsoft Store, Television) will be hidden and not displayed in the inputs list, only available if `webApiControl` enabled. | | `inputs.filterDlc` | If enabled, Dlc will be hidden and not displayed in the inputs list, only available if `webApiControl` enabled. | | `inputs.displayOrder` | Here select display order of the inputs list, `0 -None`, `1 -Ascending by Name`, `2 - Descending by Name`, `3 - Ascending by Reference`, `4 - Ascending by Reference`. | | `inputs.data[]` | Inputs data array. | | `inputs.data[].name` | Here set `Input Name` which You want expose to the `Homebridge/HomeKit`, `Screensaver`, `Television`, `TV Settings`, `Dashboard`, `Accessory`, `Settings`, `Network Troubleshooter`, `Microsoft Store`, `Xbox Guide` are created by default. | | `inputs.data[].reference` | Required to identify current running app. | | `inputs.data[].oneStoreProductId` | Required to switch apps. | | `inputs.data[].contentType` | Here select from available content types. | | `buttons[]` | Buttons array. | | `buttons[].displayType` | Here select display type in HomeKit app, possible `0 - None/Disabled`, `1 - Outlet`, `2 - Switch`. | | `buttons[].name` | Here set `Button Name` which You want expose to the `Homebridge/HomeKit`. | | `buttons[].mode` | Here select button mode, `0 - Media Control`, `1 - Game Pad Control`, `2 - TV Remote Control`, `3 - Console Control`, `4 - Game/App Control`. | | `buttons[].mediaCommand` | Here select media control command. | | `buttons[].gamePadCommand` | Here select game pad control command. | | `buttons[].tvRemoteCommand` | Here select tv remote control command. | | `buttons[].consoleControlCommand` | Here select console control command. | | `buttons[].gameAppControlCommand` | Here set `oneStoreProductId`, only possible if `webApiControl` enabled. | | `buttons[].namePrefix` | Here enable/disable the accessory name as a prefix for button name.| | `sensors[]` | Sensors array. | | `sensors[].displayType` | Here choose the sensor type to be exposed in HomeKit app, possible `0 - None/Disabled`, `1 - Motion Sensor`, `2 - Occupancy Sensor`, `3 - Contact Sensor`. | | `sensors[].mode` | Here choose the sensor mode, possible `0 - App / Games`, `1 - Power`, `2 - Volume`, `3 - Mute`, `4 - Screen Saver`, `5 - Play State`. | | `sensors[].name` | Here set own sensor `Name` which You want expose to the `Homebridge/HomeKit`. | | `sensors[].reference` | Here set mode `Reference` like `Xbox.Dashboard_8wekyb3d8bbwe!Xbox.Dashboard.Application`, sensor fired on switch to this reference. | | `sensors[].pulse` | Here enable sensor pulse, sensor send pulse and fired on every value change of selected mode. | | `sensors[].namePrefix` | Here enable the accessory name as a prefix for sensor name. | | `sensors[].level` | Here set `Level` between `0-100`, sensor fired on this level. | | `volume{}` | Volume object. | | `volume.displayType` | Here choice what a additional volume control mode You want to use `0 - None/Disabled`, `1 - Lightbulb`, `2 - Fan`, `3 - TV Speaker (only hardware buttons on R.C. app)`, `4 - TV Speaker / Lightbulb`, `5 - TV Speaker / Fan`. | | `volume.name` | Here set Your own volume control name or leave empty. | | `volume.namePrefix` | Here enable/disable the accessory name as a prefix for volume control name. | | `infoButtonCommand` | Here select the function of `I` button in RC app. | | `log{}` | Log object. | | `log.deviceInfo` | If enabled, log device info will be displayed by every connections device to the network. | | `log.sSuccess` | If enabled, success log will be displayed in console. | | `log.info` | If enabled, info log will be displayed in console. | | `log.warn` | If enabled, warn log will be displayed in console. | | `log.error` | If enabled, error log will be displayed in console. | | `log.debug` | If enabled, debug log will be displayed in console. | | `restFul{}` | RESTFul object. | | `restFul.enable` | If enabled, RESTful server will start automatically and respond to any path request. | | `restFul.port` | Here set the listening `Port` for RESTful server. | | `mqtt{}` | MQTT object. | | `mqtt.enable` | If enabled, MQTT Broker will start automatically and publish all awailable PV data. | | `mqtt.host` | Here set the `IP Address` or `Hostname` for MQTT Broker. | | `mqtt.port` | Here set the `Port` for MQTT Broker, default 1883. | | `mqtt.clientId` | Here optional set the `Client Id` of MQTT Broker. | | `mqtt.prefix` | Here set the `Prefix` for `Topic` or leave empty. | | `mqtt.auth{}` | MQTT authorization object. | | `mqtt.auth.enable` | Here enable authorization for MQTT Broker. | | `mqtt.auth.user` | Here set the MQTT Broker user. | | `mqtt.auth.passwd` | Here set the MQTT Broker password. | | `reference`, `oneStoreProductId` | If web Api enabled then all available in `./homebridge/xboxTv/inputs_xxxxxx` file. | ## Create App on Azure AD * Go to [Azure Portal](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) * Register new *App* + *New registration* * Enter a name for your app * Set *Supported account types* to *Personal Microsoft accounts only* * Click register * Choose *Redirect URIs* -> *Add a Redirect URI* * Click *Add a platform* -> *Mobile and desktop applications* * Enter custom redirect URI ** * From the overview of your app page, copy *Application (client) ID* to `webApiClientId` * Save restart plugin and authorize console again and have fun. ### RESTFul Integration * POST data as a JSON Object `{Power: true}`, content type must be `application/json` * Path `status` response all available paths. | Method | URL | Path | Response | Type | | --- | --- | --- | --- | --- | | GET | `http//ip:port` | `info`, `state`, `consoleslist`, `profile`, `apps`, `storages`, `status`. | `{"power": true, "app": Xbox.Dashboard_8wekyb3d8bbwe!Xbox.Dashboard.Application}` | JSON object. | | Method | URL | Key | Value | Type | Description | | --- | --- | --- | --- | --- | --- | | POST | `http//ip:port` | `Power` | `true`, `false` | boolean | Power state. | | | `http//ip:port` | `App` | `Xbox.Dashboard_8wekyb3d8bbwe!Xbox.Dashboard.Application` | string | Set app. | | | `http//ip:port` | `RcControl` | `fastForward` | string | Send RC command. | | | `http//ip:port` | `Volume` | `up`, `down` | string | Set volume. | | | `http//ip:port` | `Mute` | `true`, `false` | boolean | Set mute. | ### MQTT Integration * Subscribe data as a JSON Object `{App: "Xbox.Dashboard_8wekyb3d8bbwe!Xbox.Dashboard.Application"}` | Method | Topic | Message | Type | | --- | --- | --- | --- | | Publish | `Info`, `State`, `Consoles List`, `Profile`, `Apps`, `Storages`, `Status` | `{"power": true, "app": Xbox.Dashboard_8wekyb3d8bbwe!Xbox.Dashboard.Application}` | JSON object. | | Method | Topic | Key | Value | Type | Description | | --- | --- | --- | --- | --- | --- | | Subscribe | `Set` | `Power` | `true`, `false` | boolean | Power state. | | | `Set` | `App` | `Xbox.Dashboard_8wekyb3d8bbwe!Xbox.Dashboard.Application` | string | Set app. | | | `Set` | `RcControl` | `fastForward` | string | Send RC command. | | | `Set` | `Volume` | `up`, `down` | string | Set volume. | | | `Set` | `Mute` | `true`, `false` | boolean | Set mute. | ================================================ FILE: config.schema.json ================================================ { "pluginAlias": "XboxTv", "pluginType": "platform", "singular": true, "fixArrays": true, "strictValidation": true, "customUi": true, "headerDisplay": "This plugin works with Xbox Game Consoles. Devices are exposed to HomeKit as separate accessories and each needs to be manually paired.", "footerDisplay": "For documentation please see [GitHub repository](https://github.com/grzegorz914/homebridge-xbox-tv).", "schema": { "type": "object", "properties": { "name": { "title": "Platform", "type": "string", "default": "Xbox TV" }, "devices": { "type": "array", "items": { "title": "Device", "type": "object", "properties": { "name": { "title": "Name", "type": "string", "placeholder": "Game console" }, "host": { "title": "IP/Hostname", "type": "string", "placeholder": "192.168.1.10 or xbox.local", "pattern": "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}|([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\\.([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*)$" }, "xboxLiveId": { "title": "Live ID", "type": "string", "placeholder": "FD00000000000000", "description": "Xbox Live ID" }, "displayType": { "title": "Accessory type", "type": "integer", "minimum": 0, "maximum": 4, "default": 2, "anyOf": [ { "title": "None / Disabled", "enum": [ 0 ] }, { "title": "Television", "enum": [ 1 ] }, { "title": "TV Set Top Box", "enum": [ 2 ] }, { "title": "TV Streaming Stick", "enum": [ 3 ] }, { "title": "Audio Receiver", "enum": [ 4 ] } ], "description": "Accessory type for Home app" }, "webApi": { "title": "Web Api", "type": "object", "properties": { "enable": { "title": "Enable", "type": "boolean", "default": false, "description": "This enable console control over Web Api." }, "token": { "title": "Token", "type": "string", "placeholder": "Web Api Token", "description": "Here put the reponse Token (value after ?code=) from the authorization URL.", "format": "password", "condition": { "functionBody": "return model.devices[arrayIndices].webApi.enable === true;" } }, "clientId": { "title": "Client Id", "type": "string", "placeholder": "a34ac209-edab-4b08-91e7-a4558d8da1bd", "description": "Here set your own Client Id from Azure AD or leave empty if you do not have own account.", "condition": { "functionBody": "return model.devices[arrayIndices].webApi.enable === true;" } }, "clientSecret": { "title": "Client Secret", "type": "string", "placeholder": "Client Secret", "description": "Here set your Client Secret from Azure AD or leave empty if you do not have own account.", "format": "password", "condition": { "functionBody": "return model.devices[arrayIndices].webApi.enable === true;" } } }, "allOf": [ { "if": { "required": [ "enable" ], "properties": { "enable": { "const": true } } }, "then": { "required": [ "token" ] } } ] }, "inputs": { "title": "Inputs", "type": "object", "properties": { "getFromDevice": { "title": "Load Inputs From Device", "type": "boolean", "description": "This function get all available inputs direct from device, manually configured inputs will be skipped.", "condition": { "functionBody": "return model.devices[arrayIndices].webApi.enable === true;" } }, "filterGames": { "title": "Hide Games", "type": "boolean", "default": false, "description": "If enabled, Games will not be displayed on the list of inputs." }, "filterApps": { "title": "Hide Apps", "type": "boolean", "default": false, "description": "If enabled, Apps will not be displayed on the list of inputs." }, "filterSystemApps": { "title": "Hide System Apps", "type": "boolean", "default": false, "description": "If enabled, System Apps (Accessory, TV, Network Troubleshooter, Xbox Guide) will not be displayed on the list of inputs." }, "filterDlc": { "title": "Hide DLC", "type": "boolean", "default": false, "description": "If enabled, DLC will not be displayed on the list of inputs." }, "displayOrder": { "title": "Display Order", "type": "integer", "minimum": 0, "maximum": 4, "description": "Here select display order of the inputs list.", "anyOf": [ { "title": "Disabled", "enum": [ 0 ] }, { "title": "Ascending by Name", "enum": [ 1 ] }, { "title": "Descending by Name", "enum": [ 2 ] }, { "title": "Ascending by Reference", "enum": [ 3 ] }, { "title": "Descending by Reference", "enum": [ 4 ] } ] }, "data": { "title": "Inputs", "type": "array", "items": { "title": "Input", "type": "object", "properties": { "name": { "title": "Name", "type": "string", "placeholder": "Input name", "description": "Here set Your own name." }, "titleId": { "title": "Title Id", "type": "string", "placeholder": "Input Title Id", "description": "Here set title Id. If web api is enable all available in */var/lib/homebridge/xboxTv/inputs_xxxxxx* file." }, "reference": { "title": "Reference", "type": "string", "placeholder": "Input reference", "description": "Here set reference. If web api is enable all available in */var/lib/homebridge/xboxTv/inputs_xxxxxx* file." }, "oneStoreProductId": { "title": "Product Id", "type": "string", "placeholder": "oneStoreProductId", "description": "Here set the *oneStoreProductId*. If web api enable, switch app/games will be possible, all available in */var/lib/homebridge/xboxTv/inputs_xxxxxx* file.", "condition": { "functionBody": "return model.devices[arrayIndices].webApi.enable === true;" } }, "contentType": { "title": "Content Type", "type": "string", "description": "Here select source input type.", "anyOf": [ { "title": "Game", "enum": [ "Game" ] }, { "title": "Application", "enum": [ "App" ] }, { "title": "System Appliction", "enum": [ "System App" ] }, { "title": "Dlc", "enum": [ "Dlc" ] } ] } } }, "condition": { "functionBody": "return model.devices[arrayIndices].inputs.getFromDevice === false || model.devices[arrayIndices].webApi.enable === false;" } } } }, "buttons": { "title": "Button", "type": "array", "items": { "type": "object", "properties": { "displayType": { "title": "Display Type", "type": "integer", "minimum": 0, "maximum": 2, "description": "Here select display type in HomeKit app.", "anyOf": [ { "title": "Disabled", "enum": [ 0 ] }, { "title": "Outlet", "enum": [ 1 ] }, { "title": "Switch", "enum": [ 2 ] } ] }, "name": { "title": "Name", "type": "string", "placeholder": "Button name", "description": "Here set Your own name." }, "mode": { "title": "Mode", "type": "integer", "minimum": 0, "maximum": 4, "description": "Here select the function mode.", "anyOf": [ { "title": "Media Control", "enum": [ 0 ] }, { "title": "Game Pad Control", "enum": [ 1 ] }, { "title": "TV Remote Control", "enum": [ 2 ] }, { "title": "Console Control", "enum": [ 3 ] }, { "title": "Game/App Control", "enum": [ 4 ] } ], "condition": { "functionBody": "return model.devices[arrayIndices[0]].buttons[arrayIndices[1]].displayType > 0;" } }, "mediaCommand": { "title": "Media Command", "type": "string", "description": "Here select the media command.", "anyOf": [ { "title": "Play", "enum": [ "play" ] }, { "title": "Pause", "enum": [ "pause" ] }, { "title": "Play/Pause", "enum": [ "playPause" ] }, { "title": "Stop", "enum": [ "stop" ] }, { "title": "Record", "enum": [ "record" ] }, { "title": "Next Track", "enum": [ "nextTrack" ] }, { "title": "Previous Track", "enum": [ "prevTrack" ] }, { "title": "Fast Forward", "enum": [ "fastForward" ] }, { "title": "Rewind", "enum": [ "rewind" ] }, { "title": "Channel Up", "enum": [ "channelUp" ] }, { "title": "Channel Down", "enum": [ "channelDown" ] }, { "title": "Back", "enum": [ "back" ] }, { "title": "View", "enum": [ "view" ] }, { "title": "Menu", "enum": [ "menu" ] }, { "title": "Seek", "enum": [ "seek" ] } ], "condition": { "functionBody": "return model.devices[arrayIndices[0]].buttons[arrayIndices[1]].displayType > 0 && model.devices[arrayIndices[0]].buttons[arrayIndices[1]].mode === 0;" } }, "gamePadCommand": { "title": "Game Pad Command", "type": "string", "description": "Here select the controler command.", "anyOf": [ { "title": "Nexus", "enum": [ "nexus" ] }, { "title": "View", "enum": [ "view" ] }, { "title": "Menu", "enum": [ "menu" ] }, { "title": "A", "enum": [ "a" ] }, { "title": "B", "enum": [ "b" ] }, { "title": "X", "enum": [ "x" ] }, { "title": "Y", "enum": [ "y" ] }, { "title": "Up", "enum": [ "up" ] }, { "title": "Down", "enum": [ "down" ] }, { "title": "Left", "enum": [ "left" ] }, { "title": "Right", "enum": [ "right" ] } ], "condition": { "functionBody": "return model.devices[arrayIndices[0]].buttons[arrayIndices[1]].displayType > 0 && model.devices[arrayIndices[0]].buttons[arrayIndices[1]].mode === 1;" } }, "tvRemoteCommand": { "title": "TV Remote Command", "type": "string", "description": "Here select the tv remote command.", "anyOf": [ { "title": "Volume Up", "enum": [ "volUp" ] }, { "title": "Volume Down", "enum": [ "volDown" ] }, { "title": "Mute", "enum": [ "volMute" ] } ], "condition": { "functionBody": "return model.devices[arrayIndices[0]].buttons[arrayIndices[1]].displayType > 0 && model.devices[arrayIndices[0]].buttons[arrayIndices[1]].mode === 2;" } }, "consoleControlCommand": { "title": "Console Control Command", "type": "string", "description": "Here select the console control command.", "anyOf": [ { "title": "Reboot", "enum": [ "reboot" ] }, { "title": "Record Game DVR", "enum": [ "recordGameDvr" ] } ], "condition": { "functionBody": "return model.devices[arrayIndices[0]].buttons[arrayIndices[1]].displayType > 0 && model.devices[arrayIndices[0]].buttons[arrayIndices[1]].mode === 3;" } }, "gameAppControlCommand": { "title": "Product Id", "type": "string", "placeholder": "oneStoreProductId", "description": "Here set the *oneStoreProductId*. If web api enabled, switch app/games will be possible, all available in */var/lib/homebridge/xboxTv/inputs_xxxxxx* file.", "condition": { "functionBody": "return model.devices[arrayIndices[0]].buttons[arrayIndices[1]].displayType > 0 && model.devices[arrayIndices[0]].buttons[arrayIndices[1]].mode === 4;" } }, "namePrefix": { "title": "Prefix", "type": "boolean", "description": "Here enable/disable the accessory name as a prefix for button name.", "condition": { "functionBody": "return model.devices[arrayIndices[0]].buttons[arrayIndices[1]].displayType > 0;" } } }, "allOf": [ { "if": { "required": [ "displayType" ], "properties": { "displayType": { "enum": [ 1, 2 ] } } }, "then": { "required": [ "mode" ] } }, { "if": { "required": [ "displayType", "mode" ], "properties": { "displayType": { "enum": [ 1, 2 ] }, "mode": { "enum": [ 0 ] } } }, "then": { "required": [ "mediaCommand" ] } }, { "if": { "required": [ "displayType", "mode" ], "properties": { "displayType": { "enum": [ 1, 2 ] }, "mode": { "enum": [ 1 ] } } }, "then": { "required": [ "gamePadCommand" ] } }, { "if": { "required": [ "displayType", "mode" ], "properties": { "displayType": { "enum": [ 1, 2 ] }, "mode": { "enum": [ 2 ] } } }, "then": { "required": [ "tvRemoteCommand" ] } }, { "if": { "required": [ "displayType", "mode" ], "properties": { "displayType": { "enum": [ 1, 2 ] }, "mode": { "enum": [ 3 ] } } }, "then": { "required": [ "consoleControlCommand" ] } }, { "if": { "required": [ "displayType", "mode" ], "properties": { "displayType": { "enum": [ 1, 2 ] }, "mode": { "enum": [ 4 ] } } }, "then": { "required": [ "gameAppControlCommand" ] } } ] } }, "sensors": { "type": "array", "items": { "title": "Sensors", "type": "object", "properties": { "displayType": { "title": "Display Type", "type": "integer", "minimum": 0, "maximum": 3, "default": 0, "anyOf": [ { "title": "Disabled", "enum": [ 0 ] }, { "title": "Motion Sensor", "enum": [ 1 ] }, { "title": "Occupancy Sensor", "enum": [ 2 ] }, { "title": "Contact Sensor", "enum": [ 3 ] } ], "description": "Here select sensor type to be exposed in HomeKit app." }, "mode": { "title": "Mode", "type": "integer", "minimum": 0, "maximum": 5, "anyOf": [ { "title": "App / Games", "enum": [ 0 ] }, { "title": "Power", "enum": [ 1 ] }, { "title": "Volume", "enum": [ 2 ] }, { "title": "Mute", "enum": [ 3 ] }, { "title": "Screen Saver", "enum": [ 4 ] }, { "title": "Play State", "enum": [ 5 ] } ], "description": "Here select the sensor mode.", "condition": { "functionBody": "return model.devices[arrayIndices[0]].sensors[arrayIndices[1]].displayType > 0;" } }, "name": { "title": "Name", "type": "string", "placeholder": "Name", "description": "Here set Your own name." }, "namePrefix": { "title": "Prefix", "type": "boolean", "description": "Here enable the accessory name as a prefix for sensor name.", "condition": { "functionBody": "return model.devices[arrayIndices[0]].sensors[arrayIndices[1]].displayType > 0;" } }, "pulse": { "title": "Pulse", "type": "boolean", "description": "Here enable sensor pulse, sensor send pulse and fired on every value change of selected mode.", "condition": { "functionBody": "return model.devices[arrayIndices[0]].sensors[arrayIndices[1]].displayType > 0;" } }, "reference": { "title": "Reference", "type": "string", "placeholder": "Reference", "description": "Here set the channel reference, sensor fired if switch to this reference.", "condition": { "functionBody": "return model.devices[arrayIndices[0]].sensors[arrayIndices[1]].displayType > 0 && model.devices[arrayIndices[0]].sensors[arrayIndices[1]].pulse === false && model.devices[arrayIndices[0]].sensors[arrayIndices[1]].mode === 0;" } }, "level": { "title": "Level", "type": "number", "minimum": 0, "maximum": 100, "multipleOf": 1, "description": "Here set the level between 0-100% at which the sensor fired.", "condition": { "functionBody": "return model.devices[arrayIndices[0]].sensors[arrayIndices[1]].displayType > 0 && model.devices[arrayIndices[0]].sensors[arrayIndices[1]].pulse === false && model.devices[arrayIndices[0]].sensors[arrayIndices[1]].mode === 2;" } } }, "allOf": [ { "if": { "required": [ "displayType" ], "properties": { "displayType": { "enum": [ 1, 2, 3 ] } } }, "then": { "required": [ "mode" ] } }, { "if": { "required": [ "displayType", "mode", "pulse" ], "properties": { "displayType": { "enum": [ 1, 2, 3 ] }, "pulse": { "const": false }, "mode": { "enum": [ 0 ] } } }, "then": { "required": [ "reference" ] } }, { "if": { "required": [ "displayType", "mode", "pulse" ], "properties": { "displayType": { "enum": [ 1, 2, 3 ] }, "pulse": { "const": false }, "mode": { "enum": [ 2 ] } } }, "then": { "required": [ "level" ] } } ] } }, "volume": { "title": "Volume", "type": "object", "properties": { "displayType": { "title": "Display Type", "type": "integer", "description": "Here select what a extra volume/mute control type You want to use.", "anyOf": [ { "title": "Disabled", "enum": [ 0 ] }, { "title": "Lightbulb", "enum": [ 1 ] }, { "title": "Fan", "enum": [ 2 ] }, { "title": "TV Speaker (HW buttons)", "enum": [ 3 ] }, { "title": "TV Speaker / Lightbulb", "enum": [ 4 ] }, { "title": "TV Speaker / Fan", "enum": [ 5 ] } ] }, "name": { "title": "Name", "type": "string", "placeholder": "Volume Control Name", "description": "Here set Your own volume control name or leave empty.", "condition": { "functionBody": "return model.devices[arrayIndices[0]].volume.displayType > 0;" } }, "namePrefix": { "title": "Prefix", "type": "boolean", "description": "Here enable/disable the accessory name as a prefix for volume control name.", "condition": { "functionBody": "return model.devices[arrayIndices[0]].volume.displayType > 0;" } } } }, "infoButtonCommand": { "title": "Info Button", "type": "string", "default": "nexus", "description": "Here select the function of info button in RC.", "anyOf": [ { "title": "Game Pad View", "enum": [ "view" ] }, { "title": "Game Pad Nexus", "enum": [ "nexus" ] }, { "title": "Game Pad Menu", "enum": [ "menu" ] }, { "title": "Game Pad A", "enum": [ "a" ] }, { "title": "Game Pad B", "enum": [ "b" ] }, { "title": "Game Pad X", "enum": [ "x" ] }, { "title": "Game Pad Y", "enum": [ "y" ] } ] }, "log": { "title": "Log", "type": "object", "properties": { "deviceInfo": { "title": "Device Info", "type": "boolean", "default": true, "description": "This enable log level device info, will display overall device info by every plugin restart" }, "success": { "title": "Success", "type": "boolean", "default": true, "description": "This enable log level success" }, "info": { "title": "Info", "type": "boolean", "default": false, "description": "This enable log level info" }, "warn": { "title": "Warn", "type": "boolean", "default": true, "description": "This enable log level warn" }, "error": { "title": "Error", "type": "boolean", "default": true, "description": "This enable log level error" }, "debug": { "title": "Debug", "type": "boolean", "default": false, "description": "This enable log level debug" } } }, "restFul": { "title": "RESTFul", "type": "object", "properties": { "enable": { "title": "Enable", "type": "boolean", "default": false, "description": "This enable RESTful server." }, "port": { "title": "Port", "type": "integer", "placeholder": 3000, "description": "Here set the listening Port for RESTful server.", "condition": { "functionBody": "return model.devices[arrayIndices].restFul.enable === true;" } } } }, "mqtt": { "title": "MQTT", "type": "object", "properties": { "enable": { "title": "Enable", "type": "boolean", "default": false, "description": "This enable MQTT client." }, "host": { "title": "IP/Hostname", "type": "string", "placeholder": "192.168.1.20 or mqtt.local", "pattern": "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}|([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\\.([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*)$", "description": "Here set the IP/Hostname of MQTT Broker.", "condition": { "functionBody": "return model.devices[arrayIndices].mqtt.enable === true;" } }, "port": { "title": "Port", "type": "integer", "placeholder": 1883, "description": "Here set the port of MQTT Broker.", "condition": { "functionBody": "return model.devices[arrayIndices].mqtt.enable === true;" } }, "clientId": { "title": "Client ID", "type": "string", "placeholder": "client id", "description": "Here optional set the Client ID of MQTT Broker.", "condition": { "functionBody": "return model.devices[arrayIndices].mqtt.enable === true" } }, "prefix": { "title": "Prefix", "type": "string", "placeholder": "home", "description": "Here set the prefix.", "condition": { "functionBody": "return model.devices[arrayIndices].mqtt.enable === true;" } }, "auth": { "title": "Authorization", "type": "object", "properties": { "enable": { "title": "Enable", "type": "boolean", "description": "This enable authorization for MQTT Broker." }, "user": { "title": "User", "type": "string", "placeholder": "user", "description": "Here set the user of MQTT Broker.", "condition": { "functionBody": "return model.devices[arrayIndices].mqtt.auth.enable === true;" } }, "passwd": { "title": "Password", "type": "string", "placeholder": "password", "description": "Here set the password of MQTT Broker.", "format": "password", "condition": { "functionBody": "return model.devices[arrayIndices].mqtt.auth.enable === true;" } } }, "condition": { "functionBody": "return model.devices[arrayIndices].mqtt.enable === true;" }, "allOf": [ { "if": { "required": [ "enable" ], "properties": { "enable": { "const": true } } }, "then": { "required": [ "user", "passwd" ] } } ] } }, "allOf": [ { "if": { "required": [ "enable" ], "properties": { "enable": { "const": true } } }, "then": { "required": [ "host" ] } } ] } }, "required": [ "name", "host", "xboxLiveId", "displayType" ] } } } }, "layout": [ { "key": "devices", "type": "tabarray", "title": "{{ value.name || 'Device' }}", "items": [ "devices[].name", "devices[].host", "devices[].xboxLiveId", "devices[].displayType", { "key": "devices[].inputs", "type": "section", "title": "Inputs", "expandable": true, "expanded": false, "items": [ "devices[].inputs.getFromDevice", "devices[].inputs.filterGames", "devices[].inputs.filterApps", "devices[].inputs.filterSystemApps", "devices[].inputs.filterDlc", "devices[].inputs.displayOrder", { "key": "devices[].inputs.data", "type": "tabarray", "title": "{{ value.name || 'Input' }}", "items": [ "devices[].inputs.data[].name", "devices[].inputs.data[].titleId", "devices[].inputs.data[].reference", "devices[].inputs.data[].oneStoreProductId", "devices[].inputs.data[].contentType" ] } ] }, { "key": "devices[]", "type": "section", "title": "Buttons", "expandable": true, "expanded": false, "items": [ { "key": "devices[].buttons", "type": "tabarray", "title": "{{ value.name || 'Button' }}", "items": [ "devices[].buttons[].displayType", "devices[].buttons[].name", "devices[].buttons[].mode", "devices[].buttons[].mediaCommand", "devices[].buttons[].gamePadCommand", "devices[].buttons[].tvRemoteCommand", "devices[].buttons[].consoleControlCommand", "devices[].buttons[].gameAppControlCommand", "devices[].buttons[].namePrefix" ] } ] }, { "key": "devices[]", "type": "section", "title": "Advanced Settings", "expandable": true, "expanded": false, "items": [ { "key": "devices[]", "type": "tabarray", "title": "{{ value.title }}", "items": [ { "key": "devices[].volume", "title": "Volume", "items": [ "devices[].volume.displayType", "devices[].volume.name", "devices[].volume.namePrefix" ] }, { "key": "devices[]", "title": "Sensors", "items": [ { "key": "devices[].sensors", "type": "tabarray", "title": "{{ value.name || 'Sensor' }}", "items": [ "devices[].sensors[].displayType", "devices[].sensors[].mode", "devices[].sensors[].name", "devices[].sensors[].namePrefix", "devices[].sensors[].pulse", "devices[].sensors[].reference", "devices[].sensors[].level" ] } ] }, { "key": "devices[]", "title": "Device", "items": [ "devices[].infoButtonCommand" ] }, { "key": "devices[].webApi", "title": "Web Api", "items": [ "devices[].webApi.enable", { "key": "devices[].webApi.token", "type": "password" }, { "key": "devices[].webApi.clientId", "type": "password" }, { "key": "devices[].webApi.clientSecret", "type": "password" } ] }, { "key": "devices[].log", "title": "Log", "items": [ "devices[].log.deviceInfo", "devices[].log.success", "devices[].log.info", "devices[].log.warn", "devices[].log.error", "devices[].log.debug" ] }, { "key": "devices[]", "title": "External Integrations", "items": [ { "key": "devices[]", "type": "tabarray", "title": "{{ value.title }}", "items": [ { "key": "devices[].restFul", "title": "RESTFul", "items": [ "devices[].restFul.enable", "devices[].restFul.port" ] }, { "key": "devices[].mqtt", "title": "MQTT", "items": [ "devices[].mqtt.enable", "devices[].mqtt.host", "devices[].mqtt.port", "devices[].mqtt.clientId", "devices[].mqtt.prefix", { "key": "devices[].mqtt.auth", "title": "Authorization", "items": [ "devices[].mqtt.auth.enable", "devices[].mqtt.auth.user", { "key": "devices[].mqtt.auth.passwd", "type": "password" } ] } ] } ] } ] } ] } ] } ] } ] } ================================================ FILE: homebridge-ui/public/index.html ================================================
Image

================================================ FILE: homebridge-ui/server.js ================================================ import { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils'; import Authentication from '../src/webApi/authentication.js'; import Functions from '../src/functions.js'; class PluginUiServer extends HomebridgePluginUiServer { constructor() { super(); this.functions = new Functions(); //clear web api token this.onRequest('/clearToken', this.clearToken.bind(this)); //start console authorization this.onRequest('/startAuthorization', this.startAuthorization.bind(this)); //this MUST be called when you are ready to accept requests this.ready(); }; async clearToken(payload) { const hostKey = payload.host.replace(/\./g, ''); const tokensFile = `${this.homebridgeStoragePath}/xboxTv/authToken_${hostKey}`; try { const emptyTokens = { oauth: {}, user: {}, xsts: {} }; await this.functions.saveData(tokensFile, emptyTokens); return true; } catch (error) { throw new Error(`Clear token error: ${error?.message ?? error}`); } } async startAuthorization(payload) { const hostKey = payload.host.replace(/\./g, ''); const tokensFile = `${this.homebridgeStoragePath}/xboxTv/authToken_${hostKey}`; const authConfig = { clientId: payload.clientId, clientSecret: payload.clientSecret, tokensFile }; const authentication = new Authentication(authConfig); const webApiToken = payload.token; try { // Case: Console already authorized await authentication.checkAuthorization(); return { info: 'Console authorized and activated. To start a new process, please clear the Web API Token first.', status: 0 // Authorized }; } catch { if (webApiToken) { try { await authentication.accessToken(webApiToken); return { info: 'Activation successful! Now restart the plugin and have fun!', status: 2 // Token activated }; } catch (error) { throw new Error(`Activation console error: ${error?.message ?? error}`); } } // No token, generate auth URL try { const oauth2URI = await authentication.generateAuthorizationUrl(); return { info: oauth2URI, status: 1 // Needs user authorization }; } catch (error) { throw new Error(error); } } } } (() => { return new PluginUiServer(); })(); ================================================ FILE: index.js ================================================ import { join } from 'path'; import { mkdirSync, existsSync, writeFileSync } from 'fs'; import XboxDevice from './src/xboxdevice.js'; import ImpulseGenerator from './src/impulsegenerator.js'; import { PluginName, PlatformName } from './src/constants.js'; class XboxPlatform { constructor(log, config, api) { // only load if configured if (!config || !Array.isArray(config.devices)) { log.warn(`No configuration found for ${PluginName}`); return; } this.accessories = []; const prefDir = join(api.user.storagePath(), 'xboxTv'); try { mkdirSync(prefDir, { recursive: true }); } catch (error) { log.error(`Prepare directory error: ${error.message ?? error}`); return; } api.on('didFinishLaunching', () => { // Each device is set up independently — a failure in one does not // block the others. Promise.allSettled runs all in parallel. Promise.allSettled( config.devices.map(device => this.setupDevice(device, prefDir, log, api) ) ).then(results => { results.forEach((result, i) => { if (result.status === 'rejected') { log.error(`Device[${i}] setup error: ${result.reason?.message ?? result.reason}`); } }); }); }); } // ── Per-device setup ────────────────────────────────────────────────────── async setupDevice(device, prefDir, log, api) { const { name, host, xboxLiveId, displayType } = device; if (!name || !host || !xboxLiveId || !displayType) { log.warn(`Device: ${host || 'host missing'}, ${name || 'name missing'}, ${xboxLiveId || 'xbox live id missing'}${!displayType ? ', display type disabled' : ''} in config, will not be published in the Home app`); return; } const logLevel = { devInfo: device.log?.deviceInfo, success: device.log?.success, info: device.log?.info, warn: device.log?.warn, error: device.log?.error, debug: device.log?.debug, }; if (logLevel.debug) { log.info(`Device: ${host} ${name}, did finish launching.`); const safeConfig = { ...device, xboxLiveId: 'removed', webApi: { token: 'removed', clientSecret: 'removed', clientId: 'removed', }, mqtt: { auth: { ...device.mqtt?.auth, passwd: 'removed', }, }, }; log.info(`Device: ${host} ${name}, Config: ${JSON.stringify(safeConfig, null, 2)}.`); } // Resolve all file paths up front — before the impulse generator starts, // so a file-creation failure aborts early rather than inside the retry loop. const postFix = host.split('.').join(''); const authTokenFile = `${prefDir}/authToken_${postFix}`; const devInfoFile = `${prefDir}/devInfo_${postFix}`; const inputsFile = `${prefDir}/inputs_${postFix}`; const inputsNamesFile = `${prefDir}/inputsNames_${postFix}`; const inputsTargetVisibilityFile = `${prefDir}/inputsTargetVisibility_${postFix}`; try { const files = [ authTokenFile, devInfoFile, inputsFile, inputsNamesFile, inputsTargetVisibilityFile, ]; files.forEach(file => { if (!existsSync(file)) { writeFileSync(file, ''); } }); } catch (error) { if (logLevel.error) log.error(`Device: ${host} ${name}, Prepare files error: ${error.message ?? error}`); return; } // The startup impulse generator retries the full connect cycle // every 120 s until it succeeds, then hands off to the xboxDevice // impulse generator and stops itself. const impulseGenerator = new ImpulseGenerator() .on('start', async () => { try { await this.startDevice({ device, name, host, authTokenFile, devInfoFile, inputsFile, inputsNamesFile, inputsTargetVisibilityFile, logLevel, log, api, impulseGenerator, }); } catch (error) { if (logLevel.error) log.error(`Device: ${host} ${name}, Start impulse generator error: ${error.message ?? error}, trying again.`); } }) .on('state', (state) => { if (logLevel.debug) log.info(`Device: ${host} ${name}, Start impulse generator ${state ? 'started' : 'stopped'}.`); }); await impulseGenerator.state(true, [{ name: 'start', sampling: 120_000 }]); } // ── Connect and register a single Xbox device as a Homebridge accessory ─── async startDevice({ device, name, host, authTokenFile, devInfoFile, inputsFile, inputsNamesFile, inputsTargetVisibilityFile, logLevel, log, api, impulseGenerator }) { const xboxDevice = new XboxDevice(api, device, authTokenFile, devInfoFile, inputsFile, inputsNamesFile, inputsTargetVisibilityFile) .on('devInfo', (info) => logLevel.devInfo && log.info(info)) .on('success', (msg) => logLevel.success && log.success(`Device: ${host} ${name}, ${msg}`)) .on('info', (msg) => logLevel.info && log.info(`Device: ${host} ${name}, ${msg}`)) .on('debug', (msg) => logLevel.debug && log.info(`Device: ${host} ${name}, debug: ${msg}`)) .on('warn', (msg) => logLevel.warn && log.warn(`Device: ${host} ${name}, ${msg}`)) .on('error', (msg) => logLevel.error && log.error(`Device: ${host} ${name}, ${msg}`)); const accessory = await xboxDevice.start(); if (!accessory) return; api.publishExternalAccessories(PluginName, [accessory]); if (logLevel.success) log.success(`Device: ${host} ${name}, Published as external accessory.`); // Hand off to the xboxDevice impulse generator and stop the startup one. await xboxDevice.startStopImpulseGenerator(true, [{ name: 'connect', sampling: 6000 }]); await impulseGenerator.state(false); } // ── Homebridge accessory cache ──────────────────────────────────────────── configureAccessory(accessory) { this.accessories.push(accessory); } } export default (api) => { api.registerPlatform(PluginName, PlatformName, XboxPlatform); }; ================================================ FILE: package.json ================================================ { "displayName": "Xbox TV", "name": "homebridge-xbox-tv", "version": "4.1.17", "description": "Homebridge plugin to control Xbox game consoles.", "license": "MIT", "author": "grzegorz914", "maintainers": [ "grzegorz914" ], "homepage": "https://github.com/grzegorz914/homebridge-xbox-tv#readme", "repository": { "type": "git", "url": "git+https://github.com/grzegorz914/homebridge-xbox-tv.git" }, "bugs": { "url": "https://github.com/grzegorz914/homebridge-xbox-tv/issues" }, "type": "module", "exports": { ".": "./index.js" }, "files": [ "src", "homebridge-ui", "index.js", "config.schema.json", "package.json", "CHANGELOG.md", "README.md", "LICENSE" ], "engines": { "homebridge": "^1.8.0 || ^2.0.0 || ^2.0.0-beta.79 || ^2.0.0-alpha.81", "node": "^20 || ^22 || ^24 || ^25" }, "dependencies": { "@homebridge/plugin-ui-utils": "^2.2.3", "mqtt": "^5.15.1", "elliptic": "^6.6.1", "hex-to-binary": "^1.0.1", "jsrsasign": "^11.1.3", "uuid": "^14.0.0", "express": "^5.2.1", "axios": "^1.15.2" }, "keywords": [ "homebridge", "homebridge-plugin", "homekit", "xbox", "microsoft", "smartglass", "mqtt", "restful" ], "funding": { "type": "Buy Coffee To", "url": "https://buycoffee.to/grzegorz914" }, "contributors": [], "scripts": { "test": "echo \"Error: no test specified\" && exit 1" } } ================================================ FILE: sample-config.json ================================================ { "bridge": { "name": "Homebridge", "username": "AA:BB:CC:DD:EE:FF", "manufacturer": "homebridge.io", "model": "homebridge", "port": 9100, "pin": "123-45-678" }, "description": "HomeKit Bridge", "ports": { "start": 9101, "end": 9150, "comment": "In this section set the port for Homebridge accessories." }, "accessories": [], "platforms": [ { "platform": "XboxTv", "devices": [ { "name": "Xbox One", "host": "192.168.1.6", "xboxLiveId": "FD0000000000", "displayType": 1, "webApi": { "enable": false, "token": "", "clientId": "", "clientSecret": "" }, "inputs": { "getFromDevice": false, "filterGames": false, "filterApps": false, "filterSystemApps": false, "filterDlc": false, "displayOrder": 0, "data": [ { "name": "A Way Out", "reference": "AWayOut_zwks512sysnyr!AppAWayOut", "oneStoreProductId": "", "contentType": "Game" } ] }, "buttons": [ { "displayType": 0, "name": "Media Play", "mode": 0, "mediaCommand": "play", "namePrefix": false } ], "sensors": [ { "displayType": 0, "mode": 0, "name": "", "reference": "", "pulse": false, "namePrefix": false, "level": 0 } ], "volume": { "displayType": 0, "name": "Volume", "namePrefix": false }, "log": { "deviceInfo": true, "success": true, "info": false, "warn": true, "error": true, "debug": false }, "infoButtonCommand": "nexus", "restFul": { "enable": false, "port": 3000 }, "mqtt": { "enable": false, "host": "", "port": 1883, "clientId": "", "prefix": "", "auth": { "enable": false, "user": "", "passwd": "" } } } ] } ] } ================================================ FILE: src/constants.js ================================================ export const PlatformName = "XboxTv"; export const PluginName = "homebridge-xbox-tv"; export const DefaultInputs = [ { "oneStoreProductId": "Screensaver", "titleId": "851275400", "reference": "Xbox.IdleScreen_8wekyb3d8bbwe!Xbox.IdleScreen.Application", "isGame": false, "name": "Screensaver", "contentType": "Dashboard" }, { "oneStoreProductId": "Dashboard", "titleId": "750323071", "reference": "Xbox.Dashboard_8wekyb3d8bbwe!Xbox.Dashboard.Application", "isGame": false, "name": "Dashboard", "contentType": "Dashboard" }, { "oneStoreProductId": "Settings", "titleId": "1837352387", "reference": "Microsoft.Xbox.Settings_8wekyb3d8bbwe!Xbox.Settings.Application", "isGame": false, "name": "Settings", "contentType": "Dashboard" }, { "oneStoreProductId": "Television", "titleId": "371594669", "reference": "Microsoft.Xbox.LiveTV_8wekyb3d8bbwe!Microsoft.Xbox.LiveTV.Application", "isGame": false, "name": "Television", "contentType": "systemApp" }, { "oneStoreProductId": "SettingsTv", "titleId": "2019308066", "reference": "Microsoft.Xbox.TvSettings_8wekyb3d8bbwe!Microsoft.Xbox.TvSettings.Application", "isGame": false, "name": "Settings TV", "contentType": "Dashboard" }, { "oneStoreProductId": "Accessory", "titleId": "758407307", "reference": "Microsoft.XboxDevices_8wekyb3d8bbwe!App", "isGame": false, "name": "Accessory", "contentType": "systemApp" }, { "oneStoreProductId": "NetworkTroubleshooter", "titleId": "1614319806", "reference": "Xbox.NetworkTroubleshooter_8wekyb3d8bbwe!Xbox.NetworkTroubleshooter.Application", "isGame": false, "name": "Network Troubleshooter", "contentType": "systemApp" }, { "oneStoreProductId": "MicrosoftStore", "titleId": "1864271209", "reference": "Microsoft.storify_8wekyb3d8bbwe!App", "isGame": false, "name": "Microsoft Store", "contentType": "Dashboard" }, { "oneStoreProductId": "XboxGuide", "titleId": "1052052983", "reference": "Xbox.Guide_8wekyb3d8bbwe!Xbox.Guide.Application", "isGame": false, "name": "Xbox Guide", "contentType": "systemApp" } ]; export const InputSourceTypes = [ "OTHER", "HOME_SCREEN", "TUNER", "HDMI", "COMPOSITE_VIDEO", "S_VIDEO", "COMPONENT_VIDEO", "DVI", "AIRPLAY", "USB", "APPLICATION" ]; export const WebApi = { "Url": { "oauth2": "https://login.live.com/oauth20_authorize.srf", "AccessToken": "https://login.live.com/oauth20_token.srf", "RefreshToken": "https://login.live.com/oauth20_token.srf", "UserToken": "https://user.auth.xboxlive.com/user/authenticate", "XstsToken": "https://xsts.auth.xboxlive.com/xsts/authorize", "Redirect": "http://localhost:8888/auth/callback", "Xccs": "https://xccs.xboxlive.com" }, "Scopes": "XboxLive.signin XboxLive.offline_access", "ClientId": "a34ac209-edab-4b08-91e7-a4558d8da1bd", "Console": { "Name": { "XboxSeriesX": "Xbox Series X", "XboxSeriesS": "Xbox Series S", "XboxOne": "Xbox One", "XboxOneS": "Xbox One S", "XboxOneX": "Xbox One X" }, "PowerState": { "Off": 0, "On": 1, "ConnectedStandby": 2, "SystemUpdate": 3, "Unknown": 4 }, "PlaybackState": {//0 - STOP, 1 - PLAY, 2 - PAUSE "Stopped": 0, "Playing": 1, "Paused": 2, "Unknown": 3 }, "PlaybackStateHomeKit": { //0 - PLAY, 1 - PAUSE, 2 - STOP, 3 - LOADING, 4 - INTERRUPTED "Stopped": 2, "Playing": 0, "Paused": 1, "Unknown": 4 } } }; export const LocalApi = { "ParticipantId": { "Target": 0 }, "ClientId": "e8ff5828-5cce-4f90-89a4-117d127e3838", "Console": { "Name": { "1": "Xbox One", "2": "Xbox 360", "3": "Windows Desktop", "4": "Windows Store", "5": "Windows Phone", "6": "iPhone", "7": "iPad", "8": "Android" }, "PairingState": { "0": "Not Paired", "1": "Paired" } }, "Channels": { "System": { "Input": { "Id": 0, "Uuid": "fa20b8ca66fb46e0adb60b978a59d35f", "Commands": { "unpress": 0, "enroll": 1, "nexus": 2, "view": 4, "menu": 8, "a": 16, "b": 32, "x": 64, "y": 128, "up": 256, "down": 512, "left": 1024, "right": 2048, "leftShoulder": 4096, "rightShoulder": 8192, "leftThumbstick": 16384, "rightThumbstick": 32768 } }, "TvRemote": { "Id": 1, "Uuid": "d451e3b360bb4c71b3dbf994b1aca3a7", "Commands": { "volUp": "btn.vol_up", "volDown": "btn.vol_down", "volMute": "btn.vol_mute" }, "MessageType": { "Error": "Error", "GetConfiguration": "GetConfiguration", "GetHeadendInfo": "GetHeadendInfo", "GetLiveTVInfo": "GetLiveTVInfo", "GetProgramInfo": "GetProgramInfo", "GetRecentChannels": "GetRecentChannels", "GetTunerLineups": "GetTunerLineups", "GetAppChannelData": "GetAppChannelData", "GetAppChannelLineups": "GetAppChannelLineups", "GetAppChannelProgramData": "GetAppChannelProgramData", "SendKey": "SendKey", "SetChannel": "SetChannel" } }, "Media": { "Id": 2, "Uuid": "48a9ca24eb6d4e128c43d57469edd3cd", "Commands": { "play": 2, "pause": 4, "playPause": 8, "stop": 16, "record": 32, "nextTrack": 64, "previousTrack": 128, "fastForward": 256, "rewind": 512, "channelUp": 1024, "channelDown": 2048, "back": 4096, "view": 8192, "menu": 16384, "seek": 32786 } }, "Text": { "Id": 3, "Uuid": "7af3e6a2488b40cba93179c04b7da3a0" }, "Broadcast": { "Id": 4, "Uuid": "b6a117d8f5e245d7862e8fd8e3156476" }, "Title": { "Id": 5, "Uuid": "00000000000000000000000000000000" } } }, "Media": { "Types": { "0": "No Media", "1": "Music", "2": "Video", "3": "Image", "4": "Conversation", "5": "Game" }, "PlaybackState": { "0": "Closed", "1": "Changing", "2": "Stopped", "3": "Playing", "4": "Paused" }, "SoundLevel": { "0": "Muted", "1": "Low", "2": "Full" } }, "Messages": { "Category": { "d00d": "message", "dd00": "simple", "dd01": "simple", "dd02": "simple", "cc00": "simple", "cc01": "simple" }, "CategoryTypes": { "d00d": "message", "dd00": "discoveryRequest", "dd01": "discoveryResponse", "dd02": "powerOn", "cc00": "connectRequest", "cc01": "connectResponse", }, "Types": { 0x1: "acknowledge", 0x2: "group", 0x3: "localJoin", 0x5: "stopActivity", 0x19: "auxilaryStream", 0x1A: "activeSurfaceChange", 0x1B: "navigate", 0x1C: "json", 0x1D: "tunnel", 0x1E: "consoleStatus", 0x1F: "titleTextConfiguration", 0x20: "titleTextInput", 0x21: "titleTextSelection", 0x22: "mirroringRequest", 0x23: "titleLaunch", 0x26: "channelStartRequest", 0x27: "channelStartResponse", 0x28: "channelStop", 0x29: "system", 0x2A: "disconnect", 0x2E: "titleTouch", 0x2F: "accelerometer", 0x30: "gyrometer", 0x31: "inclinometer", 0x32: "compass", 0x33: "orientation", 0x36: "pairedIdentityStateChanged", 0x37: "unsnap", 0x38: "recordGameDvr", 0x39: "powerOff", 0xF00: "mediaControllerRemoved", 0xF01: "mediaCommand", 0xF02: "mediaCommandResult", 0xF03: "mediaState", 0xF0A: "gamepad", 0xF2B: "systemTextConfiguration", 0xF2C: "systemTextInput", 0xF2E: "systemTouch", 0xF34: "systemTextAck", 0xF35: "systemTextDone" }, "Flags": { acknowledge: Buffer.from('8001', 'hex'), 0x2: "group", localJoin: Buffer.from('2003', 'hex'), 0x5: "stopActivity", 0x19: "auxilaryStream", 0x1A: "activeSurfaceChange", 0x1B: "navigate", json: Buffer.from('a01c', 'hex'), 0x1D: "tunnel", consoleStatus: Buffer.from('a01e', 'hex'), 0x1F: "titleTextConfiguration", 0x20: "titleTextInput", 0x21: "titleTextSelection", 0x22: "mirroringRequest", 0x23: "titleLaunch", channelStartRequest: Buffer.from('a026', 'hex'), channelStartResponse: Buffer.from('a027', 'hex'), 0x28: "channelStop", 0x29: "system", disconnect: Buffer.from('802a', 'hex'), 0x2E: "titleTouch", 0x2F: "accelerometer", 0x30: "gyrometer", 0x31: "inclinometer", 0x32: "compass", 0x33: "orientation", 0x36: "pairedIdentityStateChanged", 0x37: "unsnap", recordGameDvr: Buffer.from('a038', 'hex'), powerOff: Buffer.from('a039', 'hex'), 0xF00: "mediaControllerRemoved", mediaCommand: Buffer.from('af01', 'hex'), mediaCommandResult: Buffer.from('af02', 'hex'), mediaState: Buffer.from('af03', 'hex'), gamepad: Buffer.from('8f0a', 'hex'), 0xF2B: "systemTextConfiguration", 0xF2C: "systemTextInput", 0xF2E: "systemTouch", 0xF34: "systemTextAck", 0xF35: "systemTextDone", powerOn: Buffer.from('dd02', 'hex'), discoveryRequest: Buffer.from('dd00', 'hex'), discoveryResponse: Buffer.from('dd01', 'hex'), connectRequest: Buffer.from('cc00', 'hex'), connectResponse: Buffer.from('cc01', 'hex'), } } }; export const DiacriticsMap = { // Polish 'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', 'ź': 'z', 'ż': 'z', 'Ą': 'A', 'Ć': 'C', 'Ę': 'E', 'Ł': 'L', 'Ń': 'N', 'Ó': 'O', 'Ś': 'S', 'Ź': 'Z', 'Ż': 'Z', // German 'ä': 'a', 'ö': 'o', 'ü': 'u', 'ß': 'ss', 'Ä': 'A', 'Ö': 'O', 'Ü': 'U', // French 'à': 'a', 'â': 'a', 'ç': 'c', 'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e', 'î': 'i', 'ï': 'i', 'ô': 'o', 'û': 'u', 'ù': 'u', 'ü': 'u', 'ÿ': 'y', 'À': 'A', 'Â': 'A', 'Ç': 'C', 'É': 'E', 'È': 'E', 'Ê': 'E', 'Ë': 'E', 'Î': 'I', 'Ï': 'I', 'Ô': 'O', 'Û': 'U', 'Ù': 'U', 'Ü': 'U', 'Ÿ': 'Y', // Spanish & Portuguese 'á': 'a', 'í': 'i', 'ó': 'o', 'ú': 'u', 'ñ': 'n', 'Á': 'A', 'Í': 'I', 'Ó': 'O', 'Ú': 'U', 'Ñ': 'N', // Scandinavian 'å': 'a', 'Å': 'A', 'ø': 'o', 'Ø': 'O', 'æ': 'ae', 'Æ': 'AE', // Other common 'Š': 'S', 'š': 's', 'Ž': 'Z', 'ž': 'z' }; ================================================ FILE: src/functions.js ================================================ import { promises as fsPromises } from 'fs'; import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import { DiacriticsMap } from './constants.js'; const execAsync = promisify(exec); class Functions { constructor() { } async saveData(path, data, stringify = true) { try { data = stringify ? JSON.stringify(data, null, 2) : data; await fsPromises.writeFile(path, data); return true; } catch (error) { throw new Error(`Save data error: ${error}`); } } async readData(path, parseJson = false) { try { const data = await fsPromises.readFile(path, 'utf8'); if (parseJson) { if (!data.trim()) { // Empty file when expecting JSON return null; } try { return JSON.parse(data); } catch (jsonError) { throw new Error(`JSON parse error in file "${path}": ${jsonError.message}`); } } // For non-JSON, just return file content (can be empty string) return data; } catch (error) { if (error.code === 'ENOENT') { // File does not exist return null; } // Preserve original error details const wrappedError = new Error(`Read data error for "${path}": ${error.message}`); wrappedError.original = error; throw wrappedError; } } async sanitizeString(str) { if (!str) return ''; // Replace diacritics using map str = str.replace(/[^\u0000-\u007E]/g, ch => DiacriticsMap[ch] || ch); // Replace separators between words with space str = str.replace(/(\w)[.:;+\-\/]+(\w)/g, '$1 $2'); // Replace remaining standalone separators with space str = str.replace(/[.:;+\-\/]/g, ' '); // Remove remaining invalid characters (keep letters, digits, space, apostrophe) str = str.replace(/[^A-Za-z0-9 ']/g, ' '); // Collapse multiple spaces str = str.replace(/\s+/g, ' '); // Trim return str.trim(); } async scaleValue(value, inMin, inMax, outMin, outMax) { const scaledValue = parseFloat((((Math.max(inMin, Math.min(inMax, value)) - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin).toFixed(0)); return scaledValue; } async ping(ipOrHost) { const isWindows = process.platform === 'win32'; const cmd = isWindows ? `ping -n 1 ${ipOrHost}` : `ping -c 1 ${ipOrHost}`; try { const { stdout } = await execAsync(cmd); const alive = /ttl=/i.test(stdout); const timeMatch = stdout.match(/time[=<]([\d.]+)\s*ms/i); const time = timeMatch ? parseFloat(timeMatch[1]) : null; return { online: alive, time }; } catch { return { online: false }; } } } export default Functions ================================================ FILE: src/impulsegenerator.js ================================================ import EventEmitter from 'events'; class ImpulseGenerator extends EventEmitter { constructor() { super(); this.timersState = false; this.timers = []; } async state(state, timers = [], runOnStart = true) { // Stop current timers before new start if (this.timersState && state) { await this.state(false); } if (state) { if (!Array.isArray(timers)) throw new Error('Timers must be an array'); for (const { name, sampling } of timers) { if (!name || !sampling) continue; if (runOnStart) this.emit(name); const interval = setInterval(() => { this.emit(name); }, sampling); this.timers.push(interval); } } else { this.timers.forEach(clearInterval); this.timers = []; } this.timersState = state; this.emit('state', state); return true; } } export default ImpulseGenerator; ================================================ FILE: src/localApi/message.js ================================================ import HexToBin from 'hex-to-binary'; import Packets from './packets.js'; import Structure from './structure.js'; import { LocalApi } from '../constants.js'; class Message { constructor(type) { this.type = type; this.packets = new Packets(); this.packet = this.packets[type]; this.channelId = '\x00\x00\x00\x00\x00\x00\x00\x00'; } readFlags(flags) { const binaryFlag = HexToBin(flags.toString('hex')); const version = parseInt(binaryFlag.slice(0, 2), 2); const needAcknowlegde = binaryFlag.slice(2, 3) === '1'; const isFragment = binaryFlag.slice(3, 4) === '1'; const type = LocalApi.Messages.Types[parseInt(binaryFlag.slice(4, 16), 2)]; return { version, needAcknowlegde, isFragment, type }; } set(key, value, subkey = false) { if (subkey === false) { this.packet[key].value = value; } else { this.packet[subkey][key].value = value; } } pack(crypto, sequenceNumber, targetParticipantId, sourceParticipantId, channelId = false) { const structure = new Structure(); for (const name in this.packet) { this.packet[name].pack(structure); } // Padding PKCS7 if (structure.toBuffer().length % 16 > 0) { const padStart = structure.toBuffer().length % 16; const padTotal = 16 - padStart; for (let i = padStart + 1; i <= 16; i++) { structure.writeUInt8(padTotal); } } const header = new Structure(); header.writeBytes(Buffer.from('d00d', 'hex')); header.writeUInt16(structure.toBuffer().length); header.writeUInt32(sequenceNumber); header.writeUInt32(targetParticipantId); header.writeUInt32(sourceParticipantId); header.writeBytes(LocalApi.Messages.Flags[this.type]); header.writeBytes(Buffer.from(channelId || this.channelId)); // Original sgcrypto.encrypt(data, key, iv) and decrypt(data, iv, key) — kept as-is const payloadEncrypted = crypto.encrypt(structure.toBuffer(), crypto.getKey(), crypto.encrypt(header.toBuffer().subarray(0, 16), crypto.getIv())); let packet = Buffer.concat([header.toBuffer(), payloadEncrypted]); const payloadProtected = crypto.sign(packet); return Buffer.concat([packet, Buffer.from(payloadProtected)]); } unpack(crypto = undefined, data = false) { const structure = new Structure(data); const typeHex = structure.readBytes(2).toString('hex'); let packet = { typeHex, payloadLength: structure.readUInt16(), sequenceNumber: structure.readUInt32(), targetParticipantId: structure.readUInt32(), sourceParticipantId: structure.readUInt32(), flags: this.readFlags(structure.readBytes(2)), channelId: structure.readBytes(8), payloadProtected: structure.readBytes() }; packet.type = packet.flags.type; // FIX: extract signature BEFORE truncating payloadProtected. // Original order was reversed: truncate first, then subarray(-32) of truncated buffer // → read bytes [-64..-32] instead of the actual last 32 bytes [-32..]. packet.signature = packet.payloadProtected.subarray(-32); packet.payloadProtected = Buffer.from(packet.payloadProtected.subarray(0, -32)); this.type = packet.type; this.channelId = packet.channelId; if (packet.payloadProtected.length > 0 && crypto) { // Original: decrypt(data, iv) — matches sgcrypto.decrypt(data, iv, key) const payloadDecrypted = crypto.decrypt(packet.payloadProtected, crypto.encrypt(data.subarray(0, 16), crypto.getIv())); const structurePayloadProtected = new Structure(payloadDecrypted); packet.payloadDecrypted = structurePayloadProtected.toBuffer(); packet.payloadProtected = {}; const packetDef = this.packets[packet.type]; if (packetDef) { for (const name in packetDef) { packet.payloadProtected[name] = packetDef[name].unpack(structurePayloadProtected); } } } return packet; } } export default Message; ================================================ FILE: src/localApi/packets.js ================================================ import { LocalApi } from '../constants.js'; class Packets { constructor(type) { this.type = type; const types = { flags(length, value) { const packet = { value, length, pack(packetStructure) { return packetStructure.writeBytes(setFlags(this.value)); }, unpack(packetStructure) { return readFlags(packetStructure.readBytes(this.length)); } }; return packet; }, bytes(length = 0, value = Buffer.alloc(length)) { const packet = { value, length, pack(packetStructure) { return packetStructure.writeBytes(this.value); }, unpack(packetStructure) { const readLength = this.length > 0 ? this.length : packetStructure.packet.length - packetStructure.offset; return packetStructure.readBytes(readLength); } }; return packet; }, uInt16(value) { const packet = { value, pack(packetStructure) { return packetStructure.writeUInt16(this.value); }, unpack(packetStructure) { return packetStructure.readUInt16(); } }; return packet; }, uInt32(value) { const packet = { value, pack(packetStructure) { return packetStructure.writeUInt32(this.value); }, unpack(packetStructure) { return packetStructure.readUInt32(); } }; return packet; }, sInt32(value) { const packet = { value, pack(packetStructure) { return packetStructure.writeInt32(this.value); }, unpack(packetStructure) { return packetStructure.readInt32(); } }; return packet; }, // FIX: uInt64 value must be a Buffer — '' produces a 0-byte buffer, // corrupting the 8-byte field on the wire. Default is Buffer.alloc(length). uInt64(length, value = Buffer.alloc(length)) { const packet = { value: Buffer.isBuffer(value) ? value : Buffer.alloc(length), length, pack(packetStructure) { return packetStructure.writeBytes(this.value); }, unpack(packetStructure) { return packetStructure.readBytes(length); } }; return packet; }, sgString(value) { const packet = { value, pack(packetStructure) { return packetStructure.writeSGString(this.value); }, unpack(packetStructure) { return packetStructure.readSGString().toString(); } }; return packet; }, sgArray(type, value = []) { const packet = { value, type, pack(packetStructure) { packetStructure.writeUInt16(this.value.length); const arrayStructure = packets[this.type]; for (const item of this.value) { Object.keys(arrayStructure).forEach(name => { arrayStructure[name].value = item[name]; packetStructure = arrayStructure[name].pack(packetStructure); }); } return packetStructure; }, unpack(packetStructure) { const arrayCount = packetStructure.readUInt16(); const array = []; for (let i = 0; i < arrayCount; i++) { const arrayStructure = packets[this.type]; const item = {}; Object.keys(arrayStructure).forEach(name => { item[name] = arrayStructure[name].unpack(packetStructure); }); array.push(item); } return array; } }; return packet; }, sgList(type, value = []) { const packet = { value, type, pack(packetStructure) { packetStructure.writeUInt32(this.value.length); const arrayStructure = packets[this.type]; for (const item of this.value) { Object.keys(arrayStructure).forEach(name => { arrayStructure[name].value = item[name]; packetStructure = arrayStructure[name].pack(packetStructure); }); } return packetStructure; }, unpack(packetStructure) { const arrayCount = packetStructure.readUInt32(); const array = []; for (let i = 0; i < arrayCount; i++) { const arrayStructure = packets[this.type]; const item = {}; Object.keys(arrayStructure).forEach(name => { item[name] = arrayStructure[name].unpack(packetStructure); }); array.push(item); } return array; } }; return packet; }, mapper(map, item) { const packet = { item, value: false, pack(packetStructure) { return item.pack(packetStructure); }, unpack(packetStructure) { const key = item.unpack(packetStructure); return map[key] ?? key; } }; return packet; } }; // FIX: uInt64 fields that were '' now default to Buffer.alloc(8) via fixed uInt64() factory. // acknowledge keeps sgList (UInt32 count) — console accepts it and original worked with it. const packets = { powerOn: { liveId: types.sgString() }, json: { json: types.sgString('{}') }, discoveryRequest: { flags: types.uInt32(0), clientType: types.uInt16(3), minVersion: types.uInt16(0), maxVersion: types.uInt16(2) }, discoveryResponse: { flags: types.uInt32(0), clientType: types.uInt16(0), consoleName: types.sgString(), uuid: types.sgString(), lastError: types.uInt32(0), certificateLength: types.uInt16(0), certificate: types.bytes() }, connectRequest: { uuid: types.bytes(16, ''), publicKeyType: types.uInt16(0), publicKey: types.bytes(64, ''), iv: types.bytes(16, ''), payloadProtected: types.bytes() }, connectResponse: { iv: types.bytes(16, ''), payloadProtected: types.bytes() }, connectRequestProtected: { userHash: types.sgString(''), token: types.sgString(''), connectRequestNum: types.uInt32(0), connectRequestGroupStart: types.uInt32(0), connectRequestGroupEnd: types.uInt32(1) }, connectResponseProtected: { connectResult: types.uInt16(1), pairingState: types.uInt16(2), participantId: types.uInt32(0) }, localJoin: { clientType: types.uInt16(3), nativeWidth: types.uInt16(1080), nativeHeight: types.uInt16(1920), dpiX: types.uInt16(96), dpiY: types.uInt16(96), deviceCapabilities: types.uInt64(8, Buffer.from('ffffffffffffffff', 'hex')), clientVersion: types.uInt32(15), osMajorVersion: types.uInt32(6), osMinorVersion: types.uInt32(2), displayName: types.sgString('Xbox-TV') }, channelStartRequest: { channelRequestId: types.uInt32(0), titleId: types.uInt32(0), service: types.bytes(16, ''), activityId: types.uInt32(0) }, channelStartResponse: { channelRequestId: types.uInt32(0), channelTargetId: types.uInt64(8), result: types.uInt32(0) }, acknowledge: { lowWatermark: types.uInt32(0), processedList: types.sgList('processedList', []), rejectedList: types.sgList('rejectedList', []) }, processedList: { id: types.uInt32(0) }, rejectedList: { id: types.uInt32(0) }, consoleStatus: { liveTvProvider: types.uInt32(0), majorVersion: types.uInt32(0), minorVersion: types.uInt32(0), buildNumber: types.uInt32(0), locale: types.sgString('en-US'), activeTitles: types.sgArray('activeTitle') }, activeTitle: { flags: types.bytes(2), titleId: types.uInt32(0), productId: types.bytes(16, ''), sandboxId: types.bytes(16, ''), aumId: types.sgString('') }, recordGameDvr: { startTimeDelta: types.sInt32(0), endTimeDelta: types.sInt32(0) }, gamepad: { timestamp: types.uInt64(8), buttons: types.uInt16(0), leftTrigger: types.uInt32(0), rightTrigger: types.uInt32(0), leftThumbstickX: types.uInt32(0), leftThumbstickY: types.uInt32(0), rightThumbstickX: types.uInt32(0), rightThumbstickY: types.uInt32(0) }, mediaState: { titleId: types.uInt32(0), aumId: types.sgString(), assetId: types.sgString(), mediaType: types.mapper(LocalApi.Media.Types, types.uInt16(0)), soundLevel: types.mapper(LocalApi.Media.SoundLevel, types.uInt16(0)), enabledCommands: types.uInt32(0), playbackStatus: types.mapper(LocalApi.Media.PlaybackState, types.uInt16(0)), rate: types.uInt32(0), position: types.uInt64(8), mediaStart: types.uInt64(8), mediaEnd: types.uInt64(8), minSeek: types.uInt64(8), maxSeek: types.uInt64(8), metadata: types.sgArray('mediaStateList', []) }, mediaStateList: { name: types.sgString(), value: types.sgString() }, mediaCommand: { requestId: types.uInt64(8), titleId: types.uInt32(0), command: types.uInt32(0) }, powerOff: { liveId: types.sgString('') }, disconnect: { reason: types.uInt32(1), errorCode: types.uInt32(0) } }; return packets; } } export default Packets; ================================================ FILE: src/localApi/sgcrypto.js ================================================ import JsRsaSign from 'jsrsasign'; import Crypto from 'crypto'; import { EOL } from 'os'; import Elliptic from 'elliptic'; const EC = Elliptic.ec; const IV = Buffer.from('\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'); class SgCrypto { constructor() { this.key = false; this.iv = false; this.hashKey = false; this.ec = new EC('p256'); // P-256 } async getPublicKey(certificate) { try { certificate = certificate.toString('base64').match(/.{0,64}/g).join('\n'); const pem = `-----BEGIN CERTIFICATE-----${EOL}${certificate}-----END CERTIFICATE-----`; const ecKey = JsRsaSign.X509.getPublicKeyFromCertPEM(pem); const sha512 = Crypto.createHash('sha512'); const key1 = this.ec.genKeyPair(); const key2 = this.ec.keyFromPublic(ecKey.pubKeyHex, 'hex'); const shared1 = key1.derive(key2.getPublic()); const derivedSecret = Buffer.from(shared1.toString(16), 'hex'); const publicKeyClient = key1.getPublic('hex'); const preSalt = Buffer.from('d637f1aae2f0418c', 'hex'); const postSalt = Buffer.from('a8f81a574e228ab7', 'hex'); const prePostSalt = Buffer.concat([preSalt, derivedSecret, postSalt]); const shaSecret = sha512.update(prePostSalt).digest(); const publicKey = Buffer.from(publicKeyClient.substring(2), 'hex'); const secret = Buffer.from(shaSecret.toString('hex'), 'hex'); this.key = secret.subarray(0, 16); this.iv = secret.subarray(16, 32); this.hashKey = secret.subarray(32, 64); return { publicKey, iv: this.iv }; } catch (error) { throw new Error(`sign public key error: ${error}`); } } getKey() { return this.key; } getIv() { return this.iv; } encrypt(data, key, iv) { data = Buffer.from(data); key = key || this.key; iv = iv || IV; const cipher = Crypto.createCipheriv('aes-128-cbc', key, iv); cipher.setAutoPadding(false); let encryptedPayload = cipher.update(data, 'binary', 'binary'); encryptedPayload += cipher.final('binary'); return Buffer.from(encryptedPayload, 'binary'); } // Note: argument order (data, iv, key) differs from encrypt(data, key, iv) — kept as original. // All call sites pass (data, iv) relying on key defaulting to this.key. decrypt(data, iv, key) { key = key || this.key; iv = iv || IV; const decipher = Crypto.createDecipheriv('aes-128-cbc', key, iv); decipher.setAutoPadding(false); let decryptedPayload = decipher.update(data, 'binary', 'binary'); decryptedPayload += decipher.final('binary'); return this.removePadding(Buffer.from(decryptedPayload, 'binary')); } sign(data) { const hashHmac = Crypto.createHmac('sha256', this.hashKey); hashHmac.update(data, 'binary', 'binary'); return Buffer.from(hashHmac.digest('binary'), 'binary'); } removePadding(payload) { const payloadLength = payload.subarray(-1).readUInt8(0); if (payloadLength > 0 && payloadLength < 16) { return payload.subarray(0, payload.length - payloadLength); } return payload; } } export default SgCrypto; ================================================ FILE: src/localApi/simple.js ================================================ import Packets from './packets.js'; import Structure from './structure.js'; import { LocalApi } from '../constants.js'; class Simple { constructor(type) { this.type = type; this.packets = new Packets(); this.packet = this.packets[type]; this.packetProtected = this.packet.payloadProtected ? this.packets[`${type}Protected`] : false; } // === Helpers === static applyPKCS7Padding(structure) { const blockSize = 16; const length = structure.toBuffer().length; const padTotal = blockSize - (length % blockSize || blockSize); for (let i = 0; i < padTotal; i++) { structure.writeUInt8(padTotal); } } set(key, value, isProtected = false) { const targetPacket = isProtected ? this.packetProtected : this.packet; if (!targetPacket || !targetPacket[key]) return; const currentLength = targetPacket[key].length || 0; targetPacket[key].value = value; targetPacket[key].length = currentLength > 0 ? value.length : currentLength; } pack(crypto = false) { const structure = new Structure(); let packet; let payloadProtectedLength = 0; let payloadProtectedLengthReal = 0; for (const name in this.packet) { if (name === 'payloadProtected' && this.packetProtected) { const structureProtected = new Structure(); for (const fieldName in this.packetProtected) { if (this.packet.payloadProtected?.value?.[fieldName] !== undefined) { this.packetProtected[fieldName].value = this.packet.payloadProtected.value[fieldName]; } this.packetProtected[fieldName].pack(structureProtected); } payloadProtectedLength = structureProtected.toBuffer().length; Simple.applyPKCS7Padding(structureProtected); payloadProtectedLengthReal = structureProtected.toBuffer().length; const payloadEncrypted = crypto.encrypt( structureProtected.toBuffer(), crypto.getKey(), this.packet.iv?.value ); structure.writeBytes(payloadEncrypted); } else { this.packet[name].pack(structure); } } const payload = structure.toBuffer(); switch (this.type) { case 'powerOn': // FIX: powerOn (dd02) wire format: [type 2B][unprotectedLen 2B][protectedLen 2B][version=2 2B][payload] // Original pack1('', ...) produced [type][len][00 00][payload] — missing protectedLen field. { const hdr = new Structure(); hdr.writeUInt16(payload.length); // unprotected_payload_length hdr.writeUInt16(0); // protected_payload_length = 0 hdr.writeUInt16(2); // version = 2 packet = Buffer.concat([LocalApi.Messages.Flags.powerOn, hdr.toBuffer(), payload]); } break; case 'discoveryRequest': packet = this.pack1(LocalApi.Messages.Flags.discoveryRequest, payload, Buffer.from('0000', 'hex')); break; case 'discoveryResponse': packet = this.pack1(LocalApi.Messages.Flags.discoveryResponse, payload, Buffer.from([0, 2])); break; case 'connectRequest': packet = this.pack1(LocalApi.Messages.Flags.connectRequest, payload, Buffer.from('0002', 'hex'), payloadProtectedLength, payloadProtectedLengthReal); const payloadProtected = crypto.sign(packet); packet = Buffer.concat([packet, Buffer.from(payloadProtected)]); break; case 'connectRequestProtected': Simple.applyPKCS7Padding(structure); // FIX: original passed crypto.getIv() as key — encrypt(data, key, iv) requires key first let payloadEncrypted = crypto.encrypt(structure.toBuffer(), crypto.getKey(), crypto.getIv()); payloadEncrypted = new Structure(payloadEncrypted); packet = payloadEncrypted.toBuffer(); break; case 'connectResponse': packet = this.pack1(LocalApi.Messages.Flags.connectResponse, payload, Buffer.from([0, 2])); break; default: packet = payload; } return packet; } pack1(type, payload, version, payloadProtectedLength = 0, payloadProtectedLengthReal = 0) { const structure = new Structure(); const structureProtected = new Structure(); if (payloadProtectedLength > 0) { structure.writeUInt16(payload.length - payloadProtectedLengthReal); const payloadLength = structure.toBuffer(); structureProtected.writeUInt16(payloadProtectedLength); payloadProtectedLength = structureProtected.toBuffer(); return Buffer.concat([type, payloadLength, payloadProtectedLength, version, payload]); } structure.writeUInt16(payload.length); const payloadLength = structure.toBuffer(); return Buffer.concat([type, payloadLength, Buffer.from([0, version[1] || 0]), payload]); } unpack(crypto = undefined, data = false) { const structure = new Structure(data); const typeHex = structure.readBytes(2).toString('hex'); const type = typeHex === 'dd02' ? 'powerOn' : this.type; let packet = { typeHex, type, payloadLength: structure.readUInt16(), version: structure.readUInt16(), }; if (packet.version !== 0 && packet.version !== 2) { packet.payloadProtectedLength = packet.version; packet.version = structure.readUInt16(); } for (const name in this.packet) { packet[name] = this.packet[name].unpack(structure); this.set(name, packet[name]); } if (packet.payloadProtected !== undefined) { // FIX: extract signature BEFORE truncating the buffer const signature = packet.payloadProtected.subarray(-32); const encryptedData = packet.payloadProtected.subarray(0, -32); // Original: decrypt(data, iv) — matches sgcrypto.decrypt(data, iv, key) signature const decrypted = crypto.decrypt(encryptedData, packet.iv).subarray(0, packet.payloadProtectedLength); packet.payloadProtected = {}; const structurePayloadDecrypted = new Structure(decrypted); const packetProtected = this.packets[`${packet.type}Protected`]; for (const name in packetProtected) { packet.payloadProtected[name] = packetProtected[name].unpack(structurePayloadDecrypted); this.set('payloadProtected', packet.payloadProtected); } // Note: crypto.verify not implemented in original sgcrypto — skipped intentionally packet.signature = signature; } return packet; } } export default Simple; ================================================ FILE: src/localApi/structure.js ================================================ class Structure { constructor(packet) { this.packet = packet ? packet : Buffer.alloc(0); this.totalLength = this.packet.length; this.offset = 0; } writeSGString(data) { if (typeof data !== 'string') { throw new Error('data must be a string'); } const dataLength = Buffer.byteLength(data, 'utf8'); if (dataLength > 65535) { throw new Error('data exceeds the maximum allowed length'); } const stringLengthBuffer = Buffer.allocUnsafe(2); stringLengthBuffer.writeUInt16BE(dataLength, 0); const dataBuffer = Buffer.from(data, 'utf8'); const nullTerminator = Buffer.from([0]); this.add(Buffer.concat([stringLengthBuffer, dataBuffer, nullTerminator])); return this; } readSGString() { const stringLength = this.readUInt16(); const stringBuffer = this.packet.subarray(this.offset, this.offset + stringLength); this.offset += stringLength + 1; // skip null terminator return stringBuffer.toString('utf8'); } writeBytes(data, type) { const dataBuffer = Buffer.from(data, type); this.add(dataBuffer); return this; } readBytes(length = false) { let rawData; if (length === false) { rawData = this.packet.subarray(this.offset); this.offset = this.totalLength; } else { rawData = this.packet.subarray(this.offset, this.offset + length); this.offset += length; } return rawData; } writeUInt8(data) { if (data < 0 || data > 255) { throw new Error('data must be a valid unsigned 8-bit integer'); } const buf = Buffer.allocUnsafe(1); buf.writeUInt8(data, 0); this.add(buf); return this; } readUInt8() { const value = this.packet.readUInt8(this.offset); this.offset += 1; return value; } writeUInt16(data) { if (data < 0 || data > 65535) { throw new Error('data must be a valid unsigned 16-bit integer'); } const buf = Buffer.allocUnsafe(2); buf.writeUInt16BE(data, 0); this.add(buf); return this; } readUInt16() { const value = this.packet.readUInt16BE(this.offset); this.offset += 2; return value; } writeUInt32(data) { if (data < 0 || data > 0xFFFFFFFF) { throw new Error('data must be a valid unsigned 32-bit integer'); } const buf = Buffer.allocUnsafe(4); buf.writeUInt32BE(data, 0); this.add(buf); return this; } readUInt32() { const value = this.packet.readUInt32BE(this.offset); this.offset += 4; return value; } writeInt32(data) { if (data < -2147483648 || data > 2147483647) { throw new Error('data must be a valid signed 32-bit integer'); } const buf = Buffer.allocUnsafe(4); buf.writeInt32BE(data, 0); this.add(buf); return this; } readInt32() { const value = this.packet.readInt32BE(this.offset); this.offset += 4; return value; } writeUInt64(value) { if (typeof value !== 'bigint') { throw new Error('value must be a BigInt'); } const buf = Buffer.allocUnsafe(8); buf.writeUInt32BE(Number(value >> 32n), 0); buf.writeUInt32BE(Number(value & 0xFFFFFFFFn), 4); this.add(buf); return this; } readUInt64() { const high = this.packet.readUInt32BE(this.offset); const low = this.packet.readUInt32BE(this.offset + 4); this.offset += 8; return (BigInt(high) << 32n) | BigInt(low); } toBuffer() { return this.packet; } add(data) { if (!Buffer.isBuffer(data)) { throw new Error('Data must be a Buffer object'); } this.packet = Buffer.concat([this.packet, data]); this.totalLength = this.packet.length; } } export default Structure; ================================================ FILE: src/localApi/xboxlocalapi.js ================================================ 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; // FIX: guard with || false so undefined becomes false 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(); // FIX: discoveryRequest in try (not finally) — only sent when socket is ready 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 { // FIX: always release the connecting lock so next heartbeat can retry 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() { // FIX: clearInterval before nulling — without this the old timer survives // 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.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; // FIX: typo 'Sqquence' → 'Sequence' 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.logWarn) this.emit('warn', `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; // FIX: targetId=0 is a broadcast (console keepalive acknowledge) — // 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; // FIX: typo 'athorized' → 'authorized' 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 : []; // FIX: power derived from activeTitles presence const power = activeTitles.length > 0; if (power) { // FIX: use last element — activeTitles ordered oldest→newest, // last entry is the foreground title const title = activeTitles[0]; this.titleId = title.titleId; this.reference = title.aumId; } this.power = power; this.playState = false; // FIX: emit stateChanged always — when activeTitles is empty // (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 — consoleStatus is the primary sign-of-life. // We ping the host every 5 s to reset the timer when the console is // idle between status packets. After 14 s silence the socket is // closed so the impulse generator can reconnect. 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; } // Network ping every 5 s — resets the watchdog when console // is reachable but has no state change to report. if (Math.round(elapsed) % 5 === 0 && Math.round(elapsed) > 0) { try { const pingResult = await this.functions.ping(this.host); if (pingResult.online) { this.heartBeatStartTime = Date.now(); if (this.logDebug) this.emit('debug', `Ping OK — console reachable`); } else { if (this.logDebug) this.emit('debug', `Ping failed — console 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; ================================================ FILE: src/mqtt.js ================================================ import { connect } from 'mqtt'; import EventEmitter from 'events'; class Mqtt extends EventEmitter { constructor(config) { super(); this.config = config; const url = `mqtt://${config.host}:${config.port}`; const subscribeTopic = `${config.prefix}/Set`; const options = { clientId: config.clientId, username: config.user, password: config.passwd, protocolVersion: 5, clean: false, properties: { sessionExpiryInterval: 60 * 60, userProperties: { source: 'node-client' } } }; this.mqttClient = connect(url, options) .on('connect', async () => { this.emit('connected', 'MQTT v5 connected.'); try { await new Promise((resolve, reject) => { this.mqttClient.subscribe(subscribeTopic, { qos: 1, properties: { userProperties: { type: 'subscription' } } }, (error) => { if (error) return reject(error); resolve(); } ); }); this.emit('connected', `MQTT Subscribe topic: ${subscribeTopic}`); } catch (error) { if (config.logWarn) this.emit('warn', `MQTT Subscribe error: ${error.message}`); } }) .on('message', (topic, payload, packet) => { try { const parsedMessage = JSON.parse(payload.toString()); if (config.logDebug) this.emit('debug', `MQTT Received Topic: ${topic}, Payload: ${JSON.stringify(parsedMessage, null, 2)}`); for (const [key, value] of Object.entries(parsedMessage)) { this.emit('set', key, value); } } catch (error) { if (config.logWarn) this.emit('warn', `MQTT Parse error: ${error.message}`); } }) .on('error', (error) => { this.emit('warn', `MQTT Error: ${error.message}`); }) .on('reconnect', () => { if (config.logDebug) this.emit('debug', 'MQTT Reconnecting...'); }) .on('close', () => { if (config.logDebug) this.emit('debug', 'MQTT Connection closed.'); }); } publish(topic, message) { return new Promise((resolve, reject) => { const fullTopic = `${this.config.prefix}/${topic}`; const publishMessage = JSON.stringify(message); this.mqttClient.publish(fullTopic, publishMessage, { qos: 1, properties: { contentType: 'application/json', userProperties: { source: 'node', action: 'set' } } }, (error) => { if (error) { if (this.config.logWarn) this.emit('warn', `MQTT Publish error: ${error.message}`); return reject(error); } if (this.config.logDebug) this.emit('debug', `MQTT Publish Topic: ${fullTopic}, Payload: ${publishMessage}`); resolve(); } ); }); } } export default Mqtt; ================================================ FILE: src/restful.js ================================================ import express, { json } from 'express'; import EventEmitter from 'events'; const DEFAULT_MESSAGE = 'This data is not available at this time.'; class RestFul extends EventEmitter { constructor(config) { super(); this.port = config.port; this.logWarn = config.logWarn; this.logDebug = config.logDebug; this.restFulData = { info: DEFAULT_MESSAGE, state: DEFAULT_MESSAGE, operation: DEFAULT_MESSAGE, consoleslist: DEFAULT_MESSAGE, profile: DEFAULT_MESSAGE, apps: DEFAULT_MESSAGE, storages: DEFAULT_MESSAGE, status: DEFAULT_MESSAGE } this.connect(); } connect() { try { const app = express(); app.set('json spaces', 2); app.use(json()); // Register GET routes for all keys for (const key of Object.keys(this.restFulData)) { app.get(`/${key}`, (req, res) => { res.json(this.restFulData[key]); }); } // Health check route app.get('/status', (req, res) => { res.json({ status: 'online', uptime: process.uptime(), available_paths: Object.keys(this.restFulData).map(k => `/${k}`) }); }); // POST route to update values app.post('/', (req, res) => { try { const obj = req.body; if (!obj || typeof obj !== 'object' || Object.keys(obj).length === 0) { if (this.logWarn) this.emit('warn', 'RESTFul Invalid JSON payload'); return res.status(400).json({ error: 'RESTFul Invalid JSON payload' }); } const key = Object.keys(obj)[0]; const value = obj[key]; this.emit('set', key, value); this.update(key, value); if (this.logDebug) this.emit('debug', `RESTFul post data: ${JSON.stringify(obj, null, 2)}`); res.json({ success: true, received: obj }); } catch (error) { if (this.logWarn) this.emit('warn', `RESTFul Parse error: ${error}`); res.status(500).json({ error: 'RESTFul Internal Server Error' }); } }); // Start the server app.listen(this.port, () => { this.emit('connected', `RESTful started on port: ${this.port}`); }); } catch (error) { if (this.logWarn) this.emit('warn', `RESTful Connect error: ${error}`); } } update(path, data) { if (this.restFulData.hasOwnProperty(path)) { this.restFulData[path] = data; } else { if (this.logWarn) this.emit('warn', `Unknown RESTFul update path: ${path}, data: ${JSON.stringify(data)}`); return; } if (this.logDebug) this.emit('debug', `RESTFul update path: ${path}, data: ${JSON.stringify(data)}`); } } export default RestFul; ================================================ FILE: src/webApi/authentication.js ================================================ import QueryString from 'querystring'; import axios from 'axios'; import { WebApi } from '../constants.js'; import Functions from '../functions.js'; class Authentication { constructor(config) { this.webApiClientId = config.clientId || WebApi.ClientId; this.webApiClientSecret = config.clientSecret; this.tokensFile = config.tokensFile; this.tokens = { oauth: {}, user: {}, xsts: {} }; this.functions = new Functions(); } async refreshToken(token) { try { const payload = { 'client_id': this.webApiClientId, 'grant_type': 'refresh_token', 'scope': WebApi.Scopes, 'refresh_token': token, }; if (this.webApiClientSecret) { payload.client_secret = this.webApiClientSecret; } const postData = QueryString.stringify(payload); // BŁ12 FIX: axios.post expects config object as 3rd arg, not bare headers object const response = await axios.post(WebApi.Url.RefreshToken, postData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); const refreshToken = response.data; refreshToken.issued = new Date().toISOString(); this.tokens.oauth = refreshToken; return true; } catch (error) { throw new Error(`Refresh token error: ${error}`); } } async getUserToken(accessToken) { try { const payload = { 'RelyingParty': 'http://auth.xboxlive.com', 'TokenType': 'JWT', 'Properties': { 'AuthMethod': 'RPS', 'SiteName': 'user.auth.xboxlive.com', 'RpsTicket': `d=${accessToken}` } }; const postData = JSON.stringify(payload); const response = await axios.post(WebApi.Url.UserToken, postData, { headers: { 'Content-Type': 'application/json' } }); const userToken = response.data; this.tokens.user = userToken; this.tokens.xsts = {}; return true; } catch (error) { throw new Error(`User token error: ${error}`); } } async getXstsToken(userToken) { try { const payload = { 'RelyingParty': 'http://xboxlive.com', 'TokenType': 'JWT', 'Properties': { 'UserTokens': [userToken], 'SandboxId': 'RETAIL', } }; const postData = JSON.stringify(payload); // BŁ12 FIX: was passing bare headers object without { headers } wrapper const response = await axios.post(WebApi.Url.XstsToken, postData, { headers: { 'Content-Type': 'application/json', 'x-xbl-contract-version': '1' } }); const xstsToken = response.data; this.tokens.xsts = xstsToken; return true; } catch (error) { throw new Error(`Xsts token error: ${error}`); } } async accessToken(webApiToken) { try { const payload = { 'client_id': this.webApiClientId, 'grant_type': 'authorization_code', 'scope': WebApi.Scopes, 'code': webApiToken, 'redirect_uri': WebApi.Url.Redirect }; if (this.webApiClientSecret) { payload.client_secret = this.webApiClientSecret; } const postData = QueryString.stringify(payload); // BŁ12 FIX: consistent { headers } wrapper const response = await axios.post(WebApi.Url.AccessToken, postData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); const accessToken = response.data; accessToken.issued = new Date().toISOString(); this.tokens.oauth = accessToken; await this.functions.saveData(this.tokensFile, this.tokens); return true; } catch (error) { throw new Error(`Access token error: ${error}`); } } async refreshTokens(type) { switch (type) { case 'user': if (this.tokens.user.Token) { const tokenExpired = new Date() > new Date(this.tokens.user.NotAfter).getTime(); if (tokenExpired) { try { await this.refreshToken(this.tokens.oauth.refresh_token); await this.getUserToken(this.tokens.oauth.access_token); await this.refreshTokens('xsts'); return true; } catch (error) { throw new Error(error); } } else { try { await this.refreshTokens('xsts'); return true; } catch (error) { throw new Error(error); } } } else { try { await this.getUserToken(this.tokens.oauth.access_token); await this.refreshTokens('xsts'); return true; } catch (error) { throw new Error(error); } } case 'xsts': if (this.tokens.xsts.Token) { const tokenExpired = new Date() > new Date(this.tokens.xsts.NotAfter).getTime(); if (tokenExpired) { try { // BŁ13 FIX: was calling refreshTokens('xsts') after getXstsToken → infinite recursion // when token expired. Now just refresh once and return. await this.getXstsToken(this.tokens.user.Token); return true; } catch (error) { throw new Error(error); } } else { return true; } } else { try { await this.getXstsToken(this.tokens.user.Token); return true; } catch (error) { throw new Error(error); } } default: throw new Error(`Unknown refresh token type: ${type}`); } } async checkAuthorization() { if (this.webApiClientId) { try { const tokens = await this.functions.readData(this.tokensFile, true); this.tokens = !tokens ? this.tokens : tokens; const refreshToken = this.tokens.oauth.refresh_token ?? false; if (refreshToken) { await this.refreshTokens('user'); await this.functions.saveData(this.tokensFile, this.tokens); return { headers: `XBL3.0 x=${this.tokens.xsts.DisplayClaims.xui[0].uhs};${this.tokens.xsts.Token}`, tokens: this.tokens }; } else { throw new Error('No oauth token found. Use authorization manager first.'); } } catch (error) { throw new Error(error); } } else { throw new Error(`Authorization not possible, check plugin settings - Client Id: ${this.webApiClientId}`); } } async generateAuthorizationUrl() { try { const payload = { 'client_id': this.webApiClientId, 'response_type': 'code', 'approval_prompt': 'auto', 'scope': WebApi.Scopes, 'redirect_uri': WebApi.Url.Redirect }; const params = QueryString.stringify(payload); const oauth2URI = `${WebApi.Url.oauth2}?${params}`; return oauth2URI; } catch (error) { throw new Error(`Authorization URL error: ${error}`); } } } export default Authentication; ================================================ FILE: src/webApi/providers/achievements.js ================================================ import axios from 'axios'; class Archivements { constructor(tokens, authorizationHeaders) { this.tokens = tokens; const headers = authorizationHeaders; headers['x-xbl-contract-version'] = '2'; //create axios instance this.axiosInstance = axios.create({ method: 'GET', headers: headers }); } getTitleAchievements(continuationToken = 0) { return new Promise(async (resolve, reject) => { try { const url = `https://achievements.xboxlive.com/users/xuid(${this.tokens.xsts.DisplayClaims.xui[0].xid})/history/titles?continuationToken=${continuationToken}`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } getTitleAchievements360(continuationToken = 0) { return new Promise(async (resolve, reject) => { try { this.headers['x-xbl-contract-version'] = 1 const url = `https://achievements.xboxlive.com/users/xuid(${this.tokens.xsts.DisplayClaims.xui[0].xid})/history/titles?continuationToken=${continuationToken}`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } getTitleId(titleId, continuationToken = 0) { return new Promise(async (resolve, reject) => { try { const url = `https://achievements.xboxlive.com/users/xuid(${this.tokens.xsts.DisplayClaims.xui[0].xid})/achievements?titleId=${titleId}&continuationToken=${continuationToken}`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } getTitleId360(titleId, continuationToken = 0) { return new Promise(async (resolve, reject) => { try { this.headers['x-xbl-contract-version'] = 1 const url = `https://achievements.xboxlive.com/users/xuid(${this.tokens.xsts.DisplayClaims.xui[0].xid})/achievements?titleId=${titleId}&continuationToken=${continuationToken}`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } } export default Archivements; ================================================ FILE: src/webApi/providers/catalog.js ================================================ import QueryString from 'querystring'; import axios from 'axios'; class Catalog { constructor(authorizationHeaders) { const headers = authorizationHeaders; headers = { 'MS-CV': '0' }; //create axios instance this.axiosInstance = axios.create({ method: 'GET', headers: headers }); } searchTitle(query, marketLocale = 'US', languagesLocale = 'en-US') { return new Promise(async (resolve, reject) => { try { const searchParams = { "languages": languagesLocale, "market": marketLocale, "platformdependencyname": 'windows.xbox', "productFamilyNames": "Games,Apps", "query": query, "topProducts": 25, } const queryParams = QueryString.stringify(searchParams); const url = `https://displaycatalog.mp.microsoft.com/v7.0/productFamilies/autosuggest?${queryParams}`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } getProductId(query, marketLocale = 'US', languagesLocale = 'en-US') { return new Promise(async (resolve, reject) => { try { const searchParams = { "actionFilter": 'Browse', "bigIds": [query], "fieldsTemplate": 'details', "languages": languagesLocale, "market": marketLocale, } const queryParams = QueryString.stringify(searchParams); const url = `https://displaycatalog.mp.microsoft.com/v7.0/products?${queryParams}`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } getProductFromAlternateId(titleId, titleType, marketLocale = 'US', languagesLocale = 'en-US') { return new Promise(async (resolve, reject) => { try { const searchParams = { "top": 25, "alternateId": titleType, "fieldsTemplate": 'details', "languages": languagesLocale, "market": marketLocale, "value": titleId, } const queryParams = QueryString.stringify(searchParams); const url = `https://displaycatalog.mp.microsoft.com/v7.0/products/lookup${queryParams}`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } } export default Catalog; ================================================ FILE: src/webApi/providers/gameclips.js ================================================ import QueryString from 'querystring'; import axios from 'axios'; class GameClip { constructor(tokens, authorizationHeaders) { this.tokens = tokens; const headers = authorizationHeaders; headers['x-xbl-contract-version'] = '1'; //create axios instance this.axiosInstance = axios.create({ method: 'GET', headers: headers }); } getUserGameclips() { return new Promise(async (resolve, reject) => { try { const url = `https://gameclipsmetadata.xboxlive.com/users/me/clips`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } getCommunityGameclipsByTitleId(titleId) { return new Promise(async (resolve, reject) => { try { const url = `https://gameclipsmetadata.xboxlive.com/public/titles/${titleId}clips/saved?qualifier=created`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } getGameclipsByXuid(titleId, skipItems, maxItems) { return new Promise(async (resolve, reject) => { try { const params = { skipitems: skipItems || 0, maxitems: maxItems || 25, } if (titleId !== undefined) { params.titleid = titleId } const queryParams = QueryString.stringify(params); const url = `https://gameclipsmetadata.xboxlive.com/users/xuid(${this.tokens.xsts.DisplayClaims.xui[0].xid})/clips?${queryParams}`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } } export default GameClip; ================================================ FILE: src/webApi/providers/messages.js ================================================ import axios from 'axios'; class Messages { constructor(tokens, authorizationHeaders) { this.tokens = tokens; const headers = authorizationHeaders; //create axios instance this.axiosInstance = axios.create({ method: 'GET', headers: headers }); } getInbox() { return new Promise(async (resolve, reject) => { try { const url = `https://xblmessaging.xboxlive.com/network/Xbox/users/me/inbox`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } getConversation() { return new Promise(async (resolve, reject) => { try { const url = `https://xblmessaging.xboxlive.com/network/Xbox/users/me/conversations/users/xuid(${this.tokens.xsts.DisplayClaims.xui[0].xid})?maxItems=100`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } } export default Messages; ================================================ FILE: src/webApi/providers/people.js ================================================ import axios from 'axios'; class People { constructor(authorizationHeaders) { const headers = authorizationHeaders; headers['x-xbl-contract-version'] = '3'; //create axios instance this.axiosInstance = axios.create({ method: 'GET', headers: headers }); } getFriends() { return new Promise(async (resolve, reject) => { try { const params = [ 'preferredcolor', 'detail', 'multiplayersummary', 'presencedetail', ] const url = `https://peoplehub.xboxlive.com/users/me/people/social/decoration/${params.join(',')}`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } recentPlayers() { return new Promise(async (resolve, reject) => { try { const url = `https://peoplehub.xboxlive.com/users/me/people/recentplayers`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } } export default People; ================================================ FILE: src/webApi/providers/pins.js ================================================ import axios from 'axios'; class Pins { constructor(tokens, authorizationHeaders) { this.tokens = tokens; const headers = authorizationHeaders; headers['Content-Type'] = 'application/json'; //create axios instance this.axiosInstance = axios.create({ method: 'GET', headers: headers }); } getPins(list = 'XBLPins') { return new Promise(async (resolve, reject) => { try { const url = `https://eplists.xboxlive.com/users/xuid(${this.tokens.xsts.DisplayClaims.xui[0].xid})/lists/PINS/${list}`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } getSaveForLater() { return new Promise(async (resolve, reject) => { try { const url = `https://eplists.xboxlive.com/users/xuid(${this.tokens.xsts.DisplayClaims.xui[0].xid})/lists/PINS/SaveForLater`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } } export default Pins; ================================================ FILE: src/webApi/providers/screenshots.js ================================================ import QueryString from 'querystring'; import axios from 'axios'; class Catalog { constructor(tokens, authorizationHeaders) { this.tokens = tokens; const headers = authorizationHeaders; headers['x-xbl-contract-version'] = '5'; //create axios instance this.axiosInstance = axios.create({ method: 'GET', headers: headers }); } getUserScreenshots() { return new Promise(async (resolve, reject) => { try { const url = `https://screenshotsmetadata.xboxlive.com/users/me/screenshot`; const response = await this.axiosInstance(url); resolve(response,data); } catch (error) { reject(error); }; }); } getCommunityScreenshotsByTitleId(titleId) { return new Promise(async (resolve, reject) => { try { const url = `https://screenshotsmetadata.xboxlive.com/public/titles/${titleId}/screenshots?qualifier=created&maxItems=10`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } getScreenshotsByXuid(titleId, skipItems, maxItems) { return new Promise(async (resolve, reject) => { try { const params = { skipitems: skipItems || 0, maxitems: maxItems || 25, } if (titleId !== undefined) { params.titleid = titleId } const queryParams = QueryString.stringify(params); const url = `https://screenshotsmetadata.xboxlive.com/users/xuid(${this.tokens.xsts.DisplayClaims.xui[0].xid})/screenshots?${queryParams}`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } } export default Catalog; ================================================ FILE: src/webApi/providers/social.js ================================================ import axios from 'axios'; class Social { constructor(authorizationHeaders) { const headers = authorizationHeaders; //create axios instance this.axiosInstance = axios.create({ method: 'GET', headers: headers }); } getFriends() { return new Promise(async (resolve, reject) => { try { const url = `https://social.xboxlive.com/users/me/summary`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } } export default Social; ================================================ FILE: src/webApi/providers/titlehub.js ================================================ import axios from 'axios'; class TitleHub { constructor(tokens, authorizationHeaders) { this.tokens = tokens; const headers = authorizationHeaders; //create axios instance this.axiosInstance = axios.create({ method: 'GET', headers: headers }); } getTitleHistory() { return new Promise(async (resolve, reject) => { try { const params = [ 'achievement', 'image', 'scid', ] const url = `https://titlehub.xboxlive.com/users/xuid(${this.tokens.xsts.DisplayClaims.xui[0].xid})/titles/titlehistory/decoration/${params.join(',')}`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } getTitleId(titleId) { return new Promise(async (resolve, reject) => { try { const params = [ 'achievement', 'image', 'detail', 'scid', 'alternateTitleId' ] const url = `https://titlehub.xboxlive.com/users/xuid(${this.tokens.xsts.DisplayClaims.xui[0].xid})/titles/titleid(${titleId})/decoration/${params.join(',')}`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } } export default TitleHub; ================================================ FILE: src/webApi/providers/userpresence.js ================================================ import axios from 'axios'; class UserPresence { constructor(authorizationHeaders) { const headers = authorizationHeaders; headers['x-xbl-contract-version'] = '3'; //create axios instance this.axiosInstance = axios.create({ method: 'GET', headers: headers }); } getCurrentUser() { return new Promise(async (resolve, reject) => { try { const url = `https://userpresence.xboxlive.com/users/me?level=all`; const response = await this.axiosInstance(url); resolve(response.data); } catch (error) { reject(error); }; }); } } export default UserPresence; ================================================ FILE: src/webApi/providers/userstats.js ================================================ import axios from 'axios'; class UserStats { constructor(tokens, authorizationHeaders) { this.tokens = tokens; const headers = authorizationHeaders; headers['x-xbl-contract-version'] = '2'; //create axios instance this.axiosInstance = axios.create({ method: 'GET', headers: headers }); } getUserTitleStats(titleId) { return new Promise(async (resolve, reject) => { try { const url = `https://userstats.xboxlive.com/batch`; const params = `{"arrangebyfield":"xuid","xuids":["${this.tokens.xsts.DisplayClaims.xui[0].xid}"],"groups":[{"name":"Hero","titleId":"${titleId}"}],"stats":[{"name":"MinutesPlayed","titleId":"${titleId}"}]}`; const response = await this.httpClient.request('POST', url, this.headers, params); resolve(response.data); } catch (error) { reject(error); }; }); } } export default UserStats; ================================================ FILE: src/webApi/xboxwebapi.js ================================================ import EventEmitter from 'events'; import { v4 as UuIdv4 } from 'uuid'; import axios from 'axios'; import Authentication from './authentication.js'; import ImpulseGenerator from '../impulsegenerator.js'; import Functions from '../functions.js'; import { WebApi, DefaultInputs } from '../constants.js'; class XboxWebApi extends EventEmitter { constructor(config, authTokenFile, inputsFile, restFulEnabled, mqttEnabled) { super(); this.liveId = config.xboxLiveId; this.getInputsFromDevice = config.inputs?.getFromDevice; this.logWarn = config.log?.warn; this.logError = config.log?.error; this.logDebug = config.log?.debug; this.inputsFile = inputsFile; // BŁ9 FIX: store restFul/mqtt flags passed from xboxdevice this.restFulEnabled = restFulEnabled || false; this.mqttEnabled = mqttEnabled || false; // Variables this.consoleAuthorized = false; this.rmEnabled = false; this.functions = new Functions(); const authConfig = { clientId: config.webApi?.clientId, clientSecret: config.webApi?.clientSecret, tokensFile: authTokenFile } this.authentication = new Authentication(authConfig); // Impulse generator this.call = false; this.impulseGenerator = new ImpulseGenerator() .on('checkAuthorization', async () => { if (this.call) return; try { this.call = true; await this.checkAuthorization(); } catch (error) { if (this.logError) this.emit('error', `Web Api generator error: ${error}`); } finally { this.call = false; } }) .on('state', (state) => { this.emit(state ? 'success' : 'warn', `Web Api monitoring ${state ? 'started' : 'stopped'}`); }); } async checkAuthorization() { try { const data = await this.authentication.checkAuthorization(); if (this.logDebug) this.emit('debug', `Authorization headers: ${JSON.stringify(data.headers, null, 2)}`); const authorized = data.tokens?.xsts?.Token?.trim() || false; if (!authorized) { if (this.logWarn) this.emit('warn', `Not authorized`); return false; } this.consoleAuthorized = true; // Axios instance with global timeout and retry this.axiosInstance = axios.create({ baseURL: WebApi.Url.Xccs, timeout: 5000, headers: { 'Authorization': data.headers, 'Accept-Language': 'en-US', 'x-xbl-contract-version': '4', 'x-xbl-client-name': 'XboxApp', 'x-xbl-client-type': 'UWA', 'x-xbl-client-version': '39.39.22001.0', 'skillplatform': 'RemoteManagement', 'Content-Type': 'application/json' } }); this.axiosInstance.interceptors.response.use(null, async (error) => { const config = error.config; if (!config || !config.retryCount) config.retryCount = 0; if (config.retryCount < 2 && (error.code === 'ECONNABORTED' || error.response?.status === 429)) { config.retryCount += 1; if (this.logDebug) this.emit('debug', `Retry ${config.retryCount} for ${config.url}`); await new Promise(res => setTimeout(res, 1000)); return this.axiosInstance(config); } return Promise.reject(error); }); // Check console data const consoleExist = await this.consolesList(); if (!consoleExist) return false; await this.consoleStatus(); await this.installedApps(); return true; } catch (error) { throw new Error(`Check authorization error: ${error}`); } } async consolesList() { try { const { data } = await this.axiosInstance.get('/lists/devices?queryCurrentDevice=false&includeStorageDevices=true'); if (this.logDebug) this.emit('debug', `Consoles list data: ${JSON.stringify(data, null, 2)}`); const status = data.status?.errorCode === 'OK'; // BŁ11 FIX: typo errorMerssage → errorMessage (applies to all methods) const error = data.status?.errorMessage; if (!status) { if (this.logDebug) this.emit('debug', `Console list data error: ${error}`); return false; } const console = data.result.find(c => c.id === this.liveId); if (!console) { if (this.logWarn) this.emit('warn', `Console with Live ID ${this.liveId} not found on server`); return false; } const obj = { id: console.id, name: console.name, locale: console.locale, region: console.region, consoleType: WebApi.Console.Name[console.consoleType], powerState: WebApi.Console.PowerState[console.powerState], digitalAssistantRemoteControlEnabled: !!console.digitalAssistantRemoteControlEnabled, remoteManagementEnabled: !!console.remoteManagementEnabled, consoleStreamingEnabled: !!console.consoleStreamingEnabled, wirelessWarning: !!console.wirelessWarning, outOfHomeWarning: !!console.outOfHomeWarning, storageDevices: console.storageDevices.map(s => ({ id: s.storageDeviceId, name: s.storageDeviceName, isDefault: s.isDefault, freeSpaceBytes: s.freeSpaceBytes, totalSpaceBytes: s.totalSpaceBytes, isGen9Compatible: s.isGen9Compatible })) }; if (!obj.remoteManagementEnabled && this.logWarn) this.emit('warn', `Console with Live ID ${this.liveId} remote management not enabled`); this.rmEnabled = obj.remoteManagementEnabled; if (this.restFulEnabled) this.emit('restFul', 'consoleslist', data); if (this.mqttEnabled) this.emit('mqtt', 'Consoles List', data); return true; } catch (error) { throw new Error(`Consoles list error: ${error}`); } } async consoleStatus() { try { const url = `/consoles/${this.liveId}`; const { data } = await this.axiosInstance.get(url); if (this.logDebug) this.emit('debug', `Console status data: ${JSON.stringify(data, null, 2)}`); const status = { id: data.id, name: data.name, locale: data.locale, region: data.region, consoleType: WebApi.Console.Name[data.consoleType], powerState: WebApi.Console.PowerState[data.powerState], playbackState: data.playbackState, loginState: data.loginState, focusAppAumid: data.focusAppAumid, isTvConfigured: !!data.isTvConfigured, digitalAssistantRemoteControlEnabled: !!data.digitalAssistantRemoteControlEnabled, consoleStreamingEnabled: !!data.consoleStreamingEnabled, remoteManagementEnabled: !!data.remoteManagementEnabled, // BŁ11 FIX: typo errorMerssage → errorMessage status: data.status?.errorCode === 'OK', error: data.status?.errorMessage }; if (!status.status) { if (this.logDebug) this.emit('debug', `Console status error: ${status.error}`); return; } this.emit('consoleStatus', status); if (this.restFulEnabled) this.emit('restFul', 'status', data); if (this.mqttEnabled) this.emit('mqtt', 'Status', data); return true; } catch (error) { throw new Error(`Console status error: ${error}`); } } async installedApps() { if (!this.getInputsFromDevice) return true; try { const url = `/lists/installedApps?deviceId=${this.liveId}`; const { data } = await this.axiosInstance.get(url); if (this.logDebug) this.emit('debug', `Installed apps data: ${JSON.stringify(data, null, 2)}`); const status = data.status?.errorCode === 'OK'; // BŁ11 FIX: typo errorMerssage → errorMessage const error = data.status?.errorMessage; if (!status) { if (this.logDebug) this.emit('debug', `Installed apps data error: ${error}`); return false; } const apps = data.result.filter(a => a.name && a.aumid).map(a => ({ name: a.name, oneStoreProductId: a.oneStoreProductId, reference: a.aumid, titleId: a.titleId, isGame: a.isGame, contentType: a.contentType, mode: 0, })); if (this.restFulEnabled) this.emit('restFul', 'apps', data); if (this.mqttEnabled) this.emit('mqtt', 'Apps', data); const inputs = [...DefaultInputs, ...apps]; await this.functions.saveData(this.inputsFile, inputs); this.emit('installedApps', inputs, false); return true; } catch (error) { throw new Error(`Installed apps error: ${error}`); } } async mediaState(tokens) { try { const url = `/users/xuid(${tokens.xsts.DisplayClaims.xui[0].xid})/devices/${this.liveId}/media`; const { data } = await this.axiosInstance.get(url); if (this.logDebug) this.emit('debug', `Media state data: ${JSON.stringify(data, null, 2)}`); const status = data.status?.errorCode === 'OK'; // BŁ11 FIX: typo errorMerssage → errorMessage const error = data.status?.errorMessage; if (!status) { if (this.logDebug) this.emit('debug', `Media state data error: ${error}`); return false; } const state = { state: data.state, title: data.title, artist: data.artist, album: data.album, position: data.position, duration: data.duration, canSeek: !!data.canSeek, volume: data.volume, muted: !!data.muted, }; this.emit('mediaState', state); if (this.restFulEnabled) this.emit('restFul', 'mediastate', data); if (this.mqttEnabled) this.emit('mqtt', 'Media State', data); return true; } catch (error) { throw new Error(`Media state error: ${error}`); } } async send(commandType, command, payload) { if (!this.consoleAuthorized || !this.rmEnabled) { if (this.logWarn) this.emit('warn', `Not authorized or remote management not enabled`); return; } const postParams = { destination: 'Xbox', type: commandType, command, sessionId: UuIdv4(), sourceId: 'com.microsoft.smartglass', parameters: payload ?? [], linkedXboxId: this.liveId }; try { const response = await this.axiosInstance.post('/commands', postParams); if (this.logDebug) this.emit('debug', `Command ${command} result: ${JSON.stringify(response.data)}`); return true; } catch (error) { await new Promise(resolve => setTimeout(resolve, 1000)); if (command === 'WakeUp') this.emit('stateChanged', false); if (command === 'TurnOff') this.emit('stateChanged', true); throw new Error(`Failed to send command: type=${commandType}, command=${command}, error=${error.message}`); } } // Media / shell helpers async next() { return this.send('Media', 'Next'); } async previous() { return this.send('Media', 'Previous'); } async pause() { return this.send('Media', 'Pause'); } async play() { return this.send('Media', 'Play'); } async goBack() { return this.send('Shell', 'GoBack'); } } export default XboxWebApi; ================================================ FILE: src/xboxdevice.js ================================================ import EventEmitter from 'events'; import RestFul from './restful.js'; import Mqtt from './mqtt.js'; import XboxWebApi from './webApi/xboxwebapi.js'; import XboxLocalApi from './localApi/xboxlocalapi.js'; import Functions from './functions.js'; import { DefaultInputs, WebApi } from './constants.js'; let Accessory, Characteristic, Service, Categories, Encode, AccessoryUUID; class XboxDevice extends EventEmitter { constructor(api, device, authTokenFile, devInfoFile, inputsFile, inputsNamesFile, inputsTargetVisibilityFile) { super(); Accessory = api.platformAccessory; Characteristic = api.hap.Characteristic; Service = api.hap.Service; Categories = api.hap.Categories; Encode = api.hap.encode; AccessoryUUID = api.hap.uuid; //device configuration this.device = device; this.name = device.name; this.liveId = device.xboxLiveId; this.displayType = device.displayType; this.webApiControl = device.webApi?.enable || false; this.getInputsFromDevice = device.webApi?.enable ? device.inputs?.getFromDevice : false; this.filterGames = device.inputs?.filterGames || false; this.filterApps = device.inputs?.filterApps || false; this.filterSystemApps = device.inputs?.filterSystemApps || false; this.filterDlc = device.inputs?.filterDlc || false; this.inputsDisplayOrder = device.inputs?.displayOrder || 0; this.inputs = (device.inputs?.data || []).filter(input => input.name && input.reference); this.buttons = (device.buttons ?? []).filter(button => (button.displayType ?? 0) > 0); this.sensors = Array.isArray(device.sensors) ? (device.sensors ?? []).filter(sensor => (sensor.displayType ?? 0) > 0 && (sensor.mode ?? -1) >= 0) : []; this.volumeControl = device.volume?.displayType || 0; this.volumeControlName = device.volume?.name || 'Volume'; this.volumeControlNamePrefix = device.volume?.namePrefix || false; this.infoButtonCommand = device.infoButtonCommand || 'nexus'; this.logInfo = device.log?.info || false; this.logWarn = device.log?.warn || false; this.logDebug = device.log?.debug || false; this.authTokenFile = authTokenFile; this.devInfoFile = devInfoFile; this.inputsFile = inputsFile; this.inputsNamesFile = inputsNamesFile; this.inputsTargetVisibilityFile = inputsTargetVisibilityFile; //external integrations this.restFul = device.restFul ?? {}; this.restFulConnected = false; this.mqtt = device.mqtt ?? {}; this.mqttConnected = false; this.functions = new Functions(); //sensors for (const sensor of this.sensors) { sensor.serviceType = ['', Service.MotionSensor, Service.OccupancySensor, Service.ContactSensor][sensor.displayType]; sensor.characteristicType = ['', Characteristic.MotionDetected, Characteristic.OccupancyDetected, Characteristic.ContactSensorState][sensor.displayType]; sensor.state = false; } //buttons for (const button of this.buttons) { button.reference = [button.mediaCommand, button.gamePadCommand, button.tvRemoteCommand, button.consoleControlCommand, button.gameAppControlCommand][button.mode]; button.serviceType = ['', Service.Outlet, Service.Switch][button.displayType]; button.state = false; } //variable this.modelName = 'Xbox'; this.inputIdentifier = 1; this.power = false; this.volume = 0; this.mute = false; this.playState = false; this.mediaState = 2; this.reference = ''; this.screenSaver = false; this.consoleAuthorized = false; } async setOverExternalIntegration(integration, key, value) { if (!this.consoleAuthorized && this.logWarn) { this.emit('warn', `Set over external integration not possible, web api not enabled`); return; } try { let set = false; switch (key) { case 'Power': switch (value) { case true: //on set = await this.xboxWebApi.send('Power', 'WakeUp'); break; case false: //off set = await this.xboxWebApi.send('Power', 'TurnOff'); break; } break; case 'App': const payload = [{ 'oneStoreProductId': value }]; set = await this.xboxWebApi.send('Shell', 'ActivateApplicationWithOneStoreProductId', payload); break; case 'Volume': switch (value) { case 'up': set = await this.xboxWebApi.send('Volume', 'Up'); break; case 'down': set = await this.xboxWebApi.send('Volume', 'Down'); break; } break; case 'Mute': switch (value) { case true: set = await this.xboxWebApi.send('Audio', 'Mute'); break; case false: set = await this.xboxWebApi.send('Audio', 'Unmute'); break; } break; case 'RcControl': set = await this.xboxWebApi.send('Shell', 'InjectKey', [{ 'keyType': value }]); break; default: if (this.logWarn) this.emit('warn', `${integration}, received key: ${key}, value: ${value}`); break; }; return set; } catch (error) { throw new Error(`${integration} set key: ${key}, value: ${value}, error: ${error}`); } } async externalIntegrations() { //RESTFul server const restFulEnabled = this.restFul.enable || false; if (restFulEnabled) { try { this.restFul1 = new RestFul({ port: this.restFul.port || 3000, logWarn: this.logWarn, logDebug: this.logDebug }) .on('connected', (message) => { this.emit('success', message); this.restFulConnected = true; }) .on('set', async (key, value) => { try { await this.setOverExternalIntegration('RESTFul', key, value); } catch (error) { if (this.logWarn) this.emit('warn', `RESTFul set error: ${error}`); } }) .on('debug', (debug) => this.emit('debug', debug)) .on('warn', (warn) => this.emit('warn', warn)) .on('error', (error) => this.emit('error', error)); } catch (error) { if (this.logWarn) this.emit('warn', `RESTFul integration start error: ${error}`); } } //mqtt client const mqttEnabled = this.mqtt.enable || false; if (mqttEnabled) { try { this.mqtt1 = new Mqtt({ host: this.mqtt.host, port: this.mqtt.port || 1883, clientId: this.mqtt.clientId ? `microsoft_${this.mqtt.clientId}_${Math.random().toString(16).slice(3)}` : `microsoft_${Math.random().toString(16).slice(3)}`, prefix: this.mqtt.prefix ? `microsoft/${this.mqtt.prefix}/${this.name}` : `microsoft/${this.name}`, user: this.mqtt.auth?.user, passwd: this.mqtt.auth?.passwd, logWarn: this.logWarn, logDebug: this.logDebug }) .on('connected', (message) => { this.emit('success', message); this.mqttConnected = true; }) .on('subscribed', (message) => { this.emit('success', message); }) .on('set', async (key, value) => { try { await this.setOverExternalIntegration('MQTT', key, value); } catch (error) { if (this.logWarn) this.emit('warn', `MQTT set error: ${error}`); } }) .on('debug', (debug) => this.emit('debug', debug)) .on('warn', (warn) => this.emit('warn', warn)) .on('error', (error) => this.emit('error', error)); } catch (error) { if (this.logWarn) this.emit('warn', `MQTT integration start error: ${error}`); } }; return true; } async prepareDataForAccessory() { try { //read dev info from file this.savedInfo = await this.functions.readData(this.devInfoFile, true) ?? {}; if (this.logDebug) this.emit('debug', `Read saved Info: ${JSON.stringify(this.savedInfo, null, 2)}`); //read inputs file this.savedInputs = await this.functions.readData(this.inputsFile, true) ?? []; if (this.logDebug) this.emit('debug', `Read saved Inputs: ${JSON.stringify(this.savedInputs, null, 2)}`); //read inputs names from file this.savedInputsNames = await this.functions.readData(this.inputsNamesFile, true) ?? {}; if (this.logDebug) this.emit('debug', `Read saved Inputs Names: ${JSON.stringify(this.savedInputsNames, null, 2)}`); //read inputs visibility from file this.savedInputsTargetVisibility = await this.functions.readData(this.inputsTargetVisibilityFile, true) ?? {}; if (this.logDebug) this.emit('debug', `Read saved Inputs Target Visibility: ${JSON.stringify(this.savedInputsTargetVisibility, null, 2)}`); return true; } catch (error) { throw new Error(`Prepare data for accessory error: ${error}`); } } async startStopImpulseGenerator(state, timers = []) { try { //start web api impulse generator if (this.webApiControl) await this.xboxWebApi.impulseGenerator.state(true, [{ name: 'checkAuthorization', sampling: 900000 }]); //start impulse generator await this.xboxLocalApi.impulseGenerator.state(state, timers); return true; } catch (error) { throw new Error(`Impulse generator start error: ${error}`); } } async displayOrder() { try { const sortStrategies = { 1: (a, b) => a.name.localeCompare(b.name), 2: (a, b) => b.name.localeCompare(a.name), 3: (a, b) => a.reference.localeCompare(b.reference), 4: (a, b) => b.reference.localeCompare(a.reference), }; const sortFn = sortStrategies[this.inputsDisplayOrder]; // Sort only if a valid function exists if (sortFn) { this.inputsServices.sort(sortFn); } // Debug if (this.logDebug) { const orderDump = this.inputsServices.map(svc => ({ name: svc.name, reference: svc.reference, identifier: svc.identifier, })); this.emit('debug', `Inputs display order:\n${JSON.stringify(orderDump, null, 2)}`); } // Always update DisplayOrder characteristic, even for "none" const displayOrder = this.inputsServices.map(svc => svc.identifier); const encodedOrder = Encode(1, displayOrder).toString('base64'); this.televisionService.updateCharacteristic(Characteristic.DisplayOrder, encodedOrder); return; } catch (error) { throw new Error(`Display order error: ${error}`); } } async addRemoveOrUpdateInput(inputs, remove = false) { try { if (!this.inputsServices) return; let updated = false; for (const input of inputs) { if (this.inputsServices.length >= 85 && !remove) continue; // Filter const contentType = input.contentType; const filterGames = this.filterGames && contentType === 'Game'; const filterApps = this.filterApps && contentType === 'App'; const filterSystemApps = this.filterSystemApps && contentType === 'systemApp'; const filterDlc = this.filterDlc && contentType === 'Dlc'; if (filterGames || filterApps || filterSystemApps || filterDlc) continue; const inputReference = input.reference; const savedName = this.savedInputsNames[inputReference] ?? input.name; const sanitizedName = await this.functions.sanitizeString(savedName); const inputMode = input.mode ?? 0; const inputTitleId = input.titleId; const inputOneStoreProductId = input.oneStoreProductId; const inputVisibility = this.savedInputsTargetVisibility[inputReference] ?? 0; if (remove) { const svc = this.inputsServices.find(s => s.reference === inputReference); if (svc) { if (this.logDebug) this.emit('debug', `Removing input: ${input.name}, reference: ${inputReference}`); this.accessory.removeService(svc); this.inputsServices = this.inputsServices.filter(s => s.reference !== inputReference); updated = true; } continue; } let inputService = this.inputsServices.find(s => s.reference === inputReference); if (inputService) { const nameChanged = inputService.name !== sanitizedName; if (nameChanged) { inputService.name = sanitizedName; inputService .updateCharacteristic(Characteristic.Name, sanitizedName) .updateCharacteristic(Characteristic.ConfiguredName, sanitizedName); if (this.logDebug) this.emit('debug', `Updated Input: ${input.name}, reference: ${inputReference}`); updated = true; } } else { const identifier = this.inputsServices.length + 1; inputService = this.accessory.addService(Service.InputSource, sanitizedName, `Input ${inputReference}`); inputService.identifier = identifier; inputService.reference = inputReference; inputService.name = sanitizedName; inputService.mode = inputMode; inputService.titleId = inputTitleId; inputService.oneStoreProductId = inputOneStoreProductId; inputService.visibility = inputVisibility; inputService .setCharacteristic(Characteristic.Identifier, identifier) .setCharacteristic(Characteristic.Name, sanitizedName) .setCharacteristic(Characteristic.ConfiguredName, sanitizedName) .setCharacteristic(Characteristic.IsConfigured, 1) .setCharacteristic(Characteristic.InputSourceType, inputMode) .setCharacteristic(Characteristic.CurrentVisibilityState, inputVisibility) .setCharacteristic(Characteristic.TargetVisibilityState, inputVisibility); // ConfiguredName persistence inputService.getCharacteristic(Characteristic.ConfiguredName) .onSet(async (value) => { try { value = await this.functions.sanitizeString(value); inputService.name = value; this.savedInputsNames[inputReference] = value; await this.functions.saveData(this.inputsNamesFile, this.savedInputsNames); if (this.logDebug) this.emit('debug', `Saved Input: ${input.name}, reference: ${inputReference}`); await this.displayOrder(); } catch (error) { if (this.logWarn) this.emit('warn', `Save Input Name error: ${error}`); } }); // TargetVisibility persistence inputService.getCharacteristic(Characteristic.TargetVisibilityState) .onSet(async (state) => { try { inputService.visibility = state; this.savedInputsTargetVisibility[inputReference] = state; await this.functions.saveData(this.inputsTargetVisibilityFile, this.savedInputsTargetVisibility); if (this.logDebug) this.emit('debug', `Saved Input: ${input.name}, reference: ${inputReference}, target visibility: ${state ? 'HIDDEN' : 'SHOWN'}`); } catch (error) { if (this.logWarn) this.emit('warn', `Save Target Visibility error: ${error}`); } }); this.inputsServices.push(inputService); this.televisionService.addLinkedService(inputService); if (this.logDebug) this.emit('debug', `Added Input: ${input.name}, reference: ${inputReference}`); updated = true; } } // Only one time run if (updated) await this.displayOrder(); return true; } catch (error) { throw new Error(`Add/Remove/Update input error: ${error}`); } } async setInput(input) { try { const { oneStoreProductId, name, reference } = input; let channelName; let command; let payload; switch (oneStoreProductId) { case 'Dashboard': case 'Settings': case 'SettingsTv': case 'Accessory': case 'Screensaver': case 'NetworkTroubleshooter': case 'MicrosoftStore': channelName = 'Shell'; command = 'GoHome'; break; case 'Television': channelName = 'TV'; command = 'ShowGuide'; break; case 'XboxGuide': channelName = 'Shell'; command = 'ShowGuideTab'; payload = [{ 'tabName': 'Guide' }]; break; default: channelName = 'Shell'; command = 'ActivateApplicationWithOneStoreProductId'; payload = [{ 'oneStoreProductId': oneStoreProductId }]; break; } await this.xboxWebApi.send(channelName, command, payload); if (this.logInfo) this.emit('info', `Set game/app: ${name}, reference: ${reference}, product id: ${oneStoreProductId}`); return; } catch (error) { if (this.logWarn) this.emit('warn', `Set game/app error: ${error}`); } } //Prepare accessory async prepareAccessory() { try { //Accessory if (this.logDebug) this.emit('debug', `Prepare accessory`); const accessoryName = this.name; const accessoryUUID = AccessoryUUID.generate(this.liveId); const accessoryCategory = [Categories.OTHER, Categories.TELEVISION, Categories.TV_SET_TOP_BOX, Categories.TV_STREAMING_STICK, Categories.AUDIO_RECEIVER][this.displayType]; const accessory = new Accessory(accessoryName, accessoryUUID, accessoryCategory); this.accessory = accessory; //Prepare information service this.informationService = accessory.getService(Service.AccessoryInformation) .setCharacteristic(Characteristic.Manufacturer, this.savedInfo.manufacturer) .setCharacteristic(Characteristic.Model, this.savedInfo.modelName) .setCharacteristic(Characteristic.SerialNumber, this.savedInfo.serialNumber ?? this.liveId) .setCharacteristic(Characteristic.FirmwareRevision, this.savedInfo.firmwareRevision) .setCharacteristic(Characteristic.ConfiguredName, accessoryName); //Prepare television service if (this.logDebug) this.emit('debug', `Prepare television service`); this.televisionService = accessory.addService(Service.Television, `${accessoryName} Television`, 'Television'); this.televisionService.setCharacteristic(Characteristic.ConfiguredName, accessoryName); this.televisionService.setCharacteristic(Characteristic.SleepDiscoveryMode, 1); this.televisionService.getCharacteristic(Characteristic.Active) .onGet(async () => { const state = this.power; return state; }) .onSet(async (state) => { if (!!state === this.power) return; if (!this.consoleAuthorized && this.logWarn) { this.emit('warn', `Set power not possible, web api not enabled`); return; } try { await this.xboxWebApi.send('Power', state ? 'WakeUp' : 'TurnOff'); if (this.logInfo) this.emit('info', `Set Power: ${state ? 'ON' : 'OFF'}`); await new Promise(resolve => setTimeout(resolve, 2000)); } catch (error) { if (this.logWarn) this.emit('warn', `Set Power, error: ${error}`); } }); this.televisionService.getCharacteristic(Characteristic.ActiveIdentifier) .onGet(async () => { const inputIdentifier = this.inputIdentifier; return inputIdentifier; }) .onSet(async (activeIdentifier) => { if (!this.consoleAuthorized && this.logWarn) { this.emit('warn', `Set game/app not possible, web api not enabled`); return; } try { const input = this.inputsServices.find(i => i.identifier === activeIdentifier); if (!input) { if (this.logWarn) this.emit('warn', `Game/App with identifier ${activeIdentifier} not found`); return; } if (!this.power) { if (this.logDebug) this.emit('debug', `Device is off, deferring game/app switch to '${activeIdentifier}'`); (async () => { for (let attempt = 0; attempt < 20; attempt++) { await new Promise(resolve => setTimeout(resolve, 1500)); if (this.power) { // if input didn't switch → retry if (this.inputIdentifier !== activeIdentifier) { if (this.logDebug) this.emit('debug', `Retrying game/app switch (${attempt + 1}/20)`); await this.setInput(input); } else { // success this.televisionService.updateCharacteristic(Characteristic.ActiveIdentifier, activeIdentifier); if (this.logInfo) this.emit('info', `Game/App set successfully: ${input.name}`); return; } } } if (this.logWarn) this.emit('warn', `Failed to set game/app after retries: ${input.name}`); })().catch(err => { if (this.logWarn) this.emit('warn', `Set game/app retry error: ${err}`); }); return; } // device is on await this.setInput(input); if (this.logInfo) this.emit('info', `Set game/app name: ${input.name}, Reference: ${input.reference}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set game/app error: ${JSON.stringify(error, null, 2)}`); } }); this.televisionService.getCharacteristic(Characteristic.RemoteKey) .onSet(async (remoteKey) => { if (!this.consoleAuthorized && this.logWarn) { this.emit('warn', `Set remote key not possible, web api not enabled`); return; } try { let channelName; let command; switch (remoteKey) { case 0: //REWIND channelName = 'Shell'; command = 'rewind'; break; case 1: //FAST_FORWARD channelName = 'Shell'; command = 'fastForward'; break; case 2: //NEXT_TRACK channelName = 'Shell'; command = 'nextTrack'; break; case 3: //PREVIOUS_TRACK channelName = 'Shell'; command = 'previousTrack'; break; case 4: //ARROW_UP channelName = 'Shell'; command = 'up'; break; case 5: //ARROW_DOWN channelName = 'Shell'; command = 'down'; break; case 6: //ARROW_LEFT channelName = 'Shell'; command = 'left'; break; case 7: //ARROW_RIGHT channelName = 'Shell'; command = 'right'; break; case 8: //SELECT channelName = 'Shell'; command = 'a'; break; case 9: //BACK channelName = 'Shell'; command = 'b'; break; case 10: //EXIT channelName = 'Shell'; command = 'nexus'; break; case 11: //PLAY_PAUSE channelName = 'Shell'; command = 'playPause'; break; case 15: //INFORMATION channelName = 'Shell'; command = this.infoButtonCommand; break; } await this.xboxWebApi.send(channelName, 'InjectKey', [{ 'keyType': command }]); if (this.logInfo) this.emit('info', `Remote Key: ${command}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set Remote Key error: ${JSON.stringify(error, null, 2)}`); } }); this.televisionService.getCharacteristic(Characteristic.CurrentMediaState) .onGet(async () => { //apple: 0 - PLAY, 1 - PAUSE, 2 - STOP, 3 - LOADING, 4 - INTERRUPTED //xbox: 0 - STOP, 1 - PLAY, 2 - PAUSE const value = this.mediaState; return value; }); this.televisionService.getCharacteristic(Characteristic.TargetMediaState) .onGet(async () => { //0 - PLAY, 1 - PAUSE, 2 - STOP const value = this.mediaState; return value; }) .onSet(async (value) => { // BŁ2 FIX: removed dead variables (newMediaState, setMediaState which was always false) try { if (this.logInfo) this.emit('info', `Set Target Media: ${['PLAY', 'PAUSE', 'STOP', 'LOADING', 'INTERRUPTED'][value]}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set Target Media error: ${error}`); } }); this.televisionService.getCharacteristic(Characteristic.PowerModeSelection) .onSet(async (powerModeSelection) => { if (!this.consoleAuthorized && this.logWarn) { this.emit('warn', `Set power mode selection not possible, web api not enabled`); return; } try { switch (powerModeSelection) { case 0: //SHOW await this.xboxWebApi.send('Shell', 'InjectKey', [{ 'keyType': 'nexus' }]); break; case 1: //HIDE await this.xboxWebApi.send('Shell', 'InjectKey', [{ 'keyType': 'b' }]); break; }; if (this.logInfo) this.emit('info', `Set Power Mode Selection: ${powerModeSelection === 0 ? 'SHOW' : 'HIDE'}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set Power Mode Selection error: ${error}`); } }); //prepare inputs service if (this.logDebug) this.emit('debug', `Prepare inputs service`); this.inputsServices = []; await this.addRemoveOrUpdateInput(this.savedInputs, false); //Prepare volume service if (this.volumeControl > 0) { const volumeServiceName = this.volumeControlNamePrefix ? `${accessoryName} ${this.volumeControlName}` : this.volumeControlName; switch (this.volumeControl) { case 1: //lightbulb if (this.logDebug) this.emit('debug', `Prepare volume service lightbulb`); this.volumeServiceLightbulb = accessory.addService(Service.Lightbulb, volumeServiceName, 'Lightbulb Speaker'); this.volumeServiceLightbulb.addOptionalCharacteristic(Characteristic.ConfiguredName); this.volumeServiceLightbulb.setCharacteristic(Characteristic.ConfiguredName, volumeServiceName); this.volumeServiceLightbulb.getCharacteristic(Characteristic.Brightness) .onGet(async () => { const volume = this.volume; return volume; }) .onSet(async (value) => { try { if (this.logInfo) this.emit('info', `Set Volume: ${value}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set Volume error: ${error}`); } }); this.volumeServiceLightbulb.getCharacteristic(Characteristic.On) .onGet(async () => { const state = this.power ? !this.mute : false; return state; }) .onSet(async (state) => { try { if (this.logInfo) this.emit('info', `Set Mute: ${!state ? 'ON' : 'OFF'}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set Mute error: ${error}`); } }); break; case 2: //fan if (this.logDebug) this.emit('debug', `Prepare volume service fan`); this.volumeServiceFan = accessory.addService(Service.Fan, volumeServiceName, 'Fan Speaker'); this.volumeServiceFan.addOptionalCharacteristic(Characteristic.ConfiguredName); this.volumeServiceFan.setCharacteristic(Characteristic.ConfiguredName, volumeServiceName); this.volumeServiceFan.getCharacteristic(Characteristic.RotationSpeed) .onGet(async () => { const volume = this.volume; return volume; }) .onSet(async (value) => { try { if (this.logInfo) this.emit('info', `Set Volume: ${value}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set Volume error: ${error}`); } }); this.volumeServiceFan.getCharacteristic(Characteristic.On) .onGet(async () => { const state = this.power ? !this.mute : false; return state; }) .onSet(async (state) => { try { if (this.logInfo) this.emit('info', `Set Mute: ${!state ? 'ON' : 'OFF'}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set Mute error: ${error}`); } }); break; case 3: // tv speaker if (this.logDebug) this.emit('debug', `Prepare television speaker service`); this.volumeServiceTvSpeaker = accessory.addService(Service.TelevisionSpeaker, volumeServiceName, 'TV Speaker'); this.volumeServiceTvSpeaker.addOptionalCharacteristic(Characteristic.ConfiguredName); this.volumeServiceTvSpeaker.setCharacteristic(Characteristic.ConfiguredName, volumeServiceName); this.volumeServiceTvSpeaker.getCharacteristic(Characteristic.Active) .onGet(async () => { const state = this.power; return state; }) .onSet(async (state) => { }); this.volumeServiceTvSpeaker.getCharacteristic(Characteristic.VolumeControlType) .onGet(async () => { const state = 3; return state; }); this.volumeServiceTvSpeaker.getCharacteristic(Characteristic.VolumeSelector) .onSet(async (volumeSelector) => { if (!this.consoleAuthorized && this.logWarn) { this.emit('warn', `Set volume selector not possible, web api not enabled`); return; } try { switch (volumeSelector) { case 0: //Up await this.xboxWebApi.send('Volume', 'Up'); break; case 1: //Down await this.xboxWebApi.send('Volume', 'Down'); break; } if (this.logInfo) this.emit('info', `Set Volume Selector: ${volumeSelector ? 'Down' : 'UP'}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set Volume Selector error: ${error}`); } }); this.volumeServiceTvSpeaker.getCharacteristic(Characteristic.Volume) .onGet(async () => { const volume = this.volume; return volume; }) .onSet(async (value) => { try { if (this.logInfo) this.emit('info', `Set Volume: ${value}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set Volume error: ${error}`); } }); this.volumeServiceTvSpeaker.getCharacteristic(Characteristic.Mute) .onGet(async () => { const state = this.mute; return state; }) .onSet(async (state) => { try { if (this.logInfo) this.emit('info', `Set Mute: ${state ? 'ON' : 'OFF'}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set Mute error: ${error}`); } }); break; case 4: // tv speaker + lightbulb if (this.logDebug) this.emit('debug', `Prepare television speaker service`); this.volumeServiceTvSpeaker = accessory.addService(Service.TelevisionSpeaker, volumeServiceName, 'TV Speaker'); this.volumeServiceTvSpeaker.addOptionalCharacteristic(Characteristic.ConfiguredName); this.volumeServiceTvSpeaker.setCharacteristic(Characteristic.ConfiguredName, volumeServiceName); this.volumeServiceTvSpeaker.getCharacteristic(Characteristic.Active) .onGet(async () => { const state = this.power; return state; }) .onSet(async (state) => { }); this.volumeServiceTvSpeaker.getCharacteristic(Characteristic.VolumeControlType) .onGet(async () => { const state = 3; return state; }); this.volumeServiceTvSpeaker.getCharacteristic(Characteristic.VolumeSelector) .onSet(async (volumeSelector) => { if (!this.consoleAuthorized && this.logWarn) { this.emit('warn', `Set volume selector not possible, web api not enabled`); return; } try { switch (volumeSelector) { case 0: //Up await this.xboxWebApi.send('Volume', 'Up'); break; case 1: //Down await this.xboxWebApi.send('Volume', 'Down'); break; } if (this.logInfo) this.emit('info', `Set Volume Selector: ${volumeSelector ? 'Down' : 'UP'}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set Volume Selector error: ${error}`); } }); this.volumeServiceTvSpeaker.getCharacteristic(Characteristic.Volume) .onGet(async () => { const volume = this.volume; return volume; }) .onSet(async (value) => { try { if (this.logInfo) this.emit('info', `Set Volume: ${value}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set Volume error: ${error}`); } }); this.volumeServiceTvSpeaker.getCharacteristic(Characteristic.Mute) .onGet(async () => { const state = this.mute; return state; }) .onSet(async (state) => { try { if (this.logInfo) this.emit('info', `Set Mute: ${state ? 'ON' : 'OFF'}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set Mute error: ${error}`); } }); // lightbulb if (this.logDebug) this.emit('debug', `Prepare volume service lightbulb`); this.volumeServiceLightbulb = accessory.addService(Service.Lightbulb, volumeServiceName, 'Lightbulb Speaker'); this.volumeServiceLightbulb.addOptionalCharacteristic(Characteristic.ConfiguredName); this.volumeServiceLightbulb.setCharacteristic(Characteristic.ConfiguredName, volumeServiceName); this.volumeServiceLightbulb.getCharacteristic(Characteristic.Brightness) .onGet(async () => { const volume = this.volume; return volume; }) .onSet(async (value) => { this.volumeServiceTvSpeaker.setCharacteristic(Characteristic.Volume, value); }); this.volumeServiceLightbulb.getCharacteristic(Characteristic.On) .onGet(async () => { const state = this.power ? !this.mute : false; return state; }) .onSet(async (state) => { this.volumeServiceTvSpeaker.setCharacteristic(Characteristic.Mute, !state); }); break; case 5: // tv speaker + fan if (this.logDebug) this.emit('debug', `Prepare television speaker service`); this.volumeServiceTvSpeaker = accessory.addService(Service.TelevisionSpeaker, volumeServiceName, 'TV Speaker'); this.volumeServiceTvSpeaker.addOptionalCharacteristic(Characteristic.ConfiguredName); this.volumeServiceTvSpeaker.setCharacteristic(Characteristic.ConfiguredName, volumeServiceName); this.volumeServiceTvSpeaker.getCharacteristic(Characteristic.Active) .onGet(async () => { const state = this.power; return state; }) .onSet(async (state) => { }); this.volumeServiceTvSpeaker.getCharacteristic(Characteristic.VolumeControlType) .onGet(async () => { const state = 3; return state; }); this.volumeServiceTvSpeaker.getCharacteristic(Characteristic.VolumeSelector) .onSet(async (volumeSelector) => { if (!this.consoleAuthorized && this.logWarn) { this.emit('warn', `Set volume selector not possible, web api not enabled`); return; } try { switch (volumeSelector) { case 0: //Up await this.xboxWebApi.send('Volume', 'Up'); break; case 1: //Down await this.xboxWebApi.send('Volume', 'Down'); break; } if (this.logInfo) this.emit('info', `Set Volume Selector: ${volumeSelector ? 'Down' : 'UP'}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set Volume Selector error: ${error}`); } }); this.volumeServiceTvSpeaker.getCharacteristic(Characteristic.Volume) .onGet(async () => { const volume = this.volume; return volume; }) .onSet(async (value) => { try { if (this.logInfo) this.emit('info', `Set Volume: ${value}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set Volume error: ${error}`); } }); this.volumeServiceTvSpeaker.getCharacteristic(Characteristic.Mute) .onGet(async () => { const state = this.mute; return state; }) .onSet(async (state) => { try { if (this.logInfo) this.emit('info', `Set Mute: ${!state ? 'ON' : 'OFF'}`); } catch (error) { if (this.logWarn) this.emit('warn', `Set Mute error: ${error}`); } }); // fan if (this.logDebug) this.emit('debug', `Prepare volume service fan`); this.volumeServiceFan = accessory.addService(Service.Fan, volumeServiceName, 'Fan Speaker'); this.volumeServiceFan.addOptionalCharacteristic(Characteristic.ConfiguredName); this.volumeServiceFan.setCharacteristic(Characteristic.ConfiguredName, volumeServiceName); this.volumeServiceFan.getCharacteristic(Characteristic.RotationSpeed) .onGet(async () => { const volume = this.volume; return volume; }) .onSet(async (value) => { this.volumeServiceTvSpeaker.setCharacteristic(Characteristic.Volume, value); }); this.volumeServiceFan.getCharacteristic(Characteristic.On) .onGet(async () => { const state = this.power ? !this.mute : false; return state; }) .onSet(async (state) => { this.volumeServiceTvSpeaker.setCharacteristic(Characteristic.Mute, !state); }); break; } } //prepare sensor service const possibleSensorCount = 99 - this.accessory.services.length; const maxSensorCount = this.sensors.length >= possibleSensorCount ? possibleSensorCount : this.sensors.length; if (maxSensorCount > 0) { this.sensorServices = []; if (this.logDebug) this.emit('debug', `Prepare sensors services`); for (let i = 0; i < maxSensorCount; i++) { const sensor = this.sensors[i]; const name = sensor.name || `Sensor ${i}`; const namePrefix = sensor.namePrefix; const serviceType = sensor.serviceType; const characteristicType = sensor.characteristicType; const serviceName = namePrefix ? `${accessoryName} ${name}` : name; const sensorService = new serviceType(serviceName, `Sensor ${i}`); sensorService.addOptionalCharacteristic(Characteristic.ConfiguredName); sensorService.setCharacteristic(Characteristic.ConfiguredName, serviceName); sensorService.getCharacteristic(characteristicType) .onGet(async () => { const state = sensor.state; return state; }); this.sensorServices.push(sensorService); accessory.addService(sensorService); } } //Prepare buttons services const possibleButtonsCount = 99 - this.accessory.services.length; const maxButtonsCount = this.buttons.length >= possibleButtonsCount ? possibleButtonsCount : this.buttons.length; if (maxButtonsCount > 0) { if (this.logDebug) this.emit('debug', `Prepare buttons services`); this.buttonsServices = []; for (let i = 0; i < maxButtonsCount; i++) { const button = this.buttons[i]; const buttonName = button.name || `Button ${i}`; const buttonMode = button.mode; const buttonCommand = button.reference; const namePrefix = button.namePrefix; const serviceType = button.serviceType; const serviceName = namePrefix ? `${accessoryName} ${buttonName}` : buttonName; const buttonService = new serviceType(serviceName, `Button ${i}`); buttonService.addOptionalCharacteristic(Characteristic.ConfiguredName); buttonService.setCharacteristic(Characteristic.ConfiguredName, serviceName); buttonService.getCharacteristic(Characteristic.On) .onGet(async () => { const state = button.state; return state; }) .onSet(async (state) => { if (!this.consoleAuthorized && this.logWarn) { this.emit('warn', `Set button not possible, web api not enabled`); return; } if (!this.power) { if (this.logWarn) this.emit('warn', `console is off`); return; } try { switch (buttonMode) { case 0: case 1: case 2: if (state) await this.xboxWebApi.send('Shell', 'InjectKey', [{ 'keyType': buttonCommand }]); break; case 3: switch (buttonCommand) { case 'reboot': if (state) await this.xboxWebApi.send('Power', 'Reboot'); break; case 'recordGameDvr': if (state) await this.xboxLocalApi.recordGameDvr(); break; } break; case 4: switch (buttonCommand) { case 'Dashboard': case 'Settings': case 'SettingsTv': case 'Accessory': case 'Screensaver': case 'NetworkTroubleshooter': case 'MicrosoftStore': if (state) await this.xboxWebApi.send('Shell', 'GoHome'); break; case 'Television': if (state) await this.xboxWebApi.send('TV', 'ShowGuide'); break; case 'XboxGuide': if (state) await this.xboxWebApi.send('Shell', 'ShowGuideTab', [{ 'tabName': 'Guide' }]); break; default: if (state) await this.xboxWebApi.send('Shell', 'ActivateApplicationWithOneStoreProductId', [{ 'oneStoreProductId': buttonCommand }]); break; } break; } } catch (error) { if (this.logWarn) this.emit('warn', `Set Button error: ${error}`); } }); this.buttonsServices.push(buttonService); accessory.addService(buttonService); } } return accessory; } catch (error) { throw new Error(error) }; } //start async start() { try { // Save inputs if (!this.getInputsFromDevice) { const inputs = [...DefaultInputs, ...this.inputs]; await this.functions.saveData(this.inputsFile, inputs); } // BŁ3/BŁ4 FIX: resolve restFulEnabled/mqttEnabled flags up front, // never pass undefined channelsFile to constructors const restFulEnabled = this.restFul.enable || false; const mqttEnabled = this.mqtt.enable || false; // Web api client if (this.webApiControl) { try { this.xboxWebApi = new XboxWebApi(this.device, this.authTokenFile, this.inputsFile, restFulEnabled, mqttEnabled) .on('consoleStatus', (status) => { this.modelName = status.consoleType; this.mediaState = WebApi.Console.PlaybackStateHomeKit[status.playbackState]; this.playState = this.mediaState === 0; this.informationService?.setCharacteristic(Characteristic.Model, this.modelName); this.televisionService?.updateCharacteristic(Characteristic.CurrentMediaState, this.mediaState); }) .on('installedApps', async (inputs, remove) => { await this.addRemoveOrUpdateInput(inputs, remove); }) .on('stateChanged', (power) => { this.power = power; this.televisionService?.updateCharacteristic(Characteristic.Active, power); if (this.logInfo) { this.emit('info', `Power: ${power ? 'ON' : 'OFF'}`); } }) .on('success', (success) => this.emit('success', success)) .on('info', (info) => this.emit('info', info)) .on('debug', (debug) => this.emit('debug', debug)) .on('warn', (warn) => this.emit('warn', warn)) .on('error', (error) => this.emit('error', error)) .on('restFul', (path, data) => { if (this.restFulConnected) this.restFul1.update(path, data); }) .on('mqtt', (topic, message) => { if (this.mqttConnected) this.mqtt1.emit('publish', topic, message); }); // Check authorization this.consoleAuthorized = await this.xboxWebApi.checkAuthorization(); } catch (error) { this.emit('error', `Start web api error: ${error}`); } } // BŁ4 FIX: pass restFulEnabled/mqttEnabled booleans, not channelsFile string this.xboxLocalApi = new XboxLocalApi(this.device, this.authTokenFile, this.devInfoFile, restFulEnabled, mqttEnabled) .on('deviceInfo', async (info) => { this.emit('devInfo', `-------- ${this.name} --------`); this.emit('devInfo', `Manufacturer: Microsoft`); this.emit('devInfo', `Model: ${this.modelName}`); this.emit('devInfo', `Serialnr: ${this.liveId}`); this.emit('devInfo', `Firmware: ${info.firmwareRevision}`); this.emit('devInfo', `Locale: ${info.locale}`); this.emit('devInfo', `----------------------------------`); const obj = { manufacturer: 'Microsoft', modelName: this.modelName, serialNumber: this.liveId, firmwareRevision: info.firmwareRevision, locale: info.locale }; await this.functions.saveData(this.devInfoFile, obj); this.informationService ?.setCharacteristic(Characteristic.Model, this.modelName) .setCharacteristic(Characteristic.FirmwareRevision, info.firmwareRevision); }) .on('stateChanged', async (power, titleId, reference, volume, mute, playState) => { const input = this.inputsServices?.find(input => input.reference === reference || input.titleId === titleId) ?? false; const inputIdentifier = input ? input.identifier : this.inputIdentifier; // Update characteristics this.televisionService ?.updateCharacteristic(Characteristic.Active, power) .updateCharacteristic(Characteristic.ActiveIdentifier, inputIdentifier); this.volumeServiceTvSpeaker ?.updateCharacteristic(Characteristic.Active, power) .updateCharacteristic(Characteristic.Volume, volume) .updateCharacteristic(Characteristic.Mute, mute); const muteV = this.power ? !mute : false; this.volumeServiceLightbulb ?.updateCharacteristic(Characteristic.Brightness, volume) .updateCharacteristic(Characteristic.On, muteV); this.volumeServiceFan ?.updateCharacteristic(Characteristic.RotationSpeed, volume) .updateCharacteristic(Characteristic.On, muteV); // BŁ6 FIX: removed volumeServiceSpeaker update — service was never created // sensors const screenSaver = (reference === 'Xbox.IdleScreen_8wekyb3d8bbwe!Xbox.IdleScreen.Application'); // BŁ5 FIX: unified playState key to 5 in both maps (was 6 in current, 5 in previous) const currentStateModeMap = { 0: reference, 1: power, 2: volume, 3: mute, 4: screenSaver, 5: playState, }; const previousStateModeMap = { 0: this.reference, 1: this.power, 2: this.volume, 3: this.mute, 4: this.screenSaver, 5: this.playState, }; for (let i = 0; i < this.sensors.length; i++) { let state = false; const sensor = this.sensors[i]; const currentValue = currentStateModeMap[sensor.mode]; const previousValue = previousStateModeMap[sensor.mode]; const pulse = sensor.pulse; const reference = sensor.reference; const level = sensor.level; const characteristicType = sensor.characteristicType; const isActiveMode = power; if (pulse && currentValue !== previousValue) { for (let step = 0; step < 2; step++) { state = isActiveMode ? (step === 0) : false; sensor.state = state; this.sensorServices?.[i]?.updateCharacteristic(characteristicType, state); await new Promise(resolve => setTimeout(resolve, 500)); } } else { if (isActiveMode) { switch (sensor.mode) { case 0: // reference mode state = currentValue === reference; break; case 2: // volume mode state = currentValue === level; break; case 1: // power case 3: // mute case 4: // screenSaver case 5: // playState state = currentValue === true; break; default: state = false; } } sensor.state = state; this.sensorServices?.[i]?.updateCharacteristic(characteristicType, state); } } //buttons for (let i = 0; i < this.buttons.length; i++) { const button = this.buttons[i]; const state = power ? button.reference === reference : false; button.state = state; this.buttonsServices?.[i]?.updateCharacteristic(Characteristic.On, state); } this.inputIdentifier = inputIdentifier; this.power = power; this.reference = reference; this.volume = volume; this.mute = mute; this.screenSaver = screenSaver; this.playState = playState; if (this.logInfo) { const name = input ? input.name : reference; const productId = input ? input.oneStoreProductId : reference; this.emit('info', `Power: ${power ? 'ON' : 'OFF'}`); this.emit('info', `Input Name: ${name}`); this.emit('info', `Reference: ${reference}`); this.emit('info', `Title Id: ${titleId}`); this.emit('info', `Product Id: ${productId}`); this.emit('info', `Volume: ${volume}%`); this.emit('info', `Mute: ${mute ? 'ON' : 'OFF'}`); this.emit('info', `Media State: ${['PLAY', 'PAUSE', 'STOPPED', 'LOADING', 'INTERRUPTED'][this.mediaState]}`); } }) .on('success', (success) => this.emit('success', success)) .on('info', (info) => this.emit('info', info)) .on('debug', (debug) => this.emit('debug', debug)) .on('warn', (warn) => this.emit('warn', warn)) .on('error', (error) => this.emit('error', error)) .on('restFul', (path, data) => { if (this.restFulConnected) this.restFul1.update(path, data); }) .on('mqtt', async (topic, message) => { if (this.mqttConnected) await this.mqtt1.publish(topic, message); }); // Connect to local api const connect = await this.xboxLocalApi.connect(); if (!connect) return false; // Start external integrations if (restFulEnabled || mqttEnabled) await this.externalIntegrations(); //prepare data for accessory await this.prepareDataForAccessory(); // Prepare accessory const accessory = await this.prepareAccessory(); return accessory; } catch (error) { throw new Error(`Start error: ${error}`); } } } export default XboxDevice;