├── docs ├── _static │ └── .keep ├── _templates │ └── .keep ├── source │ ├── modules.rst │ ├── xbox.sg.crypto.rst │ ├── xbox.sg.enum.rst │ ├── xbox.sg.constants.rst │ ├── xbox.stump.enum.rst │ ├── xbox.sg.console.rst │ ├── xbox.stump.manager.rst │ ├── xbox.sg.packet.message.rst │ ├── xbox.sg.utils.struct.rst │ ├── xbox.auxiliary.relay.rst │ ├── xbox.sg.factory.rst │ ├── xbox.auxiliary.crypto.rst │ ├── xbox.auxiliary.manager.rst │ ├── xbox.auxiliary.packer.rst │ ├── xbox.auxiliary.packet.rst │ ├── xbox.sg.protocol.rst │ ├── xbox.sg.utils.events.rst │ ├── xbox.stump.json_model.rst │ ├── xbox.sg.packer.rst │ ├── xbox.auxiliary.scripts.fo4.rst │ ├── xbox.sg.manager.rst │ ├── xbox.sg.utils.adapters.rst │ ├── xbox.sg.packet.simple.rst │ ├── xbox.sg.utils.rst │ ├── xbox.sg.packet.rst │ ├── xbox.stump.rst │ ├── xbox.auxiliary.scripts.rst │ ├── xbox.sg.rst │ ├── xbox.sg.scripts.rst │ ├── xbox.auxiliary.rst │ ├── xbox.sg.scripts.poweron.rst │ ├── xbox.sg.scripts.pcap.rst │ ├── xbox.sg.scripts.text.rst │ ├── xbox.sg.scripts.input.rst │ ├── xbox.sg.scripts.discover.rst │ ├── xbox.sg.scripts.recrypt.rst │ ├── xbox.sg.scripts.tui.rst │ ├── xbox.sg.scripts.poweroff.rst │ └── xbox.sg.scripts.client.rst ├── Makefile ├── index.rst ├── make.bat └── conf.py ├── xbox ├── stump │ ├── __init__.py │ ├── enum.py │ └── json_model.py ├── rest │ ├── routes │ │ ├── __init__.py │ │ ├── root.py │ │ ├── web.py │ │ ├── auth.py │ │ └── device.py │ ├── __init__.py │ ├── schemas │ │ ├── general.py │ │ ├── root.py │ │ ├── __init__.py │ │ ├── auth.py │ │ └── device.py │ ├── api.py │ ├── app.py │ ├── singletons.py │ ├── common.py │ └── deps.py ├── sg │ ├── packet │ │ ├── __init__.py │ │ ├── simple.py │ │ └── message.py │ ├── utils │ │ ├── __init__.py │ │ ├── events.py │ │ └── struct.py │ ├── __init__.py │ ├── constants.py │ └── packer.py ├── handlers │ ├── __init__.py │ ├── text_input.py │ ├── fallout4_relay.py │ └── gamepad_input.py ├── auxiliary │ ├── __init__.py │ ├── packet.py │ ├── packer.py │ ├── manager.py │ ├── crypto.py │ └── relay.py └── scripts │ ├── rest_server.py │ ├── __init__.py │ ├── pcap.py │ └── recrypt.py ├── readthedocs.yml ├── assets ├── xbox_tui_log.png ├── xbox_tui_list.png ├── xbox_tui_console.png └── xbox_tui_logdetail.png ├── tests ├── data │ ├── packets │ │ ├── json │ │ ├── gamepad │ │ ├── power_off │ │ ├── acknowledge │ │ ├── disconnect │ │ ├── local_join │ │ ├── media_state │ │ ├── console_status │ │ ├── gamedvr_record │ │ ├── media_command │ │ ├── system_touch │ │ ├── title_launch │ │ ├── connect_request │ │ ├── connect_response │ │ ├── poweron_request │ │ ├── system_text_done │ │ ├── discovery_request │ │ ├── discovery_response │ │ ├── system_text_input │ │ ├── active_surface_change │ │ ├── start_channel_request │ │ ├── auxiliary_stream_hello │ │ ├── fragment_media_state_0 │ │ ├── fragment_media_state_1 │ │ ├── fragment_media_state_2 │ │ ├── start_channel_response │ │ ├── system_text_acknowledge │ │ ├── connect_request_anonymous │ │ ├── system_text_configuration │ │ ├── paired_identity_state_changed │ │ ├── auxiliary_stream_connection_info │ │ └── start_channel_request_title_channel │ ├── sg_capture.pcap │ ├── selfsigned_cert.bin │ ├── stump_json │ │ ├── response_sendkey │ │ ├── request_livetv_info │ │ ├── request_configuration │ │ ├── request_headend_info │ │ ├── request_tuner_lineups │ │ ├── response_recent_channels │ │ ├── request_appchannel_lineups │ │ ├── request_recent_channels │ │ ├── request_sendkey │ │ ├── response_livetv_info │ │ ├── response_headend_info │ │ ├── response_appchannel_lineups │ │ ├── response_tuner_lineups │ │ └── response_configuration │ ├── aux_streams │ │ ├── fo4_client_to_console │ │ └── fo4_console_to_client │ └── json_fragments.json ├── test_managers.py ├── test_pcap.py ├── test_auxmanager.py ├── test_padding.py ├── test_auxstream_packing.py ├── test_cli_scripts.py ├── test_console.py ├── test_struct.py ├── test_crypto.py ├── test_adapters.py ├── test_stump_json_models.py ├── conftest.py └── test_rest_consolewrap.py ├── MANIFEST.in ├── requirements.txt ├── setup.cfg ├── Dockerfile ├── .gitignore ├── LICENSE ├── .github └── workflows │ ├── build.yml │ └── docker.yml ├── CHANGELOG.md ├── Makefile ├── setup.py └── README.md /docs/_static/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_templates/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /xbox/stump/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /xbox/rest/routes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /xbox/sg/packet/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /xbox/sg/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /xbox/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | CLI script handlers 3 | """ 4 | -------------------------------------------------------------------------------- /xbox/auxiliary/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Auxiliary stream support for smartglass 3 | """ 4 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | build: 2 | image: latest 3 | 4 | python: 5 | version: 3.8 6 | pip_install: true 7 | -------------------------------------------------------------------------------- /assets/xbox_tui_log.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/assets/xbox_tui_log.png -------------------------------------------------------------------------------- /tests/data/packets/json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/json -------------------------------------------------------------------------------- /assets/xbox_tui_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/assets/xbox_tui_list.png -------------------------------------------------------------------------------- /assets/xbox_tui_console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/assets/xbox_tui_console.png -------------------------------------------------------------------------------- /tests/data/packets/gamepad: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/gamepad -------------------------------------------------------------------------------- /tests/data/packets/power_off: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/power_off -------------------------------------------------------------------------------- /tests/data/sg_capture.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/sg_capture.pcap -------------------------------------------------------------------------------- /assets/xbox_tui_logdetail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/assets/xbox_tui_logdetail.png -------------------------------------------------------------------------------- /tests/data/packets/acknowledge: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/acknowledge -------------------------------------------------------------------------------- /tests/data/packets/disconnect: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/disconnect -------------------------------------------------------------------------------- /tests/data/packets/local_join: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/local_join -------------------------------------------------------------------------------- /tests/data/packets/media_state: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/media_state -------------------------------------------------------------------------------- /tests/data/selfsigned_cert.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/selfsigned_cert.bin -------------------------------------------------------------------------------- /tests/data/stump_json/response_sendkey: -------------------------------------------------------------------------------- 1 | { 2 | "response": "SendKey", 3 | "msgid": "xV5X1YCB.18", 4 | "params": true 5 | } -------------------------------------------------------------------------------- /tests/test_managers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.skip(reason="Not Implemented") 5 | def test_x1(): 6 | pass 7 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | xbox 2 | ==== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | xbox.sg 8 | xbox.auxiliary 9 | xbox.stump -------------------------------------------------------------------------------- /tests/data/packets/console_status: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/console_status -------------------------------------------------------------------------------- /tests/data/packets/gamedvr_record: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/gamedvr_record -------------------------------------------------------------------------------- /tests/data/packets/media_command: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/media_command -------------------------------------------------------------------------------- /tests/data/packets/system_touch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/system_touch -------------------------------------------------------------------------------- /tests/data/packets/title_launch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/title_launch -------------------------------------------------------------------------------- /tests/data/stump_json/request_livetv_info: -------------------------------------------------------------------------------- 1 | { 2 | "request": "GetLiveTVInfo", 3 | "msgid": "xV5X1YCB.17", 4 | "params": null 5 | } -------------------------------------------------------------------------------- /tests/data/packets/connect_request: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/connect_request -------------------------------------------------------------------------------- /tests/data/packets/connect_response: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/connect_response -------------------------------------------------------------------------------- /tests/data/packets/poweron_request: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/poweron_request -------------------------------------------------------------------------------- /tests/data/packets/system_text_done: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/system_text_done -------------------------------------------------------------------------------- /tests/data/stump_json/request_configuration: -------------------------------------------------------------------------------- 1 | { 2 | "request": "GetConfiguration", 3 | "msgid": "xV5X1YCB.13", 4 | "params": null 5 | } -------------------------------------------------------------------------------- /tests/data/stump_json/request_headend_info: -------------------------------------------------------------------------------- 1 | { 2 | "request": "GetHeadendInfo", 3 | "msgid": "xV5X1YCB.14", 4 | "params": null 5 | } -------------------------------------------------------------------------------- /tests/data/stump_json/request_tuner_lineups: -------------------------------------------------------------------------------- 1 | { 2 | "request": "GetTunerLineups", 3 | "msgid": "xV5X1YCB.15", 4 | "params": null 5 | } -------------------------------------------------------------------------------- /xbox/rest/__init__.py: -------------------------------------------------------------------------------- 1 | SMARTGLASS_PACKAGENAMES = [ 2 | 'xbox-smartglass-core', 3 | 'xbox-smartglass-nano', 4 | 'xbox-webapi' 5 | ] -------------------------------------------------------------------------------- /tests/data/packets/discovery_request: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/discovery_request -------------------------------------------------------------------------------- /tests/data/packets/discovery_response: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/discovery_response -------------------------------------------------------------------------------- /tests/data/packets/system_text_input: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/system_text_input -------------------------------------------------------------------------------- /tests/data/stump_json/response_recent_channels: -------------------------------------------------------------------------------- 1 | { 2 | "response": "GetRecentChannels", 3 | "msgid": "xV5X1YCB.16", 4 | "params": [] 5 | } -------------------------------------------------------------------------------- /tests/data/packets/active_surface_change: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/active_surface_change -------------------------------------------------------------------------------- /tests/data/packets/start_channel_request: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/start_channel_request -------------------------------------------------------------------------------- /tests/data/stump_json/request_appchannel_lineups: -------------------------------------------------------------------------------- 1 | { 2 | "request": "GetAppChannelLineups", 3 | "msgid": "a8ab366d-c389-4fa3-ab76-fb540c5e9f27" 4 | } -------------------------------------------------------------------------------- /tests/data/packets/auxiliary_stream_hello: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/auxiliary_stream_hello -------------------------------------------------------------------------------- /tests/data/packets/fragment_media_state_0: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/fragment_media_state_0 -------------------------------------------------------------------------------- /tests/data/packets/fragment_media_state_1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/fragment_media_state_1 -------------------------------------------------------------------------------- /tests/data/packets/fragment_media_state_2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/fragment_media_state_2 -------------------------------------------------------------------------------- /tests/data/packets/start_channel_response: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/start_channel_response -------------------------------------------------------------------------------- /tests/data/packets/system_text_acknowledge: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/system_text_acknowledge -------------------------------------------------------------------------------- /tests/data/aux_streams/fo4_client_to_console: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/aux_streams/fo4_client_to_console -------------------------------------------------------------------------------- /tests/data/aux_streams/fo4_console_to_client: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/aux_streams/fo4_console_to_client -------------------------------------------------------------------------------- /tests/data/packets/connect_request_anonymous: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/connect_request_anonymous -------------------------------------------------------------------------------- /tests/data/packets/system_text_configuration: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/system_text_configuration -------------------------------------------------------------------------------- /tests/data/packets/paired_identity_state_changed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/paired_identity_state_changed -------------------------------------------------------------------------------- /docs/source/xbox.sg.crypto.rst: -------------------------------------------------------------------------------- 1 | Cryptography 2 | ============ 3 | 4 | .. automodule:: xbox.sg.crypto 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /tests/data/packets/auxiliary_stream_connection_info: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/auxiliary_stream_connection_info -------------------------------------------------------------------------------- /tests/data/packets/start_channel_request_title_channel: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/HEAD/tests/data/packets/start_channel_request_title_channel -------------------------------------------------------------------------------- /xbox/sg/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Meta package for xbox-smartglass-core-python. 3 | Version and author information. 4 | """ 5 | 6 | __author__ = """OpenXbox""" 7 | __version__ = '1.3.0' 8 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.enum.rst: -------------------------------------------------------------------------------- 1 | Smartglass Enumerations 2 | ======================= 3 | 4 | .. automodule:: xbox.sg.enum 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.constants.rst: -------------------------------------------------------------------------------- 1 | Smartglass Constants 2 | ==================== 3 | 4 | .. automodule:: xbox.sg.constants 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/xbox.stump.enum.rst: -------------------------------------------------------------------------------- 1 | xbox.stump.enum module 2 | ====================== 3 | 4 | .. automodule:: xbox.stump.enum 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /tests/data/stump_json/request_recent_channels: -------------------------------------------------------------------------------- 1 | { 2 | "request": "GetRecentChannels", 3 | "msgid": "xV5X1YCB.16", 4 | "params": { 5 | "startindex": 0, 6 | "count": 50 7 | } 8 | } -------------------------------------------------------------------------------- /tests/data/stump_json/request_sendkey: -------------------------------------------------------------------------------- 1 | { 2 | "request": "SendKey", 3 | "msgid": "xV5X1YCB.18", 4 | "params": { 5 | "button_id": "btn.select", 6 | "device_id": null 7 | } 8 | } -------------------------------------------------------------------------------- /docs/source/xbox.sg.console.rst: -------------------------------------------------------------------------------- 1 | Console - The API to use 2 | ======================== 3 | 4 | .. automodule:: xbox.sg.console 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/xbox.stump.manager.rst: -------------------------------------------------------------------------------- 1 | xbox.stump.manager module 2 | ========================= 3 | 4 | .. automodule:: xbox.stump.manager 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /xbox/rest/schemas/general.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional 2 | from pydantic import BaseModel 3 | 4 | class GeneralResponse(BaseModel): 5 | success: bool 6 | details: Optional[Dict[str, str]] 7 | -------------------------------------------------------------------------------- /xbox/rest/schemas/root.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional 2 | from pydantic import BaseModel 3 | 4 | class IndexResponse(BaseModel): 5 | versions: Dict[str, Optional[str]] 6 | doc_path: str 7 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.packet.message.rst: -------------------------------------------------------------------------------- 1 | Message Packets (0xD00D) 2 | ======================== 3 | 4 | .. automodule:: xbox.sg.packet.message 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.utils.struct.rst: -------------------------------------------------------------------------------- 1 | Construct struct wrappers 2 | ========================= 3 | 4 | .. automodule:: xbox.sg.utils.struct 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/xbox.auxiliary.relay.rst: -------------------------------------------------------------------------------- 1 | xbox.auxiliary.relay module 2 | =========================== 3 | 4 | .. automodule:: xbox.auxiliary.relay 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.factory.rst: -------------------------------------------------------------------------------- 1 | Packet Factory - Assemble packets 2 | ================================= 3 | 4 | .. automodule:: xbox.sg.factory 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/xbox.auxiliary.crypto.rst: -------------------------------------------------------------------------------- 1 | xbox.auxiliary.crypto module 2 | ============================ 3 | 4 | .. automodule:: xbox.auxiliary.crypto 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/xbox.auxiliary.manager.rst: -------------------------------------------------------------------------------- 1 | xbox.auxiliary.manager module 2 | ============================= 3 | 4 | .. automodule:: xbox.auxiliary.manager 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/xbox.auxiliary.packer.rst: -------------------------------------------------------------------------------- 1 | xbox.auxiliary.packer module 2 | ============================ 3 | 4 | .. automodule:: xbox.auxiliary.packer 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/xbox.auxiliary.packet.rst: -------------------------------------------------------------------------------- 1 | xbox.auxiliary.packet module 2 | ============================ 3 | 4 | .. automodule:: xbox.auxiliary.packet 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.protocol.rst: -------------------------------------------------------------------------------- 1 | CoreProtocol - The inner workings 2 | ================================= 3 | 4 | .. automodule:: xbox.sg.protocol 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.utils.events.rst: -------------------------------------------------------------------------------- 1 | Event - Gevent greenlet wrapper 2 | =============================== 3 | 4 | .. automodule:: xbox.sg.utils.events 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/xbox.stump.json_model.rst: -------------------------------------------------------------------------------- 1 | xbox.stump.json\_model module 2 | ============================= 3 | 4 | .. automodule:: xbox.stump.json_model 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.packer.rst: -------------------------------------------------------------------------------- 1 | Packer - Serialize / Deserialize packets 2 | ======================================== 3 | 4 | .. automodule:: xbox.sg.packer 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/xbox.auxiliary.scripts.fo4.rst: -------------------------------------------------------------------------------- 1 | xbox.auxiliary.scripts.fo4 module 2 | ================================= 3 | 4 | .. automodule:: xbox.auxiliary.scripts.fo4 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.manager.rst: -------------------------------------------------------------------------------- 1 | Managers - Communicate with the Service Channels 2 | ================================================ 3 | 4 | .. automodule:: xbox.sg.manager 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /xbox/auxiliary/packet.py: -------------------------------------------------------------------------------- 1 | from construct import Struct, Int16ub 2 | 3 | AUX_PACKET_MAGIC = 0xDEAD 4 | 5 | aux_header_struct = Struct( 6 | 'magic' / Int16ub, 7 | 'payload_size' / Int16ub 8 | # payload 9 | # hash 10 | ) 11 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.utils.adapters.rst: -------------------------------------------------------------------------------- 1 | Adapters - Wrappers to use with construct lib 2 | ============================================= 3 | 4 | .. automodule:: xbox.sg.utils.adapters 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.md 2 | include LICENSE 3 | include README.md 4 | 5 | recursive-include tests * 6 | recursive-exclude * __pycache__ 7 | recursive-exclude * *.py[co] 8 | 9 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 10 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.packet.simple.rst: -------------------------------------------------------------------------------- 1 | SimpleMessage Packets - Poweron, Discovery and Connecting 2 | ========================================================= 3 | 4 | .. automodule:: xbox.sg.packet.simple 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /tests/test_pcap.py: -------------------------------------------------------------------------------- 1 | from xbox.scripts import pcap 2 | 3 | 4 | def test_pcap_filter(pcap_filepath): 5 | packets = list(pcap.packet_filter(pcap_filepath)) 6 | 7 | assert len(packets) == 26 8 | 9 | 10 | def test_run_parser(pcap_filepath, crypto): 11 | pcap.parse(pcap_filepath, crypto) 12 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.utils.rst: -------------------------------------------------------------------------------- 1 | Utils 2 | ===== 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. toctree:: 8 | 9 | xbox.sg.utils.adapters 10 | xbox.sg.utils.events 11 | xbox.sg.utils.struct 12 | 13 | Module contents 14 | --------------- 15 | 16 | .. automodule:: xbox.sg.utils 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.packet.rst: -------------------------------------------------------------------------------- 1 | Smartglass Message Types 2 | ======================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. toctree:: 8 | 9 | xbox.sg.packet.message 10 | xbox.sg.packet.simple 11 | 12 | Module contents 13 | --------------- 14 | 15 | .. automodule:: xbox.sg.packet 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | -------------------------------------------------------------------------------- /docs/source/xbox.stump.rst: -------------------------------------------------------------------------------- 1 | xbox.stump package 2 | ================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. toctree:: 8 | 9 | xbox.stump.enum 10 | xbox.stump.json_model 11 | xbox.stump.manager 12 | 13 | Module contents 14 | --------------- 15 | 16 | .. automodule:: xbox.stump 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | -------------------------------------------------------------------------------- /docs/source/xbox.auxiliary.scripts.rst: -------------------------------------------------------------------------------- 1 | xbox.auxiliary.scripts package 2 | ============================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. toctree:: 8 | 9 | xbox.auxiliary.scripts.fo4 10 | 11 | Module contents 12 | --------------- 13 | 14 | .. automodule:: xbox.auxiliary.scripts 15 | :members: 16 | :undoc-members: 17 | :show-inheritance: 18 | -------------------------------------------------------------------------------- /xbox/rest/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from xbox.rest.routes import root, auth, device, web 4 | 5 | api_router = APIRouter() 6 | 7 | api_router.include_router(root.router, tags=["root"]) 8 | api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) 9 | api_router.include_router(device.router, prefix="/device", tags=["device"]) 10 | api_router.include_router(web.router, prefix="/web", tags=["web"]) -------------------------------------------------------------------------------- /xbox/rest/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from .root import IndexResponse 2 | from .general import GeneralResponse 3 | from .auth import AuthenticationStatus, AuthSessionConfig 4 | 5 | from .device import ( 6 | ConsoleStatusResponse, 7 | DeviceStatusResponse, 8 | MediaStateResponse, 9 | InfraredResponse, 10 | InfraredDevice, 11 | InfraredButton, 12 | MediaCommandsResponse, 13 | TextSessionActiveResponse, 14 | InputResponse 15 | ) -------------------------------------------------------------------------------- /docs/source/xbox.sg.rst: -------------------------------------------------------------------------------- 1 | xbox.sg package 2 | =============== 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. toctree:: 8 | 9 | xbox.sg.console 10 | xbox.sg.constants 11 | xbox.sg.crypto 12 | xbox.sg.enum 13 | xbox.sg.factory 14 | xbox.sg.manager 15 | xbox.sg.packer 16 | xbox.sg.protocol 17 | 18 | Module contents 19 | --------------- 20 | 21 | .. automodule:: xbox.sg 22 | :members: 23 | :undoc-members: 24 | :show-inheritance: 25 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.scripts.rst: -------------------------------------------------------------------------------- 1 | Smartglass Scripts 2 | ================== 3 | 4 | .. toctree:: 5 | 6 | xbox.sg.scripts.client 7 | xbox.sg.scripts.discover 8 | xbox.sg.scripts.pcap 9 | xbox.sg.scripts.poweron 10 | xbox.sg.scripts.poweroff 11 | xbox.sg.scripts.recrypt 12 | xbox.sg.scripts.input 13 | xbox.sg.scripts.text 14 | xbox.sg.scripts.tui 15 | 16 | .. automodule:: xbox.sg.scripts 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | -------------------------------------------------------------------------------- /xbox/rest/schemas/auth.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from pydantic import BaseModel 3 | from xbox.webapi.authentication.manager import AuthenticationManager 4 | 5 | from xbox.webapi.authentication.models import OAuth2TokenResponse, XSTSResponse 6 | 7 | class AuthSessionConfig(BaseModel): 8 | client_id: str 9 | client_secret: str 10 | redirect_uri: str 11 | scopes: List[str] 12 | 13 | class AuthenticationStatus(BaseModel): 14 | oauth: OAuth2TokenResponse 15 | xsts: XSTSResponse 16 | session_config: AuthSessionConfig 17 | -------------------------------------------------------------------------------- /docs/source/xbox.auxiliary.rst: -------------------------------------------------------------------------------- 1 | xbox.auxiliary package 2 | ====================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | 9 | xbox.auxiliary.scripts 10 | 11 | Submodules 12 | ---------- 13 | 14 | .. toctree:: 15 | 16 | xbox.auxiliary.crypto 17 | xbox.auxiliary.manager 18 | xbox.auxiliary.packer 19 | xbox.auxiliary.packet 20 | xbox.auxiliary.relay 21 | 22 | Module contents 23 | --------------- 24 | 25 | .. automodule:: xbox.auxiliary 26 | :members: 27 | :undoc-members: 28 | :show-inheritance: 29 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.scripts.poweron.rst: -------------------------------------------------------------------------------- 1 | Poweron Xbox Console 2 | ==================== 3 | 4 | Usage: 5 | :: 6 | 7 | usage: xbox-poweron [-h] [--address ADDRESS] liveid 8 | 9 | Power on xbox one console 10 | 11 | positional arguments: 12 | liveid Console Live ID 13 | 14 | optional arguments: 15 | -h, --help show this help message and exit 16 | --address ADDRESS, -a ADDRESS 17 | IP address of console 18 | 19 | Example: 20 | :: 21 | 22 | xbox-poweron FD001212413531523 23 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Run 2 | xbox-webapi==2.0.9 3 | construct==2.10.56 4 | cryptography==3.3.2 5 | dpkt==1.9.4 6 | pydantic==1.7.4 7 | aioconsole==0.3.0 8 | fastapi==0.65.2 9 | uvicorn==0.12.2 10 | urwid==2.1.2 11 | 12 | # Dev 13 | pip==20.2.3 14 | setuptools==50.3.0 15 | bump2version==1.0.1 16 | wheel==0.35.1 17 | watchdog==0.10.3 18 | flake8==3.8.4 19 | coverage==5.3 20 | Sphinx==3.2.1 21 | sphinx_rtd_theme==0.5.0 22 | recommonmark==0.6.0 23 | twine==3.2.0 24 | 25 | pytest==6.1.1 26 | pytest-runner==5.2 27 | pytest-asyncio==0.14.0 28 | pytest-console-scripts==1.0.0 29 | -------------------------------------------------------------------------------- /xbox/rest/app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | import uvicorn 3 | import aiohttp 4 | 5 | from . import singletons 6 | from .api import api_router 7 | 8 | app = FastAPI(title='SmartGlass REST server') 9 | 10 | 11 | @app.on_event("startup") 12 | async def startup_event(): 13 | singletons.http_session = aiohttp.ClientSession() 14 | 15 | 16 | @app.on_event("shutdown") 17 | async def shutdown_event(): 18 | await singletons.http_session.close() 19 | 20 | 21 | app.include_router(api_router) 22 | 23 | if __name__ == '__main__': 24 | uvicorn.run(app, host="0.0.0.0", port=5557) 25 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.3.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version="{current_version}" 8 | replace = version="{new_version}" 9 | 10 | [bumpversion:file:xbox/sg/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [bumpversion:file:docs/conf.py] 15 | search = release = '{current_version}' 16 | replace = release = '{new_version}' 17 | 18 | [bdist_wheel] 19 | universal = 1 20 | 21 | [flake8] 22 | exclude = docs 23 | ignore = E501 24 | 25 | [aliases] 26 | test = pytest 27 | -------------------------------------------------------------------------------- /xbox/rest/routes/root.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from .. import schemas, SMARTGLASS_PACKAGENAMES 3 | 4 | router = APIRouter() 5 | 6 | 7 | @router.get('/', response_model=schemas.IndexResponse) 8 | def get_index(): 9 | import pkg_resources 10 | 11 | versions = {} 12 | for name in SMARTGLASS_PACKAGENAMES: 13 | try: 14 | versions[name] = pkg_resources.get_distribution(name).version 15 | except Exception: 16 | versions[name] = None 17 | 18 | return schemas.IndexResponse( 19 | versions=versions, 20 | doc_path='/docs' 21 | ) 22 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.scripts.pcap.rst: -------------------------------------------------------------------------------- 1 | Smartglass PCAP Analyzer 2 | ======================== 3 | 4 | Analyze sniffed network data in form of pcap. 5 | 6 | NOTE: Scripts needs a shared secret to decrypt SmartGlass data! 7 | 8 | Usage: 9 | :: 10 | 11 | usage: xbox-pcap [-h] file secret 12 | 13 | Parse PCAP files and show SG sessions 14 | 15 | positional arguments: 16 | file Path to PCAP 17 | secret Expanded secret for this session. 18 | 19 | optional arguments: 20 | -h, --help show this help message and exit 21 | 22 | Example: 23 | :: 24 | 25 | xbox-pcap packet_dump.pcap 00112233445566778899AABBCCEEFF001122334455667788 26 | -------------------------------------------------------------------------------- /xbox/handlers/text_input.py: -------------------------------------------------------------------------------- 1 | """ 2 | Text smartglass client 3 | """ 4 | import logging 5 | import asyncio 6 | import aioconsole 7 | 8 | LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | async def userinput_callback(console, prompt): 12 | print('WAITING FOR TEXT INPUT...') 13 | text = await aioconsole.ainput(prompt) 14 | 15 | await console.send_systemtext_input(text) 16 | await console.finish_text_input() 17 | 18 | 19 | def on_text_config(payload): 20 | pass 21 | 22 | 23 | def on_text_input(console, payload): 24 | asyncio.create_task(userinput_callback(console, console.text.text_prompt)) 25 | 26 | 27 | def on_text_done(payload): 28 | pass 29 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = Xbox-Smartglass-Core 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /xbox/handlers/fallout4_relay.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fallout 4 AuxiliaryStream Relay client 3 | 4 | Tunnels packets via TCP/27000 - compatible with regular PipBoy-clients 5 | """ 6 | import logging 7 | import asyncio 8 | from xbox.auxiliary.relay import AuxiliaryRelayService 9 | from xbox.sg.utils.struct import XStructObj 10 | 11 | LOGGER = logging.getLogger(__name__) 12 | 13 | FALLOUT_TITLE_ID = 0x4ae8f9b2 14 | LOCAL_PIPBOY_PORT = 27000 15 | 16 | 17 | def on_connection_info(info: XStructObj): 18 | loop = asyncio.get_running_loop() 19 | print('Setting up relay on TCP/{0}...\n'.format(LOCAL_PIPBOY_PORT)) 20 | service = AuxiliaryRelayService(loop, info, listen_port=LOCAL_PIPBOY_PORT) 21 | service.run() 22 | -------------------------------------------------------------------------------- /xbox/rest/singletons.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | from typing import Dict, Optional 3 | 4 | from .schemas.auth import AuthSessionConfig 5 | 6 | from .consolewrap import ConsoleWrap 7 | 8 | from xbox.webapi.api.client import XboxLiveClient 9 | from xbox.webapi.api.provider.titlehub import TitleHubResponse 10 | from xbox.webapi.authentication.manager import AuthenticationManager 11 | 12 | http_session: Optional[aiohttp.ClientSession] = None 13 | authentication_manager: Optional[AuthenticationManager] = None 14 | xbl_client: Optional[XboxLiveClient] = None 15 | 16 | auth_session_configs: Dict[str, AuthSessionConfig] = dict() 17 | 18 | console_cache: Dict[str, ConsoleWrap] = dict() 19 | title_cache: Dict[str, TitleHubResponse] = dict() 20 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.scripts.text.rst: -------------------------------------------------------------------------------- 1 | Basic text input client 2 | ======================= 3 | 4 | Send text input to console when a textfield is opened. 5 | 6 | Usage: 7 | :: 8 | 9 | usage: xbox-text [-h] [--tokens TOKENS] [--address ADDRESS] [--refresh] 10 | 11 | Basic smartglass client 12 | 13 | optional arguments: 14 | -h, --help show this help message and exit 15 | --tokens TOKENS, -t TOKENS 16 | Token file, created by xbox-authenticate script 17 | --address ADDRESS, -a ADDRESS 18 | IP address of console 19 | --refresh, -r Refresh xbox live tokens in provided token file 20 | 21 | Example: 22 | :: 23 | 24 | xbox-text 25 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.scripts.input.rst: -------------------------------------------------------------------------------- 1 | Basic gamepad input client 2 | ========================== 3 | 4 | Navigate through the dashboard via client's keyboard. 5 | 6 | Usage: 7 | :: 8 | 9 | usage: xbox-input [-h] [--tokens TOKENS] [--address ADDRESS] [--refresh] 10 | 11 | Basic smartglass client 12 | 13 | optional arguments: 14 | -h, --help show this help message and exit 15 | --tokens TOKENS, -t TOKENS 16 | Token file, created by xbox-authenticate script 17 | --address ADDRESS, -a ADDRESS 18 | IP address of console 19 | --refresh, -r Refresh xbox live tokens in provided token file 20 | 21 | Example: 22 | :: 23 | 24 | xbox-input 25 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.scripts.discover.rst: -------------------------------------------------------------------------------- 1 | Discover Xbox consoles on network 2 | ================================= 3 | 4 | Discover consoles on your local network. 5 | 6 | Usage: 7 | :: 8 | 9 | usage: xbox-discover [-h] [--address ADDRESS] 10 | 11 | Discover consoles on the network 12 | 13 | optional arguments: 14 | -h, --help show this help message and exit 15 | --address ADDRESS, -a ADDRESS 16 | IP address of console 17 | 18 | Example: 19 | :: 20 | 21 | xbox-discover 22 | 23 | Output: 24 | :: 25 | 26 | 27 | -------------------------------------------------------------------------------- /tests/data/stump_json/response_livetv_info: -------------------------------------------------------------------------------- 1 | { 2 | "response": "GetLiveTVInfo", 3 | "msgid": "xV5X1YCB.17", 4 | "params": { 5 | "inHdmiMode": "0", 6 | "streamingPort": "10242", 7 | "pauseBufferInfo": { 8 | "Enabled": "1", 9 | "MaxBufferSize": "18000000000", 10 | "BufferStart": "131688132168080320", 11 | "BufferEnd": "131688151636700238", 12 | "BufferCurrent": "131688132168080320", 13 | "Epoch": "0", 14 | "CurrentTime": "131688151636836518", 15 | "IsDvr": false 16 | }, 17 | "currentHdmiChannelId": "731cd976-c1e9-6b95-4799-e6757d02cab1_3SATHD_1", 18 | "currentTunerChannelId": "bb1ca492-232b-adfe-1f39-d010eabf179e_MSAHD_16", 19 | "tunerChannelType": "televisionChannel" 20 | } 21 | } -------------------------------------------------------------------------------- /docs/source/xbox.sg.scripts.recrypt.rst: -------------------------------------------------------------------------------- 1 | Recrypt binary smartglass data 2 | ============================== 3 | 4 | Recrypt smartglass packet binaries (useful for test data). 5 | 6 | Usage: 7 | :: 8 | 9 | usage: xbox-recrypt [-h] src_path src_secret dst_path dst_secret 10 | 11 | Re-Encrypt raw smartglass packets from a given filepath 12 | 13 | positional arguments: 14 | src_path Path to sourcefiles 15 | src_secret Source shared secret in hex-format 16 | dst_path Path to destination 17 | dst_secret Target shared secret in hex-format 18 | 19 | optional arguments: 20 | -h, --help show this help message and exit 21 | 22 | Example: 23 | :: 24 | 25 | xbox-recrypt dir_with_src_blobs/ 0011223344..FF dest_dir_recrypted/ FF00FF00FF00FF..FF 26 | -------------------------------------------------------------------------------- /xbox/scripts/rest_server.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import uvicorn 3 | 4 | REST_DEFAULT_SERVER_PORT=5557 5 | 6 | def main(): 7 | parser = argparse.ArgumentParser(description='Xbox REST server') 8 | parser.add_argument( 9 | '--host', '-b', default='127.0.0.1', 10 | help='Interface address to bind the server') 11 | parser.add_argument( 12 | '--port', '-p', type=int, default=REST_DEFAULT_SERVER_PORT, 13 | help=f'Port to bind to, default: {REST_DEFAULT_SERVER_PORT}') 14 | parser.add_argument( 15 | '--reload', '-r', action='store_true', 16 | help='Auto-reload server on filechanges (DEVELOPMENT)') 17 | args = parser.parse_args() 18 | 19 | uvicorn.run('xbox.rest.app:app', host=args.host, port=args.port, reload=args.reload) 20 | 21 | if __name__ == '__main__': 22 | main() -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Xbox-Smartglass-Core documentation master file, created by 2 | sphinx-quickstart on Fri Mar 9 14:30:41 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Xbox-Smartglass-Core's documentation! 7 | ================================================ 8 | 9 | .. mdinclude:: ../README.rst 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | :caption: Contents: 14 | 15 | source/xbox.sg.console 16 | source/xbox.sg.manager 17 | source/xbox.sg.protocol 18 | source/xbox.sg.crypto 19 | source/xbox.sg.factory 20 | source/xbox.scripts 21 | source/xbox.auxiliary 22 | source/xbox.stump 23 | 24 | Indices and tables 25 | ================== 26 | 27 | * :ref:`genindex` 28 | * :ref:`modindex` 29 | * :ref:`search` 30 | -------------------------------------------------------------------------------- /docs/source/xbox.sg.scripts.tui.rst: -------------------------------------------------------------------------------- 1 | Basic text user interface 2 | ========================= 3 | 4 | Text user interface, based on curses / urwid. 5 | Allows discovery, power-on, power-off, connecting and disconnecting of console. 6 | 7 | It also supports entering Text when console requests it from client. 8 | Gamepad input can be sent via keyboard. 9 | Titles can be launched via URI. 10 | 11 | Usage: 12 | :: 13 | 14 | usage: xbox-tui [-h] [--tokens TOKENS] [--consoles CONSOLES] 15 | 16 | Basic text user interface 17 | 18 | optional arguments: 19 | -h, --help show this help message and exit 20 | --tokens TOKENS, -t TOKENS 21 | Token file, created by xbox-authenticate script 22 | --consoles CONSOLES, -c CONSOLES 23 | Previously discovered consoles 24 | 25 | Example: 26 | :: 27 | 28 | xbox-tui 29 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=Xbox-Smartglass-Core 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /tests/data/stump_json/response_headend_info: -------------------------------------------------------------------------------- 1 | { 2 | "response": "GetHeadendInfo", 3 | "msgid": "xV5X1YCB.14", 4 | "params": { 5 | "headendId": "516b9ea7-5292-97ec-e7d4-f843fab6d392", 6 | "providerName": "Sky Deutschland", 7 | "streamingPort": "10242", 8 | "headendLocale": "de-DE", 9 | "preferredProvider": "29045393", 10 | "providers": [ 11 | { 12 | "headendId": "516B9EA7-5292-97EC-E7D4-F843FAB6D392", 13 | "providerName": "Sky Deutschland", 14 | "source": "hdmi", 15 | "titleId": "162615AD", 16 | "filterPreference": "ALL", 17 | "canStream": "false" 18 | }, 19 | { 20 | "headendId": "0A7FB88A-960B-C2E3-9975-7C86C5FA6C49", 21 | "providerName": "Freenet", 22 | "source": "tuner", 23 | "titleId": "162615AD", 24 | "filterPreference": "ALL", 25 | "canStream": "true" 26 | } 27 | ], 28 | "blockExplicitContentPerShow": false, 29 | "dvrEnabled": false 30 | } 31 | } -------------------------------------------------------------------------------- /xbox/sg/utils/events.py: -------------------------------------------------------------------------------- 1 | """ 2 | Wrapper around asyncio's tasks 3 | """ 4 | import asyncio 5 | 6 | 7 | class Event(object): 8 | def __init__(self, asynchronous: bool = False): 9 | self.handlers = [] 10 | self.asynchronous = asynchronous 11 | 12 | def add(self, handler): 13 | if not callable(handler): 14 | raise TypeError("Handler should be callable") 15 | self.handlers.append(handler) 16 | 17 | def remove(self, handler): 18 | if handler in self.handlers: 19 | self.handlers.remove(handler) 20 | 21 | def __iadd__(self, handler): 22 | self.add(handler) 23 | return self 24 | 25 | def __isub__(self, handler): 26 | self.remove(handler) 27 | return self 28 | 29 | def __call__(self, *args, **kwargs): 30 | for handler in self.handlers: 31 | if self.asynchronous: 32 | asyncio.create_task(handler(*args, **kwargs)) 33 | else: 34 | handler(*args, **kwargs) 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Based on https://softwarejourneyman.com/docker-python-install-wheels.html 2 | 3 | ######################################### 4 | # Image WITH C compiler, building wheels for next stage 5 | FROM python:3.8-alpine as bigimage 6 | 7 | ENV LANG C.UTF-8 8 | 9 | # Copy project files 10 | COPY . /src/smartglass-core 11 | 12 | # install the C compiler 13 | RUN apk add --no-cache jq gcc musl-dev libffi-dev openssl-dev 14 | 15 | # instead of installing, create a wheel 16 | RUN pip wheel --wheel-dir=/root/wheels /src/smartglass-core 17 | 18 | ######################################### 19 | # Image WITHOUT C compiler, installing the component from wheel 20 | FROM python:3.8-alpine as smallimage 21 | 22 | RUN apk add --no-cache openssl 23 | 24 | COPY --from=bigimage /root/wheels /root/wheels 25 | 26 | # Ignore the Python package index 27 | # and look for archives in 28 | # /root/wheels directory 29 | RUN pip install \ 30 | --no-index \ 31 | --find-links=/root/wheels \ 32 | xbox-smartglass-core 33 | 34 | CMD [ "xbox-rest-server" ] 35 | -------------------------------------------------------------------------------- /tests/test_auxmanager.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from xbox.sg.enum import ServiceChannel 4 | from xbox.auxiliary.manager import TitleManager, TitleManagerError 5 | 6 | 7 | def test_manager_messagehandling(console, decrypted_packets): 8 | manager = TitleManager(console) 9 | 10 | def handle_msg(msg): 11 | manager._pre_on_message(msg, ServiceChannel.Title) 12 | 13 | assert manager.active_surface is None 14 | assert manager.connection_info is None 15 | 16 | # Send unpacked msgs to manager 17 | handle_msg(decrypted_packets['active_surface_change']) 18 | handle_msg(decrypted_packets['auxiliary_stream_connection_info']) 19 | 20 | invalid_msg = decrypted_packets['auxiliary_stream_hello'] 21 | invalid_msg.header.flags(msg_type=0x3) 22 | with pytest.raises(TitleManagerError): 23 | handle_msg(invalid_msg) 24 | 25 | assert manager.active_surface == decrypted_packets['active_surface_change'].protected_payload 26 | assert manager.connection_info == decrypted_packets['auxiliary_stream_connection_info'].protected_payload.connection_info 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | .env/ 12 | venv/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | docs/source/*.rst 59 | 60 | # PyBuilder 61 | target/ 62 | 63 | # Intellij 64 | .idea/ 65 | 66 | # Node 67 | node_modules 68 | 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 OpenXbox 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /xbox/rest/common.py: -------------------------------------------------------------------------------- 1 | 2 | import aiohttp 3 | from xbox.webapi.authentication.manager import AuthenticationManager 4 | 5 | from .schemas.auth import AuthenticationStatus, AuthSessionConfig 6 | 7 | def generate_authentication_status( 8 | manager: AuthenticationManager 9 | ) -> AuthenticationStatus: 10 | return AuthenticationStatus( 11 | oauth=manager.oauth, 12 | xsts=manager.xsts_token, 13 | session_config=AuthSessionConfig( 14 | client_id=manager._client_id, 15 | client_secret=manager._client_secret, 16 | redirect_uri=manager._redirect_uri, 17 | scopes=manager._scopes 18 | ) 19 | ) 20 | 21 | def generate_authentication_manager( 22 | session_config: AuthSessionConfig, 23 | http_session: aiohttp.ClientSession = None 24 | ) -> AuthenticationManager: 25 | return AuthenticationManager( 26 | client_session=http_session, 27 | client_id=session_config.client_id, 28 | client_secret=session_config.client_secret, 29 | redirect_uri=session_config.redirect_uri, 30 | scopes=session_config.scopes 31 | ) -------------------------------------------------------------------------------- /docs/source/xbox.sg.scripts.poweroff.rst: -------------------------------------------------------------------------------- 1 | Power off console 2 | ================= 3 | 4 | Power off either a specific, active console or every console that can be found 5 | 6 | Usage: 7 | :: 8 | 9 | usage: xbox-poweroff [-h] [--tokens TOKENS] [--liveid LIVEID] 10 | [--address ADDRESS] [--all] [--refresh] 11 | 12 | Power off xbox one console 13 | 14 | optional arguments: 15 | -h, --help show this help message and exit 16 | --tokens TOKENS, -t TOKENS 17 | Token file, created by xbox-authenticate script 18 | --liveid LIVEID, -l LIVEID 19 | Console Live ID 20 | --address ADDRESS, -a ADDRESS 21 | IP address of console 22 | --all Power off all consoles 23 | --refresh, -r Refresh xbox live tokens in provided token file 24 | 25 | Example: 26 | :: 27 | 28 | # By Live ID 29 | xbox-poweroff --liveid FD00231241353532 30 | 31 | # By IP Address 32 | xbox-poweroff --address 10.0.0.241 33 | 34 | # Every console that can be found 35 | xbox-poweroff --all 36 | -------------------------------------------------------------------------------- /tests/data/stump_json/response_appchannel_lineups: -------------------------------------------------------------------------------- 1 | { 2 | "response": "GetAppChannelLineups", 3 | "msgid": "a8ab366d-c389-4fa3-ab76-fb540c5e9f27", 4 | "params": [ 5 | { 6 | "id": "LiveTvHdmiProvider", 7 | "providerName": "OneGuide", 8 | "primaryColor": "ff107c10", 9 | "secondaryColor": "ffebebeb", 10 | "titleId": "00000000", 11 | "channels": [] 12 | }, 13 | { 14 | "id": "LiveTvPlaylistProvider", 15 | "providerName": "OneGuide", 16 | "primaryColor": "ff107c10", 17 | "secondaryColor": "ffebebeb", 18 | "titleId": "00000000", 19 | "channels": [] 20 | }, 21 | { 22 | "id": "LiveTvUsbProvider", 23 | "providerName": "OneGuide", 24 | "primaryColor": "ff107c10", 25 | "secondaryColor": "ffebebeb", 26 | "titleId": "00000000", 27 | "channels": [] 28 | }, 29 | { 30 | "id": "TedProvider", 31 | "providerName": "TED", 32 | "providerImageUrl": "http://assets.tedcdn.com/images/xbox/TED_280x64.png", 33 | "primaryColor": "ffebebeb", 34 | "secondaryColor": "ff000000", 35 | "titleId": "5964CC45", 36 | "channels": [] 37 | } 38 | ] 39 | } -------------------------------------------------------------------------------- /xbox/scripts/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from enum import IntEnum 5 | from appdirs import user_data_dir 6 | 7 | DATA_DIR = user_data_dir('xbox', 'OpenXbox') 8 | TOKENS_FILE = os.path.join(DATA_DIR, 'tokens.json') 9 | CONSOLES_FILE = os.path.join(DATA_DIR, 'consoles.json') 10 | 11 | if not os.path.exists(DATA_DIR): 12 | os.makedirs(DATA_DIR) 13 | 14 | LOG_FMT = '[%(asctime)s] %(levelname)s: %(message)s' 15 | LOG_LEVEL_DEBUG_INCL_PACKETS = logging.DEBUG - 1 16 | logging.addLevelName(LOG_LEVEL_DEBUG_INCL_PACKETS, 'DEBUG_INCL_PACKETS') 17 | 18 | 19 | class ExitCodes(IntEnum): 20 | """ 21 | Common CLI exit codes 22 | """ 23 | OK = 0 24 | ArgParsingError = 1 25 | AuthenticationError = 2 26 | DiscoveryError = 3 27 | ConsoleChoice = 4 28 | 29 | 30 | class VerboseFormatter(logging.Formatter): 31 | def __init__(self, *args, **kwargs): 32 | super(VerboseFormatter, self).__init__(*args, **kwargs) 33 | self._verbosefmt = self._fmt + '\n%(_msg)s' 34 | 35 | def formatMessage(self, record): 36 | if '_msg' in record.__dict__: 37 | return self._verbosefmt % record.__dict__ 38 | return self._style.format(record) 39 | -------------------------------------------------------------------------------- /tests/test_padding.py: -------------------------------------------------------------------------------- 1 | from xbox.sg.crypto import Padding, PKCS7Padding, ANSIX923Padding 2 | 3 | 4 | def test_calculate_padding(): 5 | align_16 = Padding.size(12, alignment=16) 6 | align_12 = Padding.size(12, alignment=12) 7 | align_10 = Padding.size(12, alignment=10) 8 | 9 | assert align_16 == 4 10 | assert align_12 == 0 11 | assert align_10 == 8 12 | 13 | 14 | def test_remove_padding(): 15 | payload = 8 * b'\x88' + b'\x00\x00\x00\x04' 16 | unpadded = Padding.remove(payload) 17 | 18 | assert len(unpadded) == 8 19 | assert unpadded == 8 * b'\x88' 20 | 21 | 22 | def test_x923_add_padding(): 23 | payload = 7 * b'\x69' 24 | padded_12 = ANSIX923Padding.pad(payload, alignment=12) 25 | padded_7 = ANSIX923Padding.pad(payload, alignment=7) 26 | padded_3 = ANSIX923Padding.pad(payload, alignment=3) 27 | 28 | assert len(padded_12) == 12 29 | assert len(padded_7) == 7 30 | assert len(padded_3) == 9 31 | assert padded_12 == payload + b'\x00\x00\x00\x00\x05' 32 | assert padded_7 == payload 33 | assert padded_3 == payload + b'\x00\x02' 34 | 35 | 36 | def test_pkcs7_add_padding(): 37 | payload = 7 * b'\x69' 38 | padded_12 = PKCS7Padding.pad(payload, alignment=12) 39 | padded_7 = PKCS7Padding.pad(payload, alignment=7) 40 | padded_3 = PKCS7Padding.pad(payload, alignment=3) 41 | 42 | assert len(padded_12) == 12 43 | assert len(padded_7) == 7 44 | assert len(padded_3) == 9 45 | assert padded_12 == payload + b'\x05\x05\x05\x05\x05' 46 | assert padded_7 == payload 47 | assert padded_3 == payload + b'\x02\x02' 48 | -------------------------------------------------------------------------------- /xbox/rest/deps.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, Optional 2 | import aiohttp 3 | from fastapi import Query, Header, HTTPException, status 4 | from xbox.webapi.api.language import DefaultXboxLiveLanguages, XboxLiveLanguage 5 | 6 | from . import singletons 7 | from .common import generate_authentication_status 8 | from .schemas.auth import AuthenticationStatus 9 | 10 | from xbox.webapi.api.client import XboxLiveClient 11 | 12 | 13 | def console_connected(liveid: str): 14 | console = singletons.console_cache.get(liveid) 15 | if not console: 16 | raise HTTPException(status_code=400, detail=f'Console {liveid} is not alive') 17 | elif not console.connected: 18 | raise HTTPException(status_code=400, detail=f'Console {liveid} is not connected') 19 | return console 20 | 21 | 22 | def console_exists(liveid: str): 23 | console = singletons.console_cache.get(liveid) 24 | if not console: 25 | raise HTTPException(status_code=400, detail=f'Console info for {liveid} is not available') 26 | 27 | return console 28 | 29 | 30 | async def get_xbl_client() -> Optional[XboxLiveClient]: 31 | return singletons.xbl_client 32 | 33 | 34 | def get_authorization( 35 | anonymous: Optional[bool] = Query(default=True) 36 | ) -> Optional[AuthenticationStatus]: 37 | if anonymous: 38 | return None 39 | elif not singletons.authentication_manager: 40 | raise HTTPException( 41 | status_code=status.HTTP_404_NOT_FOUND, 42 | detail='Authorization data not available' 43 | ) 44 | return generate_authentication_status(singletons.authentication_manager) 45 | -------------------------------------------------------------------------------- /tests/test_auxstream_packing.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | from xbox.sg.crypto import PKCS7Padding 4 | from xbox.auxiliary import packer 5 | from xbox.auxiliary import packet 6 | 7 | 8 | def _read_aux_packets(data): 9 | with io.BytesIO(data) as stream: 10 | while stream.tell() < len(data): 11 | header_data = stream.read(4) 12 | header = packet.aux_header_struct.parse(header_data) 13 | 14 | if header.magic != packet.AUX_PACKET_MAGIC: 15 | raise Exception('Invalid packet magic received from console') 16 | 17 | padded_payload_sz = header.payload_size + PKCS7Padding.size(header.payload_size, 16) 18 | 19 | payload_data = stream.read(padded_payload_sz) 20 | hmac = stream.read(32) 21 | yield header_data + payload_data + hmac 22 | 23 | 24 | def test_client_unpack(aux_streams, aux_crypto): 25 | data = aux_streams['fo4_client_to_console'] 26 | for msg in _read_aux_packets(data): 27 | packer.unpack(msg, aux_crypto, client_data=True) 28 | 29 | 30 | def test_server_unpack(aux_streams, aux_crypto): 31 | data = aux_streams['fo4_console_to_client'] 32 | for msg in _read_aux_packets(data): 33 | packer.unpack(msg, aux_crypto) 34 | 35 | 36 | def test_decryption(aux_streams, aux_crypto): 37 | data = aux_streams['fo4_console_to_client'] 38 | messages = list(_read_aux_packets(data)) 39 | # Need to unpack messages in order, starting with the first one 40 | # -> Gets IV from previous decryption 41 | packer.unpack(messages[0], aux_crypto) 42 | json_msg = packer.unpack(messages[1], aux_crypto) 43 | assert json_msg == b'{"lang":"de","version":"1.10.52.0"}\n' 44 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: ['push', 'pull_request'] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: [3.7, 3.8, 3.9] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install -e .[dev] 23 | python setup.py develop 24 | if [ -f requirements.txt ]; then pip install -U -r requirements.txt; fi 25 | - name: Lint with flake8 26 | run: | 27 | # stop the build if there are Python syntax errors or undefined names 28 | flake8 xbox --count --select=E9,F63,F7,F82 --show-source --statistics 29 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 30 | flake8 xbox --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 31 | - name: Test with pytest 32 | run: | 33 | pytest 34 | 35 | deploy: 36 | runs-on: ubuntu-latest 37 | needs: build 38 | steps: 39 | - uses: actions/checkout@v2 40 | - name: Set up Python 41 | uses: actions/setup-python@v2 42 | with: 43 | python-version: '3.8' 44 | - name: Install dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | pip install setuptools wheel twine 48 | - name: Build and publish 49 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 50 | env: 51 | TWINE_USERNAME: __token__ 52 | TWINE_PASSWORD: ${{ secrets.PYPI_API_KEY }} 53 | run: | 54 | python setup.py sdist bdist_wheel 55 | twine upload dist/* 56 | -------------------------------------------------------------------------------- /xbox/rest/schemas/device.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Optional 2 | from pydantic import BaseModel 3 | 4 | class DeviceStatusResponse(BaseModel): 5 | liveid: str 6 | ip_address: str 7 | connection_state: str 8 | pairing_state: str 9 | device_status: str 10 | last_error: int 11 | authenticated_users_allowed: bool 12 | console_users_allowed: bool 13 | anonymous_connection_allowed: bool 14 | is_certificate_pending: bool 15 | 16 | class ActiveTitle(BaseModel): 17 | title_id: str 18 | aum: str 19 | name: str 20 | image: Optional[str] 21 | type: Optional[str] 22 | has_focus: bool 23 | title_location: str 24 | product_id: str 25 | sandbox_id: str 26 | 27 | class ConsoleStatusResponse(BaseModel): 28 | live_tv_provider: str 29 | kernel_version: str 30 | locale: str 31 | active_titles: Optional[List[ActiveTitle]] 32 | 33 | class MediaStateResponse(BaseModel): 34 | title_id: str 35 | aum_id: str 36 | asset_id: str 37 | media_type: str 38 | sound_level: str 39 | enabled_commands: str 40 | playback_status: str 41 | rate: str 42 | position: str 43 | media_start: int 44 | media_end: int 45 | min_seek: int 46 | max_seek: int 47 | metadata: Optional[Dict[str, str]] 48 | 49 | class TextSessionActiveResponse(BaseModel): 50 | text_session_active: bool 51 | 52 | class InfraredButton(BaseModel): 53 | url: str 54 | value: str 55 | 56 | class InfraredDevice(BaseModel): 57 | device_type: str 58 | device_brand: Optional[str] 59 | device_model: Optional[str] 60 | device_name: Optional[str] 61 | device_id: str 62 | buttons: Dict[str, InfraredButton] 63 | 64 | class InfraredResponse(BaseModel): 65 | __root__: Dict[str, InfraredDevice] 66 | 67 | class MediaCommandsResponse(BaseModel): 68 | commands: List[str] 69 | 70 | class InputResponse(BaseModel): 71 | buttons: List[str] 72 | -------------------------------------------------------------------------------- /tests/test_cli_scripts.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.script_launch_mode('subprocess') 5 | def test_cli_rest_server(script_runner): 6 | """ 7 | Needs to be done in subprocess due to monkey-patching 8 | """ 9 | ret = script_runner.run('xbox-rest-server', '--help') 10 | assert ret.success 11 | 12 | 13 | def test_cli_maincli(script_runner): 14 | ret = script_runner.run('xbox-cli', '--help') 15 | assert ret.success 16 | 17 | 18 | def test_cli_discover(script_runner): 19 | ret = script_runner.run('xbox-discover', '--help') 20 | assert ret.success 21 | 22 | 23 | def test_cli_poweron(script_runner): 24 | ret = script_runner.run('xbox-poweron', '--help') 25 | assert ret.success 26 | 27 | 28 | def test_cli_poweroff(script_runner): 29 | ret = script_runner.run('xbox-poweroff', '--help') 30 | assert ret.success 31 | 32 | 33 | def test_cli_repl(script_runner): 34 | ret = script_runner.run('xbox-repl', '--help') 35 | assert ret.success 36 | 37 | 38 | def test_cli_replserver(script_runner): 39 | ret = script_runner.run('xbox-replserver', '--help') 40 | assert ret.success 41 | 42 | 43 | def test_cli_textinput(script_runner): 44 | ret = script_runner.run('xbox-textinput', '--help') 45 | assert ret.success 46 | 47 | 48 | def test_cli_gamepadinput(script_runner): 49 | ret = script_runner.run('xbox-gamepadinput', '--help') 50 | assert ret.success 51 | 52 | 53 | def test_cli_tui(script_runner): 54 | ret = script_runner.run('xbox-tui', '--help') 55 | assert ret.success 56 | 57 | 58 | def test_cli_falloutrelay(script_runner): 59 | ret = script_runner.run('xbox-fo4-relay', '--help') 60 | assert ret.success 61 | 62 | 63 | def test_cli_pcap(script_runner): 64 | ret = script_runner.run('xbox-pcap', '--help') 65 | assert ret.success 66 | 67 | 68 | def test_cli_recrypt(script_runner): 69 | ret = script_runner.run('xbox-recrypt', '--help') 70 | assert ret.success 71 | -------------------------------------------------------------------------------- /tests/test_console.py: -------------------------------------------------------------------------------- 1 | from xbox.sg import console 2 | from xbox.sg import enum 3 | from xbox.sg import packer 4 | 5 | 6 | def test_init(public_key, uuid_dummy): 7 | c = console.Console( 8 | '10.0.0.23', 'XboxOne', uuid_dummy, 'FFFFFFFFFFF', 9 | enum.PrimaryDeviceFlag.AllowConsoleUsers, 0, public_key 10 | ) 11 | 12 | assert c.address == '10.0.0.23' 13 | assert c.flags == enum.PrimaryDeviceFlag.AllowConsoleUsers 14 | assert c.name == 'XboxOne' 15 | assert c.uuid == uuid_dummy 16 | assert c.liveid == 'FFFFFFFFFFF' 17 | assert c._public_key is not None 18 | assert c._crypto is not None 19 | assert c.device_status == enum.DeviceStatus.Unavailable 20 | assert c.connection_state == enum.ConnectionState.Disconnected 21 | assert c.pairing_state == enum.PairedIdentityState.NotPaired 22 | assert c.paired is False 23 | assert c.available is False 24 | assert c.connected is False 25 | 26 | assert c.authenticated_users_allowed is False 27 | assert c.anonymous_connection_allowed is False 28 | assert c.console_users_allowed is True 29 | assert c.is_certificate_pending is False 30 | 31 | 32 | def test_init_from_message(packets, crypto, uuid_dummy): 33 | msg = packer.unpack(packets['discovery_response'], crypto) 34 | 35 | c = console.Console.from_message('10.0.0.23', msg) 36 | 37 | assert c.address == '10.0.0.23' 38 | assert c.flags == enum.PrimaryDeviceFlag.AllowAuthenticatedUsers 39 | assert c.name == 'XboxOne' 40 | assert c.uuid == uuid_dummy 41 | assert c.liveid == 'FFFFFFFFFFF' 42 | assert c._public_key is not None 43 | assert c._crypto is not None 44 | assert c.device_status == enum.DeviceStatus.Available 45 | assert c.connection_state == enum.ConnectionState.Disconnected 46 | assert c.pairing_state == enum.PairedIdentityState.NotPaired 47 | assert c.paired is False 48 | assert c.available is True 49 | assert c.connected is False 50 | 51 | assert c.authenticated_users_allowed is True 52 | assert c.anonymous_connection_allowed is False 53 | assert c.console_users_allowed is False 54 | assert c.is_certificate_pending is False 55 | -------------------------------------------------------------------------------- /xbox/stump/enum.py: -------------------------------------------------------------------------------- 1 | """ 2 | Stump enumerations 3 | """ 4 | 5 | from uuid import UUID 6 | from enum import Enum 7 | 8 | 9 | class Message(str, Enum): 10 | """ 11 | Message types 12 | """ 13 | ERROR = "Error" 14 | ENSURE_STREAMING_STARTED = "EnsureStreamingStarted" 15 | CONFIGURATION = "GetConfiguration" 16 | HEADEND_INFO = "GetHeadendInfo" 17 | LIVETV_INFO = "GetLiveTVInfo" 18 | PROGRAMM_INFO = "GetProgrammInfo" 19 | RECENT_CHANNELS = "GetRecentChannels" 20 | TUNER_LINEUPS = "GetTunerLineups" 21 | APPCHANNEL_DATA = "GetAppChannelData" 22 | APPCHANNEL_LINEUPS = "GetAppChannelLineups" 23 | APPCHANNEL_PROGRAM_DATA = "GetAppChannelProgramData" 24 | SEND_KEY = "SendKey" 25 | SET_CHANNEL = "SetChannel" 26 | 27 | 28 | class Notification(str, Enum): 29 | """ 30 | Notification types 31 | """ 32 | STREAMING_ERROR = "StreamingError" 33 | CHANNEL_CHANGED = "ChannelChanged" 34 | CHANNELTYPE_CHANGED = "ChannelTypeChanged" 35 | CONFIGURATION_CHANGED = "ConfigurationChanged" 36 | DEVICE_UI_CHANGED = "DeviceUIChanged" 37 | HEADEND_CHANGED = "HeadendChanged" 38 | VIDEOFORMAT_CHANGED = "VideoFormatChanged" 39 | PROGRAM_CHANGED = "ProgrammChanged" 40 | TUNERSTATE_CHANGED = "TunerStateChanged" 41 | 42 | 43 | class Source(str, Enum): 44 | """ 45 | Streamingsources 46 | """ 47 | HDMI = "hdmi" 48 | TUNER = "tuner" 49 | 50 | 51 | class DeviceType(str, Enum): 52 | """ 53 | Devicetypes 54 | """ 55 | TV = "tv" 56 | TUNER = "tuner" 57 | SET_TOP_BOX = "stb" 58 | AV_RECEIVER = "avr" 59 | 60 | 61 | class SourceHttpQuery(str, Enum): 62 | """ 63 | Source strings used in HTTP query 64 | """ 65 | HDMI = "hdmi-in" 66 | TUNER = "zurich" 67 | 68 | 69 | class Input(object): 70 | HDMI = UUID("BA5EBA11-DEA1-4BAD-BA11-FEDDEADFAB1E") 71 | 72 | 73 | class Quality(str, Enum): 74 | """ 75 | Quality values 76 | """ 77 | LOW = "low" 78 | MEDIUM = "medium" 79 | HIGH = "high" 80 | BEST = "best" 81 | 82 | 83 | class FilterType(str, Enum): 84 | """ 85 | Channel-filter types 86 | """ 87 | ALL = "ALL" # No filter / Show all 88 | HDSD = "HDSD" # Dont show double SD-channels 89 | HD = "HD" # Only show HD Channels 90 | -------------------------------------------------------------------------------- /xbox/auxiliary/packer.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from xbox.auxiliary.crypto import AuxiliaryStreamCrypto 4 | from xbox.sg.crypto import PKCS7Padding 5 | from xbox.auxiliary.packet import aux_header_struct, AUX_PACKET_MAGIC 6 | 7 | 8 | class AuxiliaryPackerException(Exception): 9 | pass 10 | 11 | 12 | def pack( 13 | data: bytes, 14 | crypto: AuxiliaryStreamCrypto, 15 | server_data: bool = False 16 | ) -> List[bytes]: 17 | """ 18 | Encrypt auxiliary data blob 19 | 20 | Args: 21 | data: Data 22 | crypto: Crypto context 23 | server_data: Whether to encrypt with `server IV` 24 | 25 | Returns: 26 | bytes: Encrypted message 27 | """ 28 | # Store payload size without padding 29 | payload_size = len(data) 30 | 31 | # Pad data 32 | padded = PKCS7Padding.pad(data, 16) 33 | 34 | if not server_data: 35 | ciphertext = crypto.encrypt(padded) 36 | else: 37 | ciphertext = crypto.encrypt_server(padded) 38 | 39 | header = aux_header_struct.build(dict( 40 | magic=AUX_PACKET_MAGIC, 41 | payload_size=payload_size) 42 | ) 43 | 44 | msg = header + ciphertext 45 | hmac = crypto.hash(msg) 46 | msg += hmac 47 | 48 | messages = list() 49 | while len(msg) > 1448: 50 | fragment, msg = msg[:1448], msg[1448:] 51 | messages.append(fragment) 52 | 53 | messages.append(msg) 54 | 55 | return messages 56 | 57 | 58 | def unpack( 59 | data: bytes, 60 | crypto: AuxiliaryStreamCrypto, 61 | client_data: bool = False 62 | ) -> bytes: 63 | """ 64 | Split and decrypt auxiliary data blob 65 | 66 | Args: 67 | data: Data blob 68 | crypto: Crypto context 69 | client_data: Whether to decrypt with 'client IV' 70 | 71 | Returns: 72 | bytes: Decrypted message 73 | """ 74 | # Split header from rest of data 75 | header, payload, hmac = data[:4], data[4:-32], data[-32:] 76 | 77 | parsed = aux_header_struct.parse(header) 78 | 79 | if not crypto.verify(header + payload, hmac): 80 | raise AuxiliaryPackerException('Hash verification failed') 81 | 82 | if not client_data: 83 | plaintext = crypto.decrypt(payload) 84 | else: 85 | plaintext = crypto.decrypt_client(payload) 86 | 87 | # Cut off padding, before returning 88 | return plaintext[:parsed.payload_size] 89 | -------------------------------------------------------------------------------- /xbox/rest/routes/web.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from fastapi import APIRouter, Depends, HTTPException, status 4 | from ..deps import get_xbl_client 5 | 6 | from xbox.webapi.api.client import XboxLiveClient 7 | from xbox.webapi.api.provider.titlehub import TitleFields 8 | from xbox.webapi.api.provider.titlehub import models as titlehub_models 9 | from xbox.webapi.api.provider.lists import models as lists_models 10 | 11 | router = APIRouter() 12 | 13 | 14 | @router.get('/title/{title_id}', response_model=titlehub_models.Title) 15 | async def download_title_info( 16 | client: XboxLiveClient = Depends(get_xbl_client), 17 | *, 18 | title_id: int 19 | ): 20 | if not client: 21 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='You have to login first') 22 | 23 | try: 24 | resp = await client.titlehub.get_title_info(title_id, [TitleFields.IMAGE]) 25 | return resp.titles[0] 26 | except KeyError: 27 | raise HTTPException(status_code=404, detail='Cannot find titles-node json response') 28 | except IndexError: 29 | raise HTTPException(status_code=404, detail='No info for requested title not found') 30 | except Exception as e: 31 | raise HTTPException(status_code=400, detail=f'Download of titleinfo failed, error: {e}') 32 | 33 | 34 | @router.get('/titlehistory', response_model=titlehub_models.TitleHubResponse) 35 | async def download_title_history( 36 | client: XboxLiveClient = Depends(get_xbl_client), 37 | max_items: Optional[int] = 5 38 | ): 39 | if not client: 40 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='You have to login first') 41 | 42 | try: 43 | resp = await client.titlehub.get_title_history(client.xuid, max_items=max_items) 44 | return resp 45 | except Exception as e: 46 | return HTTPException(status_code=400, detail=f'Download of titlehistory failed, error: {e}') 47 | 48 | 49 | @router.get('/pins', response_model=lists_models.ListsResponse) 50 | async def download_pins( 51 | client: XboxLiveClient = Depends(get_xbl_client) 52 | ): 53 | if not client: 54 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='You have to login first') 55 | 56 | try: 57 | resp = await client.lists.get_items(client.xuid) 58 | return resp 59 | except Exception as e: 60 | return HTTPException(status_code=400, detail=f'Download of pins failed, error: {e}') 61 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 1.3.0 (2020-11-02) 4 | 5 | * Drop Python 3.6 support 6 | * Deprecate Authentication via TUI 7 | * Major rewrite (Migration from gevent -> asyncio) 8 | * Rewrite of REST Server (Migration FLASK -> FastAPI) 9 | * Adjust to xbox-webapi-python v2.0.8 10 | * New OAUTH login flow 11 | 12 | ## 1.2.2 (2020-04-03) 13 | 14 | * Fix: Assign tokenfile path to flask_app (aka. REST server instance) 15 | 16 | ## 1.2.1 (2020-03-04) 17 | 18 | * cli: Python3.6 compatibility change 19 | * HOTFIX: Add xbox.handlers to packages in setup.py 20 | 21 | ## 1.2.0 (2020-03-04) 22 | 23 | * CLI scripts rewritten, supporting log/loglevel args now, main script is called xbox-cli now 24 | * Add REPL / REPL server functionality 25 | * Updates to README and REST server documentation 26 | 27 | ## 1.1.2 (2020-02-29) 28 | 29 | * Drop support for Python 3.5 30 | * crypto: Fix deprecated cryptography functions 31 | * tests: Speed up REST server tests (discovery, poweron) 32 | * Update all dependencies 33 | 34 | ## 1.1.1 (2020-02-29) 35 | 36 | * FIX: Include static files for REST server in distributable package 37 | * REST: Remove deprecated packages from showing in /versions endpoint 38 | 39 | ## 1.1.0 (2020-02-29) 40 | 41 | * Clean up dependencies 42 | * Merge in **xbox-smartglass-rest**, deprecate standalone package 43 | * Merge in **xbox-smartglass-stump**, deprecate standalone package 44 | * Merge in **xbox-smartglass-auxiliary**, deprecate standalone package 45 | * tui: Fix crash when bringing up command menu, support ESC to exit 46 | 47 | ## 1.0.12 (2018-11-14) 48 | 49 | * Python 3.7 compatibility 50 | 51 | ## 1.0.11 (2018-11-05) 52 | 53 | * Add game_dvr_record to Console-class 54 | * Fix PCAP parser 55 | * Add last_error property to Console-class 56 | 57 | ## 1.0.10 (2018-08-14) 58 | 59 | * Safeguard around connect() functions, if userhash and xsts_token is NoneType 60 | 61 | ## 1.0.9 (2018-08-11) 62 | 63 | * Fix for Console instance poweron 64 | * Reset state after poweroff 65 | * Little fixes to TUI 66 | * Support handling MessageFragments 67 | 68 | ## 1.0.8 (2018-06-14) 69 | 70 | * Use aenum library for backwards-compat with _enum.Flag_ on py3.5 71 | 72 | ## 1.0.7 (2018-05-16) 73 | 74 | * CoreProtocol.connect: Treat ConnectionResult.Pending as error 75 | * constants.WindowsClientInfo: Update ClientVersion 15 -> 39 76 | * Make CoreProtocol.start_channel take optional title_id / activity_id arguments 77 | 78 | ## 1.0.1 (2018-05-03) 79 | 80 | * First release on PyPI. 81 | -------------------------------------------------------------------------------- /xbox/sg/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Constant values used by SmartGlass 3 | """ 4 | from uuid import UUID 5 | 6 | from xbox.sg.enum import ClientType, DeviceCapabilities 7 | 8 | 9 | class AndroidClientInfo(object): 10 | """ 11 | Client Info for Android device (tablet). Used for LocalJoin messages 12 | """ 13 | DeviceType = ClientType.Android 14 | # Resolution is portrait mode 15 | NativeWidth = 720 16 | NativeHeight = 1280 17 | DpiX = 160 18 | DpiY = 160 19 | DeviceCapabilities = DeviceCapabilities.All 20 | ClientVersion = 151117100 # v2.4.1511.17100-Beta 21 | OSMajor = 22 # Android 5.1.1 - API Version 22 22 | OSMinor = 0 23 | DisplayName = "com.microsoft.xboxone.smartglass.beta" 24 | 25 | 26 | class WindowsClientInfo(object): 27 | """ 28 | Client Info for Windows device, used for LocalJoin messages 29 | """ 30 | DeviceType = ClientType.WindowsStore 31 | NativeWidth = 1080 32 | NativeHeight = 1920 33 | DpiX = 96 34 | DpiY = 96 35 | DeviceCapabilities = DeviceCapabilities.All 36 | ClientVersion = 39 37 | OSMajor = 6 38 | OSMinor = 2 39 | DisplayName = "SmartGlass-PC" 40 | 41 | 42 | class MessageTarget(object): 43 | """ 44 | UUIDs for all ServiceChannels 45 | """ 46 | SystemInputUUID = UUID("fa20b8ca-66fb-46e0-adb60b978a59d35f") 47 | SystemInputTVRemoteUUID = UUID("d451e3b3-60bb-4c71-b3dbf994b1aca3a7") 48 | SystemMediaUUID = UUID("48a9ca24-eb6d-4e12-8c43d57469edd3cd") 49 | SystemTextUUID = UUID("7af3e6a2-488b-40cb-a93179c04b7da3a0") 50 | SystemBroadcastUUID = UUID("b6a117d8-f5e2-45d7-862e8fd8e3156476") 51 | TitleUUID = UUID('00000000-0000-0000-0000-000000000000') 52 | 53 | 54 | class XboxOneGuid(object): 55 | """ 56 | System level GUIDs 57 | """ 58 | BROWSER = "9c7e0f20-78fb-4ea7-a8bd-cf9d78059a08" 59 | MUSIC = "6D96DEDC-F3C9-43F8-89E3-0C95BF76AD2A" 60 | VIDEO = "a489d977-8a87-4983-8df6-facea1ad6d93" 61 | 62 | 63 | class TitleId(object): 64 | """ 65 | System level Title Ids 66 | """ 67 | AVATAR_EDITOR_TITLE_ID = 1481443281 68 | BROWSER_TITLE_ID = 1481115776 69 | DASH_TITLE_ID = 4294838225 70 | BLUERAY_TITLE_ID = 1783797709 71 | CONSOLE_UPDATE_APP_TITLE_ID = -1 72 | HOME_TITLE_ID = 714681658 73 | IE_TITLE_ID = 1032557327 74 | LIVETV_TITLE_ID = 371594669 75 | MUSIC_TITLE_ID = 419416564 76 | ONEGUIDE_TITLE_ID = 2055214557 77 | OOBE_APP_TITLE_ID = 951105730 78 | SNAP_AN_APP_TITLE_ID = 1783889234 79 | STORE_APP_TITLE_ID = 1407783715 80 | VIDEO_TITLE_ID = 1030770725 81 | ZUNE_TITLE_ID = 1481115739 82 | -------------------------------------------------------------------------------- /xbox/handlers/gamepad_input.py: -------------------------------------------------------------------------------- 1 | """ 2 | Input smartglass client 3 | 4 | Send controller input via stdin (terminal) to the console 5 | """ 6 | import sys 7 | import logging 8 | 9 | from xbox.sg.enum import GamePadButton 10 | 11 | LOGGER = logging.getLogger(__name__) 12 | 13 | input_map = { 14 | "i": GamePadButton.DPadUp, 15 | "k": GamePadButton.DPadDown, 16 | "j": GamePadButton.DPadLeft, 17 | "l": GamePadButton.DPadRight, 18 | 19 | "a": GamePadButton.PadA, 20 | "b": GamePadButton.PadB, 21 | "x": GamePadButton.PadX, 22 | "y": GamePadButton.PadY, 23 | 24 | "t": GamePadButton.View, 25 | "z": GamePadButton.Nexu, 26 | "u": GamePadButton.Menu 27 | } 28 | 29 | 30 | def get_getch_func(): 31 | """ 32 | Source: https://code.activestate.com/recipes/577977-get-single-keypress/ 33 | """ 34 | try: 35 | import tty 36 | import termios 37 | except ImportError: 38 | # Probably Windows. 39 | try: 40 | import msvcrt 41 | except ImportError: 42 | # Just give up here. 43 | raise ImportError('getch not available') 44 | else: 45 | return msvcrt.getch 46 | else: 47 | def getch(): 48 | """ 49 | getch() -> key character 50 | 51 | Read a single keypress from stdin and return the resulting character. 52 | Nothing is echoed to the console. This call will block if a keypress 53 | is not already available, but will not wait for Enter to be pressed. 54 | 55 | If the pressed key was a modifier key, nothing will be detected; if 56 | it were a special function key, it may return the first character of 57 | of an escape sequence, leaving additional characters in the buffer. 58 | """ 59 | fd = sys.stdin.fileno() 60 | old_settings = termios.tcgetattr(fd) 61 | try: 62 | tty.setraw(fd) 63 | ch = sys.stdin.read(1) 64 | finally: 65 | termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) 66 | return ch 67 | return getch 68 | 69 | 70 | async def input_loop(console): 71 | getch = get_getch_func() 72 | while True: 73 | ch = getch() 74 | print(ch) 75 | if ord(ch) == 3: # CTRL-C 76 | sys.exit(1) 77 | 78 | elif ch not in input_map: 79 | continue 80 | 81 | button = input_map[ch] 82 | await console.gamepad_input(button) 83 | await console.wait(0.1) 84 | await console.gamepad_input(GamePadButton.Clear) 85 | -------------------------------------------------------------------------------- /tests/test_struct.py: -------------------------------------------------------------------------------- 1 | import construct 2 | from xbox.sg.utils import struct, adapters 3 | 4 | 5 | def test_xstruct(): 6 | test_struct = struct.XStruct( 7 | 'a' / construct.Int32ub, 8 | 'b' / construct.Int16ub 9 | ) 10 | 11 | obj = test_struct(a=1, b=2) 12 | 13 | assert test_struct.a.subcon == construct.Int32ub 14 | assert test_struct.b.subcon == construct.Int16ub 15 | assert 'a' in test_struct 16 | assert 'b' in test_struct 17 | assert obj.container == construct.Container(a=1, b=2) 18 | assert obj.a == 1 19 | assert obj.b == 2 20 | assert obj.build() == b'\x00\x00\x00\x01\x00\x02' 21 | 22 | obj.parse(b'\x00\x00\x00\x02\x00\x03') 23 | assert obj.container == construct.Container(a=2, b=3) 24 | 25 | obj = test_struct.parse(b'\x00\x00\x00\x03\x00\x04') 26 | assert obj.container == construct.Container(a=3, b=4) 27 | 28 | 29 | def test_flatten(): 30 | test_sub = struct.XStruct( 31 | 'c' / construct.Int16ub 32 | ) 33 | test_struct = struct.XStruct( 34 | 'a' / construct.Int32ub, 35 | 'b' / test_sub 36 | ) 37 | 38 | obj = test_struct(a=1, b=test_sub(c=2)) 39 | flat = struct.flatten(obj.container) 40 | 41 | assert flat == construct.Container(a=1, b=construct.Container(c=2)) 42 | 43 | 44 | def test_terminated_field(): 45 | test_struct = struct.XStruct( 46 | 'a' / adapters.TerminatedField(construct.Int32ub) 47 | ) 48 | 49 | assert test_struct(a=1).build() == b'\x00\x00\x00\x01\x00' 50 | assert test_struct.parse(b'\x00\x00\x00\x01\x00').container.a == 1 51 | 52 | test_struct = struct.XStruct( 53 | 'a' / adapters.TerminatedField( 54 | construct.Int32ub, length=4, pattern=b'\xff' 55 | ) 56 | ) 57 | 58 | assert test_struct(a=1).build() == b'\x00\x00\x00\x01\xff\xff\xff\xff' 59 | assert test_struct.parse(b'\x00\x00\x00\x01\xff\xff\xff\xff').container.a == 1 60 | 61 | 62 | def test_sgstring(): 63 | test_struct = struct.XStruct( 64 | 'a' / adapters.SGString() 65 | ) 66 | 67 | assert test_struct(a='test').build() == b'\x00\x04test\x00' 68 | assert test_struct.parse(b'\x00\x04test\x00').container.a == 'test' 69 | 70 | 71 | def test_fieldin(): 72 | test_struct = struct.XStruct( 73 | 'a' / construct.Int32ub, 74 | 'b' / construct.IfThenElse( 75 | adapters.FieldIn('a', [1, 2, 3]), 76 | construct.Int32ub, construct.Int16ub 77 | ) 78 | ) 79 | 80 | assert test_struct(a=1, b=2).build() == b'\x00\x00\x00\x01\x00\x00\x00\x02' 81 | assert test_struct(a=2, b=2).build() == b'\x00\x00\x00\x02\x00\x00\x00\x02' 82 | assert test_struct(a=3, b=2).build() == b'\x00\x00\x00\x03\x00\x00\x00\x02' 83 | assert test_struct(a=4, b=2).build() == b'\x00\x00\x00\x04\x00\x02' 84 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | try: 8 | from urllib import pathname2url 9 | except: 10 | from urllib.request import pathname2url 11 | 12 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 13 | endef 14 | export BROWSER_PYSCRIPT 15 | 16 | define PRINT_HELP_PYSCRIPT 17 | import re, sys 18 | 19 | for line in sys.stdin: 20 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 21 | if match: 22 | target, help = match.groups() 23 | print("%-20s %s" % (target, help)) 24 | endef 25 | export PRINT_HELP_PYSCRIPT 26 | 27 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 28 | 29 | help: 30 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 31 | 32 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 33 | 34 | clean-build: ## remove build artifacts 35 | rm -fr build/ 36 | rm -fr dist/ 37 | rm -fr .eggs/ 38 | find . -name '*.egg-info' -exec rm -fr {} + 39 | find . -name '*.egg' -exec rm -f {} + 40 | 41 | clean-pyc: ## remove Python file artifacts 42 | find . -name '*.pyc' -exec rm -f {} + 43 | find . -name '*.pyo' -exec rm -f {} + 44 | find . -name '*~' -exec rm -f {} + 45 | find . -name '__pycache__' -exec rm -fr {} + 46 | 47 | clean-test: ## remove test and coverage artifacts 48 | rm -fr .tox/ 49 | rm -f .coverage 50 | rm -fr htmlcov/ 51 | 52 | lint: ## check style with flake8 53 | flake8 xbox tests 54 | 55 | test: ## run tests quickly with the default Python 56 | py.test 57 | 58 | test-all: ## run tests on every Python version with tox 59 | tox 60 | 61 | coverage: ## check code coverage quickly with the default Python 62 | coverage run --source xbox -m pytest 63 | coverage report -m 64 | coverage html 65 | $(BROWSER) htmlcov/index.html 66 | 67 | docs: ## generate Sphinx HTML documentation, including API docs 68 | rm -f docs/xbox.rst 69 | rm -f docs/modules.rst 70 | sphinx-apidoc --implicit-namespaces -a -e -o docs/source xbox 71 | $(MAKE) -C docs clean 72 | $(MAKE) -C docs html 73 | $(BROWSER) docs/_build/html/index.html 74 | 75 | servedocs: docs ## compile the docs watching for changes 76 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 77 | 78 | release: clean ## package and upload a release 79 | twine upload dist/* 80 | 81 | dist: clean ## builds source and wheel package 82 | python setup.py sdist 83 | python setup.py bdist_wheel 84 | ls -l dist 85 | 86 | check-meta: ## Check package metadata (setup.py) 87 | python setup.py check --strict --metadata 88 | 89 | check: dist ## Check built dist package for proper layout and structure 90 | twine check dist/* 91 | 92 | install: clean ## install the package to the active Python's site-packages 93 | python setup.py install 94 | -------------------------------------------------------------------------------- /tests/test_crypto.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from binascii import unhexlify 3 | from xbox.sg.enum import PublicKeyType 4 | 5 | 6 | def test_generate_random_iv(crypto): 7 | rand_iv = crypto.generate_iv() 8 | rand_iv_2 = crypto.generate_iv() 9 | 10 | assert len(rand_iv) == 16 11 | assert len(rand_iv_2) == 16 12 | assert rand_iv != rand_iv_2 13 | 14 | 15 | def test_generate_seeded_iv(crypto): 16 | seed = unhexlify('000102030405060708090A0B0C0D0E0F') 17 | seed2 = unhexlify('000F0E0D0C0B0A090807060504030201') 18 | 19 | seed_iv = crypto.generate_iv(seed) 20 | seed_iv_dup = crypto.generate_iv(seed) 21 | seed_iv_2 = crypto.generate_iv(seed2) 22 | 23 | assert len(seed_iv) == 16 24 | assert seed_iv == seed_iv_dup 25 | assert seed_iv != seed_iv_2 26 | 27 | 28 | def test_encrypt_decrypt(crypto): 29 | plaintext = b'Test String\x00\x00\x00\x00\x00' 30 | seed = unhexlify('000102030405060708090A0B0C0D0E0F') 31 | seed_iv = crypto.generate_iv(seed) 32 | encrypt = crypto.encrypt(seed_iv, plaintext) 33 | decrypt = crypto.decrypt(seed_iv, encrypt) 34 | 35 | assert plaintext == decrypt 36 | assert plaintext != encrypt 37 | 38 | 39 | def test_hash(crypto): 40 | plaintext = b'Test String\x00\x00\x00\x00\x00' 41 | seed = unhexlify('000102030405060708090A0B0C0D0E0F') 42 | seed_iv = crypto.generate_iv(seed) 43 | encrypt = crypto.encrypt(seed_iv, plaintext) 44 | hash = crypto.hash(encrypt) 45 | hash_dup = crypto.hash(encrypt) 46 | verify = crypto.verify(encrypt, hash) 47 | 48 | assert hash == hash_dup 49 | assert verify is True 50 | 51 | 52 | def test_from_bytes(public_key_bytes, public_key): 53 | from xbox.sg.crypto import Crypto 54 | c1 = Crypto.from_bytes(public_key_bytes) 55 | c2 = Crypto.from_bytes(public_key_bytes, PublicKeyType.EC_DH_P256) 56 | 57 | # invalid public key type passed 58 | with pytest.raises(ValueError): 59 | Crypto.from_bytes(public_key_bytes, PublicKeyType.EC_DH_P521) 60 | # invalid keylength 61 | with pytest.raises(ValueError): 62 | Crypto.from_bytes(public_key_bytes[5:]) 63 | # invalid parameter 64 | with pytest.raises(ValueError): 65 | Crypto.from_bytes(123) 66 | 67 | assert c1.foreign_pubkey.public_numbers() == public_key.public_numbers() 68 | assert c2.foreign_pubkey.public_numbers() == public_key.public_numbers() 69 | 70 | 71 | def test_from_shared_secret(shared_secret_bytes): 72 | from xbox.sg.crypto import Crypto 73 | c = Crypto.from_shared_secret(shared_secret_bytes) 74 | 75 | # invalid length 76 | with pytest.raises(ValueError): 77 | c.from_shared_secret(shared_secret_bytes[1:]) 78 | 79 | # invalid parameter 80 | with pytest.raises(ValueError): 81 | c.from_shared_secret(123) 82 | 83 | assert c._encrypt_key == unhexlify(b'82bba514e6d19521114940bd65121af2') 84 | assert c._iv_key == unhexlify(b'34c53654a8e67add7710b3725db44f77') 85 | assert c._hash_key == unhexlify( 86 | b'30ed8e3da7015a09fe0f08e9bef3853c0506327eb77c9951769d923d863a2f5e' 87 | ) 88 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup, find_namespace_packages 5 | 6 | setup( 7 | name="xbox-smartglass-core", 8 | version="1.3.0", 9 | author="OpenXbox", 10 | author_email="noreply@openxbox.org", 11 | description="A library to interact with the Xbox One gaming console via the SmartGlass protocol.", 12 | long_description=open('README.md').read() + '\n\n' + open('CHANGELOG.md').read(), 13 | long_description_content_type="text/markdown", 14 | license="MIT", 15 | keywords="xbox one smartglass auxiliary fallout title stump tv streaming livetv rest api", 16 | url="https://github.com/OpenXbox/xbox-smartglass-core-python", 17 | python_requires=">=3.7", 18 | packages=find_namespace_packages(include=['xbox.*']), 19 | zip_safe=False, 20 | include_package_data=True, 21 | classifiers=[ 22 | "Development Status :: 4 - Beta", 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: MIT License", 25 | "Topic :: Software Development :: Libraries :: Python Modules", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3.7", 28 | "Programming Language :: Python :: 3.8", 29 | "Programming Language :: Python :: 3.9" 30 | ], 31 | test_suite="tests", 32 | install_requires=[ 33 | 'xbox-webapi==2.0.9', 34 | 'construct==2.10.56', 35 | 'cryptography==3.3.2', 36 | 'dpkt==1.9.4', 37 | 'pydantic==1.7.4', 38 | 'aioconsole==0.3.0', 39 | 'fastapi==0.65.2', 40 | 'uvicorn==0.12.2', 41 | 'urwid==2.1.2' 42 | ], 43 | setup_requires=['pytest-runner'], 44 | tests_require=['pytest', 'pytest-console-scripts', 'pytest-asyncio'], 45 | extras_require={ 46 | "dev": [ 47 | "pip", 48 | "bump2version", 49 | "wheel", 50 | "watchdog", 51 | "flake8", 52 | "coverage", 53 | "Sphinx", 54 | "sphinx_rtd_theme", 55 | "recommonmark", 56 | "twine", 57 | "pytest", 58 | "pytest-asyncio", 59 | "pytest-console-scripts", 60 | "pytest-runner", 61 | ], 62 | }, 63 | entry_points={ 64 | 'console_scripts': [ 65 | 'xbox-cli=xbox.scripts.main_cli:main', 66 | 'xbox-discover=xbox.scripts.main_cli:main_discover', 67 | 'xbox-poweron=xbox.scripts.main_cli:main_poweron', 68 | 'xbox-poweroff=xbox.scripts.main_cli:main_poweroff', 69 | 'xbox-repl=xbox.scripts.main_cli:main_repl', 70 | 'xbox-replserver=xbox.scripts.main_cli:main_replserver', 71 | 'xbox-textinput=xbox.scripts.main_cli:main_textinput', 72 | 'xbox-gamepadinput=xbox.scripts.main_cli:main_gamepadinput', 73 | 'xbox-tui=xbox.scripts.main_cli:main_tui', 74 | 'xbox-fo4-relay=xbox.scripts.main_cli:main_falloutrelay', 75 | 'xbox-pcap=xbox.scripts.pcap:main', 76 | 'xbox-recrypt=xbox.scripts.recrypt:main', 77 | 'xbox-rest-server=xbox.scripts.rest_server:main' 78 | ] 79 | } 80 | ) 81 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: docker 2 | 3 | on: 4 | pull_request: 5 | branches: master 6 | push: 7 | branches: master 8 | tags: 9 | - v* 10 | 11 | jobs: 12 | buildx: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - 16 | name: Checkout 17 | uses: actions/checkout@v2 18 | - 19 | name: Prepare 20 | id: prepare 21 | run: | 22 | DOCKER_IMAGE=openxbox/xbox-smartglass-core-python 23 | DOCKER_PLATFORMS=linux/amd64,linux/arm/v7,linux/arm64 24 | VERSION=edge 25 | 26 | if [[ $GITHUB_REF == refs/tags/* ]]; then 27 | VERSION=${GITHUB_REF#refs/tags/v} 28 | fi 29 | if [ "${{ github.event_name }}" = "schedule" ]; then 30 | VERSION=nightly 31 | fi 32 | 33 | TAGS="--tag ${DOCKER_IMAGE}:${VERSION}" 34 | if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then 35 | TAGS="$TAGS --tag ${DOCKER_IMAGE}:latest" 36 | fi 37 | 38 | echo ::set-output name=docker_image::${DOCKER_IMAGE} 39 | echo ::set-output name=version::${VERSION} 40 | echo ::set-output name=buildx_args::--platform ${DOCKER_PLATFORMS} \ 41 | --build-arg VERSION=${VERSION} \ 42 | --build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \ 43 | --build-arg VCS_REF=${GITHUB_SHA::8} \ 44 | ${TAGS} --file ./Dockerfile . 45 | - 46 | name: Set up QEMU 47 | uses: docker/setup-qemu-action@v1 48 | with: 49 | platforms: all 50 | - 51 | name: Set up Docker Buildx 52 | id: buildx 53 | uses: docker/setup-buildx-action@v1 54 | with: 55 | version: latest 56 | - 57 | name: Available platforms 58 | run: echo ${{ steps.buildx.outputs.platforms }} 59 | - 60 | name: Docker Buildx (build) 61 | run: | 62 | docker buildx build --output "type=image,push=false" ${{ steps.prepare.outputs.buildx_args }} 63 | - 64 | name: Login to DockerHub 65 | if: success() && github.event_name != 'pull_request' 66 | uses: docker/login-action@v1 67 | with: 68 | username: ${{ secrets.DOCKER_USERNAME }} 69 | password: ${{ secrets.DOCKER_PASSWORD }} 70 | - 71 | name: Update Dockerhub description 72 | if: success() && github.event_name != 'pull_request' 73 | uses: peter-evans/dockerhub-description@v2 74 | env: 75 | DOCKERHUB_USERNAME: ${{ secrets.DOCKER_USERNAME }} 76 | DOCKERHUB_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 77 | DOCKERHUB_REPOSITORY: ${{ steps.prepare.outputs.docker_image }} 78 | README_FILEPATH: ./README.md 79 | - 80 | name: Docker Buildx (push) 81 | if: success() && github.event_name != 'pull_request' 82 | run: | 83 | docker buildx build --output "type=image,push=true" ${{ steps.prepare.outputs.buildx_args }} 84 | - 85 | name: Inspect image 86 | if: always() && github.event_name != 'pull_request' 87 | run: | 88 | docker buildx imagetools inspect ${{ steps.prepare.outputs.docker_image }}:${{ steps.prepare.outputs.version }} 89 | -------------------------------------------------------------------------------- /xbox/scripts/pcap.py: -------------------------------------------------------------------------------- 1 | """ 2 | Parse a pcap packet capture and show decrypted 3 | packets in a human-readable ways. 4 | 5 | Requires the shared secret for that smartglass-session. 6 | """ 7 | import os 8 | import shutil 9 | import string 10 | import textwrap 11 | import argparse 12 | from binascii import unhexlify 13 | 14 | import dpkt 15 | 16 | from xbox.sg import packer 17 | from xbox.sg.crypto import Crypto 18 | from xbox.sg.enum import PacketType 19 | 20 | from construct.lib import containers 21 | containers.setGlobalPrintFullStrings(True) 22 | 23 | 24 | def packet_filter(filepath): 25 | with open(filepath, 'rb') as fh: 26 | for ts, buf in dpkt.pcap.Reader(fh): 27 | eth = dpkt.ethernet.Ethernet(buf) 28 | 29 | # Make sure the Ethernet data contains an IP packet 30 | if not isinstance(eth.data, dpkt.ip.IP): 31 | continue 32 | 33 | ip = eth.data 34 | if not isinstance(ip.data, dpkt.udp.UDP): 35 | continue 36 | 37 | udp = ip.data 38 | if udp.sport != 5050 and udp.dport != 5050: 39 | continue 40 | 41 | is_client = udp.dport == 5050 42 | 43 | yield(udp.data, is_client, ts) 44 | 45 | 46 | def parse(pcap_filepath, crypto): 47 | width = shutil.get_terminal_size().columns 48 | col_width = width // 2 - 3 49 | wrapper = textwrap.TextWrapper(col_width, replace_whitespace=False) 50 | 51 | for packet, is_client, ts in packet_filter(pcap_filepath): 52 | try: 53 | msg = packer.unpack(packet, crypto) 54 | except Exception as e: 55 | print("Error: {}".format(e)) 56 | continue 57 | 58 | msg_type = msg.header.pkt_type 59 | type_str = msg_type.name 60 | 61 | if msg_type == PacketType.Message: 62 | type_str = msg.header.flags.msg_type.name 63 | 64 | direction = '>' if is_client else '<' 65 | print(' {} '.format(type_str).center(width, direction)) 66 | 67 | lines = str(msg).split('\n') 68 | for line in lines: 69 | line = wrapper.wrap(line) 70 | for i in line: 71 | if is_client: 72 | print('{0: <{1}}'.format(i, col_width), '│') 73 | else: 74 | print(' ' * col_width, '│', '{0}'.format(i, col_width)) 75 | 76 | 77 | def main(): 78 | parser = argparse.ArgumentParser( 79 | description='Parse PCAP files and show SG sessions' 80 | ) 81 | parser.add_argument('file', help='Path to PCAP') 82 | parser.add_argument('secret', help='Expanded secret for this session.') 83 | args = parser.parse_args() 84 | 85 | secret = args.secret 86 | if os.path.exists(secret): 87 | # Assume a file containing the secret 88 | with open(secret, 'rb') as fh: 89 | secret = fh.read() 90 | 91 | if all(chr(c) in string.hexdigits for c in secret): 92 | secret = unhexlify(secret) 93 | else: 94 | secret = unhexlify(secret) 95 | 96 | crypto = Crypto.from_shared_secret(secret) 97 | parse(args.file, crypto) 98 | 99 | 100 | if __name__ == '__main__': 101 | main() 102 | -------------------------------------------------------------------------------- /xbox/scripts/recrypt.py: -------------------------------------------------------------------------------- 1 | """ 2 | Encrypt smartglass messages (type 0xD00D) with a new key 3 | 4 | Example: 5 | usage: xbox_recrypt [-h] src_path src_secret dst_path dst_secret 6 | 7 | Re-Encrypt raw smartglass packets from a given filepath 8 | 9 | positional arguments: 10 | src_path Path to sourcefiles 11 | src_secret Source shared secret in hex-format 12 | dst_path Path to destination 13 | dst_secret Target shared secret in hex-format 14 | 15 | optional arguments: 16 | -h, --help show this help message and exit 17 | """ 18 | import os 19 | import sys 20 | import argparse 21 | from binascii import unhexlify 22 | 23 | from construct import Int16ub 24 | 25 | from xbox.sg.crypto import Crypto 26 | from xbox.sg.enum import PacketType 27 | 28 | 29 | def main(): 30 | parser = argparse.ArgumentParser( 31 | description='Re-Encrypt raw smartglass packets from a given filepath' 32 | ) 33 | parser.add_argument('src_path', type=str, help='Path to sourcefiles') 34 | parser.add_argument('src_secret', type=str, help='Source shared secret in hex-format') 35 | parser.add_argument('dst_path', type=str, help='Path to destination') 36 | parser.add_argument('dst_secret', type=str, help='Target shared secret in hex-format') 37 | args = parser.parse_args() 38 | 39 | src_secret = unhexlify(args.src_secret) 40 | dst_secret = unhexlify(args.dst_secret) 41 | 42 | src_path = args.src_path 43 | dst_path = args.dst_path 44 | 45 | if len(src_secret) != 64: 46 | print('Source key of invalid length supplied!') 47 | sys.exit(1) 48 | elif len(dst_secret) != 64: 49 | print('Destination key of invalid length supplied!') 50 | sys.exit(1) 51 | 52 | source_crypto = Crypto.from_shared_secret(src_secret) 53 | dest_crypto = Crypto.from_shared_secret(dst_secret) 54 | 55 | source_path = os.path.dirname(src_path) 56 | dest_path = os.path.dirname(dst_path) 57 | 58 | for f in os.listdir(source_path): 59 | src_filepath = os.path.join(source_path, f) 60 | with open(src_filepath, 'rb') as sfh: 61 | encrypted = sfh.read() 62 | if Int16ub.parse(encrypted[:2]) != PacketType.Message.value: 63 | print('Invalid magic, %s not a smartglass message, ignoring' 64 | % src_filepath) 65 | continue 66 | 67 | # Slice the encrypted data manually 68 | header = encrypted[:26] 69 | payload = encrypted[26:-32] 70 | hash = encrypted[-32:] 71 | 72 | if not source_crypto.verify(encrypted[:-32], hash): 73 | print('Hash mismatch, ignoring') 74 | continue 75 | 76 | # Decrypt with source shared secret 77 | iv = source_crypto.generate_iv(header[:16]) 78 | decrypted_payload = source_crypto.decrypt(iv, payload) 79 | 80 | # Encrypt with destination parameters 81 | new_iv = dest_crypto.generate_iv(header[:16]) 82 | recrypted = dest_crypto.encrypt(new_iv, decrypted_payload) 83 | new_hash = dest_crypto.hash(header + recrypted) 84 | new_packet = header + recrypted + new_hash 85 | 86 | with open(os.path.join(dest_path, f), 'wb') as dfh: 87 | dfh.write(new_packet) 88 | 89 | 90 | if __name__ == '__main__': 91 | main() 92 | -------------------------------------------------------------------------------- /tests/data/json_fragments.json: -------------------------------------------------------------------------------- 1 | { 2 | "fragments": [ 3 | {"datagram_size":"2968","datagram_id":"13","fragment_offset":"0","fragment_length":"905","fragment_data":"eyJyZXNwb25zZSI6IkdldENvbmZpZ3VyYXRpb24iLCJtc2dpZCI6InhWNVgxWUNCLjEzIiwicGFyYW1zIjpbeyJkZXZpY2VfaWQiOiIwIiwiZGV2aWNlX3R5cGUiOiJ0diIsImJ1dHRvbnMiOnsiYnRuLmJhY2siOiJCYWNrIiwiYnRuLnVwIjoiVXAiLCJidG4ucmVkIjoiUmVkIiwiYnRuLnBhZ2VfZG93biI6IlBhZ2UgRG93biIsImJ0bi5jaF9kb3duIjoiQ2hhbm5lbCBEb3duIiwiYnRuLmZ1bmNfYyI6IkxhYmVsIEMiLCJidG4uZm9ybWF0IjoiRm9ybWF0IiwiYnRuLmRpZ2l0XzIiOiIyIiwiYnRuLmZ1bmNfYSI6IkxhYmVsIEEiLCJidG4uZGlnaXRfNyI6IjciLCJidG4ubGFzdCI6Ikxhc3QiLCJidG4uaW5wdXQiOiJJbnB1dCIsImJ0bi5mYXN0X2Z3ZCI6IkZGV0QiLCJidG4ubWVudSI6Ik1lbnUiLCJidG4ucmVwbGF5IjoiU2tpcCBSRVYiLCJidG4ucG93ZXIiOiJQb3dlciIsImJ0bi5sZWZ0IjoiTGVmdCIsImJ0bi5ibHVlIjoiQmx1ZSIsImJ0bi52b2xfZG93biI6IlZvbHVtZSBEb3duIiwiYnRuLmdyZWVuIjoiR3JlZW4iLCJidG4uZGlnaXRfNCI6IjQiLCJidG4uZGlnaXRfOSI6IjkiLCJidG4ucGxheSI6IlBsYXkiLCJidG4ucGFnZV91cCI6IlBhZ2UgVXAiLCJidG4uZnVuY19iIjoiTGFiZWwgQiIsImJ0bi5wb3dlcl9vZmYiOiJPZmYiLCJidG4udm9sX211dGUiOiJNdXRlIiwiYnRuLnJlY"}, 4 | {"datagram_size":"2968","datagram_id":"13","fragment_offset":"905","fragment_length":"905","fragment_data":"29yZCI6IlJlY29yZCIsImJ0bi5zdWJ0aXRsZSI6IlN1YnRpdGxlIiwiYnRuLnJld2luZCI6IlJld2luZCIsImJ0bi5leGl0IjoiRXhpdCIsImJ0bi5kb3duIjoiRG93biIsImJ0bi5zYXAiOiJTYXAiLCJidG4ueWVsbG93IjoiWWVsbG93IiwiYnRuLmZ1bmNfZCI6IkxhYmVsIEQiLCJidG4uaW5mbyI6IkluZm8iLCJidG4uZGlnaXRfNSI6IjUiLCJidG4uZGlnaXRfMyI6IjMiLCJidG4uZGlnaXRfMCI6IjAiLCJidG4uc2tpcF9md2QiOiJTa2lwIEZXRCIsImJ0bi5kZWxpbWl0ZXIiOiJEZWxpbWl0ZXIiLCJidG4ucmlnaHQiOiJSaWdodCIsImJ0bi52b2xfdXAiOiJWb2x1bWUgVXAiLCJidG4uY2hfdXAiOiJDaGFubmVsIFVwIiwiYnRuLmRpZ2l0XzgiOiI4IiwiYnRuLmRpZ2l0XzYiOiI2IiwiYnRuLmd1aWRlIjoiR3VpZGUiLCJidG4uc3RvcCI6IlN0b3AiLCJidG4uc2VsZWN0IjoiU2VsZWN0IiwiYnRuLnBvd2VyX29uIjoiT24iLCJidG4uY2hfZW50ZXIiOiJFbnRlciIsImJ0bi5kaWdpdF8xIjoiMSIsImJ0bi5wYXVzZSI6IlBhdXNlIiwiYnRuLmR2ciI6IlJlY29yZGluZ3MifX0seyJkZXZpY2VfaWQiOiIxIiwiZGV2aWNlX2JyYW5kIjoiU2t5IERldXRzY2hsYW5kIiwiZGV2aWNlX3R5cGUiOiJzdGIiLCJidXR0b25zIjp7ImJ0bi5iYWNrIjoiQmFjayIsImJ0bi5yZWQiOiJSZWQiLCJidG4udX"}, 5 | {"datagram_size":"2968","datagram_id":"13","fragment_offset":"1810","fragment_length":"905","fragment_data":"AiOiJVcCIsImJ0bi5jaF9kb3duIjoiQ2hhbm5lbCBEb3duIiwiYnRuLmZvcm1hdCI6IkZvcm1hdCIsImJ0bi5kaWdpdF8yIjoiMiIsImJ0bi5kaWdpdF83IjoiNyIsImJ0bi5sYXN0IjoiTGFzdCIsImJ0bi5mYXN0X2Z3ZCI6IkZGV0QiLCJidG4ubWVudSI6Ik1lbnUiLCJidG4ucG93ZXIiOiJQb3dlciIsImJ0bi5sZWZ0IjoiTGVmdCIsImJ0bi5ibHVlIjoiQmx1ZSIsImJ0bi52b2xfZG93biI6IlZvbHVtZSBEb3duIiwiYnRuLmdyZWVuIjoiR3JlZW4iLCJidG4uZGlnaXRfNCI6IjQiLCJidG4uZGlnaXRfOSI6IjkiLCJidG4ucGxheSI6IlBsYXkiLCJidG4udm9sX211dGUiOiJNdXRlIiwiYnRuLnJlY29yZCI6IlJlY29yZCIsImJ0bi5yZXdpbmQiOiJSZXdpbmQiLCJidG4uZXhpdCI6IkV4aXQiLCJidG4uZG93biI6IkRvd24iLCJidG4ueWVsbG93IjoiWWVsbG93IiwiYnRuLmluZm8iOiJJbmZvIiwiYnRuLmRpZ2l0XzUiOiI1IiwiYnRuLmRpZ2l0XzMiOiIzIiwiYnRuLmRpZ2l0XzAiOiIwIiwiYnRuLmxpdmUiOiJMaXZlIiwiYnRuLnZvbF91cCI6IlZvbHVtZSBVcCIsImJ0bi5yaWdodCI6IlJpZ2h0IiwiYnRuLmNoX3VwIjoiQ2hhbm5lbCBVcCIsImJ0bi5kaWdpdF84IjoiOCIsImJ0bi5kaWdpdF82IjoiNiIsImJ0bi5ndWlkZSI6Ikd1aWRlIiwiYnRuLnN0b3AiOiJTdG9wIiwiYnRuLnNlbGV"}, 6 | {"datagram_size":"2968","datagram_id":"13","fragment_offset":"2715","fragment_length":"253","fragment_data":"jdCI6IlNlbGVjdCIsImJ0bi5kaWdpdF8xIjoiMSIsImJ0bi5wYXVzZSI6IlBhdXNlIiwiYnRuLmR2ciI6IlJlY29yZGluZ3MifX0seyJkZXZpY2VfaWQiOiJ0dW5lciIsImRldmljZV90eXBlIjoidHVuZXIiLCJidXR0b25zIjp7ImJ0bi5wbGF5IjoiUExBWSIsImJ0bi5wYXVzZSI6IlBBVVNFIiwiYnRuLnNlZWsiOiJTRUVLIn19XX0="} 7 | ] 8 | } -------------------------------------------------------------------------------- /xbox/sg/utils/struct.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom construct fields and utilities 3 | """ 4 | import construct 5 | 6 | 7 | COMPILED = True 8 | 9 | 10 | class XStruct(construct.Subconstruct): 11 | def __init__(self, *args, **kwargs): 12 | struct = construct.Struct(*args, **kwargs) 13 | super(XStruct, self).__init__(struct) 14 | self.compiled = self.compile() if COMPILED else None 15 | 16 | def parse(self, data, **contextkw): 17 | res = super(XStruct, self).parse(data, **contextkw) 18 | return XStructObj(self, res) 19 | 20 | def _parse(self, stream, context, path): 21 | if self.compiled: 22 | res = self.compiled._parse(stream, context, path) 23 | else: 24 | res = self.subcon._parse(stream, context, path) 25 | 26 | return res 27 | 28 | def _emitparse(self, code): 29 | return self.subcon._emitparse(code) 30 | 31 | def _find(self, item): 32 | sub = next( 33 | (s for s in self.subcon.subcons if s.name == item), None 34 | ) 35 | if sub: 36 | return sub 37 | 38 | return None 39 | 40 | def __call__(self, **kwargs): 41 | return XStructObj(self)(**kwargs) 42 | 43 | def __contains__(self, item): 44 | return self._find(item) is not None 45 | 46 | def __getattr__(self, item): 47 | subcon = self._find(item) 48 | 49 | if subcon: 50 | return subcon 51 | 52 | return super(XStruct, self).__getattribute__(item) 53 | 54 | 55 | class XStructObj(construct.Subconstruct): 56 | __slots__ = ['_obj'] 57 | 58 | def __init__(self, struct, container=None): 59 | super(XStructObj, self).__init__(struct) 60 | self._obj = container if container else construct.Container({ 61 | sc.name: None for sc in self.subcon.subcon.subcons 62 | }) 63 | 64 | @property 65 | def container(self): 66 | return self._obj 67 | 68 | def __call__(self, **kwargs): 69 | self._obj.update(kwargs) 70 | return self 71 | 72 | def _parse(self, stream, context, path): 73 | self._obj = self.subcon._parse(stream, context, path) 74 | return self 75 | 76 | def build(self, **contextkw): 77 | return self.subcon.build(self._obj, **contextkw) 78 | 79 | def _build(self, stream, context, path): 80 | return self.subcon._build(self._obj, stream, context, path) 81 | 82 | def __getattr__(self, item): 83 | if item in self._obj: 84 | return getattr(self._obj, item) 85 | return super(XStructObj, self).__getattribute__(item) 86 | 87 | def __repr__(self): 88 | return '(%s)' % ( 89 | self.subcon.name, self._obj 90 | ) 91 | 92 | 93 | def flatten(container): 94 | """ 95 | Flattens `StructWrap` objects into just `Container`'s. 96 | 97 | Recursively walks down each value of the `Container`, flattening 98 | possible `StructWrap` objects. 99 | 100 | Args: 101 | container (Container): The container to flatten. 102 | 103 | Returns: 104 | `Container`: A flattened container. 105 | """ 106 | obj = container.copy() 107 | for k, v in obj.items(): 108 | if isinstance(v, XStructObj): 109 | obj[k] = flatten(v.container) 110 | 111 | return obj 112 | -------------------------------------------------------------------------------- /xbox/sg/packet/simple.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | """ 3 | Construct containers for simple-message header and payloads 4 | """ 5 | from construct import * 6 | from xbox.sg import enum 7 | from xbox.sg.enum import PacketType 8 | from xbox.sg.utils.struct import XStruct 9 | from xbox.sg.utils.adapters import CryptoTunnel, XSwitch, XEnum, CertificateAdapter, UUIDAdapter, SGString, FieldIn 10 | 11 | 12 | pkt_types = [ 13 | PacketType.PowerOnRequest, 14 | PacketType.DiscoveryRequest, PacketType.DiscoveryResponse, 15 | PacketType.ConnectRequest, PacketType.ConnectResponse 16 | ] 17 | 18 | 19 | header = XStruct( 20 | 'pkt_type' / XEnum(Int16ub, PacketType), 21 | 'unprotected_payload_length' / Default(Int16ub, 0), 22 | 'protected_payload_length' / If( 23 | FieldIn('pkt_type', [PacketType.ConnectRequest, PacketType.ConnectResponse]), 24 | Default(Int16ub, 0) 25 | ), 26 | 'version' / Default(Int16ub, 2) 27 | ) 28 | 29 | 30 | power_on_request = XStruct( 31 | 'liveid' / SGString() 32 | ) 33 | 34 | 35 | discovery_request = XStruct( 36 | 'flags' / Int32ub, 37 | 'client_type' / XEnum(Int16ub, enum.ClientType), 38 | 'minimum_version' / Int16ub, 39 | 'maximum_version' / Int16ub 40 | ) 41 | 42 | 43 | discovery_response = XStruct( 44 | 'flags' / XEnum(Int32ub, enum.PrimaryDeviceFlag), 45 | 'type' / XEnum(Int16ub, enum.ClientType), 46 | 'name' / SGString(), 47 | 'uuid' / UUIDAdapter('utf8'), 48 | 'last_error' / Int32ub, 49 | 'cert' / CertificateAdapter() 50 | ) 51 | 52 | 53 | connect_request_unprotected = XStruct( 54 | 'sg_uuid' / UUIDAdapter(), 55 | 'public_key_type' / XEnum(Int16ub, enum.PublicKeyType), 56 | 'public_key' / XSwitch(this.public_key_type, { 57 | enum.PublicKeyType.EC_DH_P256: Bytes(0x40), 58 | enum.PublicKeyType.EC_DH_P384: Bytes(0x60), 59 | enum.PublicKeyType.EC_DH_P521: Bytes(0x84) 60 | }), 61 | 'iv' / Bytes(0x10) 62 | ) 63 | 64 | 65 | connect_request_protected = XStruct( 66 | 'userhash' / SGString(), 67 | 'jwt' / SGString(), 68 | 'connect_request_num' / Int32ub, 69 | 'connect_request_group_start' / Int32ub, 70 | 'connect_request_group_end' / Int32ub 71 | ) 72 | 73 | 74 | connect_response_unprotected = XStruct( 75 | 'iv' / Bytes(0x10) 76 | ) 77 | 78 | 79 | connect_response_protected = XStruct( 80 | 'connect_result' / XEnum(Int16ub, enum.ConnectionResult), 81 | 'pairing_state' / XEnum(Int16ub, enum.PairedIdentityState), 82 | 'participant_id' / Int32ub 83 | ) 84 | 85 | 86 | struct = XStruct( 87 | 'header' / header, 88 | 'unprotected_payload' / XSwitch( 89 | this.header.pkt_type, { 90 | PacketType.PowerOnRequest: power_on_request, 91 | PacketType.DiscoveryRequest: discovery_request, 92 | PacketType.DiscoveryResponse: discovery_response, 93 | PacketType.ConnectRequest: connect_request_unprotected, 94 | PacketType.ConnectResponse: connect_response_unprotected 95 | } 96 | ), 97 | 'protected_payload' / CryptoTunnel( 98 | XSwitch( 99 | this.header.pkt_type, { 100 | PacketType.ConnectRequest: connect_request_protected, 101 | PacketType.ConnectResponse: connect_response_protected 102 | }, 103 | Pass 104 | ) 105 | ) 106 | ) 107 | -------------------------------------------------------------------------------- /xbox/auxiliary/manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from typing import Any 4 | 5 | from xbox.sg import factory 6 | from xbox.sg.manager import Manager 7 | from xbox.sg.enum import ServiceChannel, MessageType 8 | from xbox.sg.constants import MessageTarget 9 | from xbox.sg.utils.events import Event 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | class TitleManagerError(Exception): 15 | """ 16 | Exception thrown by TitleManager 17 | """ 18 | pass 19 | 20 | 21 | class TitleManager(Manager): 22 | __namespace__ = 'title' 23 | 24 | def __init__(self, console): 25 | """ 26 | Title Manager (ServiceChannel.Title) 27 | 28 | Args: 29 | console (:class:`.Console`): Console object, internally passed 30 | by `Console.add_manager` 31 | """ 32 | super(TitleManager, self).__init__(console, ServiceChannel.Title) 33 | self._active_surface = None 34 | self._connection_info = None 35 | 36 | self.on_surface_change = Event() 37 | self.on_connection_info = Event() 38 | 39 | def _on_message(self, msg, channel): 40 | """ 41 | Internal handler method to receive messages from Title Channel 42 | 43 | Args: 44 | msg (:class:`XStructObj`): Message 45 | channel (:class:`ServiceChannel`): Service channel 46 | """ 47 | msg_type = msg.header.flags.msg_type 48 | payload = msg.protected_payload 49 | if msg_type == MessageType.AuxilaryStream: 50 | if payload.connection_info_flag == 0: 51 | log.debug('Received AuxiliaryStream HELLO') 52 | self._request_connection_info() 53 | else: 54 | log.debug('Received AuxiliaryStream CONNECTION INFO') 55 | self.connection_info = payload.connection_info 56 | 57 | elif msg_type == MessageType.ActiveSurfaceChange: 58 | self.active_surface = payload 59 | 60 | else: 61 | raise TitleManagerError( 62 | f'Unhandled Msg: {msg_type}, Payload: {payload}' 63 | ) 64 | 65 | async def _request_connection_info(self) -> None: 66 | msg = factory.title_auxiliary_stream() 67 | return await self._send_message(msg) 68 | 69 | async def start_title_channel(self, title_id: int) -> Any: 70 | return await self.console.protocol.start_channel( 71 | ServiceChannel.Title, 72 | MessageTarget.TitleUUID, 73 | title_id=title_id 74 | ) 75 | 76 | @property 77 | def active_surface(self): 78 | """ 79 | Get `Active Surface`. 80 | 81 | Returns: 82 | :class:`XStructObj`: Active Surface 83 | """ 84 | return self._active_surface 85 | 86 | @active_surface.setter 87 | def active_surface(self, value): 88 | """ 89 | Set `Active Surface`. 90 | 91 | Args: 92 | value (:class:`XStructObj`): Active Surface payload 93 | 94 | Returns: None 95 | """ 96 | self._active_surface = value 97 | self.on_surface_change(value) 98 | 99 | @property 100 | def connection_info(self): 101 | """ 102 | Get current `Connection info` 103 | 104 | Returns: 105 | :class:`XStructObj`: Connection info 106 | """ 107 | return self._connection_info 108 | 109 | @connection_info.setter 110 | def connection_info(self, value): 111 | """ 112 | Set `Connection info` and setup `Crypto`-context 113 | 114 | Args: 115 | value (:class:`XStructObj`): Connection info 116 | 117 | Returns: None 118 | """ 119 | self._connection_info = value 120 | self.on_connection_info(value) 121 | -------------------------------------------------------------------------------- /xbox/auxiliary/crypto.py: -------------------------------------------------------------------------------- 1 | """ 2 | Cryptography portion used for Title Channel aka Auxiliary Stream 3 | """ 4 | import hmac 5 | import hashlib 6 | from cryptography.hazmat.backends import default_backend 7 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 8 | 9 | 10 | class AuxiliaryStreamCrypto(object): 11 | _backend = default_backend() 12 | 13 | def __init__(self, crypto_key, hash_key, server_iv, client_iv): 14 | """ 15 | Initialize Auxiliary Stream Crypto-context. 16 | """ 17 | 18 | self._encrypt_key = crypto_key 19 | self._hash_key = hash_key 20 | self._server_iv = server_iv 21 | self._client_iv = client_iv 22 | 23 | self._server_cipher = Cipher( 24 | algorithms.AES(self._encrypt_key), 25 | modes.CBC(self._server_iv), 26 | backend=AuxiliaryStreamCrypto._backend 27 | ) 28 | 29 | self._server_encryptor = self._server_cipher.encryptor() 30 | self._server_decryptor = self._server_cipher.decryptor() 31 | 32 | self._client_cipher = Cipher( 33 | algorithms.AES(self._encrypt_key), 34 | modes.CBC(self._client_iv), 35 | backend=AuxiliaryStreamCrypto._backend 36 | ) 37 | 38 | self._client_encryptor = self._client_cipher.encryptor() 39 | self._client_decryptor = self._client_cipher.decryptor() 40 | 41 | @classmethod 42 | def from_connection_info(cls, connection_info): 43 | """ 44 | Initialize Crypto context via AuxiliaryStream-message 45 | connection info. 46 | """ 47 | return cls( 48 | connection_info.crypto_key, 49 | connection_info.sign_hash, 50 | connection_info.server_iv, 51 | connection_info.client_iv 52 | ) 53 | 54 | def encrypt(self, plaintext): 55 | """ 56 | Encrypts plaintext with AES-128-CBC 57 | 58 | No padding is added here, data has to be aligned to 59 | block size (16 bytes). 60 | 61 | Args: 62 | plaintext (bytes): The plaintext to encrypt. 63 | 64 | Returns: 65 | bytes: Encrypted Data 66 | """ 67 | return AuxiliaryStreamCrypto._crypt(self._client_encryptor, plaintext) 68 | 69 | def encrypt_server(self, plaintext): 70 | return AuxiliaryStreamCrypto._crypt(self._server_encryptor, plaintext) 71 | 72 | def decrypt(self, ciphertext): 73 | """ 74 | Decrypts ciphertext 75 | 76 | No padding is removed here. 77 | 78 | Args: 79 | ciphertext (bytes): Ciphertext to be decrypted 80 | 81 | Returns: 82 | bytes: Decrypted data 83 | """ 84 | return AuxiliaryStreamCrypto._crypt(self._server_decryptor, ciphertext) 85 | 86 | def decrypt_client(self, ciphertext): 87 | return AuxiliaryStreamCrypto._crypt(self._client_decryptor, ciphertext) 88 | 89 | def hash(self, data): 90 | """ 91 | Securely hashes data with HMAC SHA-256 92 | 93 | Args: 94 | data (bytes): The data to securely hash. 95 | 96 | Returns: 97 | bytes: Hashed data 98 | """ 99 | return AuxiliaryStreamCrypto._secure_hash(self._hash_key, data) 100 | 101 | def verify(self, data, secure_hash): 102 | """ 103 | Verifies that the given data generates the given secure_hash 104 | 105 | Args: 106 | data (bytes): The data to validate. 107 | secure_hash (bytes): The secure hash to validate against. 108 | 109 | Returns: 110 | bool: True on success, False otherwise 111 | """ 112 | return secure_hash == self.hash(data) 113 | 114 | @staticmethod 115 | def _secure_hash(key, data): 116 | return hmac.new(key, data, hashlib.sha256).digest() 117 | 118 | @staticmethod 119 | def _crypt(cryptor, data): 120 | return cryptor.update(data) 121 | -------------------------------------------------------------------------------- /tests/data/stump_json/response_tuner_lineups: -------------------------------------------------------------------------------- 1 | { 2 | "response": "GetTunerLineups", 3 | "msgid": "xV5X1YCB.15", 4 | "params": { 5 | "providers": [ 6 | { 7 | "headendId": "0A7FB88A-960B-C2E3-9975-7C86C5FA6C49", 8 | "cqsChannels": [ 9 | "178442d3-2b13-e02b-9747-a3d4ebebcf62_PHOENIHD_23", 10 | "4f74d25b-881f-3a59-e223-8a3c63402514_ONEHD_22", 11 | "746a7737-3234-614d-bf55-04323301eb72_SWRFERN_39", 12 | "bb1ca492-232b-adfe-1f39-d010eabf179e_MSAHD_16", 13 | "f5072b30-a789-6195-0afc-6ab2528854ea_WDRKOELN_80", 14 | "fc458347-db5a-ab9a-b982-a7f74c632730_ARTEHD_3", 15 | "ff907416-852f-53a4-19ca-2f4452384db3_TG24HD_40" 16 | ], 17 | "foundChannels": [ 18 | { 19 | "channelId": "000021146A000301", 20 | "displayName": "Das Erste HD", 21 | "channelNumber": "0" 22 | }, 23 | { 24 | "channelId": "000021146A000401", 25 | "displayName": "WDR HD Aachen", 26 | "channelNumber": "0" 27 | }, 28 | { 29 | "channelId": "000021146A00040A", 30 | "displayName": "WDR HD Bonn", 31 | "channelNumber": "0" 32 | }, 33 | { 34 | "channelId": "000021146A00040C", 35 | "displayName": "Test-R", 36 | "channelNumber": "0" 37 | }, 38 | { 39 | "channelId": "000021146A000622", 40 | "displayName": "BR FS S\u00fcd HD (Internet)", 41 | "channelNumber": "0" 42 | }, 43 | { 44 | "channelId": "000021146A000623", 45 | "displayName": "ARD-alpha HD (Internet)", 46 | "channelNumber": "0" 47 | }, 48 | { 49 | "channelId": "000021146A000641", 50 | "displayName": "hr-fernsehen HD (Internet)", 51 | "channelNumber": "0" 52 | }, 53 | { 54 | "channelId": "000021146A0006B1", 55 | "displayName": "rbb Berlin HD (Internet)", 56 | "channelNumber": "0" 57 | }, 58 | { 59 | "channelId": "000021146A00080C", 60 | "displayName": "1LIVE (Internet)", 61 | "channelNumber": "0" 62 | }, 63 | { 64 | "channelId": "000021146A00080D", 65 | "displayName": "WDR 2 (Internet)", 66 | "channelNumber": "0" 67 | }, 68 | { 69 | "channelId": "000021146A00080E", 70 | "displayName": "WDR 3 (Internet)", 71 | "channelNumber": "0" 72 | }, 73 | { 74 | "channelId": "000021146A00080F", 75 | "displayName": "WDR 4 (Internet)", 76 | "channelNumber": "0" 77 | }, 78 | { 79 | "channelId": "000021146A000810", 80 | "displayName": "WDR 5 (Internet)", 81 | "channelNumber": "0" 82 | }, 83 | { 84 | "channelId": "000021146A000811", 85 | "displayName": "WDRcosmo (Internet)", 86 | "channelNumber": "0" 87 | }, 88 | { 89 | "channelId": "000021146A000812", 90 | "displayName": "KIRAKA (Internet)", 91 | "channelNumber": "0" 92 | }, 93 | { 94 | "channelId": "000021146A000813", 95 | "displayName": "1LIVE diGGi (Internet)", 96 | "channelNumber": "0" 97 | }, 98 | { 99 | "channelId": "000021146A000814", 100 | "displayName": "WDR Event (Internet)", 101 | "channelNumber": "0" 102 | }, 103 | { 104 | "channelId": "000021146A000815", 105 | "displayName": "WDR Mediathek (Internet)", 106 | "channelNumber": "0" 107 | }, 108 | { 109 | "channelId": "000021146F000381", 110 | "displayName": "NDR FS NDS HD", 111 | "channelNumber": "0" 112 | } 113 | ] 114 | } 115 | ] 116 | } 117 | } -------------------------------------------------------------------------------- /docs/source/xbox.sg.scripts.client.rst: -------------------------------------------------------------------------------- 1 | Example Smartglass client 2 | ========================= 3 | 4 | Bare client example. It just connects to the console, shows sent/received 5 | packets and keeps the connection alive. 6 | 7 | NOTE: It connects to the first console that's found! 8 | 9 | Usage: 10 | :: 11 | 12 | usage: xbox-client [-h] [--tokens TOKENS] [--address ADDRESS] [--refresh] 13 | [--verbose] 14 | 15 | Basic smartglass client 16 | 17 | optional arguments: 18 | -h, --help show this help message and exit 19 | --tokens TOKENS, -t TOKENS 20 | Token file, created by xbox-authenticate script 21 | --address ADDRESS, -a ADDRESS 22 | IP address of console 23 | --refresh, -r Refresh xbox live tokens in provided token file 24 | --verbose, -v Verbose flag, also log message content 25 | 26 | Example: 27 | :: 28 | 29 | xbox-client -v 30 | 31 | 32 | Output: 33 | :: 34 | 35 | INFO:authentication:Loaded token from file 36 | INFO:authentication:Loaded token from file 37 | INFO:authentication:Loaded token from file 38 | INFO:authentication:Loaded token from file 39 | DEBUG:xbox.sg.protocol:Received DiscoverResponse from 10.0.0.241 40 | DEBUG:xbox.sg.protocol:Received DiscoverResponse from 10.0.0.241 41 | DEBUG:xbox.sg.protocol:Sending ConnectRequest to 10.0.0.241 42 | DEBUG:xbox.sg.protocol:Sending ConnectRequest to 10.0.0.241 43 | DEBUG:xbox.sg.protocol:Sending ConnectRequest to 10.0.0.241 44 | DEBUG:xbox.sg.protocol:Received DiscoverResponse from 10.0.0.241 45 | DEBUG:xbox.sg.protocol:Received ConnectResponse from 10.0.0.241 46 | DEBUG:xbox.sg.protocol:Sending LocalJoin message on ServiceChannel Core to 10.0.0.241 47 | DEBUG:xbox.sg.protocol:Received Ack message on ServiceChannel Ack from 10.0.0.241 48 | DEBUG:xbox.sg.protocol:Sending StartChannelRequest message on ServiceChannel Core to 10.0.0.241 49 | DEBUG:xbox.sg.protocol:Received ConsoleStatus message on ServiceChannel Core from 10.0.0.241 50 | DEBUG:xbox.sg.protocol:Received Ack message on ServiceChannel Ack from 10.0.0.241 51 | DEBUG:xbox.sg.protocol:Sending StartChannelRequest message on ServiceChannel Core to 10.0.0.241 52 | DEBUG:xbox.sg.protocol:Received Ack message on ServiceChannel Ack from 10.0.0.241 53 | DEBUG:xbox.sg.protocol:Sending StartChannelRequest message on ServiceChannel Core to 10.0.0.241 54 | DEBUG:xbox.sg.protocol:Received Ack message on ServiceChannel Ack from 10.0.0.241 55 | DEBUG:xbox.sg.protocol:Sending StartChannelRequest message on ServiceChannel Core to 10.0.0.241 56 | DEBUG:xbox.sg.protocol:Received Ack message on ServiceChannel Ack from 10.0.0.241 57 | DEBUG:xbox.sg.protocol:Sending StartChannelRequest message on ServiceChannel Core to 10.0.0.241 58 | DEBUG:xbox.sg.protocol:Received Ack message on ServiceChannel Ack from 10.0.0.241 59 | DEBUG:xbox.sg.protocol:Received StartChannelResponse message on ServiceChannel Core from 10.0.0.241 60 | DEBUG:xbox.sg.protocol:Acquired ServiceChannel SystemInput -> Channel: 0x6 61 | DEBUG:xbox.sg.protocol:Received StartChannelResponse message on ServiceChannel Core from 10.0.0.241 62 | DEBUG:xbox.sg.protocol:Acquired ServiceChannel SystemInputTVRemote -> Channel: 0x7 63 | DEBUG:xbox.sg.protocol:Received StartChannelResponse message on ServiceChannel Core from 10.0.0.241 64 | DEBUG:xbox.sg.protocol:Acquired ServiceChannel SystemMedia -> Channel: 0x8 65 | DEBUG:xbox.sg.protocol:Received StartChannelResponse message on ServiceChannel Core from 10.0.0.241 66 | DEBUG:xbox.sg.protocol:Acquired ServiceChannel SystemText -> Channel: 0x9 67 | DEBUG:xbox.sg.protocol:Received StartChannelResponse message on ServiceChannel Core from 10.0.0.241 68 | DEBUG:xbox.sg.protocol:Acquired ServiceChannel SystemBroadcast -> Channel: 0xa 69 | DEBUG:xbox.sg.protocol:Received Json message on ServiceChannel SystemBroadcast from 10.0.0.241 70 | DEBUG:xbox.sg.protocol:Received Json message on ServiceChannel SystemBroadcast from 10.0.0.241 71 | -------------------------------------------------------------------------------- /xbox/rest/routes/auth.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | import aiohttp 3 | 4 | from typing import Optional 5 | from fastapi import APIRouter, HTTPException, status 6 | from fastapi.responses import RedirectResponse 7 | 8 | from .. import singletons, schemas 9 | from ..common import generate_authentication_status, generate_authentication_manager 10 | 11 | from xbox.webapi.scripts import CLIENT_ID, CLIENT_SECRET 12 | from xbox.webapi.api.client import XboxLiveClient 13 | 14 | router = APIRouter() 15 | 16 | 17 | @router.get('/', response_model=schemas.AuthenticationStatus) 18 | def authentication_overview(): 19 | if not singletons.authentication_manager: 20 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='You have to login first') 21 | 22 | return generate_authentication_status(singletons.authentication_manager) 23 | 24 | 25 | @router.get('/login', status_code=status.HTTP_307_TEMPORARY_REDIRECT) 26 | async def xboxlive_login( 27 | client_id: str = CLIENT_ID, 28 | client_secret: Optional[str] = CLIENT_SECRET, 29 | redirect_uri: str = 'http://localhost:5557/auth/callback', 30 | scopes: str = 'Xboxlive.signin,Xboxlive.offline_access' 31 | ): 32 | if scopes: 33 | scopes = scopes.split(',') 34 | 35 | auth_session_config = schemas.AuthSessionConfig( 36 | client_id=client_id, 37 | client_secret=client_secret, 38 | redirect_uri=redirect_uri, 39 | scopes=scopes 40 | ) 41 | 42 | # Create local instance of AuthenticationManager 43 | # -> Final instance will be created and saved in the 44 | # callback route 45 | auth_mgr = generate_authentication_manager(auth_session_config) 46 | 47 | # Generating a random state to transmit in the authorization url 48 | session_state = secrets.token_hex(10) 49 | # Storing the state/session config so it can be retrieved 50 | # in the callback function 51 | singletons.auth_session_configs.update({session_state: auth_session_config}) 52 | 53 | authorization_url = auth_mgr.generate_authorization_url(state=session_state) 54 | return RedirectResponse(authorization_url) 55 | 56 | 57 | @router.get('/callback', status_code=status.HTTP_307_TEMPORARY_REDIRECT) 58 | async def xboxlive_login_callback( 59 | code: str, 60 | state: str, 61 | error: Optional[str] = None 62 | ): 63 | if error: 64 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) 65 | elif not code or not state: 66 | parameter_name = 'Code' if not code else 'State' 67 | error_detail = f'{parameter_name} missing from authorization callback' 68 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_detail) 69 | 70 | # Get auth session config that was set previously when 71 | # generating authorization redirect 72 | auth_session_config = singletons.auth_session_configs.get(state) 73 | if not auth_session_config: 74 | raise HTTPException( 75 | status_code=status.HTTP_404_NOT_FOUND, 76 | detail=f'Auth session config for state \'{state}\' not found' 77 | ) 78 | 79 | # Construct authentication manager that will be cached 80 | auth_mgr = generate_authentication_manager( 81 | auth_session_config, 82 | singletons.http_session 83 | ) 84 | await auth_mgr.request_tokens(code) 85 | 86 | singletons.authentication_manager = auth_mgr 87 | singletons.xbl_client = XboxLiveClient(singletons.authentication_manager) 88 | return RedirectResponse(url='/auth') 89 | 90 | 91 | @router.get('/refresh', status_code=status.HTTP_307_TEMPORARY_REDIRECT) 92 | async def refresh_tokens(): 93 | if not singletons.authentication_manager: 94 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='You have to login first') 95 | 96 | async with aiohttp.ClientSession() as http_session: 97 | singletons.authentication_manager.session = http_session 98 | singletons.authentication_manager.oauth = await singletons.authentication_manager.refresh_oauth_token() 99 | singletons.authentication_manager.user_token = await singletons.authentication_manager.request_user_token() 100 | singletons.authentication_manager.xsts_token = await singletons.authentication_manager.request_xsts_token() 101 | 102 | return RedirectResponse(url='/auth') 103 | 104 | 105 | @router.get('/logout', response_model=schemas.GeneralResponse) 106 | async def xboxlive_logout(): 107 | singletons.authentication_manager = None 108 | return schemas.GeneralResponse(success=True) 109 | -------------------------------------------------------------------------------- /tests/test_adapters.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import construct 3 | from xbox.sg.utils import adapters 4 | 5 | 6 | @pytest.mark.skip(reason="Not Implemented") 7 | def test_cryptotunnel(): 8 | pass 9 | 10 | 11 | def test_json(): 12 | import json 13 | 14 | test_data = {"Z": "ABC", "A": "XYZ", "B": 23} 15 | bytes_data = json.dumps(test_data, 16 | separators=(',', ':'), 17 | sort_keys=True).encode('utf-8') 18 | adapter = adapters.JsonAdapter(construct.GreedyString("utf8")) 19 | 20 | parsed = adapter.parse(bytes_data) 21 | built = adapter.build(test_data) 22 | 23 | with pytest.raises(json.JSONDecodeError): 24 | adapter.parse(b'invalid data') 25 | 26 | with pytest.raises(TypeError): 27 | adapter.parse(234) 28 | 29 | with pytest.raises(TypeError): 30 | adapter.parse("string") 31 | 32 | with pytest.raises(TypeError): 33 | adapter.build(b'invalid data') 34 | 35 | with pytest.raises(TypeError): 36 | adapter.build(234) 37 | 38 | with pytest.raises(TypeError): 39 | adapter.build("string") 40 | 41 | assert parsed == test_data 42 | assert built == bytes_data 43 | 44 | 45 | def test_uuid(uuid_dummy): 46 | import struct 47 | test_data = uuid_dummy 48 | uuid_string = str(test_data).upper() 49 | uuid_stringbytes = uuid_string.encode('utf-8') 50 | uuid_sgstring = struct.pack('>H', len(uuid_stringbytes)) + uuid_stringbytes + b'\x00' 51 | uuid_bytes = test_data.bytes 52 | 53 | adapter_bytes = adapters.UUIDAdapter() 54 | parsed_bytes = adapter_bytes.parse(uuid_bytes) 55 | built_bytes = adapter_bytes.build(test_data) 56 | 57 | adapter_string = adapters.UUIDAdapter("utf-8") 58 | parsed_sgstring = adapter_string.parse(uuid_sgstring) 59 | built_sgstring = adapter_string.build(test_data) 60 | 61 | adapter_invalid = adapters.UUIDAdapter("utf-invalid") 62 | 63 | with pytest.raises(LookupError): 64 | adapter_invalid.parse(uuid_sgstring) 65 | 66 | with pytest.raises(LookupError): 67 | adapter_invalid.build(test_data) 68 | 69 | with pytest.raises(construct.StreamError): 70 | adapter_bytes.parse(uuid_bytes[:-2]) 71 | 72 | with pytest.raises(TypeError): 73 | adapter_bytes.parse('string, not bytes object') 74 | 75 | with pytest.raises(TypeError): 76 | adapter_bytes.build('some-string, not UUID object') 77 | 78 | with pytest.raises(construct.StreamError): 79 | adapter_string.parse(uuid_sgstring[:-3]) 80 | 81 | with pytest.raises(TypeError): 82 | adapter_string.parse('string, not sgstring-bytes') 83 | 84 | with pytest.raises(TypeError): 85 | adapter_string.build('some-string, not UUID object') 86 | 87 | assert parsed_bytes == test_data 88 | assert built_bytes == uuid_bytes 89 | assert parsed_sgstring == test_data 90 | assert built_sgstring == uuid_sgstring 91 | 92 | 93 | def test_certificate(certificate_data): 94 | import struct 95 | prefixed_data = struct.pack('>H', len(certificate_data)) + certificate_data 96 | certinfo = adapters.CertificateInfo(certificate_data) 97 | 98 | adapter = adapters.CertificateAdapter() 99 | 100 | parsed = adapter.parse(prefixed_data) 101 | built = adapter.build(certinfo) 102 | 103 | with pytest.raises(construct.core.StreamError): 104 | adapter.parse(prefixed_data[:-6]) 105 | 106 | with pytest.raises(construct.core.StreamError): 107 | adapter.parse(b'\xAB' * 10) 108 | 109 | with pytest.raises(construct.core.StreamError): 110 | adapter.parse(certificate_data) 111 | 112 | with pytest.raises(TypeError): 113 | adapter.parse('string, not bytes') 114 | 115 | with pytest.raises(TypeError): 116 | adapter.parse(123) 117 | 118 | with pytest.raises(TypeError): 119 | adapter.build(b'\xAB' * 10) 120 | 121 | with pytest.raises(TypeError): 122 | adapter.build(certificate_data) 123 | 124 | with pytest.raises(TypeError): 125 | adapter.build('string, not bytes') 126 | 127 | with pytest.raises(TypeError): 128 | adapter.build(123) 129 | 130 | assert isinstance(parsed, adapters.CertificateInfo) is True 131 | assert parsed == certinfo 132 | assert built == prefixed_data 133 | 134 | 135 | def test_certificateinfo(certificate_data): 136 | certinfo = adapters.CertificateInfo(certificate_data) 137 | 138 | with pytest.raises(ValueError): 139 | adapters.CertificateInfo(certificate_data[:-1]) 140 | 141 | with pytest.raises(ValueError): 142 | adapters.CertificateInfo(b'\x23' * 10) 143 | 144 | with pytest.raises(TypeError): 145 | adapters.CertificateInfo('some string') 146 | 147 | with pytest.raises(TypeError): 148 | adapters.CertificateInfo(123) 149 | 150 | with pytest.raises(TypeError): 151 | certinfo.dump(encoding='invalid param') 152 | 153 | assert certinfo.dump() == certificate_data 154 | assert certinfo.liveid == 'FFFFFFFFFFF' 155 | -------------------------------------------------------------------------------- /xbox/stump/json_model.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON models for deserializing Stump messages 3 | """ 4 | from typing import List, Dict, Union, Optional 5 | from uuid import UUID 6 | from pydantic import BaseModel 7 | from xbox.stump.enum import Message 8 | 9 | 10 | class StumpJsonError(Exception): 11 | pass 12 | 13 | 14 | # Root-level containers 15 | class StumpRequest(BaseModel): 16 | msgid: str 17 | request: str 18 | params: Optional[dict] 19 | 20 | class StumpResponse(BaseModel): 21 | msgid: str 22 | response: str 23 | 24 | 25 | class StumpError(BaseModel): 26 | msgid: str 27 | error: str 28 | 29 | 30 | class StumpNotification(BaseModel): 31 | notification: str 32 | 33 | 34 | # Nested models 35 | class _FoundChannel(BaseModel): 36 | channelNumber: int 37 | displayName: str 38 | channelId: str 39 | 40 | 41 | class _LineupProvider(BaseModel): 42 | foundChannels: List[_FoundChannel] 43 | cqsChannels: List[str] 44 | headendId: UUID 45 | 46 | 47 | class _EnsureStreamingStarted(BaseModel): 48 | currentChannelId: str 49 | source: str 50 | streamingPort: int 51 | tunerChannelType: str 52 | userCanViewChannel: str 53 | 54 | 55 | class _TunerLineups(BaseModel): 56 | providers: List[_LineupProvider] 57 | 58 | 59 | class _RecentChannel(BaseModel): 60 | channelNum: str # Can be "NumberUnused" instead of int 61 | providerId: UUID 62 | channelId: str 63 | 64 | 65 | class _PauseBufferInfo(BaseModel): 66 | Enabled: bool 67 | IsDvr: bool 68 | MaxBufferSize: int 69 | BufferCurrent: int 70 | BufferStart: int 71 | BufferEnd: int 72 | CurrentTime: int 73 | Epoch: int 74 | 75 | 76 | class _LiveTvInfo(BaseModel): 77 | streamingPort: Optional[int] 78 | inHdmiMode: bool 79 | tunerChannelType: Optional[str] 80 | currentTunerChannelId: Optional[str] 81 | currentHdmiChannelId: Optional[str] 82 | pauseBufferInfo: Optional[_PauseBufferInfo] 83 | 84 | 85 | class _HeadendProvider(BaseModel): 86 | providerName: str 87 | filterPreference: str 88 | headendId: UUID 89 | source: str 90 | titleId: str 91 | canStream: bool 92 | 93 | 94 | class _HeadendInfo(BaseModel): 95 | providerName: str 96 | headendId: UUID 97 | blockExplicitContentPerShow: bool 98 | dvrEnabled: bool 99 | headendLocale: str 100 | streamingPort: Optional[int] 101 | preferredProvider: Optional[str] 102 | providers: List[_HeadendProvider] 103 | 104 | 105 | class _DeviceConfiguration(BaseModel): 106 | device_id: str 107 | device_type: str 108 | device_brand: Optional[str] 109 | device_model: Optional[str] 110 | device_name: Optional[str] 111 | buttons: Dict[str, str] 112 | 113 | 114 | class _AppChannel(BaseModel): 115 | name: str 116 | id: str 117 | 118 | 119 | class _AppProvider(BaseModel): 120 | id: str 121 | providerName: str 122 | titleId: str 123 | primaryColor: str 124 | secondaryColor: str 125 | providerImageUrl: Optional[str] 126 | channels: List[_AppChannel] 127 | 128 | 129 | # Stump responses 130 | class AppChannelLineups(StumpResponse): 131 | params: List[_AppProvider] 132 | 133 | 134 | class EnsureStreamingStarted(StumpResponse): 135 | params: _EnsureStreamingStarted 136 | 137 | 138 | class TunerLineups(StumpResponse): 139 | params: _TunerLineups 140 | 141 | 142 | class SendKey(StumpResponse): 143 | params: bool 144 | 145 | 146 | class RecentChannels(StumpResponse): 147 | params: List[_RecentChannel] 148 | 149 | 150 | class Configuration(StumpResponse): 151 | params: List[_DeviceConfiguration] 152 | 153 | 154 | class LiveTvInfo(StumpResponse): 155 | params: _LiveTvInfo 156 | 157 | 158 | class HeadendInfo(StumpResponse): 159 | params: _HeadendInfo 160 | 161 | 162 | response_map = { 163 | Message.CONFIGURATION: Configuration, 164 | Message.ENSURE_STREAMING_STARTED: EnsureStreamingStarted, 165 | Message.SEND_KEY: SendKey, 166 | Message.RECENT_CHANNELS: RecentChannels, 167 | Message.SET_CHANNEL: None, 168 | Message.APPCHANNEL_PROGRAM_DATA: None, 169 | Message.APPCHANNEL_DATA: None, 170 | Message.APPCHANNEL_LINEUPS: AppChannelLineups, 171 | Message.TUNER_LINEUPS: TunerLineups, 172 | Message.PROGRAMM_INFO: None, 173 | Message.LIVETV_INFO: LiveTvInfo, 174 | Message.HEADEND_INFO: HeadendInfo, 175 | Message.ERROR: None 176 | } 177 | 178 | 179 | def deserialize_stump_message(data: dict) -> Union[StumpError, StumpNotification, StumpResponse]: 180 | """ 181 | Helper for deserializing JSON stump messages 182 | 183 | Args: 184 | data (dict): Stump message 185 | 186 | Returns: 187 | Model: Parsed JSON object 188 | """ 189 | response = data.get('response') 190 | notification = data.get('notification') 191 | error = data.get('error') 192 | 193 | if response: 194 | response = Message(response) 195 | model = response_map.get(response) 196 | if not issubclass(model, StumpResponse): 197 | raise StumpJsonError('Model not of subclass StumpResponse') 198 | 199 | return model.parse_obj(data) 200 | elif notification: 201 | return StumpNotification.load(data) 202 | elif error: 203 | return StumpError.load(data) 204 | -------------------------------------------------------------------------------- /xbox/auxiliary/relay.py: -------------------------------------------------------------------------------- 1 | import logging 2 | # import gevent 3 | import asyncio 4 | from typing import Optional 5 | 6 | from xbox.sg.crypto import PKCS7Padding 7 | from xbox.sg.utils.events import Event 8 | from xbox.auxiliary import packer 9 | from xbox.auxiliary.packet import aux_header_struct, AUX_PACKET_MAGIC 10 | from xbox.auxiliary.crypto import AuxiliaryStreamCrypto 11 | from xbox.sg.utils.struct import XStructObj 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | class AuxiliaryPackerException(Exception): 17 | pass 18 | 19 | 20 | class ConsoleConnection(object): 21 | BUFFER_SIZE = 2048 22 | 23 | def __init__(self, address, port, crypto): 24 | self.address = address 25 | self.port = port 26 | self.crypto = crypto 27 | 28 | self._reader: Optional[asyncio.StreamReader] = None 29 | self._writer: Optional[asyncio.StreamWriter] = None 30 | 31 | self._recv_task: Optional[asyncio.Task] = None 32 | self.on_message = Event() 33 | 34 | def start(self): 35 | self._reader, self._writer = asyncio.open_connection( 36 | self.address, self.port 37 | ) 38 | self._recv_task = asyncio.create_task(self._recv()) 39 | 40 | def stop(self): 41 | self._recv_task.cancel() 42 | 43 | def handle(self, data): 44 | try: 45 | msg = packer.unpack(data, self.crypto) 46 | # Fire event 47 | self.on_message(msg) 48 | except Exception as e: 49 | log.exception("Exception while handling Console Aux data, error: {}".format(e)) 50 | 51 | async def _recv(self): 52 | while True: 53 | data = await self._reader.read(4) 54 | header = aux_header_struct.parse(data) 55 | 56 | if header.magic != AUX_PACKET_MAGIC: 57 | raise Exception('Invalid packet magic received from console') 58 | 59 | payload_sz = header.payload_size + PKCS7Padding.size( 60 | header.payload_size, 16 61 | ) 62 | remaining_payload_bytes = payload_sz 63 | 64 | while remaining_payload_bytes > 0: 65 | tmp = await self._reader.read(remaining_payload_bytes) 66 | remaining_payload_bytes -= len(tmp) 67 | data += tmp 68 | 69 | data += await self._reader.read(32) 70 | 71 | self.handle(data) 72 | 73 | async def send(self, msg): 74 | packets = packer.pack(msg, self.crypto) 75 | 76 | if not packets: 77 | raise Exception('No data') 78 | 79 | for packet in packets: 80 | self._writer.write(packet) 81 | 82 | 83 | class LocalConnection(asyncio.Protocol): 84 | data_received_event = Event() 85 | connection_made_event = Event() 86 | 87 | def connection_made(self, transport: asyncio.BaseTransport) -> None: 88 | self.transport = transport 89 | self.connection_made(transport) 90 | 91 | def data_received(self, data: bytes) -> None: 92 | self.data_received(data) 93 | 94 | def close_connection(self) -> None: 95 | print('Close the client socket') 96 | self.transport.close() 97 | 98 | 99 | class AuxiliaryRelayService(object): 100 | def __init__( 101 | self, 102 | loop: asyncio.AbstractEventLoop, 103 | connection_info: XStructObj, 104 | listen_port: int 105 | ): 106 | if len(connection_info.endpoints) > 1: 107 | raise Exception( 108 | 'Auxiliary Stream advertises more than one endpoint!' 109 | ) 110 | 111 | self._loop = loop 112 | self.crypto = AuxiliaryStreamCrypto.from_connection_info( 113 | connection_info 114 | ) 115 | self.target_ip = connection_info.endpoints[0].ip 116 | self.target_port = connection_info.endpoints[0].port 117 | 118 | self.console_connection = ConsoleConnection( 119 | self.target_ip, 120 | self.target_port, 121 | self.crypto 122 | ) 123 | 124 | self.server = self._loop.create_server( 125 | lambda: LocalConnection(), 126 | '0.0.0.0', listen_port 127 | ) 128 | 129 | self.client_transport = None 130 | 131 | async def run(self): 132 | async with self.server as local_connection: 133 | local_connection.data_received_event += self._handle_client_data 134 | local_connection.connection_made_event += self.connection_made 135 | 136 | while True: 137 | # HACK / FIXME 138 | await asyncio.sleep(10000) 139 | 140 | def connection_made(self, transport): 141 | self.client_transport = transport 142 | peername = transport.get_extra_info('peername') 143 | print('Connection from {}'.format(peername)) 144 | 145 | self.console_connection.on_message += self._handle_console_data 146 | self.console_connection.start() 147 | 148 | def _handle_console_data(self, data): 149 | # Data from console gets decrypted and forwarded to aux client 150 | if self.client_transport: 151 | self.client_transport.send(data) 152 | 153 | def _handle_client_data(self, data): 154 | # Data from aux client gets encrypted and sent to console 155 | self.console_connection.send(data) 156 | -------------------------------------------------------------------------------- /xbox/sg/packer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Smartglass message un-/packing 3 | 4 | Also handles de/encryption internally, this means: 5 | You feed plaintext data (wrapped in :class:`.XStruct`) and you get plaintext data 6 | 7 | **Note on encryption** 8 | Depending on the packet-type, acquiring the `Initialization Vector` and 9 | encrypting the `protected payload` happens differently: 10 | 11 | * ConnectRequest: The `IV` is randomly chosen from calculated 12 | Elliptic Curve (which happens in :class:`Crypto`) and is delivered 13 | inside the `unprotected payload` section of the ConnectRequest message. 14 | * Messages: The `IV` is generated by encrypting the first 16 bytes of 15 | the `unencrypted` header. 16 | 17 | **Note on padding** 18 | If message has `protected payload` it might need padding according 19 | to PKCS#7 (e.g. padding is in whole bytes, the value of each added byte is 20 | the number of bytes that are added, i.e. N bytes, each of value N are 21 | added. thx wikipedia). 22 | """ 23 | from construct import Int16ub 24 | from xbox.sg.enum import PacketType 25 | from xbox.sg.crypto import PKCS7Padding 26 | from xbox.sg.packet import simple, message 27 | from xbox.sg.utils.struct import flatten 28 | 29 | 30 | class PackerError(Exception): 31 | """ 32 | Custom exceptions for usage in packer module 33 | """ 34 | pass 35 | 36 | 37 | def unpack(buf, crypto=None): 38 | """ 39 | Unpacks messages from Smartglass CoreProtocol. 40 | 41 | For messages that require decryption, a Crypto instance needs to be passed 42 | as well. 43 | 44 | The decryption happens in :class:`CryptoTunnel`. 45 | 46 | Args: 47 | buf (bytes): A byte string to be deserialized into a message. 48 | crypto (Crypto): Instance of :class:`Crypto`. 49 | 50 | Raises: 51 | PackerError: On various errors, instance of :class:`PackerError`. 52 | 53 | Returns: 54 | Container: The deserialized message, instance of :class:`Container`. 55 | """ 56 | msg_struct = None 57 | pkt_type = PacketType(Int16ub.parse(buf[:2])) 58 | 59 | if pkt_type not in PacketType: 60 | raise PackerError("Invalid packet type") 61 | 62 | if pkt_type in simple.pkt_types: 63 | msg_struct = simple.struct 64 | elif pkt_type == PacketType.Message: 65 | msg_struct = message.struct 66 | 67 | return msg_struct.parse(buf, _crypto=crypto) 68 | 69 | 70 | def pack(msg, crypto=None): 71 | """ 72 | Packs messages for Smartglass CoreProtocol. 73 | 74 | For messages that require encryption, a Crypto instance needs to be passed 75 | as well. 76 | 77 | Args: 78 | msg (XStructObj): A serializable message, instance of 79 | :class:`XStructObj`. 80 | crypto (Crypto): Instance of :class:`Crypto`. 81 | 82 | Returns: 83 | bytes: The serialized bytes. 84 | """ 85 | container = flatten(msg.container) 86 | packed_unprotected = b'' 87 | packed_protected = b'' 88 | 89 | if container.get('unprotected_payload', None): 90 | packed_unprotected = msg.subcon.unprotected_payload.build( 91 | container.unprotected_payload, **container 92 | ) 93 | 94 | if container.get('protected_payload', None): 95 | packed_protected = msg.subcon.protected_payload.build( 96 | container.protected_payload, **container 97 | ) 98 | 99 | container.header.unprotected_payload_length = len(packed_unprotected) 100 | container.header.protected_payload_length = len(packed_protected) 101 | packed_header = msg.subcon.header.build( 102 | container.header, **container 103 | ) 104 | 105 | if container.get('protected_payload', None) and packed_protected: 106 | connect_types = [PacketType.ConnectRequest, PacketType.ConnectResponse] 107 | if container.header.pkt_type in connect_types: 108 | iv = container.unprotected_payload.iv 109 | elif container.header.pkt_type == PacketType.Message: 110 | iv = crypto.generate_iv(packed_header[:16]) 111 | else: 112 | raise PackerError("Incompatible packet type for encryption") 113 | 114 | if PKCS7Padding.size(len(packed_protected), 16) > 0: 115 | packed_protected = PKCS7Padding.pad(packed_protected, 16) 116 | 117 | packed_protected = crypto.encrypt(iv, packed_protected) 118 | buffer = packed_header + packed_unprotected + packed_protected 119 | return buffer + crypto.hash(buffer) 120 | else: 121 | return packed_header + packed_unprotected 122 | 123 | 124 | def payload_length(msg): 125 | """ 126 | Calculates the packed length in bytes of the given message. 127 | 128 | Args: 129 | msg (XStructObj): A serializable message, instance of 130 | :class:`XStructObj`. 131 | 132 | Returns: 133 | int: The packed message length in bytes. 134 | """ 135 | container = flatten(msg.container) 136 | packed_unprotected = b'' 137 | packed_protected = b'' 138 | 139 | if container.get('unprotected_payload', None): 140 | packed_unprotected = msg.subcon.unprotected_payload.build( 141 | container.unprotected_payload, **container 142 | ) 143 | 144 | if container.get('protected_payload', None): 145 | packed_protected = msg.subcon.protected_payload.build( 146 | container.protected_payload, **container 147 | ) 148 | 149 | return len(packed_unprotected + packed_protected) 150 | -------------------------------------------------------------------------------- /tests/test_stump_json_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from xbox.stump import json_model 3 | from xbox.stump.enum import Message 4 | 5 | 6 | def test_stump_response(stump_json): 7 | data = stump_json['response_recent_channels'] 8 | msg = json_model.deserialize_stump_message(data) 9 | 10 | assert msg.msgid == 'xV5X1YCB.16' 11 | assert Message(msg.response) == Message.RECENT_CHANNELS 12 | assert isinstance(msg.params, list) 13 | 14 | 15 | def test_tuner_lineups(stump_json): 16 | data = stump_json['response_tuner_lineups'] 17 | msg = json_model.deserialize_stump_message(data) 18 | 19 | assert len(msg.params.providers) == 1 20 | provider = msg.params.providers[0] 21 | 22 | assert len(provider.foundChannels) == 19 23 | found_channel = provider.foundChannels[0] 24 | assert found_channel.channelNumber == 0 25 | assert found_channel.displayName == 'Das Erste HD' 26 | assert found_channel.channelId == '000021146A000301' 27 | 28 | assert len(provider.cqsChannels) == 7 29 | assert provider.cqsChannels[0] == '178442d3-2b13-e02b-9747-a3d4ebebcf62_PHOENIHD_23' 30 | 31 | assert str(provider.headendId) == '0a7fb88a-960b-c2e3-9975-7c86c5fa6c49' 32 | 33 | 34 | def test_recent_channels(stump_json): 35 | data = stump_json['response_recent_channels'] 36 | msg = json_model.deserialize_stump_message(data) 37 | 38 | assert len(msg.params) == 0 39 | # assert msg.params[0].channelNum == '' 40 | # assert msg.params[0].providerId == '' 41 | # assert msg.params[0].channelId == '' 42 | 43 | 44 | def test_livetv_info(stump_json): 45 | data = stump_json['response_livetv_info'] 46 | msg = json_model.deserialize_stump_message(data) 47 | 48 | assert msg.params.streamingPort == 10242 49 | assert msg.params.inHdmiMode is False 50 | assert msg.params.tunerChannelType == 'televisionChannel' 51 | assert msg.params.currentTunerChannelId == 'bb1ca492-232b-adfe-1f39-d010eabf179e_MSAHD_16' 52 | assert msg.params.currentHdmiChannelId == '731cd976-c1e9-6b95-4799-e6757d02cab1_3SATHD_1' 53 | assert msg.params.pauseBufferInfo is not None 54 | assert msg.params.pauseBufferInfo.Enabled is True 55 | assert msg.params.pauseBufferInfo.IsDvr is False 56 | assert msg.params.pauseBufferInfo.MaxBufferSize == 18000000000 57 | assert msg.params.pauseBufferInfo.BufferCurrent == 131688132168080320 58 | assert msg.params.pauseBufferInfo.BufferStart == 131688132168080320 59 | assert msg.params.pauseBufferInfo.BufferEnd == 131688151636700238 60 | assert msg.params.pauseBufferInfo.CurrentTime == 131688151636836518 61 | assert msg.params.pauseBufferInfo.Epoch == 0 62 | 63 | 64 | def test_headend_info(stump_json): 65 | data = stump_json['response_headend_info'] 66 | msg = json_model.deserialize_stump_message(data) 67 | 68 | assert msg.params.providerName == 'Sky Deutschland' 69 | assert str(msg.params.headendId) == '516b9ea7-5292-97ec-e7d4-f843fab6d392' 70 | assert msg.params.blockExplicitContentPerShow is False 71 | assert msg.params.dvrEnabled is False 72 | assert msg.params.headendLocale == 'de-DE' 73 | assert msg.params.streamingPort == 10242 74 | assert msg.params.preferredProvider == '29045393' 75 | 76 | assert len(msg.params.providers) == 2 77 | provider = msg.params.providers[0] 78 | assert provider.providerName == 'Sky Deutschland' 79 | assert provider.filterPreference == 'ALL' 80 | assert str(provider.headendId) == '516b9ea7-5292-97ec-e7d4-f843fab6d392' 81 | assert provider.source == 'hdmi' 82 | assert provider.titleId == '162615AD' 83 | assert provider.canStream is False 84 | 85 | 86 | def test_configuration(stump_json): 87 | data = stump_json['response_configuration'] 88 | msg = json_model.deserialize_stump_message(data) 89 | 90 | assert len(msg.params) == 4 91 | device_config = msg.params[0] 92 | assert device_config.device_brand == 'Samsung' 93 | assert device_config.device_id == '0' 94 | assert device_config.device_type == 'tv' 95 | assert isinstance(device_config.buttons, dict) is True 96 | 97 | 98 | @pytest.mark.skip 99 | def test_ensure_streaming_started(stump_json): 100 | data = stump_json['response_ensure_streaming_started'] 101 | msg = json_model.deserialize_stump_message(data) 102 | 103 | assert msg.params.currentChannelId == '' 104 | assert msg.params.source == '' 105 | assert msg.params.streamingPort == 0 106 | assert msg.params.tunerChannelType == '' 107 | assert msg.params.userCanViewChannel == '' 108 | 109 | 110 | def test_app_channel_lineups(stump_json): 111 | data = stump_json['response_appchannel_lineups'] 112 | msg = json_model.deserialize_stump_message(data) 113 | 114 | assert len(msg.params) == 4 115 | provider = msg.params[0] 116 | assert provider.id == 'LiveTvHdmiProvider' 117 | assert provider.providerName == 'OneGuide' 118 | assert provider.titleId == '00000000' 119 | assert provider.primaryColor == 'ff107c10' 120 | assert provider.secondaryColor == 'ffebebeb' 121 | assert len(provider.channels) == 0 122 | # channel = provider.channels[0] 123 | # assert channel.name == '' 124 | # assert channel.id == '' 125 | 126 | 127 | def test_send_key(stump_json): 128 | data = stump_json['response_sendkey'] 129 | msg = json_model.deserialize_stump_message(data) 130 | 131 | assert msg.params is True 132 | -------------------------------------------------------------------------------- /tests/data/stump_json/response_configuration: -------------------------------------------------------------------------------- 1 | { 2 | "response": "GetConfiguration", 3 | "msgid": "xV5X1YCB.13", 4 | "params": [ 5 | { 6 | "device_id": "0", 7 | "device_brand": "Samsung", 8 | "device_type": "tv", 9 | "buttons": { 10 | "btn.back": "Back", 11 | "btn.up": "Up", 12 | "btn.red": "Red", 13 | "btn.page_down": "Page Down", 14 | "btn.ch_down": "Channel Down", 15 | "btn.func_c": "Label C", 16 | "btn.format": "Format", 17 | "btn.digit_2": "2", 18 | "btn.func_a": "Label A", 19 | "btn.digit_7": "7", 20 | "btn.last": "Last", 21 | "btn.input": "Input", 22 | "btn.fast_fwd": "FFWD", 23 | "btn.menu": "Menu", 24 | "btn.replay": "Skip REV", 25 | "btn.power": "Power", 26 | "btn.left": "Left", 27 | "btn.blue": "Blue", 28 | "btn.vol_down": "Volume Down", 29 | "btn.green": "Green", 30 | "btn.digit_4": "4", 31 | "btn.digit_9": "9", 32 | "btn.play": "Play", 33 | "btn.page_up": "Page Up", 34 | "btn.func_b": "Label B", 35 | "btn.power_off": "Off", 36 | "btn.vol_mute": "Mute", 37 | "btn.record": "Record", 38 | "btn.subtitle": "Subtitle", 39 | "btn.rewind": "Rewind", 40 | "btn.exit": "Exit", 41 | "btn.down": "Down", 42 | "btn.sap": "Sap", 43 | "btn.yellow": "Yellow", 44 | "btn.func_d": "Label D", 45 | "btn.info": "Info", 46 | "btn.digit_5": "5", 47 | "btn.digit_3": "3", 48 | "btn.digit_0": "0", 49 | "btn.skip_fwd": "Skip FWD", 50 | "btn.delimiter": "Delimiter", 51 | "btn.right": "Right", 52 | "btn.vol_up": "Volume Up", 53 | "btn.ch_up": "Channel Up", 54 | "btn.digit_8": "8", 55 | "btn.digit_6": "6", 56 | "btn.guide": "Guide", 57 | "btn.stop": "Stop", 58 | "btn.select": "Select", 59 | "btn.power_on": "On", 60 | "btn.ch_enter": "Enter", 61 | "btn.digit_1": "1", 62 | "btn.pause": "Pause", 63 | "btn.dvr": "Recordings" 64 | } 65 | }, 66 | { 67 | "device_id": "1", 68 | "device_type": "stb", 69 | "buttons": { 70 | "btn.back": "Back", 71 | "btn.up": "Up", 72 | "btn.red": "Red", 73 | "btn.page_down": "Page Down", 74 | "btn.ch_down": "Channel Down", 75 | "btn.func_c": "Label C", 76 | "btn.digit_2": "2", 77 | "btn.func_a": "Label A", 78 | "btn.digit_7": "7", 79 | "btn.last": "Last", 80 | "btn.input": "Input", 81 | "btn.fast_fwd": "FFWD", 82 | "btn.menu": "Menu", 83 | "btn.replay": "Skip REV", 84 | "btn.power": "Power", 85 | "btn.left": "Left", 86 | "btn.blue": "Blue", 87 | "btn.green": "Green", 88 | "btn.vol_down": "Volume Down", 89 | "btn.digit_4": "4", 90 | "btn.digit_9": "9", 91 | "btn.play": "Play", 92 | "btn.page_up": "Page Up", 93 | "btn.func_b": "Label B", 94 | "btn.power_off": "Off", 95 | "btn.vol_mute": "Mute", 96 | "btn.record": "Record", 97 | "btn.rewind": "Rewind", 98 | "btn.exit": "Exit", 99 | "btn.down": "Down", 100 | "btn.yellow": "Yellow", 101 | "btn.info": "Info", 102 | "btn.func_d": "Label D", 103 | "btn.digit_5": "5", 104 | "btn.digit_3": "3", 105 | "btn.plus_100": "Plus 100", 106 | "btn.digit_0": "0", 107 | "btn.skip_fwd": "Skip FWD", 108 | "btn.right": "Right", 109 | "btn.vol_up": "Volume Up", 110 | "btn.ch_up": "Channel Up", 111 | "btn.digit_8": "8", 112 | "btn.digit_6": "6", 113 | "btn.guide": "Guide", 114 | "btn.stop": "Stop", 115 | "btn.select": "Select", 116 | "btn.power_on": "On", 117 | "btn.ch_enter": "Enter", 118 | "btn.digit_1": "1", 119 | "btn.pause": "Pause" 120 | } 121 | }, 122 | { 123 | "device_id": "2", 124 | "device_brand": "Onkyo", 125 | "device_model": "DTX7.7", 126 | "device_name": "OnkyoDTX7.7", 127 | "device_type": "avr", 128 | "buttons": { 129 | "btn.back": "Back", 130 | "btn.up": "Up", 131 | "btn.ch_down": "Channel Down", 132 | "btn.digit_2": "2", 133 | "btn.digit_7": "7", 134 | "btn.last": "Last", 135 | "btn.input": "Input", 136 | "btn.fast_fwd": "FFWD", 137 | "btn.menu": "Menu", 138 | "btn.replay": "Skip REV", 139 | "btn.power": "Power", 140 | "btn.left": "Left", 141 | "btn.vol_down": "Volume Down", 142 | "btn.digit_4": "4", 143 | "btn.digit_9": "9", 144 | "btn.play": "Play", 145 | "btn.power_off": "Off", 146 | "btn.vol_mute": "Mute", 147 | "btn.record": "Record", 148 | "btn.rewind": "Rewind", 149 | "btn.exit": "Exit", 150 | "btn.setup": "Setup", 151 | "btn.down": "Down", 152 | "btn.sap": "Sap", 153 | "btn.info": "Info", 154 | "btn.digit_5": "5", 155 | "btn.digit_3": "3", 156 | "btn.digit_0": "0", 157 | "btn.skip_fwd": "Skip FWD", 158 | "btn.delimiter": "Delimiter", 159 | "btn.vol_up": "Volume Up", 160 | "btn.right": "Right", 161 | "btn.ch_up": "Channel Up", 162 | "btn.digit_8": "8", 163 | "btn.digit_6": "6", 164 | "btn.stop": "Stop", 165 | "btn.select": "Select", 166 | "btn.power_on": "On", 167 | "btn.ch_enter": "Enter", 168 | "btn.digit_1": "1", 169 | "btn.pause": "Pause", 170 | "btn.dvr": "Recordings" 171 | } 172 | }, 173 | { 174 | "device_id": "tuner", 175 | "device_type": "tuner", 176 | "buttons": { 177 | "btn.play": "PLAY", 178 | "btn.pause": "PAUSE", 179 | "btn.seek": "SEEK" 180 | } 181 | } 182 | ] 183 | } -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/stable/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | # import os 16 | # import sys 17 | # sys.path.insert(0, os.path.abspath('.')) 18 | import os 19 | import sys 20 | sys.path.insert(0, os.path.abspath('../')) 21 | 22 | # -- Project information ----------------------------------------------------- 23 | 24 | project = 'Xbox-Smartglass-Core' 25 | copyright = '2018, OpenXbox' 26 | author = 'OpenXbox' 27 | 28 | # The short X.Y version 29 | version = '1.0' 30 | # The full version, including alpha/beta/rc tags 31 | release = '1.3.0' 32 | 33 | 34 | # -- General configuration --------------------------------------------------- 35 | 36 | # If your documentation needs a minimal Sphinx version, state it here. 37 | # 38 | # needs_sphinx = '1.0' 39 | 40 | # Add any Sphinx extension module names here, as strings. They can be 41 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 42 | # ones. 43 | extensions = [ 44 | 'sphinx.ext.autodoc', 45 | 'sphinx.ext.intersphinx', 46 | 'sphinx.ext.napoleon', 47 | 'recommonmark' 48 | ] 49 | 50 | # Add any paths that contain templates here, relative to this directory. 51 | templates_path = ['_templates'] 52 | 53 | # The suffix(es) of source filenames. 54 | # You can specify multiple suffix as a list of string: 55 | # 56 | # source_suffix = ['.rst', '.md'] 57 | source_suffix = ['.rst', '.md'] 58 | 59 | # The master toctree document. 60 | master_doc = 'index' 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | # 65 | # This is also used if you do content translation via gettext catalogs. 66 | # Usually you set "language" from the command line for these cases. 67 | language = None 68 | 69 | # List of patterns, relative to source directory, that match files and 70 | # directories to ignore when looking for source files. 71 | # This pattern also affects html_static_path and html_extra_path . 72 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 73 | 74 | # The name of the Pygments (syntax highlighting) style to use. 75 | pygments_style = 'sphinx' 76 | 77 | 78 | # -- Options for HTML output ------------------------------------------------- 79 | 80 | # The theme to use for HTML and HTML Help pages. See the documentation for 81 | # a list of builtin themes. 82 | # 83 | html_theme = 'sphinx_rtd_theme' 84 | 85 | # Theme options are theme-specific and customize the look and feel of a theme 86 | # further. For a list of options available for each theme, see the 87 | # documentation. 88 | # 89 | # html_theme_options = {} 90 | 91 | # Add any paths that contain custom static files (such as style sheets) here, 92 | # relative to this directory. They are copied after the builtin static files, 93 | # so a file named "default.css" will overwrite the builtin "default.css". 94 | html_static_path = ['_static'] 95 | 96 | # Custom sidebar templates, must be a dictionary that maps document names 97 | # to template names. 98 | # 99 | # The default sidebars (for documents that don't match any pattern) are 100 | # defined by theme itself. Builtin themes are using these templates by 101 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 102 | # 'searchbox.html']``. 103 | # 104 | # html_sidebars = {} 105 | 106 | 107 | # -- Options for HTMLHelp output --------------------------------------------- 108 | 109 | # Output file base name for HTML help builder. 110 | htmlhelp_basename = 'Xbox-Smartglass-Coredoc' 111 | 112 | 113 | # -- Options for LaTeX output ------------------------------------------------ 114 | 115 | latex_elements = { 116 | # The paper size ('letterpaper' or 'a4paper'). 117 | # 118 | # 'papersize': 'letterpaper', 119 | 120 | # The font size ('10pt', '11pt' or '12pt'). 121 | # 122 | # 'pointsize': '10pt', 123 | 124 | # Additional stuff for the LaTeX preamble. 125 | # 126 | # 'preamble': '', 127 | 128 | # Latex figure (float) alignment 129 | # 130 | # 'figure_align': 'htbp', 131 | } 132 | 133 | # Grouping the document tree into LaTeX files. List of tuples 134 | # (source start file, target name, title, 135 | # author, documentclass [howto, manual, or own class]). 136 | latex_documents = [ 137 | (master_doc, 'Xbox-Smartglass-Core.tex', 'Xbox-Smartglass-Core Documentation', 138 | 'OpenXbox', 'manual'), 139 | ] 140 | 141 | 142 | # -- Options for manual page output ------------------------------------------ 143 | 144 | # One entry per manual page. List of tuples 145 | # (source start file, name, description, authors, manual section). 146 | man_pages = [ 147 | (master_doc, 'xbox-smartglass-core', 'Xbox-Smartglass-Core Documentation', 148 | [author], 1) 149 | ] 150 | 151 | 152 | # -- Options for Texinfo output ---------------------------------------------- 153 | 154 | # Grouping the document tree into Texinfo files. List of tuples 155 | # (source start file, target name, title, author, 156 | # dir menu entry, description, category) 157 | texinfo_documents = [ 158 | (master_doc, 'Xbox-Smartglass-Core', 'Xbox-Smartglass-Core Documentation', 159 | author, 'Xbox-Smartglass-Core', 'One line description of project.', 160 | 'Miscellaneous'), 161 | ] 162 | 163 | 164 | # -- Extension configuration ------------------------------------------------- 165 | 166 | # -- Options for intersphinx extension --------------------------------------- 167 | 168 | # Example configuration for intersphinx: refer to the Python standard library. 169 | intersphinx_mapping = {'https://docs.python.org/': None} 170 | 171 | # -- Options for napoleon extension ------------------------------------------ 172 | napoleon_google_docstring = True 173 | napoleon_numpy_docstring = True 174 | napoleon_include_init_with_doc = True 175 | napoleon_include_private_with_doc = True 176 | 177 | # -- Autodoc settings 178 | autodoc_member_order = 'bysource' 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Xbox-Smartglass-Core 2 | 3 | [![PyPi version](https://pypip.in/version/xbox-smartglass-core/badge.svg)](https://pypi.python.org/pypi/xbox-smartglass-core) 4 | [![Docs](https://readthedocs.org/projects/xbox-smartglass-core-python/badge/?version=latest)](http://xbox-smartglass-core-python.readthedocs.io/en/latest/?badge=latest) 5 | [![Build status](https://img.shields.io/github/workflow/status/OpenXbox/xbox-smartglass-core-python/build?label=build)](https://github.com/OpenXbox/xbox-smartglass-core-python/actions?query=workflow%3Abuild) 6 | [![Discord chat](https://img.shields.io/discord/338946086775554048)](https://openxbox.org/discord) 7 | 8 | This library provides the core foundation for the smartglass protocol that is used 9 | with the Xbox One Gaming console 10 | 11 | For in-depth information, check out the documentation: 12 | 13 | **NOTE: Since 29.02.2020 the following modules are integrated into core: stump, auxiliary, rest-server** 14 | **NOTE: Nano module is still offered seperately** 15 | 16 | ## Features 17 | 18 | * Power on / off the console 19 | * Get system info (running App/Game/Title, dashboard version) 20 | * Media player control (seeing content id, content app, playback actions etc.) 21 | * Stump protocol (Live-TV Streaming / IR control) 22 | * Title / Auxiliary stream protocol (f.e. Fallout 4 companion app) 23 | * Trigger GameDVR remotely 24 | * REST Server 25 | 26 | ## Major frameworks used 27 | 28 | * Xbox WebAPI 29 | * construct - Binary parsing 30 | * cryptography - cryptography magic 31 | * dpkt - pcap parsing 32 | * FastAPI - REST API 33 | * urwid - TUI app 34 | * pydantic - JSON models 35 | 36 | ## Install 37 | 38 | Via pip 39 | 40 | ```text 41 | pip install xbox-smartglass-core 42 | ``` 43 | 44 | See the end of this README for development-targeted instructions. 45 | 46 | ## How to use 47 | 48 | There are several command line utilities to check out:: 49 | 50 | ```text 51 | xbox-cli 52 | ``` 53 | 54 | Some functionality, such as GameDVR record, requires authentication 55 | with your Microsoft Account to validate you have the right to trigger 56 | such action. 57 | 58 | To authenticate / get authentication tokens use:: 59 | 60 | ```text 61 | xbox-authenticate 62 | ``` 63 | 64 | ## REST server 65 | 66 | ### Start the server daemon 67 | 68 | Usage information 69 | 70 | Example localhost: 71 | 72 | ```sh 73 | # Serve on '127.0.0.1:5557' 74 | $ xbox-rest-server 75 | INFO: Started server process [927195] 76 | INFO: Waiting for application startup. 77 | INFO: Application startup complete. 78 | INFO: Uvicorn running on http://127.0.0.1:5557 (Press CTRL+C to quit) 79 | ``` 80 | 81 | Example local network: 82 | 83 | __192.168.0.100__ is the IP address of your computer running the server: 84 | 85 | ```sh 86 | xbox-rest-server --host 192.168.0.100 -p 1234 87 | INFO: Started server process [927195] 88 | INFO: Waiting for application startup. 89 | INFO: Application startup complete. 90 | INFO: Uvicorn running on http://192.168.0.100:1234 (Press CTRL+C to quit) 91 | ``` 92 | 93 | ### REST API 94 | 95 | Since the migration from Flask framework to FastAPI, there is a nice 96 | OpenAPI documentation available: 97 | 98 | 99 | 100 | ### Authentication 101 | 102 | If your server runs on something else than 127.0.0.1:5557 or 127.0.0.1:8080 you 103 | need to register your own OAUTH application on **Azure AD** and supply appropriate 104 | parameters to the login-endpoint of the REST server. 105 | 106 | Check out: 107 | 108 | ## Fallout 4 relay service 109 | 110 | To forward the title communication from the Xbox to your local host 111 | to use third-party Fallout 4 Pip boy applications or extensions 112 | 113 | ```text 114 | xbox-fo4-relay 115 | ``` 116 | 117 | ## Screenshots 118 | 119 | Here you can see the SmartGlass TUI (Text user interface): 120 | 121 | ![TUI list](https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/master/assets/xbox_tui_list.png) 122 | ![TUI console](https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/master/assets/xbox_tui_console.png) 123 | ![TUI log](https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/master/assets/xbox_tui_log.png) 124 | ![TUI log detail](https://raw.githubusercontent.com/OpenXbox/xbox-smartglass-core-python/master/assets/xbox_tui_logdetail.png) 125 | 126 | ## Development workflow 127 | 128 | Ready to contribute? Here's how to set up `xbox-smartglass-core-python` for local development. 129 | 130 | 1. Fork the `xbox-smartglass-core-python` repo on GitHub. 131 | 2. Clone your fork locally 132 | 133 | ```text 134 | git clone git@github.com:your_name_here/xbox-smartglass-core-python.git 135 | ``` 136 | 137 | 3. Install your local copy into a virtual environment. This is how you set up your fork for local development 138 | 139 | ```text 140 | python -m venv ~/pyvenv/xbox-smartglass 141 | source ~/pyvenv/xbox-smartglass/bin/activate 142 | cd xbox-smartglass-core-python 143 | pip install -e .[dev] 144 | ``` 145 | 146 | 5. Create a branch for local development:: 147 | 148 | ```text 149 | git checkout -b name-of-your-bugfix-or-feature 150 | ``` 151 | 152 | 6. Make your changes. 153 | 154 | 7. Before pushing the changes to git, please verify they actually work 155 | 156 | ```text 157 | pytest 158 | ``` 159 | 160 | 8. Commit your changes and push your branch to GitHub:: 161 | 162 | ```text 163 | git commit -m "Your detailed description of your changes." 164 | git push origin name-of-your-bugfix-or-feature 165 | ``` 166 | 167 | 9. Submit a pull request through the GitHub website. 168 | 169 | ### Pull Request Guidelines 170 | 171 | Before you submit a pull request, check that it meets these guidelines: 172 | 173 | 1. Code includes unit-tests. 174 | 2. Added code is properly named and documented. 175 | 3. On major changes the README is updated. 176 | 4. Run tests / linting locally before pushing to remote. 177 | 178 | ## Credits 179 | 180 | Kudos to [joelday](https://github.com/joelday) for figuring out the AuxiliaryStream / TitleChannel communication first! 181 | You can find the original implementation here: [SmartGlass.CSharp](https://github.com/OpenXbox/Xbox-Smartglass-csharp) 182 | 183 | This package uses parts of [Cookiecutter](https://github.com/audreyr/cookiecutter) and the 184 | [audreyr/cookiecutter-pypackage project template](https://github.com/audreyr/cookiecutter-pypackage) 185 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | import uuid 4 | import json 5 | from fastapi import FastAPI 6 | from fastapi.testclient import TestClient 7 | 8 | from binascii import unhexlify 9 | from construct import Container 10 | 11 | from xbox.sg import enum, packer, packet 12 | 13 | from xbox.sg.console import Console 14 | from xbox.sg.crypto import Crypto 15 | from xbox.sg.manager import MediaManager, TextManager, InputManager 16 | 17 | from xbox.auxiliary.crypto import AuxiliaryStreamCrypto 18 | 19 | from xbox.stump.manager import StumpManager 20 | 21 | from xbox.rest.app import app as rest_app 22 | from xbox.rest.consolewrap import ConsoleWrap 23 | 24 | 25 | @pytest.fixture(scope='session') 26 | def uuid_dummy(): 27 | return uuid.UUID('de305d54-75b4-431b-adb2-eb6b9e546014') 28 | 29 | 30 | @pytest.fixture(scope='session') 31 | def console_address(): 32 | return '10.11.12.12' 33 | 34 | 35 | @pytest.fixture(scope='session') 36 | def console_name(): 37 | return 'TestConsole' 38 | 39 | 40 | @pytest.fixture(scope='session') 41 | def console_liveid(): 42 | return 'FD0000123456789' 43 | 44 | 45 | @pytest.fixture(scope='session') 46 | def console_flags(): 47 | return enum.PrimaryDeviceFlag.AllowAnonymousUsers | enum.PrimaryDeviceFlag.AllowAuthenticatedUsers 48 | 49 | 50 | @pytest.fixture(scope='session') 51 | def public_key_bytes(): 52 | return unhexlify( 53 | b'041815d5382df79bd792a8d8342fbc717eacef6a258f779279e5463573e06b' 54 | b'f84c6a88fac904870bf3a26f856e65f483195c4323eef47a048f23a031da6bd0929d' 55 | ) 56 | 57 | 58 | @pytest.fixture(scope='session') 59 | def shared_secret_bytes(): 60 | return unhexlify( 61 | '82bba514e6d19521114940bd65121af234c53654a8e67add7710b3725db44f77' 62 | '30ed8e3da7015a09fe0f08e9bef3853c0506327eb77c9951769d923d863a2f5e' 63 | ) 64 | 65 | 66 | @pytest.fixture(scope='session') 67 | def crypto(shared_secret_bytes): 68 | return Crypto.from_shared_secret(shared_secret_bytes) 69 | 70 | 71 | @pytest.fixture(scope='session') 72 | def console(console_address, console_name, uuid_dummy, console_liveid, console_flags, public_key_bytes): 73 | c = Crypto.from_bytes(public_key_bytes) 74 | console = Console( 75 | console_address, console_name, uuid_dummy, 76 | console_liveid, console_flags, 0, c.foreign_pubkey 77 | ) 78 | console.add_manager(StumpManager) 79 | console.add_manager(MediaManager) 80 | console.add_manager(TextManager) 81 | console.add_manager(InputManager) 82 | return console 83 | 84 | 85 | @pytest.fixture(scope='session') 86 | def public_key(public_key_bytes): 87 | c = Crypto.from_bytes(public_key_bytes) 88 | return c.foreign_pubkey 89 | 90 | 91 | @pytest.fixture(scope='session') 92 | def packets(): 93 | # Who cares about RAM anyway? 94 | data = {} 95 | data_path = os.path.join(os.path.dirname(__file__), 'data', 'packets') 96 | for f in os.listdir(data_path): 97 | with open(os.path.join(data_path, f), 'rb') as fh: 98 | data[f] = fh.read() 99 | 100 | return data 101 | 102 | 103 | @pytest.fixture(scope='session') 104 | def stump_json(): 105 | # Who cares about RAM anyway? 106 | data = {} 107 | data_path = os.path.join(os.path.dirname(__file__), 'data', 'stump_json') 108 | for f in os.listdir(data_path): 109 | with open(os.path.join(data_path, f), 'rt') as fh: 110 | data[f] = json.load(fh) 111 | 112 | return data 113 | 114 | 115 | @pytest.fixture(scope='session') 116 | def decrypted_packets(packets, crypto): 117 | return {k: packer.unpack(v, crypto) for k, v in packets.items()} 118 | 119 | 120 | @pytest.fixture(scope='session') 121 | def pcap_filepath(): 122 | return os.path.join(os.path.dirname(__file__), 'data', 'sg_capture.pcap') 123 | 124 | 125 | @pytest.fixture(scope='session') 126 | def certificate_data(): 127 | filepath = os.path.join(os.path.dirname(__file__), 'data', 'selfsigned_cert.bin') 128 | with open(filepath, 'rb') as f: 129 | data = f.read() 130 | return data 131 | 132 | 133 | @pytest.fixture(scope='session') 134 | def json_fragments(): 135 | filepath = os.path.join(os.path.dirname(__file__), 'data', 'json_fragments.json') 136 | with open(filepath, 'rt') as f: 137 | data = json.load(f) 138 | return data['fragments'] 139 | 140 | 141 | @pytest.fixture(scope='session') 142 | def aux_streams(): 143 | data = {} 144 | data_path = os.path.join(os.path.dirname(__file__), 'data', 'aux_streams') 145 | for f in os.listdir(data_path): 146 | with open(os.path.join(data_path, f), 'rb') as fh: 147 | data[f] = fh.read() 148 | 149 | return data 150 | 151 | 152 | @pytest.fixture(scope='session') 153 | def aux_crypto(decrypted_packets): 154 | connection_info = decrypted_packets['auxiliary_stream_connection_info'].protected_payload.connection_info 155 | return AuxiliaryStreamCrypto.from_connection_info(connection_info) 156 | 157 | 158 | @pytest.fixture 159 | def rest_client(): 160 | app = FastAPI() 161 | client = TestClient(app) 162 | yield client 163 | 164 | 165 | @pytest.fixture(scope='session') 166 | def media_state(): 167 | return packet.message.media_state( 168 | title_id=274278798, 169 | aum_id='AIVDE_s9eep9cpjhg6g!App', 170 | asset_id='', 171 | media_type=enum.MediaType.Video, 172 | sound_level=enum.SoundLevel.Full, 173 | enabled_commands=enum.MediaControlCommand.Play | enum.MediaControlCommand.Pause, 174 | playback_status=enum.MediaPlaybackStatus.Playing, 175 | rate=1.00, 176 | position=0, 177 | media_start=0, 178 | media_end=0, 179 | min_seek=0, 180 | max_seek=0, 181 | metadata=[ 182 | Container(name='title', value='Some Movietitle'), 183 | Container(name='subtitle', value='') 184 | ] 185 | ) 186 | 187 | 188 | @pytest.fixture(scope='session') 189 | def active_title(): 190 | struct = packet.message._active_title( 191 | title_id=714681658, 192 | product_id=uuid.UUID('00000000-0000-0000-0000-000000000000'), 193 | sandbox_id=uuid.UUID('00000000-0000-0000-0000-000000000000'), 194 | aum='Xbox.Home_8wekyb3d8bbwe!Xbox.Home.Application', 195 | disposition=Container( 196 | has_focus=True, 197 | title_location=enum.ActiveTitleLocation.StartView 198 | ) 199 | ) 200 | return struct 201 | 202 | 203 | @pytest.fixture(scope='session') 204 | def active_media_title(): 205 | struct = packet.message._active_title( 206 | title_id=714681658, 207 | product_id=uuid.UUID('00000000-0000-0000-0000-000000000000'), 208 | sandbox_id=uuid.UUID('00000000-0000-0000-0000-000000000000'), 209 | aum='AIVDE_s9eep9cpjhg6g!App', 210 | disposition=Container( 211 | has_focus=True, 212 | title_location=enum.ActiveTitleLocation.StartView 213 | ) 214 | ) 215 | return struct 216 | 217 | 218 | @pytest.fixture(scope='session') 219 | def console_status(active_title): 220 | return packet.message.console_status( 221 | live_tv_provider=0, 222 | major_version=10, 223 | minor_version=0, 224 | build_number=14393, 225 | locale='en-US', 226 | active_titles=[ 227 | active_title 228 | ] 229 | ) 230 | 231 | 232 | @pytest.fixture(scope='session') 233 | def console_status_with_media(active_media_title): 234 | return packet.message.console_status( 235 | live_tv_provider=0, 236 | major_version=10, 237 | minor_version=0, 238 | build_number=14393, 239 | locale='en-US', 240 | active_titles=[ 241 | active_media_title 242 | ] 243 | ) 244 | -------------------------------------------------------------------------------- /tests/test_rest_consolewrap.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from xbox.rest.consolewrap import ConsoleWrap 4 | from xbox.sg import enum 5 | 6 | 7 | def test_consolewrap_init(console): 8 | wrap = ConsoleWrap(console) 9 | 10 | assert wrap.console == console 11 | assert 'text' in wrap.console.managers 12 | assert 'input' in wrap.console.managers 13 | assert 'stump' in wrap.console.managers 14 | assert 'media' in wrap.console.managers 15 | 16 | @pytest.mark.asyncio 17 | async def test_discover(): 18 | discovered = await ConsoleWrap.discover(tries=1, blocking=False, timeout=1) 19 | 20 | assert isinstance(discovered, list) 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_poweron(): 25 | await ConsoleWrap.power_on('FD0123456789', tries=1, iterations=1) 26 | 27 | 28 | def test_media_commands(console): 29 | commands = ConsoleWrap(console).media_commands 30 | 31 | assert isinstance(commands, dict) 32 | assert 'play' in commands 33 | for k, v in commands.items(): 34 | assert isinstance(k, str) 35 | assert isinstance(v, enum.MediaControlCommand) 36 | 37 | 38 | def test_input_keys(console): 39 | keys = ConsoleWrap(console).input_keys 40 | 41 | assert isinstance(keys, dict) 42 | assert 'nexus' in keys 43 | for k, v in keys.items(): 44 | assert isinstance(k, str) 45 | assert isinstance(v, enum.GamePadButton) 46 | 47 | 48 | def test_liveid(console): 49 | assert ConsoleWrap(console).liveid == console.liveid 50 | 51 | 52 | def test_available(console): 53 | console._device_status = enum.DeviceStatus.Unavailable 54 | assert ConsoleWrap(console).available is False 55 | 56 | console._device_status = enum.DeviceStatus.Available 57 | assert ConsoleWrap(console).available is True 58 | 59 | 60 | def test_connected(console): 61 | console._connection_state = enum.ConnectionState.Disconnected 62 | assert ConsoleWrap(console).connected is False 63 | 64 | console._connection_state = enum.ConnectionState.Connected 65 | assert ConsoleWrap(console).connected is True 66 | 67 | console._connection_state = enum.ConnectionState.Connecting 68 | assert ConsoleWrap(console).connected is False 69 | 70 | 71 | def test_usable(console): 72 | console._connection_state = enum.ConnectionState.Reconnecting 73 | assert ConsoleWrap(console).usable is False 74 | 75 | console._connection_state = enum.ConnectionState.Disconnecting 76 | assert ConsoleWrap(console).usable is False 77 | 78 | console._connection_state = enum.ConnectionState.Disconnected 79 | assert ConsoleWrap(console).usable is False 80 | 81 | console._connection_state = enum.ConnectionState.Error 82 | assert ConsoleWrap(console).usable is False 83 | 84 | console._connection_state = enum.ConnectionState.Connected 85 | assert ConsoleWrap(console).usable is True 86 | 87 | 88 | def test_connection_state(console): 89 | console._connection_state = enum.ConnectionState.Reconnecting 90 | 91 | assert ConsoleWrap(console).connection_state == enum.ConnectionState.Reconnecting 92 | 93 | 94 | def test_pairing_state(console): 95 | console._pairing_state = enum.PairedIdentityState.Paired 96 | 97 | assert ConsoleWrap(console).pairing_state == enum.PairedIdentityState.Paired 98 | 99 | 100 | def test_device_status(console): 101 | console._device_status = enum.DeviceStatus.Available 102 | 103 | assert ConsoleWrap(console).device_status == enum.DeviceStatus.Available 104 | 105 | 106 | def test_authenticated_users_allowed(console): 107 | console.flags = enum.PrimaryDeviceFlag.AllowAuthenticatedUsers 108 | assert ConsoleWrap(console).authenticated_users_allowed is True 109 | 110 | console.flags = enum.PrimaryDeviceFlag.AllowConsoleUsers 111 | assert ConsoleWrap(console).authenticated_users_allowed is False 112 | 113 | console.flags = enum.PrimaryDeviceFlag.CertificatePending | enum.PrimaryDeviceFlag.AllowConsoleUsers 114 | assert ConsoleWrap(console).authenticated_users_allowed is False 115 | 116 | 117 | def test_console_users_allowed(console): 118 | console.flags = enum.PrimaryDeviceFlag.AllowAuthenticatedUsers 119 | assert ConsoleWrap(console).console_users_allowed is False 120 | 121 | console.flags = enum.PrimaryDeviceFlag.AllowConsoleUsers 122 | assert ConsoleWrap(console).console_users_allowed is True 123 | 124 | console.flags = enum.PrimaryDeviceFlag.CertificatePending | enum.PrimaryDeviceFlag.AllowConsoleUsers 125 | assert ConsoleWrap(console).console_users_allowed is True 126 | 127 | 128 | def test_anonymous_connection_allowed(console): 129 | console.flags = enum.PrimaryDeviceFlag.AllowAuthenticatedUsers 130 | assert ConsoleWrap(console).anonymous_connection_allowed is False 131 | 132 | console.flags = enum.PrimaryDeviceFlag.AllowConsoleUsers 133 | assert ConsoleWrap(console).anonymous_connection_allowed is False 134 | 135 | console.flags = enum.PrimaryDeviceFlag.CertificatePending | enum.PrimaryDeviceFlag.AllowAnonymousUsers 136 | assert ConsoleWrap(console).anonymous_connection_allowed is True 137 | 138 | 139 | def test_is_certificate_pending(console): 140 | console.flags = enum.PrimaryDeviceFlag.AllowAuthenticatedUsers 141 | assert ConsoleWrap(console).is_certificate_pending is False 142 | 143 | console.flags = enum.PrimaryDeviceFlag.AllowConsoleUsers 144 | assert ConsoleWrap(console).is_certificate_pending is False 145 | 146 | console.flags = enum.PrimaryDeviceFlag.CertificatePending | enum.PrimaryDeviceFlag.AllowConsoleUsers 147 | assert ConsoleWrap(console).is_certificate_pending is True 148 | 149 | 150 | def test_console_status(console, console_status): 151 | console._console_status = None 152 | assert ConsoleWrap(console).console_status is None 153 | 154 | console._console_status = console_status 155 | status = ConsoleWrap(console).console_status 156 | assert status is not None 157 | 158 | 159 | def test_media_status(console, media_state, console_status, console_status_with_media): 160 | console.media._media_state = None 161 | assert ConsoleWrap(console).media_status is None 162 | 163 | console.media._media_state = media_state 164 | console._console_status = console_status_with_media 165 | console._connection_state = enum.ConnectionState.Disconnecting 166 | assert ConsoleWrap(console).media_status is None 167 | 168 | console._console_status = console_status # miss-matched apps 169 | console._connection_state = enum.ConnectionState.Connected 170 | state = ConsoleWrap(console).media_status 171 | assert ConsoleWrap(console).media_status is None 172 | 173 | console._console_status = console_status_with_media 174 | state = ConsoleWrap(console).media_status 175 | 176 | 177 | def test_status(console): 178 | status = ConsoleWrap(console).status 179 | assert status is not None 180 | 181 | 182 | @pytest.mark.asyncio 183 | async def test_connect(console): 184 | console.flags = enum.PrimaryDeviceFlag.AllowAuthenticatedUsers 185 | console._device_status = enum.DeviceStatus.Available 186 | console._connection_state = enum.ConnectionState.Disconnected 187 | console._pairing_state = enum.PairedIdentityState.NotPaired 188 | 189 | with pytest.raises(Exception): 190 | await ConsoleWrap(console).connect() 191 | 192 | console._connection_state = enum.ConnectionState.Connected 193 | state = await ConsoleWrap(console).connect() 194 | assert state == enum.ConnectionState.Connected 195 | 196 | console._connection_state = enum.ConnectionState.Disconnected 197 | console.flags = enum.PrimaryDeviceFlag.AllowAnonymousUsers 198 | # blocks forever 199 | # state = ConsoleWrap(console).connect() 200 | # assert state == enum.ConnectionState.Disconnected 201 | -------------------------------------------------------------------------------- /xbox/sg/packet/message.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | """ 3 | Construct containers for message header and payloads 4 | """ 5 | from construct import * 6 | from xbox.sg import enum 7 | from xbox.sg.enum import PacketType, MessageType 8 | from xbox.sg.utils.struct import XStruct 9 | from xbox.sg.utils.adapters import CryptoTunnel, UUIDAdapter, JsonAdapter, XSwitch, XEnum, SGString, PrefixedBytes 10 | 11 | 12 | header = XStruct( 13 | 'pkt_type' / Default(XEnum(Int16ub, PacketType), PacketType.Message), 14 | 'protected_payload_length' / Default(Int16ub, 0), 15 | 'sequence_number' / Int32ub, 16 | 'target_participant_id' / Int32ub, 17 | 'source_participant_id' / Int32ub, 18 | 'flags' / BitStruct( 19 | 'version' / Default(BitsInteger(2), 2), 20 | 'need_ack' / Flag, 21 | 'is_fragment' / Flag, 22 | 'msg_type' / XEnum(BitsInteger(12), MessageType) 23 | ), 24 | 'channel_id' / Int64ub 25 | ) 26 | 27 | 28 | fragment = XStruct( 29 | 'sequence_begin' / Int32ub, 30 | 'sequence_end' / Int32ub, 31 | 'data' / PrefixedBytes(Int16ub) 32 | ) 33 | 34 | 35 | acknowledge = XStruct( 36 | 'low_watermark' / Int32ub, 37 | 'processed_list' / PrefixedArray(Int32ub, Int32ub), 38 | 'rejected_list' / PrefixedArray(Int32ub, Int32ub) 39 | ) 40 | 41 | 42 | json = XStruct( 43 | 'text' / JsonAdapter(SGString()) 44 | ) 45 | 46 | 47 | local_join = XStruct( 48 | 'device_type' / XEnum(Int16ub, enum.ClientType), 49 | 'native_width' / Int16ub, 50 | 'native_height' / Int16ub, 51 | 'dpi_x' / Int16ub, 52 | 'dpi_y' / Int16ub, 53 | 'device_capabilities' / XEnum(Int64ub, enum.DeviceCapabilities), 54 | 'client_version' / Int32ub, 55 | 'os_major_version' / Int32ub, 56 | 'os_minor_version' / Int32ub, 57 | 'display_name' / SGString() 58 | ) 59 | 60 | 61 | auxiliary_stream = XStruct( 62 | 'connection_info_flag' / Int8ub, 63 | 'connection_info' / If(this.connection_info_flag == 1, Struct( 64 | 'crypto_key' / PrefixedBytes(Int16ub), 65 | 'server_iv' / PrefixedBytes(Int16ub), 66 | 'client_iv' / PrefixedBytes(Int16ub), 67 | 'sign_hash' / PrefixedBytes(Int16ub), 68 | 'endpoints' / PrefixedArray(Int16ub, Struct( 69 | 'ip' / SGString(), 70 | 'port' / SGString() 71 | )) 72 | )) 73 | ) 74 | 75 | 76 | power_off = XStruct( 77 | 'liveid' / SGString() 78 | ) 79 | 80 | 81 | game_dvr_record = XStruct( 82 | 'start_time_delta' / Int32sb, 83 | 'end_time_delta' / Int32sb 84 | ) 85 | 86 | 87 | unsnap = XStruct( 88 | 'unk' / Bytes(1) 89 | ) 90 | 91 | 92 | gamepad = XStruct( 93 | 'timestamp' / Int64ub, 94 | 'buttons' / XEnum(Int16ub, enum.GamePadButton), 95 | 'left_trigger' / Float32b, 96 | 'right_trigger' / Float32b, 97 | 'left_thumbstick_x' / Float32b, 98 | 'left_thumbstick_y' / Float32b, 99 | 'right_thumbstick_x' / Float32b, 100 | 'right_thumbstick_y' / Float32b 101 | ) 102 | 103 | 104 | paired_identity_state_changed = XStruct( 105 | 'state' / XEnum(Int16ub, enum.PairedIdentityState) 106 | ) 107 | 108 | 109 | media_state = XStruct( 110 | 'title_id' / Int32ub, 111 | 'aum_id' / SGString(), 112 | 'asset_id' / SGString(), 113 | 'media_type' / XEnum(Int16ub, enum.MediaType), 114 | 'sound_level' / XEnum(Int16ub, enum.SoundLevel), 115 | 'enabled_commands' / XEnum(Int32ub, enum.MediaControlCommand), 116 | 'playback_status' / XEnum(Int16ub, enum.MediaPlaybackStatus), 117 | 'rate' / Float32b, 118 | 'position' / Int64ub, 119 | 'media_start' / Int64ub, 120 | 'media_end' / Int64ub, 121 | 'min_seek' / Int64ub, 122 | 'max_seek' / Int64ub, 123 | 'metadata' / PrefixedArray(Int16ub, Struct( 124 | 'name' / SGString(), 125 | 'value' / SGString() 126 | )) 127 | ) 128 | 129 | 130 | media_controller_removed = XStruct( 131 | 'title_id' / Int32ub 132 | ) 133 | 134 | 135 | media_command_result = XStruct( 136 | 'request_id' / Int64ub, 137 | 'result' / Int32ub 138 | ) 139 | 140 | 141 | media_command = XStruct( 142 | 'request_id' / Int64ub, 143 | 'title_id' / Int32ub, 144 | 'command' / XEnum(Int32ub, enum.MediaControlCommand), 145 | 'seek_position' / If(this.command == enum.MediaControlCommand.Seek, Int64ub) 146 | ) 147 | 148 | 149 | orientation = XStruct( 150 | 'timestamp' / Int64ub, 151 | 'rotation_matrix_value' / Float32b, 152 | 'w' / Float32b, 153 | 'x' / Float32b, 154 | 'y' / Float32b, 155 | 'z' / Float32b 156 | ) 157 | 158 | 159 | compass = XStruct( 160 | 'timestamp' / Int64ub, 161 | 'magnetic_north' / Float32b, 162 | 'true_north' / Float32b 163 | ) 164 | 165 | 166 | inclinometer = XStruct( 167 | 'timestamp' / Int64ub, 168 | 'pitch' / Float32b, 169 | 'roll' / Float32b, 170 | 'yaw' / Float32b 171 | ) 172 | 173 | 174 | gyrometer = XStruct( 175 | 'timestamp' / Int64ub, 176 | 'angular_velocity_x' / Float32b, 177 | 'angular_velocity_y' / Float32b, 178 | 'angular_velocity_z' / Float32b 179 | ) 180 | 181 | 182 | accelerometer = XStruct( 183 | 'timestamp' / Int64ub, 184 | 'acceleration_x' / Float32b, 185 | 'acceleration_y' / Float32b, 186 | 'acceleration_z' / Float32b 187 | ) 188 | 189 | 190 | _touchpoint = XStruct( 191 | 'touchpoint_id' / Int32ub, 192 | 'touchpoint_action' / XEnum(Int16ub, enum.TouchAction), 193 | 'touchpoint_x' / Int32ub, 194 | 'touchpoint_y' / Int32ub 195 | ) 196 | 197 | 198 | touch = XStruct( 199 | 'touch_msg_timestamp' / Int32ub, 200 | 'touchpoints' / PrefixedArray(Int16ub, _touchpoint) 201 | ) 202 | 203 | 204 | disconnect = XStruct( 205 | 'reason' / XEnum(Int32ub, enum.DisconnectReason), 206 | 'error_code' / Int32ub 207 | ) 208 | 209 | 210 | stop_channel = XStruct( 211 | 'target_channel_id' / Int64ub 212 | ) 213 | 214 | 215 | start_channel_request = XStruct( 216 | 'channel_request_id' / Int32ub, 217 | 'title_id' / Int32ub, 218 | 'service' / UUIDAdapter(), 219 | 'activity_id' / Int32ub 220 | ) 221 | 222 | 223 | start_channel_response = XStruct( 224 | 'channel_request_id' / Int32ub, 225 | 'target_channel_id' / Int64ub, 226 | 'result' / XEnum(Int32ub, enum.SGResultCode) 227 | ) 228 | 229 | 230 | title_launch = XStruct( 231 | 'location' / XEnum(Int16ub, enum.ActiveTitleLocation), 232 | 'uri' / SGString() 233 | ) 234 | 235 | 236 | system_text_done = XStruct( 237 | 'text_session_id' / Int32ub, 238 | 'text_version' / Int32ub, 239 | 'flags' / Int32ub, 240 | 'result' / XEnum(Int32ub, enum.TextResult) 241 | ) 242 | 243 | 244 | system_text_acknowledge = XStruct( 245 | 'text_session_id' / Int32ub, 246 | 'text_version_ack' / Int32ub 247 | ) 248 | 249 | _system_text_input_delta = XStruct( 250 | 'offset' / Int32ub, 251 | 'delete_count' / Int32ub, 252 | 'insert_content' / SGString() 253 | ) 254 | 255 | system_text_input = XStruct( 256 | 'text_session_id' / Int32ub, 257 | 'base_version' / Int32ub, 258 | 'submitted_version' / Int32ub, 259 | 'total_text_byte_len' / Int32ub, 260 | 'selection_start' / Int32sb, 261 | 'selection_length' / Int32sb, 262 | 'flags' / Int16ub, 263 | 'text_chunk_byte_start' / Int32ub, 264 | 'text_chunk' / SGString(), 265 | 'delta' / Optional(PrefixedArray(Int16ub, _system_text_input_delta)) 266 | ) 267 | 268 | 269 | title_text_selection = XStruct( 270 | 'text_session_id' / Int64ub, 271 | 'text_buffer_version' / Int32ub, 272 | 'start' / Int32ub, 273 | 'length' / Int32ub 274 | ) 275 | 276 | 277 | title_text_input = XStruct( 278 | 'text_session_id' / Int64ub, 279 | 'text_buffer_version' / Int32ub, 280 | 'result' / XEnum(Int16ub, enum.TextResult), 281 | 'text' / SGString() 282 | ) 283 | 284 | 285 | text_configuration = XStruct( 286 | 'text_session_id' / Int64ub, 287 | 'text_buffer_version' / Int32ub, 288 | 'text_options' / XEnum(Int32ub, enum.TextOption), 289 | 'input_scope' / XEnum(Int32ub, enum.TextInputScope), 290 | 'max_text_length' / Int32ub, 291 | 'locale' / SGString(), 292 | 'prompt' / SGString() 293 | ) 294 | 295 | 296 | _active_title = XStruct( 297 | 'title_id' / Int32ub, 298 | 'disposition' / BitStruct( 299 | 'has_focus' / Flag, 300 | 'title_location' / XEnum(BitsInteger(15), enum.ActiveTitleLocation) 301 | ), 302 | 'product_id' / UUIDAdapter(), 303 | 'sandbox_id' / UUIDAdapter(), 304 | 'aum' / SGString() 305 | ) 306 | 307 | 308 | console_status = XStruct( 309 | 'live_tv_provider' / Int32ub, 310 | 'major_version' / Int32ub, 311 | 'minor_version' / Int32ub, 312 | 'build_number' / Int32ub, 313 | 'locale' / SGString(), 314 | 'active_titles' / PrefixedArray(Int16ub, _active_title) 315 | ) 316 | 317 | 318 | active_surface_change = XStruct( 319 | 'surface_type' / XEnum(Int16ub, enum.ActiveSurfaceType), 320 | 'server_tcp_port' / Int16ub, 321 | 'server_udp_port' / Int16ub, 322 | 'session_id' / UUIDAdapter(), 323 | 'render_width' / Int16ub, 324 | 'render_height' / Int16ub, 325 | 'master_session_key' / Bytes(0x20) 326 | ) 327 | 328 | message_structs = { 329 | MessageType.Ack: acknowledge, 330 | MessageType.Group: Pass, 331 | MessageType.LocalJoin: local_join, 332 | MessageType.StopActivity: Pass, 333 | MessageType.AuxilaryStream: auxiliary_stream, 334 | MessageType.ActiveSurfaceChange: active_surface_change, 335 | MessageType.Navigate: Pass, 336 | MessageType.Json: json, 337 | MessageType.Tunnel: Pass, 338 | MessageType.ConsoleStatus: console_status, 339 | MessageType.TitleTextConfiguration: text_configuration, 340 | MessageType.TitleTextInput: title_text_input, 341 | MessageType.TitleTextSelection: title_text_selection, 342 | MessageType.MirroringRequest: Pass, 343 | MessageType.TitleLaunch: title_launch, 344 | MessageType.StartChannelRequest: start_channel_request, 345 | MessageType.StartChannelResponse: start_channel_response, 346 | MessageType.StopChannel: stop_channel, 347 | MessageType.System: Pass, 348 | MessageType.Disconnect: disconnect, 349 | MessageType.TitleTouch: touch, 350 | MessageType.Accelerometer: accelerometer, 351 | MessageType.Gyrometer: gyrometer, 352 | MessageType.Inclinometer: inclinometer, 353 | MessageType.Compass: compass, 354 | MessageType.Orientation: orientation, 355 | MessageType.PairedIdentityStateChanged: paired_identity_state_changed, 356 | MessageType.Unsnap: unsnap, 357 | MessageType.GameDvrRecord: game_dvr_record, 358 | MessageType.PowerOff: power_off, 359 | MessageType.MediaControllerRemoved: media_controller_removed, 360 | MessageType.MediaCommand: media_command, 361 | MessageType.MediaCommandResult: media_command_result, 362 | MessageType.MediaState: media_state, 363 | MessageType.Gamepad: gamepad, 364 | MessageType.SystemTextConfiguration: text_configuration, 365 | MessageType.SystemTextInput: system_text_input, 366 | MessageType.SystemTouch: touch, 367 | MessageType.SystemTextAck: system_text_acknowledge, 368 | MessageType.SystemTextDone: system_text_done 369 | } 370 | 371 | struct = XStruct( 372 | 'header' / header, 373 | 'protected_payload' / CryptoTunnel( 374 | IfThenElse(this.header.flags.is_fragment, fragment, 375 | XSwitch( 376 | this.header.flags.msg_type, 377 | message_structs, 378 | Pass 379 | ) 380 | ) 381 | ) 382 | ) 383 | -------------------------------------------------------------------------------- /xbox/rest/routes/device.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional, List 3 | 4 | from fastapi import APIRouter, Depends, HTTPException 5 | 6 | from .. import schemas, singletons 7 | from ..deps import console_exists, console_connected, get_xbl_client, get_authorization 8 | from ..consolewrap import ConsoleWrap 9 | 10 | from xbox.webapi.api.client import XboxLiveClient 11 | from xbox.webapi.api.provider.titlehub import TitleFields 12 | from xbox.sg import enum 13 | from xbox.stump import json_model as stump_schemas 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | router = APIRouter() 18 | 19 | 20 | @router.get('/', response_model=List[schemas.DeviceStatusResponse]) 21 | async def device_overview(addr: Optional[str] = None): 22 | discovered = await ConsoleWrap.discover(addr=addr) 23 | discovered = discovered.copy() 24 | 25 | liveids = [d.liveid for d in discovered] 26 | for i, c in enumerate(singletons.console_cache.values()): 27 | if c.liveid in liveids: 28 | # Refresh existing entries 29 | index = liveids.index(c.liveid) 30 | 31 | if c.device_status != discovered[index].device_status: 32 | singletons.console_cache[c.liveid] = ConsoleWrap(discovered[index]) 33 | del discovered[index] 34 | del liveids[index] 35 | elif c.liveid not in liveids: 36 | # Set unresponsive consoles to Unavailable 37 | singletons.console_cache[c.liveid].console.device_status = enum.DeviceStatus.Unavailable 38 | 39 | # Extend by new entries 40 | for d in discovered: 41 | singletons.console_cache.update({d.liveid: ConsoleWrap(d)}) 42 | 43 | # Filter for specific console when ip address query is supplied (if available) 44 | consoles = [console.status for console in singletons.console_cache.values() 45 | if (addr and console.status.ip_address == addr) or not addr] 46 | return consoles 47 | 48 | 49 | @router.get('/{liveid}/poweron', response_model=schemas.GeneralResponse) 50 | async def poweron(liveid: str, addr: Optional[str] = None): 51 | await ConsoleWrap.power_on(liveid, addr=addr) 52 | return schemas.GeneralResponse(success=True) 53 | 54 | 55 | """ 56 | Require enumerated console 57 | """ 58 | 59 | 60 | @router.get('/{liveid}', response_model=schemas.DeviceStatusResponse) 61 | def device_info( 62 | console: ConsoleWrap = Depends(console_exists) 63 | ): 64 | return console.status 65 | 66 | 67 | @router.get('/{liveid}/connect', response_model=schemas.GeneralResponse) 68 | async def force_connect( 69 | console: ConsoleWrap = Depends(console_exists), 70 | authentication_data: schemas.AuthenticationStatus = Depends(get_authorization) 71 | ): 72 | try: 73 | userhash = '' 74 | xtoken = '' 75 | if authentication_data: 76 | userhash = authentication_data.xsts.userhash 77 | xtoken = authentication_data.xsts.token 78 | 79 | state = await console.connect(userhash, xtoken) 80 | except Exception as e: 81 | raise 82 | 83 | if state != enum.ConnectionState.Connected: 84 | raise HTTPException(status_code=400, detail='Connection failed') 85 | 86 | return schemas.GeneralResponse(success=True, details={'connection_state': state.name}) 87 | 88 | 89 | """ 90 | Require connected console 91 | """ 92 | 93 | 94 | @router.get('/{liveid}/disconnect', response_model=schemas.GeneralResponse) 95 | async def disconnect( 96 | console: ConsoleWrap = Depends(console_connected) 97 | ): 98 | await console.disconnect() 99 | return schemas.GeneralResponse(success=True) 100 | 101 | 102 | @router.get('/{liveid}/poweroff', response_model=schemas.GeneralResponse) 103 | async def poweroff( 104 | console: ConsoleWrap = Depends(console_connected) 105 | ): 106 | if not await console.power_off(): 107 | raise HTTPException(status_code=400, detail='Failed to power off') 108 | 109 | return schemas.GeneralResponse(success=True) 110 | 111 | 112 | @router.get('/{liveid}/console_status', response_model=schemas.ConsoleStatusResponse) 113 | async def console_status( 114 | console: ConsoleWrap = Depends(console_connected), 115 | xbl_client: XboxLiveClient = Depends(get_xbl_client) 116 | ): 117 | status = console.console_status 118 | # Update Title Info, if authorization data is available 119 | if xbl_client and status: 120 | for t in status.active_titles: 121 | try: 122 | title_id = t.title_id 123 | resp = singletons.title_cache.get(title_id) 124 | if not resp: 125 | resp = await xbl_client.titlehub.get_title_info(title_id, [TitleFields.IMAGE]) 126 | if resp.titles[0]: 127 | singletons.title_cache[title_id] = resp 128 | t.name = resp.titles[0].name 129 | t.image = resp.titles[0].display_image 130 | t.type = resp.titles[0].type 131 | except Exception as e: 132 | logger.exception(f'Failed to download title metadata for AUM: {t.aum}', exc_info=e) 133 | return status 134 | 135 | 136 | @router.get('/{liveid}/launch/{app_id}', response_model=schemas.GeneralResponse, deprecated=True) 137 | async def launch_title( 138 | console: ConsoleWrap = Depends(console_connected), 139 | *, 140 | app_id: str 141 | ): 142 | await console.launch_title(app_id) 143 | return schemas.GeneralResponse(success=True, details={'launched': app_id}) 144 | 145 | 146 | @router.get('/{liveid}/media_status', response_model=schemas.MediaStateResponse) 147 | def media_status( 148 | console: ConsoleWrap = Depends(console_connected) 149 | ): 150 | return console.media_status 151 | 152 | 153 | @router.get('/{liveid}/ir', response_model=schemas.InfraredResponse) 154 | async def infrared( 155 | console: ConsoleWrap = Depends(console_connected) 156 | ): 157 | stump_config = await console.get_stump_config() 158 | 159 | devices = {} 160 | for device_config in stump_config.params: 161 | button_links = {} 162 | for button in device_config.buttons: 163 | button_links[button] = schemas.InfraredButton( 164 | url=f'/device/{console.liveid}/ir/{device_config.device_id}/{button}', 165 | value=device_config.buttons[button] 166 | ) 167 | 168 | devices[device_config.device_type] = schemas.InfraredDevice( 169 | device_type=device_config.device_type, 170 | device_brand=device_config.device_brand, 171 | device_model=device_config.device_model, 172 | device_name=device_config.device_name, 173 | device_id=device_config.device_id, 174 | buttons=button_links 175 | ) 176 | 177 | return schemas.InfraredResponse(__root__=devices) 178 | 179 | 180 | @router.get('/{liveid}/ir/{device_id}', response_model=schemas.InfraredDevice) 181 | async def infrared_available_keys( 182 | console: ConsoleWrap = Depends(console_connected), 183 | *, 184 | device_id: str 185 | ): 186 | stump_config = await console.get_stump_config() 187 | for device_config in stump_config.params: 188 | if device_config.device_id != device_id: 189 | continue 190 | 191 | button_links = {} 192 | for button in device_config.buttons: 193 | button_links[button] = schemas.InfraredButton( 194 | url=f'/device/{console.liveid}/ir/{device_config.device_id}/{button}', 195 | value=device_config.buttons[button] 196 | ) 197 | 198 | return schemas.InfraredDevice( 199 | device_type=device_config.device_type, 200 | device_brand=device_config.device_brand, 201 | device_model=device_config.device_model, 202 | device_name=device_config.device_name, 203 | device_id=device_config.device_id, 204 | buttons=button_links 205 | ) 206 | 207 | raise HTTPException(status_code=400, detail=f'Device Id \'{device_id}\' not found') 208 | 209 | 210 | @router.get('/{liveid}/ir/{device_id}/{button}', response_model=schemas.GeneralResponse) 211 | async def infrared_send( 212 | console: ConsoleWrap = Depends(console_connected), 213 | *, 214 | device_id: str, 215 | button: str 216 | ): 217 | if not await console.send_stump_key(device_id, button): 218 | raise HTTPException(status_code=400, detail='Failed to send button') 219 | 220 | return schemas.GeneralResponse(success=True, details={'sent_key': button, 'device_id': device_id}) 221 | 222 | 223 | @router.get('/{liveid}/media', response_model=schemas.MediaCommandsResponse) 224 | def media_overview( 225 | console: ConsoleWrap = Depends(console_connected) 226 | ): 227 | return schemas.MediaCommandsResponse(commands=list(console.media_commands.keys())) 228 | 229 | 230 | @router.get('/{liveid}/media/{command}', response_model=schemas.GeneralResponse) 231 | async def media_command( 232 | console: ConsoleWrap = Depends(console_connected), 233 | *, 234 | command: str, 235 | seek_position: Optional[int] = None 236 | ): 237 | cmd = console.media_commands.get(command) 238 | if not cmd: 239 | raise HTTPException(status_code=400, detail=f'Invalid command passed, command: {command}') 240 | elif cmd == enum.MediaControlCommand.Seek and seek_position is None: 241 | raise HTTPException(status_code=400, detail=f'Seek command requires seek_position argument') 242 | 243 | await console.send_media_command(cmd, seek_position=seek_position) 244 | return schemas.GeneralResponse(success=True) 245 | 246 | 247 | @router.get('/{liveid}/input', response_model=schemas.InputResponse) 248 | def input_overview( 249 | console: ConsoleWrap = Depends(console_connected) 250 | ): 251 | return schemas.InputResponse(buttons=list(console.input_keys.keys())) 252 | 253 | 254 | @router.get('/{liveid}/input/{button}', response_model=schemas.GeneralResponse) 255 | async def input_send_button( 256 | console: ConsoleWrap = Depends(console_connected), 257 | *, 258 | button: str 259 | ): 260 | btn = console.input_keys.get(button) 261 | if not btn: 262 | raise HTTPException(status_code=400, detail=f'Invalid button passed, button: {button}') 263 | 264 | await console.send_gamepad_button(btn) 265 | return schemas.GeneralResponse(success=True) 266 | 267 | 268 | @router.get('/{liveid}/stump/headend', response_model=stump_schemas.HeadendInfo) 269 | async def stump_headend_info( 270 | console: ConsoleWrap = Depends(console_connected) 271 | ): 272 | return await console.get_headend_info() 273 | 274 | 275 | @router.get('/{liveid}/stump/livetv', response_model=stump_schemas.LiveTvInfo) 276 | async def stump_livetv_info( 277 | console: ConsoleWrap = Depends(console_connected) 278 | ): 279 | return await console.get_livetv_info() 280 | 281 | 282 | @router.get('/{liveid}/stump/tuner_lineups', response_model=stump_schemas.TunerLineups) 283 | async def stump_tuner_lineups( 284 | console: ConsoleWrap = Depends(console_connected) 285 | ): 286 | return await console.get_tuner_lineups() 287 | 288 | 289 | @router.get('/{liveid}/text', response_model=schemas.device.TextSessionActiveResponse) 290 | def text_overview( 291 | console: ConsoleWrap = Depends(console_connected) 292 | ): 293 | return schemas.TextSessionActiveResponse(text_session_active=console.text_active) 294 | 295 | 296 | @router.get('/{liveid}/text/{text}', response_model=schemas.GeneralResponse) 297 | async def text_send( 298 | console: ConsoleWrap = Depends(console_connected), 299 | *, 300 | text: str 301 | ): 302 | await console.send_text(text) 303 | return schemas.GeneralResponse(success=True) 304 | 305 | 306 | @router.get('/{liveid}/gamedvr', response_model=schemas.GeneralResponse) 307 | async def gamedvr_record( 308 | console: ConsoleWrap = Depends(console_connected), 309 | start: Optional[int] = -60, 310 | end: Optional[int] = 0 311 | ): 312 | """ 313 | Default to record last 60 seconds 314 | Adjust with start/end query parameter 315 | (delta time in seconds) 316 | """ 317 | try: 318 | await console.dvr_record(start, end) 319 | except Exception as e: 320 | raise HTTPException(status_code=400, detail=f'GameDVR failed, error: {e}') 321 | 322 | return schemas.GeneralResponse(success=True) 323 | --------------------------------------------------------------------------------