├── .github └── workflows │ └── build.yml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── conf.py │ ├── device.rst │ ├── examples │ ├── async_client.rst │ ├── client.rst │ ├── examples.rst │ └── gamepad.rst │ ├── index.rst │ ├── live_stream.rst │ ├── readme.rst │ ├── registering.rst │ └── session.rst ├── examples ├── async_client.py ├── client.py ├── ds4_mapping.yaml ├── gamepad.py └── x360_mapping.yaml ├── external └── takion.proto ├── pyremoteplay ├── __init__.py ├── __main__.py ├── __version__.py ├── av.py ├── const.py ├── controller.py ├── crypt.py ├── ddp.py ├── device.py ├── errors.py ├── gamepad │ ├── __init__.py │ └── mapping.py ├── gui │ ├── __init__.py │ ├── __main__.py │ ├── audio.py │ ├── controls.py │ ├── device_grid.py │ ├── joystick.py │ ├── main_window.py │ ├── options.py │ ├── stream_window.py │ ├── toolbar.py │ ├── util.py │ ├── video.py │ ├── widgets.py │ └── workers.py ├── keys.py ├── oauth.py ├── profile.py ├── protobuf.py ├── receiver │ └── __init__.py ├── register.py ├── session.py ├── socket.py ├── stream.py ├── stream_packets.py ├── takion_pb2.py ├── tracker.py └── util.py ├── requirements-dev.txt ├── requirements-gui.txt ├── requirements.txt ├── script ├── protoc └── release ├── setup.py └── tests ├── __init__.py ├── test_crypt.py ├── test_fec.py ├── test_register.py └── test_stream_packets.py /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | sdist: 9 | name: sdist 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | 15 | - name: Install Python 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: 3.8 19 | 20 | - name: Build sdist 21 | run: | 22 | pip install wheel 23 | python setup.py sdist bdist_wheel 24 | 25 | - name: Install/Test sdist 26 | run: | 27 | pip install dist/*.tar.gz 28 | pip install pyjerasure 29 | pip install pytest 30 | pytest tests 31 | 32 | - name: Upload Artifact 33 | uses: actions/upload-artifact@v3 34 | with: 35 | name: dist 36 | path: dist/ 37 | 38 | publish: 39 | runs-on: ubuntu-latest 40 | needs: [sdist] 41 | if: startsWith(github.ref, 'refs/tags') 42 | steps: 43 | - name: Download Wheels 44 | uses: actions/download-artifact@v3 45 | with: 46 | name: dist 47 | path: dist/ 48 | - name: Publish 49 | uses: pypa/gh-action-pypi-publish@master 50 | with: 51 | user: __token__ 52 | password: ${{ secrets.PYPI_PASSWORD }} 53 | skip_existing: true 54 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | docs/build/ 74 | docs/source/reference 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/source/conf.py 11 | 12 | # Build documentation with MkDocs 13 | #mkdocs: 14 | # configuration: mkdocs.yml 15 | 16 | # Optionally build your docs in additional formats such as PDF and ePub 17 | formats: all 18 | 19 | # Optionally set the version of Python and requirements required to build your docs 20 | python: 21 | version: 3.8 22 | install: 23 | - requirements: docs/requirements.txt 24 | - requirements: requirements.txt 25 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | include requirements-gui.txt 3 | include requirements-dev.txt 4 | include README.md 5 | include LICENSE 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyremoteplay # 2 | [![PyPi](https://img.shields.io/pypi/v/pyremoteplay.svg)](https://pypi.org/project/pyremoteplay/) 3 | [![Build Status](https://github.com/ktnrg45/pyremoteplay/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/ktnrg45/pyremoteplay/actions/?query=workflow%3Abuild) 4 | [![Documentation Status](https://readthedocs.org/projects/pyremoteplay/badge/?version=latest)](https://pyremoteplay.readthedocs.io/en/latest/?badge=latest) 5 | 6 | Python PlayStation Remote Play API 7 | 8 | [Documentation](https://pyremoteplay.readthedocs.io/en/latest) 9 | 10 | ## About ## 11 | This project provides an API to programmatically connect to and control Remote Play hosts (PS4 and PS5). The low-level networking internals is written using the Asyncio framework. In addition it includes an optional GUI, allowing to view the live stream and control the host through keyboard/mouse input. This library is based on the C/C++ project [Chiaki](https://github.com/thestr4ng3r/chiaki). 12 | 13 | ## Features ## 14 | - API to programatically control host and expose live audio/video stream 15 | - Registering client for Remote Play on the host 16 | - Interface for controlling the host, which emulates a DualShock controller 17 | - Ability to power off/on the host if standby is enabled 18 | - GUI which displays the live stream and supports keyboard/mouse input 19 | - Support for controllers 20 | 21 | ## Requirements ## 22 | - Python 3.8+ 23 | - OS: Linux, Windows 10 24 | 25 | - Note: Untested on MacOS 26 | 27 | ## Network Requirements ## 28 | This project will only work with local devices; devices on the same local network. 29 | You may be able to connect with devices on different subnets, but this is not guaranteed. 30 | 31 | ## GUI Dependencies ## 32 | The GUI requires dependencies that may be complex to install. 33 | Below is a list of such dependencies. 34 | - pyav (May require FFMPEG to be installed) 35 | - PySide6 36 | 37 | `uvloop` is supported for the GUI and will be used if installed. 38 | 39 | ## Installation ## 40 | It is recommended to install in a virtual environment. 41 | 42 | ``` 43 | python3 -m venv . 44 | source bin/activate 45 | ``` 46 | 47 | ### From pip ### 48 | To install core package run: 49 | ``` 50 | pip install pyremoteplay 51 | ``` 52 | 53 | To install with optional GUI run: 54 | ``` 55 | pip install pyremoteplay[gui] 56 | ``` 57 | 58 | ### From Source ### 59 | To Install from source, clone this repo and navigate to the top level directory. 60 | 61 | ``` 62 | pip install -r requirements.txt 63 | python setup.py install 64 | ``` 65 | 66 | To Install GUI dependencies run: 67 | ``` 68 | pip install -r requirements-gui.txt 69 | ``` 70 | 71 | ## Setup ## 72 | There are some steps that must be completed to use this library from a user standpoint. 73 | - Registering a PSN Account 74 | - Linking PSN Account and client to the Remote Play Host 75 | 76 | Configuration files are saved in the `.pyremoteplay` folder in the users home directory. Both the CLI and GUI utilize the same files. 77 | 78 | ### CLI Setup ### 79 | Registering and linking can be completed through the cli by following the prompts after using the below command: 80 | 81 | `pyremoteplay {host IP Address} --register` 82 | 83 | Replace `{host IP Address}` with the IP Address of the Remote Play host. 84 | 85 | ### GUI Setup ### 86 | Registering and linking can be performed in the options screen. 87 | 88 | ## Usage ## 89 | To run the terminal only CLI use the following command: 90 | `pyremoteplay {host IP Address}` 91 | 92 | To run the GUI use the following command: 93 | `pyremoteplay-gui` 94 | 95 | ## Notes ## 96 | - Video decoding is performed by the CPU by default. Hardware Decoding can be enabled in the options screen in the GUI. 97 | - You may have to install `ffmpeg` with hardware decoding enabled and then install `pyav` with the following command to allow for hardware decoding: 98 | `pip install av --no-binary av` 99 | 100 | 101 | ## Baseline measurements ## 102 | The CLI instance runs at 5-10% CPU usage with around 50Mb memory usage according to `top` on this author's machine: ODroid N2. 103 | 104 | ## Known Issues/To Do ## 105 | - Text sending functions 106 | - Add support for HDR 107 | - Audio stutters 108 | -------------------------------------------------------------------------------- /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 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /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=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-rtd-theme 3 | myst-parser 4 | sphinxcontrib-apidoc 5 | sphinx-autodoc-typehints 6 | 7 | av>=8.0.0 8 | pygame>=2.1.2 -------------------------------------------------------------------------------- /docs/source/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/master/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 | 18 | sys.path.insert(0, os.path.abspath("../../")) 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = "pyremoteplay" 24 | copyright = "2022, ktnrg45" 25 | author = "ktnrg45" 26 | 27 | from pyremoteplay.__version__ import VERSION 28 | 29 | # The short X.Y version 30 | version = ".".join(VERSION.split(".")[:2]) 31 | # The full version, including alpha/beta/rc tags 32 | release = VERSION 33 | 34 | 35 | # -- General configuration --------------------------------------------------- 36 | 37 | # If your documentation needs a minimal Sphinx version, state it here. 38 | # 39 | # needs_sphinx = '1.0' 40 | 41 | # Add any Sphinx extension module names here, as strings. They can be 42 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 43 | # ones. 44 | extensions = [ 45 | "myst_parser", 46 | "sphinx.ext.autodoc", 47 | "sphinxcontrib.apidoc", 48 | "sphinx_autodoc_typehints", 49 | # 'sphinx.ext.doctest', 50 | # 'sphinx.ext.coverage', 51 | # 'sphinx.ext.viewcode', 52 | ] 53 | 54 | apidoc_module_dir = "../../pyremoteplay" 55 | apidoc_output_dir = "reference" 56 | apidoc_separate_modules = True 57 | apidoc_toc_file = "modules" 58 | apidoc_excluded_paths = [ 59 | "gui", 60 | "takion_pb2.py", 61 | "keys.py", 62 | "av.py", 63 | "stream_packets.py", 64 | "protobuf.py", 65 | "crypt.py", 66 | "stream.py", 67 | "util.py", 68 | ] 69 | 70 | # Add any paths that contain templates here, relative to this directory. 71 | templates_path = ["_templates"] 72 | 73 | # The suffix(es) of source filenames. 74 | # You can specify multiple suffix as a list of string: 75 | # 76 | # source_suffix = ['.rst', '.md'] 77 | source_suffix = { 78 | ".rst": "restructuredtext", 79 | ".txt": "markdown", 80 | ".md": "markdown", 81 | } 82 | 83 | # The master toctree document. 84 | master_doc = "index" 85 | 86 | # The language for content autogenerated by Sphinx. Refer to documentation 87 | # for a list of supported languages. 88 | # 89 | # This is also used if you do content translation via gettext catalogs. 90 | # Usually you set "language" from the command line for these cases. 91 | language = None 92 | 93 | # List of patterns, relative to source directory, that match files and 94 | # directories to ignore when looking for source files. 95 | # This pattern also affects html_static_path and html_extra_path. 96 | exclude_patterns = [] 97 | 98 | # The name of the Pygments (syntax highlighting) style to use. 99 | pygments_style = None 100 | 101 | 102 | # -- Options for HTML output ------------------------------------------------- 103 | 104 | # The theme to use for HTML and HTML Help pages. See the documentation for 105 | # a list of builtin themes. 106 | # 107 | html_theme = "sphinx_rtd_theme" 108 | 109 | # Theme options are theme-specific and customize the look and feel of a theme 110 | # further. For a list of options available for each theme, see the 111 | # documentation. 112 | # 113 | # html_theme_options = {} 114 | 115 | # Add any paths that contain custom static files (such as style sheets) here, 116 | # relative to this directory. They are copied after the builtin static files, 117 | # so a file named "default.css" will overwrite the builtin "default.css". 118 | html_static_path = ["_static"] 119 | 120 | # Custom sidebar templates, must be a dictionary that maps document names 121 | # to template names. 122 | # 123 | # The default sidebars (for documents that don't match any pattern) are 124 | # defined by theme itself. Builtin themes are using these templates by 125 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 126 | # 'searchbox.html']``. 127 | # 128 | # html_sidebars = {} 129 | 130 | 131 | # Add any paths that contain custom static files (such as style sheets) here, 132 | # relative to this directory. They are copied after the builtin static files, 133 | # so a file named "default.css" will overwrite the builtin "default.css". 134 | # html_static_path = ['_static'] 135 | master_doc = "index" 136 | 137 | autodoc_member_order = "bysource" 138 | autodoc_default_options = { 139 | "undoc-members": True, 140 | "show-inheritance": True, 141 | } 142 | 143 | # -- Options for HTMLHelp output --------------------------------------------- 144 | 145 | # Output file base name for HTML help builder. 146 | htmlhelp_basename = "pyremoteplaydoc" 147 | 148 | 149 | # -- Options for LaTeX output ------------------------------------------------ 150 | 151 | latex_elements = { 152 | # The paper size ('letterpaper' or 'a4paper'). 153 | # 154 | # 'papersize': 'letterpaper', 155 | # The font size ('10pt', '11pt' or '12pt'). 156 | # 157 | # 'pointsize': '10pt', 158 | # Additional stuff for the LaTeX preamble. 159 | # 160 | # 'preamble': '', 161 | # Latex figure (float) alignment 162 | # 163 | # 'figure_align': 'htbp', 164 | } 165 | 166 | # Grouping the document tree into LaTeX files. List of tuples 167 | # (source start file, target name, title, 168 | # author, documentclass [howto, manual, or own class]). 169 | latex_documents = [ 170 | (master_doc, "pyremoteplay.tex", "pyremoteplay Documentation", "ktnrg45", "manual"), 171 | ] 172 | 173 | 174 | # -- Options for manual page output ------------------------------------------ 175 | 176 | # One entry per manual page. List of tuples 177 | # (source start file, name, description, authors, manual section). 178 | man_pages = [(master_doc, "pyremoteplay", "pyremoteplay Documentation", [author], 1)] 179 | 180 | 181 | # -- Options for Texinfo output ---------------------------------------------- 182 | 183 | # Grouping the document tree into Texinfo files. List of tuples 184 | # (source start file, target name, title, author, 185 | # dir menu entry, description, category) 186 | texinfo_documents = [ 187 | ( 188 | master_doc, 189 | "pyremoteplay", 190 | "pyremoteplay Documentation", 191 | author, 192 | "pyremoteplay", 193 | "One line description of project.", 194 | "Miscellaneous", 195 | ), 196 | ] 197 | 198 | 199 | # -- Options for Epub output ------------------------------------------------- 200 | 201 | # Bibliographic Dublin Core info. 202 | epub_title = project 203 | 204 | # The unique identifier of the text. This can be a ISBN number 205 | # or the project homepage. 206 | # 207 | # epub_identifier = '' 208 | 209 | # A unique identification for the text. 210 | # 211 | # epub_uid = '' 212 | 213 | # A list of files that should not be packed into the epub file. 214 | epub_exclude_files = ["search.html"] 215 | 216 | 217 | # -- Extension configuration ------------------------------------------------- 218 | 219 | # typehints_fully_qualified = True 220 | -------------------------------------------------------------------------------- /docs/source/device.rst: -------------------------------------------------------------------------------- 1 | Devices 2 | =============================================================================================== 3 | 4 | The :class:`RPDevice ` class represents a Remote Play host / console. 5 | 6 | Ideally, most interactions should be made using this class. 7 | 8 | Devices are identified uniquely via it's MAC address, which will differ depending on the network interface it is using (WiFi/Ethernet). 9 | 10 | The instance will need a valid status to be usable. This can be done with the :meth:`RPDevice.get_status() ` method. 11 | 12 | Once the device has a valid status, actions can be performed such as connecting to a session, turning off/on the device. 13 | 14 | 15 | Discovery 16 | +++++++++++++++++++++++++++++++++++++++++++++ 17 | 18 | Devices can be discovered using the :meth:`RPDevice.search() ` method. 19 | All devices that are discovered on the local network will be returned. 20 | 21 | 22 | Creating Devices 23 | +++++++++++++++++++++++++++++++++++++++++++++ 24 | 25 | Alternatively devices can be created manually. To create a device, the ip address or hostname needs to be known. 26 | 27 | :: 28 | 29 | from pyremoteplay import RPDevice 30 | 31 | device = RPDevice("192.168.86.2") 32 | device2 = RPDevice("my_device_hostname") 33 | 34 | 35 | This will create a device if the hostname is valid. However, this does not mean that the device associated with the hostname is in fact a Remote Play device. 36 | 37 | -------------------------------------------------------------------------------- /docs/source/examples/async_client.rst: -------------------------------------------------------------------------------- 1 | Async Client 2 | =============================================================================================== 3 | 4 | .. literalinclude:: ../../../examples/async_client.py 5 | :language: python 6 | 7 | -------------------------------------------------------------------------------- /docs/source/examples/client.rst: -------------------------------------------------------------------------------- 1 | Client 2 | =============================================================================================== 3 | 4 | .. literalinclude:: ../../../examples/client.py 5 | :language: python 6 | 7 | -------------------------------------------------------------------------------- /docs/source/examples/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | =============================================================================================== 3 | .. toctree:: 4 | :maxdepth: 2 5 | 6 | client 7 | async_client 8 | gamepad -------------------------------------------------------------------------------- /docs/source/examples/gamepad.rst: -------------------------------------------------------------------------------- 1 | Gamepad 2 | =============================================================================================== 3 | 4 | .. literalinclude:: ../../../examples/gamepad.py 5 | :language: python 6 | 7 | 8 | Mappings 9 | +++++++++++++++++++++++++++++++++++ 10 | For `DualShock 4` and `DualSense` controllers, the appropriate mapping will be set automatically. 11 | 12 | Other controllers are supported but will likely need a custom mapping. 13 | This can be done by creating a `.yaml` file and then loading it at runtime. 14 | 15 | Gamepad support is provided through `pygame`_. 16 | 17 | For more information on mappings see the `pygame docs`_. 18 | 19 | DualShock 4 Mapping Example 20 | ----------------------------------- 21 | .. literalinclude:: ../../../examples/ds4_mapping.yaml 22 | :language: yaml 23 | 24 | Xbox 360 Mapping Example 25 | ----------------------------------- 26 | .. literalinclude:: ../../../examples/x360_mapping.yaml 27 | :language: yaml 28 | 29 | 30 | 31 | .. _pygame: https://www.pygame.org 32 | .. _pygame docs: https://www.pygame.org/docs/ref/joystick.html -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | 2 | Welcome to pyremoteplay's documentation! 3 | ======================================== 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | :caption: Table of Contents: 8 | 9 | readme 10 | registering 11 | device 12 | live_stream 13 | session 14 | examples/examples 15 | reference/modules 16 | 17 | Indices and tables 18 | ================== 19 | 20 | * :ref:`genindex` 21 | * :ref:`modindex` 22 | * :ref:`search` 23 | -------------------------------------------------------------------------------- /docs/source/live_stream.rst: -------------------------------------------------------------------------------- 1 | Audio / Video Stream 2 | =============================================================================================== 3 | 4 | The live audio/video stream is exposed through the :class:`AVReceiver ` class. 5 | 6 | The `AVReceiver` class **must** be **subclassed** and have implementations for the 7 | :meth:`AVReceiver.handle_video() ` 8 | and 9 | :meth:`AVReceiver.handle_audio() ` 10 | methods. The audio and video frames that are passed to these methods are `pyav `_ frames. 11 | 12 | A generic receiver is provided in this library with the :class:`QueueReceiver ` class. 13 | 14 | Usage 15 | +++++++++++++++++++++++++++++++++++++++++++++ 16 | To use a receiver, the receiver must be passed as a keyword argument to the 17 | :meth:`RPDevice.create_session() ` 18 | method like in the example below. 19 | 20 | :: 21 | 22 | from pyremoteplay import RPDevice 23 | from pyremoteplay.receiver import QueueReceiver 24 | 25 | ip_address = "192.168.86.2" 26 | device = RPDevice(ip_address) 27 | device.get_status() 28 | user = device.get_users()[0] 29 | receiver = QueueReceiver() 30 | device.create_session(user, receiver=receiver) -------------------------------------------------------------------------------- /docs/source/readme.rst: -------------------------------------------------------------------------------- 1 | Overview 2 | =============================================================================================== 3 | .. toctree:: 4 | :maxdepth: 2 5 | 6 | .. include:: ../../README.md 7 | :parser: myst_parser.sphinx_ 8 | -------------------------------------------------------------------------------- /docs/source/registering.rst: -------------------------------------------------------------------------------- 1 | Registering 2 | =============================================================================================== 3 | 4 | To get started, you will need to complete the following. 5 | 6 | - Retrieving PSN account info. 7 | 8 | - Linking PSN account with Remote Play device. 9 | 10 | These steps can be accomplished via the CLI or GUI, but this will cover how to programatically complete these steps. 11 | 12 | Retrieving PSN account info 13 | +++++++++++++++++++++++++++++++++++++++++++++ 14 | This step only has to be done once per PSN user. Once the data is saved you will not have to complete this step again. 15 | 16 | :: 17 | 18 | from pyremoteplay import RPDevice 19 | from pyremoteplay import oauth 20 | 21 | # This is the url that users will need to sign in 22 | # Must be done in a web browser 23 | url = oauth.get_login_url() 24 | 25 | # User should be redirected to a page that says 'redirect' 26 | # Have the user supply the url of this page 27 | account = oauth.get_account_info(redirect_url) 28 | 29 | # Format Account to User Profile 30 | user_profile = oauth.format_user_account(account) 31 | 32 | # User Profile should be saved for future use 33 | profiles = RPDevice.get_profiles() 34 | profiles.update_user(user_profile) 35 | profiles.save() 36 | 37 | 38 | Alternatively, you can also use the helper method :func:`pyremoteplay.profile.Profiles.new_user()` 39 | 40 | :: 41 | 42 | profiles.new_user(redirect_url, save=True) 43 | 44 | 45 | 46 | Linking PSN account with Remote Play device 47 | +++++++++++++++++++++++++++++++++++++++++++++ 48 | 49 | Now that we have a user profile. We can link the User to a Remote Play device. 50 | Linking needs to be performed once for each device per user. 51 | 52 | The PSN User must be logged in on the device. 53 | The user should supply the linking PIN from 'Remote Play' settings on the device. 54 | The pin must be a string. 55 | 56 | :: 57 | 58 | ip_address = '192.169.0.2' 59 | device = RPDevice(ip_address) 60 | device.get_status() # Device needs a valid status 61 | 62 | device.register(user_profile.name, pin, save=True) 63 | -------------------------------------------------------------------------------- /docs/source/session.rst: -------------------------------------------------------------------------------- 1 | Sessions 2 | =============================================================================================== 3 | 4 | The :class:`Session ` class is responsible for connecting to a Remote Play session. 5 | 6 | It is recommended to create a `Session` using the :meth:`RPDevice.create_session() `, 7 | method instead of creating it directly. 8 | 9 | A :class:`RPDevice ` instance can only have one `Session` instance coupled to it at a time. 10 | 11 | There are multiple parameters for creating a session which will configure options such as frame rate and 12 | the resolution of the video stream. 13 | 14 | Creating a Session 15 | +++++++++++++++++++++++++++++++++++++++++++++ 16 | 17 | The following are parameters for :meth:`RPDevice.create_session() ` 18 | 19 | The only required argument is `user`. The remaining arguments should be passed as **keyword arguments**. 20 | 21 | .. list-table:: Parameters for :meth:`RPDevice.create_session() ` 22 | :widths: 25 10 15 50 23 | :header-rows: 1 24 | 25 | * - Parameter 26 | - Type 27 | - Default 28 | - Description 29 | 30 | * - **user** 31 | - :class:`str ` 32 | - <**required**> 33 | - | The username / PSN ID to connect with. 34 | | A list of users can be found with 35 | | :meth:`RPDevice.get_users() `. 36 | 37 | * - **profiles** 38 | - :class:`Profiles ` 39 | - `None` 40 | - | A profiles object. Generally not needed 41 | | as the :class:`RPDevice ` class will 42 | | pass this to `Session`. 43 | 44 | * - **loop** 45 | - :class:`asyncio.AbstractEventLoop ` 46 | - `None` 47 | - | The `asyncio` Event Loop to use. 48 | | Must be running. Generally not needed. 49 | | If not specified, the current 50 | | running loop will be used. 51 | 52 | * - **receiver** 53 | - :class:`AVReceiver ` 54 | - `None` 55 | - | The receiver to use. 56 | | **Note:** Must be a sub-class of 57 | | AVReceiver; See :class:`QueueReceiver `. 58 | | The receiver exposes audio and video 59 | | frames from the live stream. 60 | | If not provided then no video/audio 61 | | will be processed. 62 | 63 | * - **resolution** 64 | - :class:`Resolution ` or :class:`str ` or :class:`int ` 65 | - `720p` 66 | - | The resolution to use for video stream. 67 | | Must be one of 68 | | ["360p", "540p", "720p", "1080p"]. 69 | 70 | * - **fps** 71 | - :class:`FPS ` or :class:`str ` or :class:`int ` 72 | - `low` 73 | - | The FPS / frame rate for the video stream. 74 | | Can be expressed as 75 | | ["low", "high"] or [30, 60]. 76 | 77 | * - **quality** 78 | - :class:`Quality ` or :class:`str ` or :class:`int ` 79 | - `default` 80 | - | The quality of the video stream. 81 | | Represents the bitrate of the stream. 82 | | Must be a valid member of the `Quality` enum. 83 | | Using `DEFAULT` will use the appropriate 84 | | bitrate for a specific resolution. 85 | 86 | * - **codec** 87 | - :class:`str ` 88 | - `h264` 89 | - | The `FFMPEG` video codec to use. 90 | | Valid codecs start with either "h264" or "hevc". 91 | | There are several FFMPEG Hardware Decoding 92 | | codecs that can be used such as "h264_cuvid". 93 | | On devices which do not support "hevc", 94 | | "h264" will always be used. 95 | 96 | * - **hdr** 97 | - :class:`bool ` 98 | - `False` 99 | - | Whether HDR should be used for the video stream. 100 | | This is only used with the "hevc" codec. 101 | 102 | Connecting to a Session 103 | +++++++++++++++++++++++++++++++++++++++++++++ 104 | 105 | To connect to a created session, use the async coroutine :meth:`RPDevice.connect() `. 106 | 107 | After connecting, one should wait for it to be ready before using it. 108 | This can be done with the :meth:`RPDevice.wait_for_session() ` method or 109 | the :meth:`RPDevice.async_wait_for_session() ` coroutine. 110 | 111 | The :meth:`RPDevice.ready ` property will return True if the Session is ready. 112 | 113 | Disconnecting from a Session 114 | +++++++++++++++++++++++++++++++++++++++++++++ 115 | 116 | To disconnect, simply call the :meth:`RPDevice.disconnect() ` method. 117 | 118 | **Note:** This will also destroy the Session object and the :meth:`RPDevice.session ` property will be set to `None`. -------------------------------------------------------------------------------- /examples/async_client.py: -------------------------------------------------------------------------------- 1 | """Async Client Example. 2 | 3 | This example is meant to be run as script. 4 | 5 | We are assuming that we have already linked a PSN profile to our Remote Play device. 6 | """ 7 | 8 | import asyncio 9 | import argparse 10 | 11 | from pyremoteplay import RPDevice 12 | 13 | 14 | async def task(device): 15 | """Task to run. This presses D-Pad buttons repeatedly.""" 16 | buttons = ("LEFT", "RIGHT", "UP", "DOWN") 17 | 18 | # Wait for session to be ready. 19 | await device.async_wait_for_session() 20 | while device.connected: 21 | for button in buttons: 22 | await device.controller.async_button(button) 23 | await asyncio.sleep(1) 24 | print("Device disconnected") 25 | 26 | 27 | async def get_user(device): 28 | """Return user.""" 29 | if not await device.async_get_status(): 30 | print("Could not get device status") 31 | return None 32 | users = device.get_users() 33 | if not users: 34 | print("No Users") 35 | return None 36 | user = users[0] 37 | return user 38 | 39 | 40 | async def runner(host, standby): 41 | """Run client.""" 42 | device = RPDevice(host) 43 | user = await get_user(device) 44 | if not user: 45 | return 46 | 47 | if standby: 48 | await device.standby(user) 49 | print("Device set to standby") 50 | return 51 | 52 | # If device is not on, Turn On and wait for a 'On' status 53 | if not device.is_on: 54 | device.wakeup(user) 55 | if not await device.async_wait_for_wakeup(): 56 | print("Timed out waiting for device to wakeup") 57 | return 58 | 59 | device.create_session(user) 60 | if not await device.connect(): 61 | print("Failed to start Session") 62 | return 63 | 64 | # Now that we have connected to session we can run our task. 65 | asyncio.create_task(task(device)) 66 | 67 | # This is included to keep the asyncio loop running. 68 | while device.connected: 69 | try: 70 | await asyncio.sleep(0) 71 | except KeyboardInterrupt: 72 | device.disconnect() 73 | break 74 | 75 | 76 | def main(): 77 | parser = argparse.ArgumentParser(description="Async Remote Play Client.") 78 | parser.add_argument("host", type=str, help="IP address of Remote Play host") 79 | parser.add_argument( 80 | "-s", "--standby", action="store_true", help="Place host in standby" 81 | ) 82 | args = parser.parse_args() 83 | host = args.host 84 | standby = args.standby 85 | loop = asyncio.get_event_loop() 86 | loop.run_until_complete(runner(host, standby)) 87 | 88 | 89 | if __name__ == "__main__": 90 | main() 91 | -------------------------------------------------------------------------------- /examples/client.py: -------------------------------------------------------------------------------- 1 | """Example of running client. 2 | 3 | We are assuming that we have already linked a PSN profile to our Remote Play device. 4 | """ 5 | 6 | import asyncio 7 | import threading 8 | import atexit 9 | 10 | from pyremoteplay import RPDevice 11 | from pyremoteplay.receiver import QueueReceiver 12 | 13 | 14 | def stop(device, thread): 15 | loop = device.session.loop 16 | device.disconnect() 17 | loop.stop() 18 | thread.join(3) 19 | print("stopped") 20 | 21 | 22 | def worker(device): 23 | loop = asyncio.new_event_loop() 24 | task = loop.create_task(device.connect()) 25 | loop.run_until_complete(task) 26 | loop.run_forever() 27 | 28 | 29 | def start(ip_address): 30 | """Return device. Start Remote Play session.""" 31 | device = RPDevice(ip_address) 32 | if not device.get_status(): # Device needs a valid status to get users 33 | print("No Status") 34 | return None 35 | users = device.get_users() 36 | if not users: 37 | print("No users registered") 38 | return None 39 | user = users[0] # Gets first user name 40 | receiver = QueueReceiver() 41 | device.create_session(user, receiver=receiver) 42 | thread = threading.Thread(target=worker, args=(device,), daemon=True) 43 | thread.start() 44 | atexit.register( 45 | lambda: stop(device, thread) 46 | ) # Make sure we stop the thread on exit. 47 | 48 | # Wait for session to be ready 49 | device.wait_for_session() 50 | return device 51 | 52 | 53 | # Usage: 54 | # 55 | # Starting session: 56 | # >> ip_address = '192.168.86.2' # ip address of Remote Play device 57 | # >> device = start(ip_address) 58 | # 59 | # Retrieving latest video frames: 60 | # >> device.session.receiver.video_frames 61 | # 62 | # Tap Controller Button: 63 | # >> device.controller.button("cross", "tap") 64 | # 65 | # Start Controller Stick Worker 66 | # >> device.controller.start() 67 | # 68 | # Emulate moving Left Stick all the way right: 69 | # >> device.controller.stick("left", axis="x", value=1.0) 70 | # 71 | # Release Left stick: 72 | # >> device.controller.stick("left", axis="x", value=0) 73 | # 74 | # Move Left stick diagonally left and down halfway 75 | # >> device.controller.stick("left", point=(-0.5, 0.5)) 76 | # 77 | # Standby; Only available when session is connected: 78 | # >> device.session.standby() 79 | # 80 | # Wakeup/turn on using first user: 81 | # >> device.wakeup(device.get_users[0]) 82 | -------------------------------------------------------------------------------- /examples/ds4_mapping.yaml: -------------------------------------------------------------------------------- 1 | # DualShock 4 Map. 2 | 3 | # DualShock 4 does not have hats 4 | 5 | button: 6 | 0: CROSS 7 | 1: CIRCLE 8 | 2: SQUARE 9 | 3: TRIANGLE 10 | 4: SHARE 11 | 5: PS 12 | 6: OPTIONS 13 | 7: L3 14 | 8: R3 15 | 9: L1 16 | 10: R1 17 | 11: UP 18 | 12: DOWN 19 | 13: LEFT 20 | 14: RIGHT 21 | 15: TOUCHPAD 22 | axis: 23 | 0: LEFT_X 24 | 1: LEFT_Y 25 | 2: RIGHT_X 26 | 3: RIGHT_Y 27 | 4: L2 28 | 5: R2 29 | hat: 30 | -------------------------------------------------------------------------------- /examples/gamepad.py: -------------------------------------------------------------------------------- 1 | """Example of using gamepad. 2 | 3 | We are assuming that we have connected to a session like in 'client.py' 4 | """ 5 | 6 | from pyremoteplay import RPDevice 7 | from pyremoteplay.gamepad import Gamepad 8 | 9 | ip_address = "192.168.0.2" 10 | device = RPDevice(ip_address) 11 | gamepads = Gamepad.get_all() 12 | gamepad = gamepads[0] # Use first gamepad 13 | 14 | ########### 15 | # After connecting to device session. 16 | ########### 17 | 18 | if not gamepad.available: 19 | print("Gamepad not available") 20 | gamepad.controller = device.controller 21 | 22 | # We can now use the gamepad. 23 | 24 | # Load custom mapping. 25 | gamepad.load_map("path-to-mapping.yaml") 26 | 27 | 28 | # When done using 29 | gamepad.close() 30 | -------------------------------------------------------------------------------- /examples/x360_mapping.yaml: -------------------------------------------------------------------------------- 1 | # Xbox 360 Map 2 | 3 | # D-Pad buttons are mapped to hat 4 | 5 | button: 6 | 0: CROSS 7 | 1: CIRCLE 8 | 2: SQUARE 9 | 3: TRIANGLE 10 | 4: L1 11 | 5: R1 12 | 6: SHARE 13 | 7: OPTIONS 14 | 8: L3 15 | 9: R3 16 | 10: PS 17 | axis: 18 | 0: LEFT_X 19 | 1: LEFT_Y 20 | 2: L2 21 | 3: RIGHT_X 22 | 4: RIGHT_Y 23 | 5: R2 24 | hat: 25 | left: LEFT 26 | right: RIGHT 27 | down: DOWN 28 | up: UP 29 | -------------------------------------------------------------------------------- /pyremoteplay/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for pyremoteplay.""" 2 | from . import register, oauth, profile, tracker, const, session 3 | from .device import RPDevice 4 | -------------------------------------------------------------------------------- /pyremoteplay/__version__.py: -------------------------------------------------------------------------------- 1 | """Version for pyremoteplay.""" 2 | VERSION = "0.7.6" 3 | MIN_PY_VERSION = "3.8" 4 | 5 | if __name__ == "__main__": 6 | print(VERSION) 7 | -------------------------------------------------------------------------------- /pyremoteplay/const.py: -------------------------------------------------------------------------------- 1 | """Constants for pyremoteplay.""" 2 | from __future__ import annotations 3 | from enum import IntEnum 4 | from typing import Union 5 | 6 | PROFILE_DIR = ".pyremoteplay" 7 | PROFILE_FILE = ".profile.json" 8 | OPTIONS_FILE = ".options.json" 9 | CONTROLS_FILE = ".controls.json" 10 | 11 | RP_CRYPT_SIZE = 16 12 | DEFAULT_POLL_COUNT = 10 13 | 14 | OS_TYPE = "Win10.0.0" 15 | USER_AGENT = "remoteplay Windows" 16 | RP_VERSION_PS4 = "10.0" 17 | RP_VERSION_PS5 = "1.0" 18 | TYPE_PS4 = "PS4" 19 | TYPE_PS5 = "PS5" 20 | 21 | UDP_IP = "0.0.0.0" 22 | BROADCAST_IP = "255.255.255.255" 23 | RP_PORT = 9295 24 | DDP_PORT_PS4 = 987 25 | DDP_PORT_PS5 = 9302 26 | UDP_PORT = 0 27 | DEFAULT_UDP_PORT = 9303 # Necessary for PS5 28 | DDP_PORTS = { 29 | TYPE_PS4: DDP_PORT_PS4, 30 | TYPE_PS5: DDP_PORT_PS5, 31 | } 32 | 33 | DEFAULT_STANDBY_DELAY = 50 34 | DEFAULT_SESSION_TIMEOUT = 5 35 | 36 | FFMPEG_PADDING = 64 # AV_INPUT_BUFFER_PADDING_SIZE 37 | 38 | 39 | class StreamType(IntEnum): 40 | """Enums for Stream type. Represents Video stream type. 41 | 42 | Do Not Change. 43 | """ 44 | 45 | H264 = 1 46 | HEVC = 2 47 | HEVC_HDR = 3 48 | 49 | @staticmethod 50 | def parse(value: Union[StreamType, str, int]) -> StreamType: 51 | """Return Enum from enum, name or value.""" 52 | if isinstance(value, StreamType): 53 | return value 54 | if isinstance(value, str): 55 | _enum = StreamType.__members__.get(value.upper()) 56 | if _enum is not None: 57 | return _enum 58 | return StreamType(value) 59 | 60 | @staticmethod 61 | def preset(value: Union[StreamType, str, int]) -> str: 62 | """Return Stream Type name.""" 63 | return StreamType.parse(value).name.replace("_HDR", "").lower() 64 | 65 | 66 | class Quality(IntEnum): 67 | """Enums for quality. Value represents video bitrate. 68 | 69 | Using `DEFAULT` will automatically find the appropriate bitrate for a specific resolution. 70 | """ 71 | 72 | DEFAULT = 0 73 | VERY_LOW = 2000 74 | LOW = 4000 75 | MEDIUM = 6000 76 | HIGH = 10000 77 | VERY_HIGH = 15000 78 | 79 | @staticmethod 80 | def parse(value: Union[Quality, str, int]) -> Quality: 81 | """Return Enum from enum, name or value.""" 82 | if isinstance(value, Quality): 83 | return value 84 | if isinstance(value, str): 85 | _enum = Quality.__members__.get(value.upper()) 86 | if _enum is not None: 87 | return _enum 88 | return Quality(value) 89 | 90 | @staticmethod 91 | def preset(value: Union[Quality, str, int]) -> int: 92 | """Return Quality Value.""" 93 | return Quality.parse(value).value 94 | 95 | 96 | RESOLUTION_360P = { 97 | "width": 640, 98 | "height": 360, 99 | "bitrate": int(Quality.VERY_LOW), 100 | } 101 | 102 | RESOLUTION_540P = { 103 | "width": 960, 104 | "height": 540, 105 | "bitrate": int(Quality.MEDIUM), 106 | } 107 | 108 | RESOLUTION_720P = { 109 | "width": 1280, 110 | "height": 720, 111 | "bitrate": int(Quality.HIGH), 112 | } 113 | 114 | RESOLUTION_1080P = { 115 | "width": 1920, 116 | "height": 1080, 117 | "bitrate": int(Quality.VERY_HIGH), 118 | } 119 | 120 | RESOLUTION_PRESETS = { 121 | "360p": RESOLUTION_360P, 122 | "540p": RESOLUTION_540P, 123 | "720p": RESOLUTION_720P, 124 | "1080p": RESOLUTION_1080P, 125 | } 126 | 127 | 128 | class FPS(IntEnum): 129 | """Enum for FPS.""" 130 | 131 | LOW = 30 132 | HIGH = 60 133 | 134 | @staticmethod 135 | def parse(value: Union[FPS, str, int]) -> FPS: 136 | """Return Enum from enum, name or value.""" 137 | if isinstance(value, FPS): 138 | return value 139 | if isinstance(value, str): 140 | _enum = FPS.__members__.get(value.upper()) 141 | if _enum is not None: 142 | return _enum 143 | return FPS(value) 144 | 145 | @staticmethod 146 | def preset(value: Union[FPS, str, int]) -> int: 147 | """Return FPS Value.""" 148 | return FPS.parse(value).value 149 | 150 | 151 | class Resolution(IntEnum): 152 | """Enum for resolution.""" 153 | 154 | RESOLUTION_360P = 1 155 | RESOLUTION_540P = 2 156 | RESOLUTION_720P = 3 157 | RESOLUTION_1080P = 4 158 | 159 | @staticmethod 160 | def parse(value: Union[Resolution, str, int]) -> Resolution: 161 | """Return Enum from enum, name or value.""" 162 | if isinstance(value, Resolution): 163 | return value 164 | if isinstance(value, str): 165 | _enum = None 166 | try: 167 | # Accept string like 'RESOLUTION_360P' 168 | _enum = Resolution[value.upper()] 169 | return _enum 170 | except KeyError: 171 | pass 172 | # Accept string like '360P' 173 | _value = f"RESOLUTION_{value}".upper() 174 | _enum = Resolution.__members__.get(_value.upper()) 175 | if _enum is not None: 176 | return _enum 177 | return Resolution(value) 178 | 179 | @staticmethod 180 | def preset(value: Union[Resolution, str, int]) -> dict: 181 | """Return Resolution preset dict.""" 182 | enum = Resolution.parse(value) 183 | return RESOLUTION_PRESETS[enum.name.replace("RESOLUTION_", "").lower()] 184 | 185 | 186 | # AV_CODEC_OPTIONS_H264 = { 187 | # # "profile": "high", 188 | # # "level": "3.2", 189 | # "tune": "zerolatency", 190 | # "cabac": "1", 191 | # "ref": "3", 192 | # "deblock": "1:0:0", 193 | # "analyse": "0x3:0x113", 194 | # "me": "hex", 195 | # "subme": "7", 196 | # "psy": "1", 197 | # "psy_rd": "1.00:0.00", 198 | # "mixed_ref": "1", 199 | # "me_range": "16", 200 | # "chroma_me": "1", 201 | # "trellis": "1", 202 | # "8x8dct": "1", 203 | # "cqm": "0", 204 | # "deadzone": "21,11", 205 | # "fast_pskip": "1", 206 | # "chroma_qp_offset": "-2", 207 | # "threads": "9", 208 | # "lookahead_threads": "1", 209 | # "sliced_threads": "0", 210 | # "nr": "0", 211 | # "decimate": "1", 212 | # "interlaced": "0", 213 | # "bluray_compat": "0", 214 | # "constrained_intra": "0", 215 | # "bframes": "3", 216 | # "b_pyramid": "2", 217 | # "b_adapt": "1", 218 | # "b_bias": "0", 219 | # "direct": "1", 220 | # "weightb": "1", 221 | # "open_gop": "0", 222 | # "weightp": "2", 223 | # "keyint": "250", 224 | # "keyint_min": "25", 225 | # "scenecut": "40", 226 | # "intra_refresh": "0", 227 | # "rc_lookahead": "40", 228 | # "rc": "crf", 229 | # "mbtree": "1", 230 | # "crf": "23.0", 231 | # "qcomp": "0.60", 232 | # "qpmin": "0", 233 | # "qpmax": "69", 234 | # "qpstep": "4", 235 | # "ip_ratio": "1.40", 236 | # "aq": "1:1.00", 237 | # } 238 | -------------------------------------------------------------------------------- /pyremoteplay/controller.py: -------------------------------------------------------------------------------- 1 | """Controller methods.""" 2 | from __future__ import annotations 3 | import logging 4 | import threading 5 | import sys 6 | import traceback 7 | from typing import Iterable, Union 8 | from collections import deque 9 | from enum import IntEnum, auto 10 | import time 11 | import asyncio 12 | 13 | from .stream_packets import FeedbackEvent, FeedbackHeader, ControllerState, StickState 14 | from .errors import RemotePlayError 15 | from .session import Session 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | class Controller: 21 | """Controller Interface. Sends user input to Remote Play Session.""" 22 | 23 | class ButtonAction(IntEnum): 24 | """Button Action Types.""" 25 | 26 | PRESS = auto() 27 | RELEASE = auto() 28 | TAP = auto() 29 | 30 | MAX_EVENTS = 5 31 | STATE_INTERVAL_MAX_MS = 0.200 32 | STATE_INTERVAL_MIN_MS = 0.100 33 | 34 | @staticmethod 35 | def buttons() -> list: 36 | """Return list of valid buttons.""" 37 | return [button.name for button in FeedbackEvent.Type] 38 | 39 | def __init__(self, session=None): 40 | self._session = session 41 | self._sequence_event = 0 42 | self._sequence_state = 0 43 | self._event_buf = deque([], Controller.MAX_EVENTS) 44 | self._last_state = ControllerState() 45 | self._stick_state = ControllerState() 46 | 47 | self._should_send = threading.Semaphore() 48 | self._stop_event = threading.Event() 49 | self._thread: threading.Thread = None 50 | 51 | def __del__(self): 52 | self.disconnect() 53 | 54 | def __reset_session(self): 55 | self._sequence_event = 0 56 | self._sequence_state = 0 57 | self._event_buf = deque([], Controller.MAX_EVENTS) 58 | self._last_state = ControllerState() 59 | self._stick_state = ControllerState() 60 | 61 | def __reset_worker(self): 62 | self._should_send = threading.Semaphore() 63 | self._stop_event = threading.Event() 64 | self._thread = None 65 | 66 | def __worker(self): 67 | """Worker for sending feedback packets. Run in thread.""" 68 | self._should_send.acquire(timeout=1) 69 | while self.running: 70 | try: 71 | self._should_send.acquire(timeout=Controller.STATE_INTERVAL_MAX_MS) 72 | if self.ready: 73 | self.update_sticks() 74 | except Exception as error: # pylint: disable=broad-except 75 | _LOGGER.error("Error in controller thread: %s", error) 76 | if _LOGGER.level == logging.DEBUG: 77 | exc_type, exc_value, exc_traceback = sys.exc_info() 78 | traceback.print_exception( 79 | exc_type, exc_value, exc_traceback, file=sys.stdout 80 | ) 81 | self.__reset_worker() 82 | _LOGGER.info("Controller stopped") 83 | 84 | def connect(self, session: Session): 85 | """Connect controller to session.""" 86 | if self._session is not None: 87 | _LOGGER.warning("Controller already connected. Call `disconnect()` first") 88 | return 89 | 90 | if session is not None: 91 | if not isinstance(session, Session): 92 | raise TypeError(f"Expected {Session}. Got {type(session)}") 93 | if session.is_running: 94 | raise RemotePlayError("Cannot set a running session") 95 | if session.is_stopped: 96 | raise RemotePlayError("Cannot set a stopped session") 97 | 98 | self.__reset_session() 99 | self.__reset_worker() 100 | self._session = session 101 | 102 | def start(self): 103 | """Start Controller. 104 | 105 | This starts the controller worker which listens for when the sticks move 106 | and sends the state to the host. If this is not called, the 107 | :meth:`update_sticks() ` 108 | method needs to be called for the host to receive the state. 109 | """ 110 | if self._thread is not None: 111 | _LOGGER.warning("Controller is running. Call `stop()` first") 112 | return 113 | if self._session is None: 114 | _LOGGER.warning("Controller has no session. Call `connect()` first") 115 | return 116 | self._thread = threading.Thread(target=self.__worker, daemon=True) 117 | self._thread.start() 118 | 119 | def stop(self): 120 | """Stop Controller.""" 121 | self._stop_event.set() 122 | 123 | def disconnect(self): 124 | """Stop and Disconnect Controller. Must be called to change session.""" 125 | self.stop() 126 | self.__reset_session() 127 | self._session = None 128 | 129 | def update_sticks(self): 130 | """Send controller stick state to host. 131 | 132 | Will be called automatically if controller has been started with 133 | :meth:`start() `. 134 | """ 135 | if not self._check_session(): 136 | return 137 | if self.stick_state == self._last_state: 138 | return 139 | self._last_state.left = self.stick_state.left 140 | self._last_state.right = self.stick_state.right 141 | self._session.stream.send_feedback( 142 | FeedbackHeader.Type.STATE, self._sequence_state, state=self.stick_state 143 | ) 144 | self._sequence_state += 1 145 | 146 | def _send_event(self): 147 | """Send controller button event.""" 148 | data = b"".join(self._event_buf) 149 | if not data: 150 | return 151 | self._session.stream.send_feedback( 152 | FeedbackHeader.Type.EVENT, self._sequence_event, data=data 153 | ) 154 | self._sequence_event += 1 155 | 156 | def _add_event_buffer(self, event: FeedbackEvent): 157 | """Append event to beginning of byte buffer. 158 | Oldest event is at the end and is removed 159 | when buffer is full and a new event is added 160 | """ 161 | buf = bytearray(FeedbackEvent.LENGTH) 162 | event.pack(buf) 163 | self._event_buf.appendleft(buf) 164 | 165 | def _button( 166 | self, 167 | name: Union[str, FeedbackEvent.Type], 168 | action: Union[str, ButtonAction], 169 | ) -> tuple[FeedbackEvent.Type, ButtonAction]: 170 | if not self._check_session(): 171 | return None 172 | if isinstance(action, self.ButtonAction): 173 | _action = action 174 | else: 175 | try: 176 | _action = self.ButtonAction[action.upper()] 177 | except KeyError: 178 | _LOGGER.error("Invalid Action: %s", action) 179 | return None 180 | if isinstance(name, FeedbackEvent.Type): 181 | button = name 182 | else: 183 | try: 184 | button = FeedbackEvent.Type[name.upper()] 185 | except KeyError: 186 | _LOGGER.error("Invalid button: %s", name) 187 | return None 188 | 189 | if _action == self.ButtonAction.PRESS: 190 | self._add_event_buffer(FeedbackEvent(button, is_active=True)) 191 | elif _action == self.ButtonAction.RELEASE: 192 | self._add_event_buffer(FeedbackEvent(button, is_active=False)) 193 | elif _action == self.ButtonAction.TAP: 194 | self._add_event_buffer(FeedbackEvent(button, is_active=True)) 195 | self._send_event() 196 | return button, _action 197 | 198 | def button( 199 | self, 200 | name: Union[str, FeedbackEvent.Type], 201 | action: Union[str, ButtonAction] = "tap", 202 | delay=0.1, 203 | ): 204 | """Emulate pressing or releasing button. 205 | 206 | If action is `tap` this method will block by delay. 207 | 208 | :param name: The name of button. Use buttons() to show valid buttons. 209 | :param action: One of `press`, `release`, `tap`, or `Controller.ButtonAction`. 210 | :param delay: Delay between press and release. Only used when action is `tap`. 211 | """ 212 | data = self._button(name, action) 213 | if not data: 214 | return 215 | button, _action = data 216 | if _action == self.ButtonAction.TAP: 217 | time.sleep(delay) 218 | self.button(button, self.ButtonAction.RELEASE) 219 | 220 | async def async_button( 221 | self, 222 | name: Union[str, FeedbackEvent.Type], 223 | action: Union[str, ButtonAction] = "tap", 224 | delay=0.1, 225 | ): 226 | """Emulate pressing or releasing button. Async. 227 | 228 | If action is `tap` this coroutine will sleep by delay. 229 | 230 | :param name: The name of button. Use buttons() to show valid buttons. 231 | :param action: One of `press`, `release`, `tap`, or `Controller.ButtonAction`. 232 | :param delay: Delay between press and release. Only used when action is `tap`. 233 | """ 234 | data = self._button(name, action) 235 | if not data: 236 | return 237 | button, _action = data 238 | if _action == self.ButtonAction.TAP: 239 | await asyncio.sleep(delay) 240 | await self.async_button(button, self.ButtonAction.RELEASE) 241 | 242 | def stick( 243 | self, 244 | stick_name: str, 245 | axis: str = None, 246 | value: float = None, 247 | point: Iterable[float, float] = None, 248 | ): 249 | """Set Stick State. 250 | 251 | If controller has not been started with 252 | :meth:`start() `, 253 | the :meth:`update_sticks() ` 254 | method needs to be called manually to send stick state. 255 | 256 | The value param represents how far to push the stick away from center. 257 | 258 | The direction mapping is shown below: 259 | 260 | X Axis: Left -1.0, Right 1.0 261 | 262 | Y Axis: Up -1.0, Down 1.0 263 | 264 | Center 0.0 265 | 266 | :param stick_name: The stick to move. One of 'left' or 'right' 267 | :param axis: The axis to move. One of 'x' or 'y' 268 | :param value: The value to move stick to. Must be between -1.0 and 1.0 269 | :param point: An iterable of two floats, which represent coordinates. 270 | Point takes precedence over axis and value. 271 | The first value represents the x axis and the second represents the y axis 272 | """ 273 | stick_name = stick_name.lower() 274 | if stick_name == "left": 275 | stick = self._stick_state.left 276 | elif stick_name == "right": 277 | stick = self._stick_state.right 278 | else: 279 | raise ValueError("Invalid stick: Expected 'left', 'right'") 280 | 281 | if point is not None: 282 | state = StickState(*point) 283 | if stick_name == "left": 284 | self._stick_state.left = state 285 | else: 286 | self._stick_state.right = state 287 | self._should_send.release() 288 | return 289 | 290 | if axis is None or value is None: 291 | raise ValueError("Axis and Value can not be None") 292 | axis = axis.lower() 293 | values = [stick.x, stick.y] 294 | if axis == "x": 295 | values[0] = value 296 | elif axis == "y": 297 | values[1] = value 298 | else: 299 | raise ValueError("Invalid axis: Expected 'x', 'y'") 300 | state = StickState(*values) 301 | if stick_name == "left": 302 | self._stick_state.left = state 303 | else: 304 | self._stick_state.right = state 305 | self._should_send.release() 306 | 307 | def _check_session(self) -> bool: 308 | if self.session is None: 309 | _LOGGER.warning("Controller has no session") 310 | return False 311 | if self.session.is_stopped: 312 | _LOGGER.warning("Session is stopped") 313 | return False 314 | if not self.session.is_ready: 315 | _LOGGER.warning("Session is not ready") 316 | return False 317 | return True 318 | 319 | @property 320 | def stick_state(self) -> ControllerState: 321 | """Return stick state.""" 322 | return self._stick_state 323 | 324 | @property 325 | def running(self) -> bool: 326 | """Return True if running.""" 327 | if not self._session: 328 | return False 329 | return not self._session.is_stopped and not self._stop_event.is_set() 330 | 331 | @property 332 | def ready(self) -> bool: 333 | """Return True if controller can be used""" 334 | if not self.session: 335 | return False 336 | return self.session.is_ready 337 | 338 | @property 339 | def session(self) -> Session: 340 | """Return Session.""" 341 | return self._session 342 | -------------------------------------------------------------------------------- /pyremoteplay/errors.py: -------------------------------------------------------------------------------- 1 | """Errors for pyremoteplay.""" 2 | from enum import Enum, IntEnum 3 | 4 | 5 | class RPErrorHandler: 6 | """Remote Play Errors.""" 7 | 8 | def __init__(self): 9 | pass 10 | 11 | def __call__(self, error: int) -> str: 12 | try: 13 | error = RPErrorHandler.Type(error) 14 | except ValueError: 15 | return f"Unknown Error type: {error}" 16 | error = RPErrorHandler.Message[error.name].value 17 | return error 18 | 19 | class Type(IntEnum): 20 | """Enum for errors.""" 21 | 22 | REGIST_FAILED = 0x80108B09 23 | INVALID_PSN_ID = 0x80108B02 24 | RP_IN_USE = 0x80108B10 25 | CRASH = 0x80108B15 26 | RP_VERSION_MISMATCH = 0x80108B11 27 | UNKNOWN = 0x80108BFF 28 | 29 | class Message(Enum): 30 | """Messages for Error.""" 31 | 32 | REGIST_FAILED = "Registering Failed" 33 | INVALID_PSN_ID = "PSN ID does not exist on host" 34 | RP_IN_USE = "Another Remote Play session is connected to host" 35 | CRASH = "RP Crashed on Host; Host needs restart" 36 | RP_VERSION_MISMATCH = "Remote Play versions do not match on host and client" 37 | UNKNOWN = "Unknown" 38 | 39 | 40 | class RemotePlayError(Exception): 41 | """General Remote Play Exception.""" 42 | 43 | 44 | class CryptError(Exception): 45 | """General Crypt Exception.""" 46 | -------------------------------------------------------------------------------- /pyremoteplay/gamepad/mapping.py: -------------------------------------------------------------------------------- 1 | """Mappings for Gamepad.""" 2 | from __future__ import annotations 3 | from enum import IntEnum, auto 4 | from pyremoteplay.stream_packets import FeedbackEvent 5 | 6 | TRIGGERS = (FeedbackEvent.Type.R2.name, FeedbackEvent.Type.L2.name) 7 | 8 | 9 | class AxisType(IntEnum): 10 | """Axis Type Enum.""" 11 | 12 | LEFT_X = auto() 13 | LEFT_Y = auto() 14 | RIGHT_X = auto() 15 | RIGHT_Y = auto() 16 | 17 | 18 | class HatType(IntEnum): 19 | """Hat Type Enum.""" 20 | 21 | left = auto() 22 | right = auto() 23 | down = auto() 24 | up = auto() 25 | 26 | # HAT_UP = hy = 1 27 | # HAT_DOWN = hy = -1 28 | # HAT_RIGHT = hx = 1 29 | # HAT_LEFT = hx = -1 30 | 31 | 32 | def rp_map_keys() -> list[str]: 33 | """Return RP Mapping Keys.""" 34 | keys = [item.name for item in FeedbackEvent.Type] 35 | keys.extend([item.name for item in AxisType]) 36 | return keys 37 | 38 | 39 | def dualshock4_map() -> dict: 40 | """Return Dualshock4 Map.""" 41 | return { 42 | "button": { 43 | 0: FeedbackEvent.Type.CROSS.name, 44 | 1: FeedbackEvent.Type.CIRCLE.name, 45 | 2: FeedbackEvent.Type.SQUARE.name, 46 | 3: FeedbackEvent.Type.TRIANGLE.name, 47 | 4: FeedbackEvent.Type.SHARE.name, 48 | 5: FeedbackEvent.Type.PS.name, 49 | 6: FeedbackEvent.Type.OPTIONS.name, 50 | 7: FeedbackEvent.Type.L3.name, 51 | 8: FeedbackEvent.Type.R3.name, 52 | 9: FeedbackEvent.Type.L1.name, 53 | 10: FeedbackEvent.Type.R1.name, 54 | 11: FeedbackEvent.Type.UP.name, 55 | 12: FeedbackEvent.Type.DOWN.name, 56 | 13: FeedbackEvent.Type.LEFT.name, 57 | 14: FeedbackEvent.Type.RIGHT.name, 58 | 15: FeedbackEvent.Type.TOUCHPAD.name, 59 | }, 60 | "axis": { 61 | 0: AxisType.LEFT_X.name, 62 | 1: AxisType.LEFT_Y.name, 63 | 2: AxisType.RIGHT_X.name, 64 | 3: AxisType.RIGHT_Y.name, 65 | 4: FeedbackEvent.Type.L2.name, 66 | 5: FeedbackEvent.Type.R2.name, 67 | }, 68 | "hat": {}, 69 | } 70 | 71 | 72 | def dualsense_map() -> dict: 73 | """Return DualSense Map.""" 74 | return { 75 | "button": { 76 | 0: FeedbackEvent.Type.CROSS.name, 77 | 1: FeedbackEvent.Type.CIRCLE.name, 78 | 2: FeedbackEvent.Type.SQUARE.name, 79 | 3: FeedbackEvent.Type.TRIANGLE.name, 80 | 4: FeedbackEvent.Type.SHARE.name, # CREATE 81 | 5: FeedbackEvent.Type.PS.name, 82 | 6: FeedbackEvent.Type.OPTIONS.name, 83 | 7: FeedbackEvent.Type.L3.name, 84 | 8: FeedbackEvent.Type.R3.name, 85 | 9: FeedbackEvent.Type.L1.name, 86 | 10: FeedbackEvent.Type.R1.name, 87 | 11: FeedbackEvent.Type.UP.name, 88 | 12: FeedbackEvent.Type.DOWN.name, 89 | 13: FeedbackEvent.Type.LEFT.name, 90 | 14: FeedbackEvent.Type.RIGHT.name, 91 | 15: FeedbackEvent.Type.TOUCHPAD.name, 92 | 16: None, # MIC Button 93 | }, 94 | "axis": { 95 | 0: AxisType.LEFT_X.name, 96 | 1: AxisType.LEFT_Y.name, 97 | 2: AxisType.RIGHT_X.name, 98 | 3: AxisType.RIGHT_Y.name, 99 | 4: FeedbackEvent.Type.L2.name, 100 | 5: FeedbackEvent.Type.R2.name, 101 | }, 102 | "hat": {}, 103 | } 104 | 105 | 106 | def xbox360_map() -> dict: 107 | """Return XBOX 360 Map.""" 108 | return { 109 | "button": { 110 | 0: FeedbackEvent.Type.CROSS.name, 111 | 1: FeedbackEvent.Type.CIRCLE.name, 112 | 2: FeedbackEvent.Type.SQUARE.name, 113 | 3: FeedbackEvent.Type.TRIANGLE.name, 114 | 4: FeedbackEvent.Type.L1.name, 115 | 5: FeedbackEvent.Type.R1.name, 116 | 6: FeedbackEvent.Type.SHARE.name, 117 | 7: FeedbackEvent.Type.OPTIONS.name, 118 | 8: FeedbackEvent.Type.L3.name, 119 | 9: FeedbackEvent.Type.R3.name, 120 | 10: FeedbackEvent.Type.PS.name, 121 | }, 122 | "axis": { 123 | 0: AxisType.LEFT_X.name, 124 | 1: AxisType.LEFT_Y.name, 125 | 2: FeedbackEvent.Type.L2.name, 126 | 3: AxisType.RIGHT_X.name, 127 | 4: AxisType.RIGHT_Y.name, 128 | 5: FeedbackEvent.Type.R2.name, 129 | }, 130 | "hat": { 131 | 0: { 132 | HatType.left.name: FeedbackEvent.Type.LEFT.name, 133 | HatType.right.name: FeedbackEvent.Type.RIGHT.name, 134 | HatType.down.name: FeedbackEvent.Type.DOWN.name, 135 | HatType.up.name: FeedbackEvent.Type.UP.name, 136 | }, 137 | }, 138 | } 139 | 140 | 141 | def default_maps(): 142 | """Return Default Maps.""" 143 | return { 144 | "PS4 Controller": dualshock4_map(), 145 | "PS5 Controller": dualsense_map(), 146 | "Xbox 360 Controller": xbox360_map(), 147 | } 148 | -------------------------------------------------------------------------------- /pyremoteplay/gui/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for pyremoteplay.gui.""" 2 | -------------------------------------------------------------------------------- /pyremoteplay/gui/__main__.py: -------------------------------------------------------------------------------- 1 | """GUI Main Methods for pyremoteplay.""" 2 | import sys 3 | import logging 4 | 5 | from PySide6 import QtCore, QtWidgets 6 | 7 | from .main_window import MainWindow 8 | 9 | 10 | def main(): 11 | """Run GUI.""" 12 | if "-v" in sys.argv: 13 | level = logging.DEBUG 14 | else: 15 | level = logging.INFO 16 | logging.basicConfig(level=level) 17 | 18 | QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy( 19 | QtCore.Qt.HighDpiScaleFactorRoundingPolicy.Floor 20 | ) 21 | app = QtWidgets.QApplication([]) 22 | app.setApplicationName("PyRemotePlay") 23 | widget = MainWindow() 24 | widget.resize(800, 600) 25 | widget.show() 26 | sys.exit(app.exec()) 27 | 28 | 29 | if __name__ == "__main__": 30 | main() 31 | -------------------------------------------------------------------------------- /pyremoteplay/gui/audio.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=c-extension-no-member,invalid-name 2 | """Audio Workers.""" 3 | from collections import deque 4 | import logging 5 | import sounddevice 6 | from PySide6 import QtCore, QtMultimedia 7 | import av 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | class AbstractAudioWorker(QtCore.QObject): 13 | """Abstract Worker for Audio.""" 14 | 15 | def __init__(self): 16 | super().__init__() 17 | self._output = None 18 | self._buffer = None 19 | self._device = None 20 | self._config = {} 21 | 22 | self._thread = QtCore.QThread() 23 | self.moveToThread(self._thread) 24 | self._thread.started.connect(self._init_audio) 25 | 26 | def setConfig(self, config: dict): 27 | """Set Config.""" 28 | self._config = config 29 | 30 | def setDevice(self, device): 31 | """Set Device.""" 32 | self._device = device 33 | 34 | def start(self, device, config: dict): 35 | """Start worker.""" 36 | self.setConfig(config) 37 | self.setDevice(device) 38 | self._thread.start(QtCore.QThread.TimeCriticalPriority) 39 | 40 | def _init_audio(self): 41 | raise NotImplementedError 42 | 43 | @QtCore.Slot(av.AudioFrame) 44 | def next_audio_frame(self, frame: av.AudioFrame): 45 | """Handle next audio frame.""" 46 | buf = bytes(frame.planes[0])[: self._config["packet_size"]] 47 | self._send_audio(buf) 48 | 49 | def _send_audio(self, buf: bytes): 50 | raise NotImplementedError 51 | 52 | def quit(self): 53 | """Quit Worker.""" 54 | if self._output: 55 | self._output.stop() 56 | self._buffer = None 57 | self._thread.quit() 58 | 59 | 60 | class QtAudioWorker(AbstractAudioWorker): 61 | """Worker for audio using QT.""" 62 | 63 | def _init_audio(self): 64 | config = self._config 65 | if not config: 66 | return 67 | if self._buffer or self._output: 68 | return 69 | audio_format = QtMultimedia.QAudioFormat() 70 | audio_format.setChannelCount(config["channels"]) 71 | audio_format.setSampleRate(config["rate"]) 72 | audio_format.setSampleFormat(QtMultimedia.QAudioFormat.Int16) 73 | 74 | # bytes_per_second = audio_format.bytesForDuration(1000 * 1000) 75 | # interval = int(1 / (bytes_per_second / config["packet_size"]) * 1000) 76 | # _LOGGER.debug(interval) 77 | 78 | self._output = QtMultimedia.QAudioSink(self._device, format=audio_format) 79 | self._output.setBufferSize(config["packet_size"] * 4) 80 | self._buffer = self._output.start() 81 | _LOGGER.debug("Audio Worker init") 82 | 83 | def _send_audio(self, buf: bytes): 84 | if self._buffer is not None: 85 | self._buffer.write(buf) 86 | 87 | 88 | class SoundDeviceAudioWorker(AbstractAudioWorker): 89 | """Worker for audio using sounddevice.""" 90 | 91 | def setDevice(self, device): 92 | """Set Device.""" 93 | self._device = device.get("index") 94 | 95 | def _init_audio(self): 96 | config = self._config 97 | if not config: 98 | return 99 | self._output = sounddevice.RawOutputStream( 100 | samplerate=config["rate"], 101 | blocksize=config["frame_size"], 102 | channels=config["channels"], 103 | dtype=f"int{config['bits']}", 104 | latency="low", 105 | dither_off=True, 106 | callback=self._callback, 107 | device=self._device, 108 | ) 109 | max_len = 5 110 | buf = [self.__blank_frame()] * max_len 111 | self._buffer = deque(buf, maxlen=max_len) 112 | 113 | self._output.start() 114 | _LOGGER.debug("Audio Worker init") 115 | 116 | def _send_audio(self, buf: bytes): 117 | if self._buffer is not None: 118 | self._buffer.append(buf) 119 | 120 | # pylint: disable=unused-argument 121 | def _callback(self, buf, frames, _time, status): 122 | """Callback to write new frames.""" 123 | try: 124 | data = self._buffer.popleft() 125 | except IndexError: 126 | data = self.__blank_frame() 127 | buf[:] = data 128 | 129 | def __blank_frame(self) -> bytes: 130 | if self._config: 131 | return bytes(self._config["packet_size"]) 132 | return bytes() 133 | -------------------------------------------------------------------------------- /pyremoteplay/gui/device_grid.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=c-extension-no-member,invalid-name 2 | """Device Grid Widget.""" 3 | from __future__ import annotations 4 | import logging 5 | from PySide6 import QtCore, QtGui, QtWidgets 6 | from PySide6.QtCore import Qt # pylint: disable=no-name-in-module 7 | from pyremoteplay.device import RPDevice 8 | 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | class DeviceButton(QtWidgets.QPushButton): 14 | """Button that represents a Remote Play Device.""" 15 | 16 | COLOR_DARK = "#000000" 17 | COLOR_LIGHT = "#FFFFFF" 18 | COLOR_BG = "#E9ECEF" 19 | 20 | BORDER_COLOR_ON = ("#6EA8FE", "#0D6EFD") 21 | BORDER_COLOR_OFF = ("#FEB272", "#FFC107") 22 | BORDER_COLOR_UNKNOWN = ("#A3A3A3", "#A3A3A3") 23 | 24 | power_toggled = QtCore.Signal(RPDevice) 25 | connect_requested = QtCore.Signal(RPDevice) 26 | 27 | def __init__(self, device: RPDevice): 28 | super().__init__() 29 | self._device = device 30 | self._status = device.status 31 | self._info_show = False 32 | self._text_color = self.COLOR_DARK 33 | self._bg_color = self.COLOR_BG 34 | 35 | self._update_text() 36 | self._set_image() 37 | self._set_style() 38 | 39 | self.clicked.connect(self._on_click) 40 | 41 | def sizeHint(self) -> QtCore.QSize: 42 | """Return Size Hint.""" 43 | return QtCore.QSize(275, 275) 44 | 45 | def contextMenuEvent(self, event): # pylint: disable=unused-argument 46 | """Context Menu Event.""" 47 | info_text = "View Info" if not self._info_show else "Hide Info" 48 | power_text = "Standby" if self._device.is_on else "Wakeup" 49 | menu = QtWidgets.QMenu(self) 50 | action_info = QtGui.QAction(info_text, menu) 51 | action_power = QtGui.QAction(power_text, menu) 52 | action_info.triggered.connect(self._toggle_info) 53 | action_power.triggered.connect(self._power_toggle) 54 | menu.addActions([action_info, action_power]) 55 | if self.state_unknown(): 56 | action_power.setDisabled(True) 57 | menu.popup(QtGui.QCursor.pos()) 58 | 59 | def update_state(self): 60 | """Callback for when state is updated.""" 61 | state = self._device.status 62 | cur_id = self._status.get("running-app-titleid") 63 | new_id = state.get("running-app-titleid") 64 | self._status = state 65 | self._update_text() 66 | if cur_id != new_id: 67 | self._set_image() 68 | self._set_style() 69 | 70 | def state_unknown(self) -> bool: 71 | """Return True if state unknown.""" 72 | return not self._device.status_name 73 | 74 | def _on_click(self): 75 | if self.state_unknown(): 76 | return 77 | self.setEnabled(False) 78 | self.setToolTip("Device unavailable.\nWaiting for session to close...") 79 | self.connect_requested.emit(self._device) 80 | 81 | def _set_style(self): 82 | if self.state_unknown(): 83 | border_color = DeviceButton.BORDER_COLOR_UNKNOWN 84 | else: 85 | if self._device.is_on: 86 | border_color = DeviceButton.BORDER_COLOR_ON 87 | else: 88 | border_color = DeviceButton.BORDER_COLOR_OFF 89 | self.setStyleSheet( 90 | "".join( 91 | [ 92 | "QPushButton {border-radius:25%;", 93 | f"border: 5px solid {border_color[0]};", 94 | f"color: {self._text_color};", 95 | f"background-color: {self._bg_color};", 96 | "}", 97 | "QPushButton:hover {", 98 | f"border: 5px solid {border_color[1]};", 99 | f"color: {self._text_color};", 100 | "}", 101 | ] 102 | ) 103 | ) 104 | 105 | def _set_image(self): 106 | self._bg_color = self.COLOR_BG 107 | title_id = self._device.app_id 108 | if title_id: 109 | image = self._device.image 110 | if image is not None: 111 | pix = QtGui.QPixmap() 112 | pix.loadFromData(image) 113 | self.setIcon(pix) 114 | self.setIconSize(QtCore.QSize(100, 100)) 115 | img = pix.toImage() 116 | self._bg_color = img.pixelColor(25, 25).name() 117 | contrast = self._calc_contrast(self._bg_color) 118 | if contrast >= 1 / 4.5: 119 | self._text_color = self.COLOR_LIGHT 120 | else: 121 | self._text_color = self.COLOR_DARK 122 | else: 123 | self.setIcon(QtGui.QIcon()) 124 | self._text_color = self.COLOR_DARK 125 | 126 | def _calc_contrast(self, hex_color): 127 | colors = (self.COLOR_DARK, hex_color) 128 | lum = [] 129 | for color in colors: 130 | lum.append(self._calc_luminance(color)) 131 | lum = sorted(lum) 132 | contrast = (lum[0] + 0.05) / lum[1] + 0.05 133 | return contrast 134 | 135 | def _calc_luminance(self, hex_color): 136 | assert len(hex_color) == 7 137 | hex_color = hex_color.replace("#", "") 138 | assert len(hex_color) == 6 139 | color = [] 140 | for index in range(0, 3): 141 | start = 2 * index 142 | rgb = int.from_bytes( 143 | bytes.fromhex(hex_color[start : start + 2]), 144 | "little", 145 | ) 146 | rgb /= 255 147 | rgb = rgb / 12.92 if rgb <= 0.04045 else ((rgb + 0.055) / 1.055) ** 2.4 148 | color.append(rgb) 149 | luminance = (0.2126 * color[0]) + (0.7152 * color[1]) + (0.0722 * color[2]) 150 | return luminance 151 | 152 | def _update_text(self): 153 | text = "" 154 | if self._info_show: 155 | text = self._get_info_text() 156 | else: 157 | text = self._get_main_text() 158 | self.setText(text) 159 | 160 | def _get_main_text(self) -> str: 161 | if self._device.host_type == "PS4": 162 | device_type = "PlayStation 4" 163 | elif self._device.host_type == "PS5": 164 | device_type = "PlayStation 5" 165 | else: 166 | device_type = "Unknown" 167 | app = self._device.app_name 168 | if not app: 169 | if self.state_unknown(): 170 | app = "Unknown" 171 | else: 172 | if self._device.is_on: 173 | app = "Idle" 174 | else: 175 | app = "Standby" 176 | 177 | return f"{self._device.host_name}\n" f"{device_type}\n\n" f"{app}" 178 | 179 | def _get_info_text(self) -> str: 180 | text = ( 181 | f"Type: {self._device.host_type}\n" 182 | f"Name: {self._device.host_name}\n" 183 | f"IP Address: {self._device.host}\n" 184 | f"Mac Address: {self._device.mac_address}\n\n" 185 | f"Status: {self._device.status_name}\n" 186 | f"Playing: {self._device.app_name}" 187 | ) 188 | return text 189 | 190 | def _toggle_info(self): 191 | self._info_show = not self._info_show 192 | self._update_text() 193 | 194 | def _power_toggle(self): 195 | self.power_toggled.emit(self._device) 196 | 197 | @property 198 | def device(self) -> RPDevice: 199 | """Return Device.""" 200 | return self._device 201 | 202 | 203 | class DeviceGridWidget(QtWidgets.QWidget): 204 | """Widget that contains device buttons.""" 205 | 206 | MAX_COLS = 3 207 | 208 | power_toggled = QtCore.Signal(RPDevice) 209 | connect_requested = QtCore.Signal(RPDevice) 210 | devices_available = QtCore.Signal() 211 | 212 | def __init__(self, *args, **kwargs): 213 | super().__init__(*args, **kwargs) 214 | self.setStyleSheet("QPushButton {padding: 50px 25px;}") 215 | self.setLayout(QtWidgets.QGridLayout()) 216 | self.layout().setColumnMinimumWidth(0, 100) 217 | 218 | @QtCore.Slot(RPDevice) 219 | def _power_toggle(self, device: RPDevice): 220 | self.power_toggled.emit(device) 221 | 222 | @QtCore.Slot(RPDevice) 223 | def _connect_request(self, device: RPDevice): 224 | self.connect_requested.emit(device) 225 | 226 | def add(self, button, row, col): 227 | """Add button to grid.""" 228 | self.layout().addWidget(button, row, col, Qt.AlignCenter) 229 | 230 | def create_grid(self, devices: dict): 231 | """Create Button Grid.""" 232 | for widget in self.buttons(): 233 | widget.update_state() 234 | if devices: 235 | cur_index = len(self.buttons()) - 1 236 | current = [button.device.host for button in self.buttons()] 237 | for ip_address, device in devices.items(): 238 | if ip_address in current: 239 | continue 240 | cur_index += 1 241 | col = cur_index % self.MAX_COLS 242 | row = cur_index // self.MAX_COLS 243 | button = DeviceButton(device) 244 | self.add(button, row, col) 245 | button.power_toggled.connect(self._power_toggle) 246 | button.connect_requested.connect(self._connect_request) 247 | 248 | if self.buttons(): 249 | self.devices_available.emit() 250 | 251 | def enable_buttons(self): 252 | """Enable all buttons.""" 253 | for button in self.buttons(): 254 | button.setDisabled(False) 255 | button.setToolTip("") 256 | 257 | def buttons(self) -> list[DeviceButton]: 258 | """Return buttons.""" 259 | count = self.layout().count() 260 | return [self.layout().itemAt(index).widget() for index in range(0, count)] 261 | -------------------------------------------------------------------------------- /pyremoteplay/gui/joystick.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=c-extension-no-member,invalid-name 2 | """Joystick Widget for stream window.""" 3 | from __future__ import annotations 4 | from enum import Enum 5 | from typing import TYPE_CHECKING 6 | from PySide6 import QtWidgets, QtGui, QtCore 7 | from PySide6.QtCore import Qt # pylint: disable=no-name-in-module 8 | 9 | if TYPE_CHECKING: 10 | from .stream_window import StreamWindow 11 | 12 | 13 | class JoystickWidget(QtWidgets.QFrame): 14 | """Container Widget for joysticks.""" 15 | 16 | def __init__(self, parent: StreamWindow): 17 | super().__init__(parent) 18 | self._last_pos = None 19 | self._grab_outside = False 20 | self._left = Joystick(self, "left") 21 | self._right = Joystick(self, "right") 22 | self.setLayout(QtWidgets.QHBoxLayout()) 23 | self.layout().setAlignment(Qt.AlignCenter) 24 | self.layout().setContentsMargins(0, 0, 0, 0) 25 | self.setStyleSheet( 26 | "background-color: rgba(255, 255, 255, 0.4); border-radius:25%;" 27 | ) 28 | cursor = QtGui.QCursor() 29 | cursor.setShape(Qt.SizeAllCursor) 30 | self.setCursor(cursor) 31 | self.layout().addWidget(self._left) 32 | self.layout().addWidget(self._right) 33 | 34 | # pylint: disable=useless-super-delegation 35 | def window(self) -> StreamWindow: 36 | """Return Window.""" 37 | return super().window() 38 | 39 | def hide_sticks(self): 40 | """Hide Joysticks.""" 41 | self._left.hide() 42 | self._right.hide() 43 | 44 | def show_sticks(self, left=False, right=False): 45 | """Show Joysticks.""" 46 | width = 0 47 | if left: 48 | width += Joystick.SIZE 49 | self._left.show() 50 | if right: 51 | width += Joystick.SIZE 52 | self._right.show() 53 | self.resize(width, Joystick.SIZE) 54 | self.show() 55 | 56 | def default_pos(self): 57 | """Move widget to default position.""" 58 | if self.window().options().fullscreen: 59 | width = self.screen().virtualSize().width() 60 | height = self.screen().virtualSize().height() 61 | else: 62 | width = self.window().size().width() 63 | height = self.window().size().height() 64 | x_pos = width / 2 - self.size().width() / 2 65 | y_pos = height - self.size().height() 66 | new_pos = QtCore.QPoint(x_pos, y_pos) 67 | self.move(new_pos) 68 | 69 | def mousePressEvent(self, event): 70 | """Mouse Press Event""" 71 | event.accept() 72 | self._grab_outside = True 73 | self._last_pos = event.globalPos() 74 | 75 | def mouseReleaseEvent(self, event): # pylint: disable=unused-argument 76 | """Mouse Release Event.""" 77 | event.accept() 78 | self._grab_outside = False 79 | 80 | def mouseMoveEvent(self, event): 81 | """Mouse Move Event.""" 82 | event.accept() 83 | if event.buttons() == QtCore.Qt.NoButton: 84 | return 85 | if self._grab_outside: 86 | cur_pos = self.mapToGlobal(self.pos()) 87 | global_pos = event.globalPos() 88 | diff = global_pos - self._last_pos 89 | new_pos = self.mapFromGlobal(cur_pos + diff) 90 | if self.window().options().fullscreen: 91 | max_x = self.screen().virtualSize().width() 92 | max_y = self.screen().virtualSize().height() 93 | else: 94 | max_x = self.window().size().width() 95 | max_y = self.window().size().height() 96 | x_pos = min(max(new_pos.x(), 0), max_x - self.size().width()) 97 | y_pos = min(max(new_pos.y(), 0), max_y - self.size().height()) 98 | new_pos = QtCore.QPoint(x_pos, y_pos) 99 | self.move(new_pos) 100 | self._last_pos = global_pos 101 | 102 | 103 | class Joystick(QtWidgets.QLabel): 104 | """Draggable Joystick Widget.""" 105 | 106 | SIZE = 180 107 | RADIUS = 50 108 | 109 | class Direction(Enum): 110 | """Enums for directions.""" 111 | 112 | LEFT = 0 113 | RIGHT = 1 114 | UP = 2 115 | DOWN = 3 116 | 117 | def __init__(self, parent: JoystickWidget, stick: str): 118 | self._stick = stick 119 | self._grabbed = False 120 | super().__init__(parent) 121 | self.setMinimumSize(Joystick.SIZE, Joystick.SIZE) 122 | self._moving_offset = QtCore.QPointF(0, 0) 123 | 124 | self.setStyleSheet("background-color: rgba(0, 0, 0, 0.0)") 125 | self._set_cursor() 126 | 127 | # pylint: disable=useless-super-delegation 128 | def parent(self) -> JoystickWidget: 129 | """Return Parent.""" 130 | return super().parent() 131 | 132 | # pylint: disable=useless-super-delegation 133 | def window(self) -> StreamWindow: 134 | """Return Window.""" 135 | return super().window() 136 | 137 | def _set_cursor(self, shape=Qt.SizeAllCursor): 138 | cursor = QtGui.QCursor() 139 | cursor.setShape(shape) 140 | self.setCursor(cursor) 141 | 142 | def paintEvent(self, event): # pylint: disable=unused-argument 143 | """Paint Event.""" 144 | painter = QtGui.QPainter(self) 145 | painter.setRenderHint(QtGui.QPainter.Antialiasing) 146 | bounds = QtCore.QRectF( 147 | -Joystick.RADIUS, 148 | -Joystick.RADIUS, 149 | Joystick.RADIUS * 2, 150 | Joystick.RADIUS * 2, 151 | ).translated(self.center) 152 | painter.setBrush(QtGui.QColor(75, 75, 75, 150)) 153 | painter.drawEllipse(bounds) 154 | painter.setBrush(Qt.black) 155 | painter.drawEllipse(self._center_ellipse()) 156 | 157 | def _center_ellipse(self): 158 | if self._grabbed: 159 | return QtCore.QRectF(-40, -40, 80, 80).translated(self._moving_offset) 160 | return QtCore.QRectF(-40, -40, 80, 80).translated(self.center) 161 | 162 | def _limit_bounds(self, point): 163 | limit_line = QtCore.QLineF(self.center, point) 164 | if limit_line.length() > Joystick.RADIUS: 165 | limit_line.setLength(Joystick.RADIUS) 166 | return limit_line.p2() 167 | 168 | def mousePressEvent(self, event): 169 | """Mouse Press Event.""" 170 | if self._center_ellipse().contains(event.pos()): 171 | event.accept() 172 | self._grabbed = True 173 | self._set_cursor(Qt.ClosedHandCursor) 174 | self._moving_offset = self._limit_bounds(event.pos()) 175 | self.window().move_stick(self._stick, self.joystick_position) 176 | self.update() 177 | else: 178 | event.ignore() 179 | self.parent().mousePressEvent(event) 180 | 181 | def mouseReleaseEvent(self, event): 182 | """Mouse Release Event.""" 183 | if self._grabbed: 184 | event.accept() 185 | self._grabbed = False 186 | self._moving_offset = QtCore.QPointF(0, 0) 187 | self.window().move_stick(self._stick, self.joystick_position) 188 | self._set_cursor(Qt.OpenHandCursor) 189 | self.update() 190 | else: 191 | event.ignore() 192 | self.parent().mouseReleaseEvent(event) 193 | 194 | def mouseMoveEvent(self, event): 195 | """Mouse Move Event.""" 196 | if self._grabbed: 197 | event.accept() 198 | self._set_cursor(Qt.ClosedHandCursor) 199 | self._moving_offset = self._limit_bounds(event.pos()) 200 | self.window().move_stick(self._stick, self.joystick_position) 201 | self.update() 202 | else: 203 | event.ignore() 204 | 205 | @property 206 | def center(self) -> QtCore.QPointF: 207 | """Return Center.""" 208 | return QtCore.QPointF(self.width() / 2, self.height() / 2) 209 | 210 | @property 211 | def joystick_position(self) -> QtCore.QPointF: 212 | """Return Joystick Position.""" 213 | if not self._grabbed: 214 | return QtCore.QPointF(0.0, 0.0) 215 | vector = QtCore.QLineF(self.center, self._moving_offset) 216 | point = vector.p2() 217 | return (point - self.center) / Joystick.RADIUS 218 | -------------------------------------------------------------------------------- /pyremoteplay/gui/main_window.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=c-extension-no-member,invalid-name 2 | """Main Window for pyremoteplay GUI.""" 3 | from __future__ import annotations 4 | import logging 5 | 6 | from PySide6 import QtCore, QtWidgets 7 | from PySide6.QtCore import Qt # pylint: disable=no-name-in-module 8 | 9 | from pyremoteplay.__version__ import VERSION 10 | from pyremoteplay.device import RPDevice 11 | 12 | from .device_grid import DeviceGridWidget 13 | from .options import OptionsWidget 14 | from .controls import ControlsWidget 15 | from .stream_window import StreamWindow 16 | from .toolbar import ToolbarWidget 17 | from .util import message 18 | from .workers import AsyncHandler 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | class MainWindow(QtWidgets.QMainWindow): 24 | """Main Window.""" 25 | 26 | def __init__(self): 27 | super().__init__() 28 | self.setWindowTitle("PyRemotePlay") 29 | self._toolbar = None 30 | self._device_grid = None 31 | self.async_handler = AsyncHandler() 32 | self._stream_window = None 33 | self.rp_worker = self.async_handler.rp_worker 34 | 35 | self._center_text = QtWidgets.QLabel( 36 | "Searching for devices...", alignment=Qt.AlignCenter 37 | ) 38 | self._center_text.setWordWrap(True) 39 | self._center_text.setObjectName("center-text") 40 | self._device_grid = DeviceGridWidget() 41 | self._device_grid.hide() 42 | 43 | self._main_frame = QtWidgets.QWidget(self) 44 | self._main_frame.setLayout(QtWidgets.QVBoxLayout()) 45 | self._main_frame.layout().addWidget(self._center_text) 46 | self._main_frame.layout().addWidget(self._device_grid) 47 | 48 | self._toolbar = ToolbarWidget(self) 49 | self._options = OptionsWidget(self) 50 | self._controls = ControlsWidget(self) 51 | self.addToolBar(self._toolbar) 52 | self.setStatusBar(QtWidgets.QStatusBar()) 53 | self.statusBar().showMessage(f"v{VERSION}") 54 | 55 | widget = QtWidgets.QStackedWidget() 56 | widget.addWidget(self._main_frame) 57 | widget.addWidget(self._options) 58 | widget.addWidget(self._controls) 59 | self.setCentralWidget(widget) 60 | self._set_style() 61 | 62 | self.async_handler.rp_worker.standby_done.connect(self._standby_callback) 63 | self.async_handler.status_updated.connect(self._event_status_updated) 64 | self.async_handler.manual_search_done.connect(self._options.search_complete) 65 | self._toolbar.buttonClicked.connect(self._toolbar_button_clicked) 66 | self._device_grid.power_toggled.connect(self._power_toggle) 67 | self._device_grid.connect_requested.connect(self.connect_host) 68 | self._device_grid.devices_available.connect(self._devices_available) 69 | self._options.search_requested.connect(self._manual_search) 70 | self._options.device_added.connect(self.add_devices) 71 | self._options.device_removed.connect(self.remove_device) 72 | self._options.register_finished.connect(self.session_stop) 73 | 74 | self.add_devices() 75 | 76 | self._toolbar.refresh().setChecked(True) 77 | self._start_update() 78 | QtCore.QTimer.singleShot(7000, self._startup_check_grid) 79 | 80 | def closeEvent(self, event): 81 | """Close Event.""" 82 | self._stop_update() 83 | if self._stream_window: 84 | self._stream_window.close() 85 | self.hide() 86 | self.async_handler.shutdown() 87 | event.accept() 88 | 89 | def wakeup(self, device): 90 | """Wakeup Host.""" 91 | user = self._options.options.get("profile") 92 | profile = self.check_profile(user, device) 93 | if not profile: 94 | return 95 | device.wakeup(user=user) 96 | message( 97 | self, 98 | "Wakeup Sent", 99 | f"Sent Wakeup command to device at {device.host}", 100 | "info", 101 | ) 102 | 103 | def connect_host(self, device: RPDevice): 104 | """Connect to Host.""" 105 | options = self._options.options_data 106 | user = options.profile 107 | profile = self.check_profile(user, device) 108 | if not profile: 109 | return 110 | self._device_grid.setEnabled(False) 111 | self._stop_update() 112 | audio_device = self._options.get_audio_device() 113 | gamepad = None 114 | if self._controls.use_gamepad(): 115 | gamepad = self._controls.get_gamepad() 116 | self._stream_window = StreamWindow( 117 | self.rp_worker, 118 | device, 119 | options, 120 | audio_device, 121 | self._controls.get_keyboard_map(), 122 | self._controls.get_keyboard_options(), 123 | gamepad, 124 | ) 125 | self._stream_window.started.connect(self.session_start) 126 | self._stream_window.stopped.connect(self.session_stop) 127 | self._stream_window.start() 128 | QtWidgets.QApplication.instance().setActiveWindow(self._stream_window) 129 | 130 | def session_start(self, device: RPDevice): 131 | """Start Session.""" 132 | self.rp_worker.run(device) 133 | 134 | def session_stop(self, error: str = ""): 135 | """Callback for stopping session.""" 136 | _LOGGER.debug("Detected Session Stop") 137 | self.rp_worker.stop() 138 | 139 | QtWidgets.QApplication.instance().setActiveWindow(self) 140 | 141 | if self._toolbar.refresh().isChecked(): 142 | self._start_update() 143 | self._device_grid.setDisabled(False) 144 | QtCore.QTimer.singleShot(10000, self._device_grid.enable_buttons) 145 | 146 | if self._stream_window: 147 | self._stream_window.hide() 148 | self._stream_window = None 149 | if error: 150 | message(self, "Error", error) 151 | 152 | @QtCore.Slot() 153 | def add_devices(self): 154 | """Add devices to grid.""" 155 | for host in self._options.devices: 156 | if host not in self.async_handler.tracker.devices: 157 | self.async_handler.tracker.add_device(host) 158 | 159 | @QtCore.Slot(str) 160 | def remove_device(self, host: str): 161 | """Remove Device from grid.""" 162 | self.async_handler.tracker.remove_device(host) 163 | self._event_status_updated() 164 | 165 | def standby(self, device: RPDevice): 166 | """Place host in standby mode.""" 167 | options = self._options.options 168 | user = options.get("profile") 169 | profile = self.check_profile(user, device) 170 | if not profile: 171 | return 172 | self.async_handler.standby(device, user) 173 | 174 | @QtCore.Slot(QtWidgets.QPushButton) 175 | def _toolbar_button_clicked(self, button): 176 | if button == self._toolbar.home(): 177 | self.centralWidget().setCurrentWidget(self._main_frame) 178 | elif button == self._toolbar.options(): 179 | self.centralWidget().setCurrentWidget(self._options) 180 | elif button == self._toolbar.controls(): 181 | self.centralWidget().setCurrentWidget(self._controls) 182 | elif button == self._toolbar.refresh(): 183 | if button.isChecked(): 184 | self._start_update() 185 | else: 186 | self._stop_update() 187 | 188 | def _devices_available(self): 189 | self._center_text.hide() 190 | self._device_grid.show() 191 | 192 | def _startup_check_grid(self): 193 | if not self._device_grid.buttons(): 194 | self._center_text.setText( 195 | "No Devices Found.\n" "Try adding a device in options." 196 | ) 197 | 198 | def _set_style(self): 199 | style = ( 200 | "QPushButton {border: 1px solid #0a58ca;border-radius: 10px;padding: 10px;margin: 5px;}" 201 | "QPushButton:hover {background-color:#6ea8fe;color:black;}" 202 | "QPushButton:pressed {background-color:#0a58ca;color:white;}" 203 | "QPushButton:checked {background-color:#0D6EFD;color:white;}" 204 | "#center-text {font-size: 24px;}" 205 | ) 206 | self.setStyleSheet(style) 207 | 208 | def _event_status_updated(self): 209 | """Callback for status updates.""" 210 | devices = self.async_handler.tracker.devices 211 | self._device_grid.create_grid(devices) 212 | 213 | def check_profile(self, name: str, device: RPDevice): 214 | """Return profile if profile is registered.""" 215 | if not self._options.profiles: 216 | message( 217 | self, 218 | "Error: No PSN Accounts found", 219 | "Click 'Options' -> 'Add Account' to add PSN Account.", 220 | ) 221 | self._device_grid.enable_buttons() 222 | return None 223 | profile = self._options.profiles.get(name) 224 | if not profile: 225 | message( 226 | self, 227 | "Error: No PSN Account Selected.", 228 | "Click 'Options' -> and select a PSN Account.", 229 | ) 230 | self._device_grid.enable_buttons() 231 | return None 232 | if device.mac_address not in profile["hosts"]: 233 | text = ( 234 | f"PSN account: {name} has not been registered with this device. " 235 | "Click 'Ok' to register." 236 | ) 237 | message( 238 | self, 239 | "Needs Registration", 240 | text, 241 | "info", 242 | callback=lambda: self._options.register(device, name), 243 | escape=True, 244 | ) 245 | self._device_grid.enable_buttons() 246 | return None 247 | return profile 248 | 249 | @QtCore.Slot(str) 250 | def _standby_callback(self, error: str): 251 | """Callback after attempting standby.""" 252 | if error: 253 | message(self, "Standby Error", error) 254 | else: 255 | message(self, "Standby Success", "Set device to Standby", "info") 256 | 257 | def _power_toggle(self, device: RPDevice): 258 | if device.is_on: 259 | self.standby(device) 260 | else: 261 | self.wakeup(device) 262 | 263 | def _start_update(self): 264 | """Start update service.""" 265 | self.async_handler.poll() 266 | 267 | def _stop_update(self): 268 | """Stop Updatw Service.""" 269 | self.async_handler.stop_poll() 270 | 271 | @QtCore.Slot(str) 272 | def _manual_search(self, host: str): 273 | self.async_handler.manual_search(host) 274 | -------------------------------------------------------------------------------- /pyremoteplay/gui/toolbar.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=c-extension-no-member 2 | """Toolbar Widget.""" 3 | from PySide6 import QtWidgets, QtCore 4 | from PySide6.QtCore import Qt 5 | 6 | 7 | class ToolbarWidget(QtWidgets.QToolBar): 8 | """Toolbar Widget.""" 9 | 10 | buttonClicked = QtCore.Signal(QtWidgets.QPushButton) 11 | 12 | def __init__(self, *args, **kwargs): 13 | super().__init__(*args, **kwargs) 14 | self.setMovable(False) 15 | self.toggleViewAction().setEnabled(False) 16 | self.setContextMenuPolicy(Qt.CustomContextMenu) 17 | self._refresh = QtWidgets.QPushButton("Auto Refresh") 18 | self._controls = QtWidgets.QPushButton("Controls") 19 | self._options = QtWidgets.QPushButton("Options") 20 | self._home = QtWidgets.QPushButton("Home") 21 | 22 | spacer = QtWidgets.QWidget() 23 | spacer.setSizePolicy( 24 | QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred 25 | ) 26 | widgets = [self._refresh, spacer, self._home, self._controls, self._options] 27 | 28 | for widget in widgets: 29 | self.addWidget(widget) 30 | if not isinstance(widget, QtWidgets.QPushButton): 31 | continue 32 | widget.setMaximumWidth(200) 33 | widget.setCheckable(True) 34 | widget.clicked.connect(self._button_click) 35 | self._button_group = QtWidgets.QButtonGroup() 36 | self._button_group.addButton(self._controls) 37 | self._button_group.addButton(self._options) 38 | self._button_group.addButton(self._home) 39 | self._button_group.setExclusive(True) 40 | self._home.setChecked(True) 41 | 42 | @QtCore.Slot() 43 | def _button_click(self): 44 | button = self.sender() 45 | self.buttonClicked.emit(button) 46 | 47 | def home(self) -> QtWidgets.QPushButton: 48 | """Return Home Button.""" 49 | return self._home 50 | 51 | def refresh(self) -> QtWidgets.QPushButton: 52 | """Return Refresh Button.""" 53 | return self._refresh 54 | 55 | def controls(self) -> QtWidgets.QPushButton: 56 | """Return Controls Button.""" 57 | return self._controls 58 | 59 | def options(self) -> QtWidgets.QPushButton: 60 | """Return Options Button.""" 61 | return self._options 62 | -------------------------------------------------------------------------------- /pyremoteplay/gui/util.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=c-extension-no-member 2 | """GUI utilities.""" 3 | 4 | from PySide6 import QtWidgets 5 | 6 | 7 | def format_qt_key(key: str) -> str: 8 | """Return formatted Qt Key name.""" 9 | return key.replace("Key_", "").replace("Button", " Click") 10 | 11 | 12 | def spacer(): 13 | """Return Spacer.""" 14 | return QtWidgets.QSpacerItem(20, 40) 15 | 16 | 17 | def message( 18 | widget, title, text, level="critical", callback=None, escape=False, should_exec=True 19 | ): 20 | """Return Message box.""" 21 | 22 | def clicked(msg, callback): 23 | button = msg.clickedButton() 24 | text = button.text().lower() 25 | if "ok" in text: 26 | callback() 27 | 28 | icon = QtWidgets.QMessageBox.Critical 29 | if level == "critical": 30 | icon = QtWidgets.QMessageBox.Critical 31 | elif level == "info": 32 | icon = QtWidgets.QMessageBox.Information 33 | elif level == "warning": 34 | icon = QtWidgets.QMessageBox.Warning 35 | msg = QtWidgets.QMessageBox(widget) 36 | msg.setIcon(icon) 37 | msg.setWindowTitle(title) 38 | msg.setText(text) 39 | if escape: 40 | msg.setStandardButtons(QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel) 41 | else: 42 | msg.setStandardButtons(QtWidgets.QMessageBox.Ok) 43 | if callback is not None: 44 | msg.buttonClicked.connect(lambda: clicked(msg, callback)) 45 | if should_exec: 46 | msg.exec() 47 | return msg 48 | 49 | 50 | def label(parent, text: str, wrap: bool = False): 51 | """Return Label.""" 52 | _label = QtWidgets.QLabel(parent) 53 | _label.setText(text) 54 | _label.setWordWrap(wrap) 55 | return _label 56 | -------------------------------------------------------------------------------- /pyremoteplay/gui/video.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=c-extension-no-member,invalid-name,no-name-in-module 2 | """Video Output for Stream.""" 3 | from __future__ import annotations 4 | from textwrap import dedent 5 | from typing import TYPE_CHECKING 6 | import av 7 | from OpenGL import GL 8 | from PySide6 import QtCore, QtGui, QtWidgets 9 | from PySide6.QtCore import Signal 10 | from PySide6.QtGui import QOpenGLFunctions, QSurfaceFormat 11 | from PySide6.QtOpenGL import ( 12 | QOpenGLShader, 13 | QOpenGLShaderProgram, 14 | QOpenGLTexture, 15 | QOpenGLVertexArrayObject, 16 | ) 17 | from PySide6.QtOpenGLWidgets import QOpenGLWidget 18 | from shiboken6 import VoidPtr 19 | 20 | if TYPE_CHECKING: 21 | from .stream_window import StreamWindow 22 | 23 | YUV_VERT = dedent( 24 | """ 25 | #version 150 core 26 | uniform mat4 pos_matrix; 27 | uniform vec4 draw_pos; 28 | 29 | const vec2 verts[4] = vec2[] ( 30 | vec2(-0.5, 0.5), 31 | vec2(-0.5, -0.5), 32 | vec2( 0.5, 0.5), 33 | vec2( 0.5, -0.5) 34 | ); 35 | 36 | const vec2 texcoords[4] = vec2[] ( 37 | vec2(0.0, 1.0), 38 | vec2(0.0, 0.0), 39 | vec2(1.0, 1.0), 40 | vec2(1.0, 0.0) 41 | ); 42 | 43 | out vec2 v_coord; 44 | 45 | void main() { 46 | vec2 vert = verts[gl_VertexID]; 47 | vec4 p = vec4((0.5 * draw_pos.z) + draw_pos.x + (vert.x * draw_pos.z), 48 | (0.5 * draw_pos.w) + draw_pos.y + (vert.y * draw_pos.w), 49 | 0, 1); 50 | gl_Position = pos_matrix * p; 51 | v_coord = texcoords[gl_VertexID]; 52 | } 53 | """ 54 | ) 55 | 56 | 57 | YUV_FRAG = dedent( 58 | """ 59 | #version 150 core 60 | uniform sampler2D plane1; 61 | uniform sampler2D plane2; 62 | uniform sampler2D plane3; 63 | in vec2 v_coord; 64 | out vec4 out_color; 65 | 66 | void main() { 67 | vec3 yuv = vec3( 68 | (texture(plane1, v_coord).r - (16.0 / 255.0)) / ((235.0 - 16.0) / 255.0), 69 | (texture(plane2, v_coord).r - (16.0 / 255.0)) / ((240.0 - 16.0) / 255.0) - 0.5, 70 | (texture(plane3, v_coord).r - (16.0 / 255.0)) / ((240.0 - 16.0) / 255.0) - 0.5); 71 | vec3 rgb = mat3( 72 | 1.0, 1.0, 1.0, 73 | 0.0, -0.21482, 2.12798, 74 | 1.28033, -0.38059, 0.0) * yuv; 75 | out_color = vec4(rgb, 1.0); 76 | } 77 | """ 78 | ) 79 | 80 | 81 | YUV_FRAG_NV12 = dedent( 82 | """ 83 | #version 150 core 84 | uniform sampler2D plane1; 85 | uniform sampler2D plane2; 86 | in vec2 v_coord; 87 | out vec4 out_color; 88 | 89 | void main() { 90 | vec3 yuv = vec3( 91 | (texture(plane1, v_coord).r - (16.0 / 255.0)) / ((235.0 - 16.0) / 255.0), 92 | (texture(plane2, v_coord).r - (16.0 / 255.0)) / ((240.0 - 16.0) / 255.0) - 0.5, 93 | (texture(plane2, v_coord).g - (16.0 / 255.0)) / ((240.0 - 16.0) / 255.0) - 0.5); 94 | vec3 rgb = mat3( 95 | 1.0, 1.0, 1.0, 96 | 0.0, -0.21482, 2.12798, 97 | 1.28033, -0.38059, 0.0) * yuv; 98 | out_color = vec4(rgb, 1.0); 99 | } 100 | """ 101 | ) 102 | 103 | 104 | class YUVGLWidget(QOpenGLWidget, QOpenGLFunctions): 105 | """YUV to RGB Opengl Widget.""" 106 | 107 | TEXTURE_NAMES = ("plane1", "plane2", "plane3") 108 | 109 | frame_updated = Signal() 110 | 111 | @staticmethod 112 | def surface_format(): 113 | """Return default surface format.""" 114 | surface_format = QSurfaceFormat.defaultFormat() 115 | surface_format.setProfile(QSurfaceFormat.CoreProfile) 116 | surface_format.setVersion(3, 3) 117 | surface_format.setSwapInterval(0) 118 | QSurfaceFormat.setDefaultFormat(surface_format) 119 | return surface_format 120 | 121 | def __init__(self, width, height, is_nv12=False, parent=None): 122 | self._is_nv12 = is_nv12 123 | QOpenGLWidget.__init__(self, parent) 124 | QOpenGLFunctions.__init__(self) 125 | surface_format = YUVGLWidget.surface_format() 126 | self.setFormat(surface_format) 127 | self.textures = [] 128 | self.frame_width = width 129 | self.frame_height = height 130 | self.resize(self.frame_width, self.frame_height) 131 | 132 | self.program = QOpenGLShaderProgram(self) 133 | self.vao = QOpenGLVertexArrayObject() 134 | self.frame = self.draw_pos = None 135 | 136 | def __del__(self): 137 | self.makeCurrent() 138 | for texture in self.textures: 139 | texture.destroy() 140 | self.doneCurrent() 141 | 142 | def initializeGL(self): 143 | """Initilize GL Program and textures.""" 144 | self.initializeOpenGLFunctions() 145 | 146 | frag_shader = YUV_FRAG_NV12 if self._is_nv12 else YUV_FRAG 147 | 148 | # Setup shaders 149 | assert self.program.addShaderFromSourceCode(QOpenGLShader.Vertex, YUV_VERT) 150 | assert self.program.addShaderFromSourceCode(QOpenGLShader.Fragment, frag_shader) 151 | 152 | self.program.link() 153 | self.program.bind() 154 | 155 | self.program.setUniformValue("draw_pos", 0, 0, self.width(), self.height()) 156 | self._create_textures() 157 | 158 | self.vao.create() 159 | self.vao.bind() 160 | 161 | def paintGL(self): 162 | """Paint GL.""" 163 | if not self.textures or not self.frame: 164 | return 165 | self.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT) 166 | matrix = QtGui.QMatrix4x4() 167 | matrix.ortho(0, self.width(), self.height(), 0, 0.0, 100.0) 168 | 169 | self.program.setUniformValue("pos_matrix", matrix) 170 | 171 | # self.glViewport(0, 0, self.width(), self.height()) 172 | 173 | for index, plane in enumerate(self.frame.planes): 174 | self.update_texture(index, bytes(plane)) 175 | 176 | self.glDrawArrays(GL.GL_TRIANGLE_STRIP, 0, 4) 177 | 178 | def _get_texture_config( 179 | self, index: int 180 | ) -> tuple[int, int, QOpenGLTexture.PixelFormat, QOpenGLTexture.TextureFormat]: 181 | tex_format = QOpenGLTexture.R8_UNorm 182 | pix_format = QOpenGLTexture.Red 183 | width = self.frame_width 184 | height = self.frame_height 185 | if index > 0: 186 | width /= 2 187 | height /= 2 188 | if self._is_nv12: 189 | tex_format = QOpenGLTexture.RG8_UNorm 190 | pix_format = QOpenGLTexture.RG 191 | return width, height, pix_format, tex_format 192 | 193 | def _create_textures(self): 194 | """Create Textures.""" 195 | self.textures = [] 196 | for index, name in enumerate(YUVGLWidget.TEXTURE_NAMES): 197 | width, height, pix_format, tex_format = self._get_texture_config(index) 198 | 199 | texture = QOpenGLTexture(QOpenGLTexture.Target2D) 200 | texture.setFormat(tex_format) 201 | texture.setSize(width, height) 202 | texture.allocateStorage(pix_format, QOpenGLTexture.UInt8) 203 | texture.setMinMagFilters(QOpenGLTexture.Linear, QOpenGLTexture.Linear) 204 | texture.setWrapMode(QOpenGLTexture.DirectionS, QOpenGLTexture.ClampToEdge) 205 | texture.setWrapMode(QOpenGLTexture.DirectionT, QOpenGLTexture.ClampToEdge) 206 | 207 | self.program.setUniformValue(name, index) 208 | self.program.setUniformValue1i(self.program.uniformLocation(name), index) 209 | self.textures.append(texture) 210 | 211 | def update_texture(self, index, pixels): 212 | """Update texture with video plane.""" 213 | width, height, pix_format, _ = self._get_texture_config(index) 214 | 215 | self.glActiveTexture(GL.GL_TEXTURE0 + index) 216 | texture = self.textures[index] 217 | texture.bind(GL.GL_TEXTURE0 + index) 218 | texture.setData( 219 | 0, 220 | 0, 221 | 0, 222 | width, 223 | height, 224 | 0, 225 | pix_format, 226 | QOpenGLTexture.UInt8, 227 | VoidPtr(pixels), 228 | ) 229 | 230 | @QtCore.Slot(av.VideoFrame) 231 | def next_video_frame(self, frame: av.VideoFrame): 232 | """Update widget with next video frame.""" 233 | self.frame = frame 234 | self.update() 235 | self.frame_updated.emit() 236 | 237 | 238 | class VideoWidget(QtWidgets.QLabel): 239 | """Video output widget using Pixmap. Requires RGB frame.""" 240 | 241 | frame_updated = Signal() 242 | 243 | def __init__(self, width: int, height: int, *args, **kwargs): 244 | super().__init__(*args, **kwargs) 245 | self.frame_width = width 246 | self.frame_height = height 247 | 248 | # pylint: disable=useless-super-delegation 249 | def parent(self) -> StreamWindow: 250 | """Return Parent.""" 251 | return super().parent() 252 | 253 | @QtCore.Slot(av.VideoFrame) 254 | def next_video_frame(self, frame: av.VideoFrame): 255 | """Update widget with next video frame.""" 256 | image = QtGui.QImage( 257 | bytes(frame.planes[0]), 258 | frame.width, 259 | frame.height, 260 | frame.width * 3, 261 | QtGui.QImage.Format_RGB888, 262 | ) 263 | pixmap = QtGui.QPixmap.fromImage(image) 264 | if self.parent().fullscreen(): 265 | pixmap = pixmap.scaled( 266 | self.parent().size(), 267 | aspectMode=QtCore.Qt.KeepAspectRatio, 268 | mode=QtCore.Qt.SmoothTransformation, 269 | ) 270 | self.setPixmap(pixmap) 271 | self.frame_updated.emit() 272 | -------------------------------------------------------------------------------- /pyremoteplay/gui/widgets.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=c-extension-no-member,invalid-name,no-name-in-module 2 | """Generic custom QT Widgets.""" 3 | 4 | 5 | from PySide6.QtCore import ( 6 | Property, 7 | QEasingCurve, 8 | QParallelAnimationGroup, 9 | QPoint, 10 | QPointF, 11 | QPropertyAnimation, 12 | QRectF, 13 | QSize, 14 | Qt, 15 | QTimer, 16 | ) 17 | from PySide6.QtGui import QBrush, QColor, QPainter, QPaintEvent, QPen 18 | from PySide6.QtWidgets import QCheckBox, QLabel, QWidget, QHBoxLayout 19 | 20 | 21 | class FadeOutLabel(QLabel): 22 | """Fade Out Label.""" 23 | 24 | def __init__(self, *args, **kwargs): 25 | super().__init__(*args, **kwargs) 26 | self._opacity = 1.0 27 | self._duration = 3000 28 | self.setAutoFillBackground(True) 29 | self.setWordWrap(True) 30 | self.setStyleSheet("font-size: 24px;") 31 | self.anim = QPropertyAnimation(self, b"opacity", self) 32 | self.apply_opacity() 33 | 34 | def show(self, fade=False): 35 | """Show Widget.""" 36 | self.raise_() 37 | self.anim.stop() 38 | if not fade: 39 | self._opacity = 1.0 40 | self.apply_opacity() 41 | super().show() 42 | else: 43 | self._opacity = 0.0 44 | self.apply_opacity() 45 | self.anim.setEasingCurve(QEasingCurve.OutCubic) 46 | self.anim.setDuration(self._duration) 47 | self.anim.setStartValue(0.0) 48 | self.anim.setEndValue(1.0) 49 | super().show() 50 | self.anim.start() 51 | 52 | def hide(self, fade=False): 53 | """Hide Widget.""" 54 | self.raise_() 55 | self.anim.stop() 56 | if not fade: 57 | self._opacity = 0.0 58 | self.apply_opacity() 59 | super().hide() 60 | else: 61 | self._opacity = 1.0 62 | self.apply_opacity() 63 | self.anim.setEasingCurve(QEasingCurve.InOutCubic) 64 | self.anim.setDuration(self._duration) 65 | self.anim.setStartValue(1.0) 66 | self.anim.setEndValue(0.0) 67 | self.anim.start() 68 | 69 | def show_and_hide(self): 70 | """Fade in and out.""" 71 | timer = QTimer(self) 72 | timer.setSingleShot(True) 73 | timer.timeout.connect(lambda: self.hide(fade=True)) 74 | self.show(fade=True) 75 | timer.start(self._duration + 1000) 76 | 77 | def apply_opacity(self): 78 | """Apply Opacity.""" 79 | palette = self.palette() 80 | bg_color = QColor("#000000") 81 | fg_color = QColor("#FFFFFF") 82 | bg_color.setAlphaF(self._opacity) 83 | fg_color.setAlphaF(self._opacity) 84 | palette.setColor(self.backgroundRole(), bg_color) 85 | palette.setColor(self.foregroundRole(), fg_color) 86 | self.setPalette(palette) 87 | 88 | @Property(float) 89 | def opacity(self): 90 | """Return opacity.""" 91 | return self._opacity 92 | 93 | @opacity.setter 94 | def opacity(self, value): 95 | """Set Opacity.""" 96 | self._opacity = value 97 | self.apply_opacity() 98 | 99 | 100 | class AnimatedToggle(QCheckBox): 101 | """Checkbox shown as a toggle sliding button.""" 102 | 103 | _TRANSPARENT_PEN = QPen(Qt.transparent) 104 | _LIGHT_GRAY_PEN = QPen(Qt.lightGray) 105 | _TOGGLE_SIZE = 45 106 | 107 | def __init__( 108 | self, 109 | text, 110 | parent=None, 111 | bar_unchecked_color=Qt.gray, 112 | toggle_unchecked_color=Qt.white, 113 | toggle_checked_color="#00B0FF", 114 | pulse_unchecked_color="#44999999", 115 | pulse_checked_color="#4400B0EE", 116 | ): 117 | super().__init__(text, parent) 118 | self.setContentsMargins(8, 0, 8, 0) 119 | self._toggle_position = 0 120 | self._pulse_radius = 0 121 | self._text = text 122 | 123 | self._bar_unchecked_brush = QBrush(bar_unchecked_color) 124 | self._bar_checked_brush = QBrush(QColor(toggle_checked_color).lighter()) 125 | 126 | self._toggle_unchecked_brush = QBrush(toggle_unchecked_color) 127 | self._toggle_checked_brush = QBrush(QColor(toggle_checked_color)) 128 | 129 | self._pulse_unchecked_color = QBrush(QColor(pulse_unchecked_color)) 130 | self._pulse_checked_color = QBrush(QColor(pulse_checked_color)) 131 | 132 | self.slide_anim = QPropertyAnimation(self, b"toggle_position", self) 133 | self.slide_anim.setEasingCurve(QEasingCurve.InOutCubic) 134 | self.slide_anim.setDuration(200) 135 | 136 | self.pulse_anim = QPropertyAnimation(self, b"pulse_radius", self) 137 | self.pulse_anim.setDuration(300) 138 | self.pulse_anim.setStartValue(5) 139 | self.pulse_anim.setEndValue(20) 140 | 141 | self.animations_group = QParallelAnimationGroup() 142 | self.animations_group.addAnimation(self.slide_anim) 143 | self.animations_group.addAnimation(self.pulse_anim) 144 | 145 | self.stateChanged.connect(self._setup_animation) 146 | 147 | def sizeHint(self): 148 | """Return Size Hint.""" 149 | size = super().sizeHint() 150 | width = size.width() + self._TOGGLE_SIZE * (2 / 3) 151 | height = max([size.height(), self._TOGGLE_SIZE]) 152 | return QSize(width, height) 153 | 154 | def hitButton(self, pos: QPoint): 155 | """Return True if pos in rect.""" 156 | return self.container_rect.contains(pos) 157 | 158 | def _setup_animation(self, value): 159 | self.animations_group.stop() 160 | if value: 161 | self.slide_anim.setEndValue(1) 162 | else: 163 | self.slide_anim.setEndValue(0) 164 | self.animations_group.start() 165 | 166 | def paintEvent(self, event: QPaintEvent): # pylint: disable=unused-argument 167 | """Paint Event.""" 168 | painter = QPainter(self) 169 | painter.setRenderHint(QPainter.Antialiasing) 170 | text_rect = self.contentsRect() 171 | text_rect.setX(self.container_rect.width() + self.toggle_radius) 172 | painter.drawText(text_rect, Qt.AlignVCenter | Qt.TextWordWrap, self._text) 173 | painter.setPen(self._TRANSPARENT_PEN) 174 | 175 | track_rect = QRectF( 176 | 0, 177 | 0, 178 | self.container_rect.width() - self.toggle_radius, 179 | 0.40 * self.container_rect.height(), 180 | ) 181 | track_rect.moveCenter(self.container_rect.center()) 182 | rounding = track_rect.height() / 2 183 | 184 | x_pos = ( 185 | self.container_rect.x() 186 | + self.toggle_radius 187 | + self.track_length * self.toggle_position 188 | ) 189 | 190 | if self.pulse_anim.state() == QPropertyAnimation.Running: 191 | painter.setBrush( 192 | self._pulse_checked_color 193 | if self.isChecked() and self.isEnabled() 194 | else self._pulse_unchecked_color 195 | ) 196 | painter.drawEllipse( 197 | QPointF(x_pos, track_rect.center().y()), 198 | self._pulse_radius, 199 | self._pulse_radius, 200 | ) 201 | 202 | if self.isChecked() and self.isEnabled(): 203 | painter.setBrush(self._bar_checked_brush) 204 | painter.drawRoundedRect(track_rect, rounding, rounding) 205 | painter.setBrush(self._toggle_checked_brush) 206 | 207 | else: 208 | painter.setBrush(self._bar_unchecked_brush) 209 | painter.drawRoundedRect(track_rect, rounding, rounding) 210 | painter.setPen(self._LIGHT_GRAY_PEN) 211 | painter.setBrush(self._toggle_unchecked_brush) 212 | 213 | painter.drawEllipse( 214 | QPointF(x_pos, track_rect.center().y()), 215 | self.toggle_radius, 216 | self.toggle_radius, 217 | ) 218 | painter.end() 219 | 220 | @property 221 | def container_rect(self): 222 | """Return rect that includes toggle and track.""" 223 | rect = self.contentsRect() 224 | rect.setWidth(self._TOGGLE_SIZE) 225 | return rect 226 | 227 | @property 228 | def track_length(self): 229 | """Return the length of the track.""" 230 | return self.container_rect.width() - 2 * self.toggle_radius 231 | 232 | @property 233 | def toggle_radius(self): 234 | """Return the toggle radius size.""" 235 | return round(0.24 * self.container_rect.height()) 236 | 237 | @Property(float) 238 | def toggle_position(self): 239 | """Return toggle position.""" 240 | return self._toggle_position 241 | 242 | @toggle_position.setter 243 | def toggle_position(self, pos): 244 | self._toggle_position = pos 245 | self.update() 246 | 247 | @Property(float) 248 | def pulse_radius(self): 249 | """Return pulse radius.""" 250 | return self._pulse_radius 251 | 252 | @pulse_radius.setter 253 | def pulse_radius(self, pos): 254 | self._pulse_radius = pos 255 | self.update() 256 | 257 | 258 | class LabeledWidget(QWidget): 259 | """Widget with Label.""" 260 | 261 | def __init__(self, text: str, widget: QWidget, *args, **kwargs): 262 | super().__init__(*args, **kwargs) 263 | self._widget = widget 264 | self.setLayout(QHBoxLayout()) 265 | self.layout().addWidget(QLabel(text, self), alignment=Qt.AlignLeft) 266 | self.layout().addWidget(widget, stretch=1, alignment=Qt.AlignLeft) 267 | 268 | def widget(self) -> QWidget: 269 | """Return Widget.""" 270 | return self._widget 271 | -------------------------------------------------------------------------------- /pyremoteplay/gui/workers.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=c-extension-no-member,invalid-name 2 | """Workers for GUI.""" 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | import sys 8 | import time 9 | 10 | 11 | from PySide6 import QtCore 12 | from pyremoteplay.device import RPDevice 13 | from pyremoteplay.tracker import DeviceTracker 14 | from pyremoteplay.ddp import async_get_status 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class RPWorker(QtCore.QObject): 20 | """Worker to interface with RP Session.""" 21 | 22 | finished = QtCore.Signal() 23 | started = QtCore.Signal() 24 | standby_done = QtCore.Signal(str) 25 | 26 | def __init__(self): 27 | super().__init__() 28 | self._loop = None 29 | 30 | def setLoop(self, loop: asyncio.AbstractEventLoop): 31 | """Set Loop.""" 32 | self._loop = loop 33 | 34 | def run(self, device: RPDevice): 35 | """Run Session.""" 36 | if not device: 37 | _LOGGER.warning("No Device") 38 | self.stop() 39 | return 40 | if not device.session: 41 | _LOGGER.warning("No Session") 42 | self.stop() 43 | return 44 | device.session.events.on("stop", self.stop) 45 | self._loop.create_task(self.start(device)) 46 | 47 | def stop(self): 48 | """Stop session.""" 49 | self.finished.emit() 50 | 51 | async def start(self, device: RPDevice): 52 | """Start Session.""" 53 | _LOGGER.debug("Session Start") 54 | started = await device.connect() 55 | 56 | if not started: 57 | _LOGGER.warning("Session Failed to Start") 58 | self.stop() 59 | return 60 | 61 | device.controller.start() 62 | self.started.emit() 63 | 64 | if device.session.stop_event: 65 | await device.session.stop_event.wait() 66 | _LOGGER.info("Session Finished") 67 | 68 | def send_stick( 69 | self, 70 | device: RPDevice, 71 | stick: str, 72 | point: QtCore.QPointF, 73 | ): 74 | """Send stick state""" 75 | if not device or not device.controller: 76 | return 77 | device.controller.stick(stick, point=(point.x(), point.y())) 78 | 79 | def send_button(self, device: RPDevice, button, action): 80 | """Send button.""" 81 | if not device or not device.controller: 82 | return 83 | device.controller.button(button, action) 84 | 85 | async def standby(self, device: RPDevice, user: str): 86 | """Place Device in standby.""" 87 | await device.standby(user) 88 | self.standby_done.emit(device.session.error) 89 | 90 | @property 91 | def loop(self) -> asyncio.AbstractEventLoop: 92 | """Return Loop.""" 93 | return self._loop 94 | 95 | 96 | class AsyncHandler(QtCore.QObject): 97 | """Handler for async methods.""" 98 | 99 | status_updated = QtCore.Signal() 100 | manual_search_done = QtCore.Signal(str, dict) 101 | 102 | def __init__(self): 103 | super().__init__() 104 | self.loop = None 105 | self.tracker = None 106 | self.rp_worker = RPWorker() 107 | self.__task = None 108 | self._thread = QtCore.QThread() 109 | 110 | self.moveToThread(self._thread) 111 | self.rp_worker.moveToThread(self._thread) 112 | self._thread.started.connect(self.start) 113 | self._thread.start() 114 | 115 | def start(self): 116 | """Start and run polling.""" 117 | if sys.platform == "win32": 118 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 119 | else: 120 | try: 121 | import uvloop 122 | 123 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 124 | _LOGGER.info("Using uvloop") 125 | except ModuleNotFoundError: 126 | pass 127 | 128 | self.loop = asyncio.new_event_loop() 129 | self.rp_worker.setLoop(self.loop) 130 | self.__task = self.loop.create_task(self.run()) 131 | try: 132 | self.loop.run_until_complete(self.__task) 133 | except asyncio.CancelledError: 134 | pass 135 | 136 | def poll(self): 137 | """Start polling.""" 138 | if self.tracker: 139 | self.tracker.start() 140 | 141 | def stop_poll(self): 142 | """Stop Polling.""" 143 | if self.tracker: 144 | self.tracker.stop() 145 | 146 | def shutdown(self): 147 | """Shutdown handler.""" 148 | self.stop_poll() 149 | self.tracker.shutdown() 150 | _LOGGER.debug("Shutting down async event loop") 151 | start = time.time() 152 | while self.loop.is_running(): 153 | if time.time() - start > 5: 154 | break 155 | _LOGGER.debug("Loop stopped") 156 | self._thread.quit() 157 | 158 | async def run(self): 159 | """Start poll service.""" 160 | self.tracker = DeviceTracker( 161 | default_callback=self.status_updated.emit, directed=True 162 | ) 163 | await self.tracker.run() 164 | 165 | async def _manual_search(self, host: str): 166 | """Search for device.""" 167 | _LOGGER.info("Manual Search: %s", host) 168 | status = await async_get_status(host) 169 | self.manual_search_done.emit(host, status) 170 | 171 | def manual_search(self, host: str): 172 | """Search for device.""" 173 | self.run_coro(self._manual_search, host) 174 | 175 | def run_coro(self, coro, *args, **kwargs): 176 | """Run coroutine.""" 177 | asyncio.run_coroutine_threadsafe(coro(*args, **kwargs), self.loop) 178 | 179 | def standby(self, device: RPDevice, user: str): 180 | """Place host in standby.""" 181 | self.run_coro(self.rp_worker.standby, device, user) 182 | -------------------------------------------------------------------------------- /pyremoteplay/oauth.py: -------------------------------------------------------------------------------- 1 | """OAuth methods for getting PSN credentials.""" 2 | from __future__ import annotations 3 | import base64 4 | import logging 5 | from urllib.parse import parse_qs, urlparse 6 | from typing import TYPE_CHECKING 7 | 8 | import requests 9 | import aiohttp 10 | from Cryptodome.Hash import SHA256 11 | 12 | if TYPE_CHECKING: 13 | from .profile import UserProfile 14 | 15 | __CLIENT_ID = "ba495a24-818c-472b-b12d-ff231c1b5745" 16 | __CLIENT_SECRET = "bXZhaVprUnNBc0kxSUJrWQ==" 17 | 18 | __REDIRECT_URL = "https://remoteplay.dl.playstation.net/remoteplay/redirect" 19 | __LOGIN_URL = ( 20 | "https://auth.api.sonyentertainmentnetwork.com/" 21 | "2.0/oauth/authorize" 22 | "?service_entity=urn:service-entity:psn" 23 | f"&response_type=code&client_id={__CLIENT_ID}" 24 | f"&redirect_uri={__REDIRECT_URL}" 25 | "&scope=psn:clientapp" 26 | "&request_locale=en_US&ui=pr" 27 | "&service_logo=ps" 28 | "&layout_type=popup" 29 | "&smcid=remoteplay" 30 | "&prompt=always" 31 | "&PlatformPrivacyWs1=minimal" 32 | "&no_captcha=true&" 33 | ) 34 | 35 | __TOKEN_URL = "https://auth.api.sonyentertainmentnetwork.com/2.0/oauth/token" 36 | __TOKEN_BODY = ( 37 | "grant_type=authorization_code" "&code={}" f"&redirect_uri={__REDIRECT_URL}&" 38 | ) 39 | __HEADERS = {"Content-Type": "application/x-www-form-urlencoded"} 40 | 41 | _LOGGER = logging.getLogger(__name__) 42 | 43 | 44 | def get_login_url() -> str: 45 | """Return Login Url.""" 46 | return __LOGIN_URL 47 | 48 | 49 | def get_user_account(redirect_url: str) -> dict: 50 | """Return user account. 51 | 52 | Account should be formatted with \ 53 | :meth:`format_user_account() ` before use. 54 | 55 | :param redirect_url: Redirect url found after logging in 56 | """ 57 | code = _parse_redirect_url(redirect_url) 58 | if code is None: 59 | return None 60 | token = _get_token(code) 61 | if token is None: 62 | return None 63 | account = _fetch_account_info(token) 64 | return account 65 | 66 | 67 | async def async_get_user_account(redirect_url: str) -> dict: 68 | """Return user account. Async. 69 | 70 | Account should be formatted with \ 71 | :meth:`format_user_account() ` before use. 72 | 73 | :param redirect_url: Redirect url found after logging in 74 | """ 75 | code = _parse_redirect_url(redirect_url) 76 | if code is None: 77 | return None 78 | token = await _async_get_token(code) 79 | if token is None: 80 | return None 81 | account = await _async_fetch_account_info(token) 82 | return account 83 | 84 | 85 | def _get_token(code: str) -> str: 86 | _LOGGER.debug("Sending POST request") 87 | body = __TOKEN_BODY.format(code).encode("ascii") 88 | resp = requests.post( 89 | __TOKEN_URL, 90 | headers=__HEADERS, 91 | data=body, 92 | auth=(__CLIENT_ID, base64.b64decode(__CLIENT_SECRET.encode()).decode()), 93 | timeout=3, 94 | ) 95 | if resp.status_code == 200: 96 | content = resp.json() 97 | token = content.get("access_token") 98 | return token 99 | _LOGGER.error("Error getting token. Got response: %s", resp.status_code) 100 | return None 101 | 102 | 103 | async def _async_get_token(code: str) -> str: 104 | _LOGGER.debug("Sending POST request") 105 | auth = aiohttp.BasicAuth( 106 | __CLIENT_ID, password=base64.b64decode(__CLIENT_SECRET.encode()).decode() 107 | ) 108 | body = __TOKEN_BODY.format(code).encode("ascii") 109 | async with aiohttp.ClientSession() as session: 110 | async with session.post( 111 | url=__TOKEN_URL, auth=auth, headers=__HEADERS, data=body, timeout=3 112 | ) as resp: 113 | if resp.status == 200: 114 | content = await resp.json() 115 | token = content.get("access_token") 116 | return token 117 | _LOGGER.error("Error getting token. Got response: %s", resp.status) 118 | await resp.release() 119 | return None 120 | 121 | 122 | def _fetch_account_info(token: str) -> dict: 123 | resp = requests.get( 124 | f"{__TOKEN_URL}/{token}", 125 | headers=__HEADERS, 126 | auth=(__CLIENT_ID, base64.b64decode(__CLIENT_SECRET.encode()).decode()), 127 | timeout=3, 128 | ) 129 | if resp.status_code == 200: 130 | account_info = resp.json() 131 | account_info = _format_account_info(account_info) 132 | return account_info 133 | _LOGGER.error("Error getting account. Got response: %s", resp.status_code) 134 | return None 135 | 136 | 137 | async def _async_fetch_account_info(token: str) -> dict: 138 | auth = aiohttp.BasicAuth( 139 | __CLIENT_ID, password=base64.b64decode(__CLIENT_SECRET.encode()).decode() 140 | ) 141 | async with aiohttp.ClientSession() as session: 142 | async with session.get( 143 | url=f"{__TOKEN_URL}/{token}", auth=auth, timeout=3 144 | ) as resp: 145 | if resp.status == 200: 146 | account_info = await resp.json() 147 | account_info = _format_account_info(account_info) 148 | return account_info 149 | _LOGGER.error("Error getting account. Got response: %s", resp.status) 150 | await resp.release() 151 | return None 152 | 153 | 154 | def _format_account_info(account_info: dict) -> dict: 155 | user_id = account_info["user_id"] 156 | user_b64 = _format_user_id(user_id, "base64") 157 | user_creds = _format_user_id(user_id, "sha256") 158 | account_info["user_rpid"] = user_b64 159 | account_info["credentials"] = user_creds 160 | return account_info 161 | 162 | 163 | def _parse_redirect_url(redirect_url): 164 | if not redirect_url.startswith(__REDIRECT_URL): 165 | _LOGGER.error("Redirect URL does not start with %s", __REDIRECT_URL) 166 | return None 167 | code_url = urlparse(redirect_url) 168 | query = parse_qs(code_url.query) 169 | code = query.get("code") 170 | if code is None: 171 | _LOGGER.error("Code not in query") 172 | return None 173 | code = code[0] 174 | if len(code) <= 1: 175 | _LOGGER.error("Code is too short") 176 | return None 177 | _LOGGER.debug("Got Auth Code: %s", code) 178 | return code 179 | 180 | 181 | def _format_user_id(user_id: str, encoding="base64"): 182 | """Format user id into useable encoding.""" 183 | valid_encodings = {"base64", "sha256"} 184 | if encoding not in valid_encodings: 185 | raise TypeError(f"{encoding} encoding is not valid. Use {valid_encodings}") 186 | 187 | if user_id is not None: 188 | if encoding == "sha256": 189 | user_id = SHA256.new(user_id.encode()) 190 | user_id = user_id.digest().hex() 191 | elif encoding == "base64": 192 | user_id = base64.b64encode(int(user_id).to_bytes(8, "little")).decode() 193 | return user_id 194 | 195 | 196 | def prompt() -> dict: 197 | """Prompt for input and return account info.""" 198 | msg = ( 199 | "\r\n\r\nGo to the url below in a web browser, " 200 | "log into your PSN Account, " 201 | "then copy and paste the URL of the page that shows 'redirect'." 202 | f"\r\n\r\n{__LOGIN_URL} \r\n\r\nEnter Redirect URL >" 203 | ) 204 | 205 | redirect_url = input(msg) 206 | if redirect_url is not None: 207 | account_info = get_user_account(redirect_url) 208 | return account_info 209 | return None 210 | -------------------------------------------------------------------------------- /pyremoteplay/profile.py: -------------------------------------------------------------------------------- 1 | """Collections for User Profiles. 2 | 3 | These classes shouldn't be created manually. 4 | Use the helper methods such as: 5 | :meth:`pyremoteplay.profile.Profiles.load() ` 6 | and 7 | :meth:`pyremoteplay.device.RPDevice.get_profiles() ` 8 | 9 | """ 10 | from __future__ import annotations 11 | from collections import UserDict 12 | from typing import Union 13 | import logging 14 | 15 | from pyremoteplay.oauth import get_user_account 16 | from .util import get_profiles, write_profiles, get_users, add_regist_data 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | def format_user_account(user_data: dict) -> UserProfile: 22 | """Format account data to user profile. Return user profile. 23 | 24 | :param user_data: User data. \ 25 | See :meth:`pyremoteplay.oauth.get_user_account() ` 26 | """ 27 | user_id = user_data.get("user_rpid") 28 | if not isinstance(user_id, str) and not user_id: 29 | _LOGGER.error("Invalid user id or user id not found") 30 | return None 31 | name = user_data["online_id"] 32 | data = { 33 | "id": user_id, 34 | "hosts": {}, 35 | } 36 | return UserProfile(name, data) 37 | 38 | 39 | class HostProfile(UserDict): 40 | """Host Profile for User.""" 41 | 42 | def __init__(self, name: str, data: dict): 43 | if not name or not isinstance(name, str): 44 | raise ValueError("Name must be a non-blank string") 45 | self.__name = name 46 | self.__type = data["type"] 47 | super().__init__(data["data"]) 48 | self._verify() 49 | 50 | def _verify(self): 51 | assert self.name, "Attribute 'name' cannot be empty" 52 | assert self.regist_key, "Attribute 'regist_key' cannot be empty" 53 | assert self.rp_key, "Attribute 'rp_key' cannot be empty" 54 | 55 | @property 56 | def name(self) -> str: 57 | """Return Name / Mac Address.""" 58 | return self.__name 59 | 60 | @property 61 | def type(self) -> str: 62 | """Return type.""" 63 | return self.__type 64 | 65 | @property 66 | def regist_key(self) -> str: 67 | """Return Regist Key.""" 68 | return self.data["RegistKey"] 69 | 70 | @property 71 | def rp_key(self) -> str: 72 | """Return RP Key.""" 73 | return self.data["RP-Key"] 74 | 75 | 76 | class UserProfile(UserDict): 77 | """PSN User Profile. Stores Host Profiles for user.""" 78 | 79 | def __init__(self, name: str, data: dict): 80 | if not name or not isinstance(name, str): 81 | raise ValueError("Name must be a non-blank string") 82 | self.__name = name 83 | super().__init__(data) 84 | self._verify() 85 | 86 | def _verify(self): 87 | assert self.name, "Attribute 'name' cannot be empty" 88 | assert self.id, "Attribute 'id' cannot be empty" 89 | 90 | def update_host(self, host_profile: HostProfile): 91 | """Update host profile. 92 | 93 | :param: host_profile: Host Profile 94 | """ 95 | if not isinstance(host_profile, HostProfile): 96 | raise ValueError( 97 | f"Expected instance of {HostProfile}. Got {type(host_profile)}" 98 | ) 99 | # pylint: disable=protected-access 100 | host_profile._verify() 101 | self[host_profile.name] = host_profile.data 102 | 103 | def add_regist_data(self, host_status: dict, data: dict): 104 | """Add regist data to user profile. 105 | 106 | :param host_status: Status from device. \ 107 | See :meth:`pyremoteplay.device.RPDevice.get_status() \ 108 | ` 109 | :param data: Data from registering. \ 110 | See :func:`pyremoteplay.register.register() ` 111 | """ 112 | add_regist_data(self.data, host_status, data) 113 | 114 | @property 115 | def name(self) -> str: 116 | """Return PSN Username.""" 117 | return self.__name 118 | 119 | # pylint: disable=invalid-name 120 | @property 121 | def id(self) -> str: 122 | """Return Base64 encoded User ID.""" 123 | return self.data["id"] 124 | 125 | @property 126 | def hosts(self) -> list[HostProfile]: 127 | """Return Host profiles.""" 128 | hosts = self.data.get("hosts") 129 | if not hosts: 130 | return [] 131 | return [HostProfile(name, data) for name, data in hosts.items()] 132 | 133 | 134 | class Profiles(UserDict): 135 | """Collection of User Profiles.""" 136 | 137 | __DEFAULT_PATH: str = "" 138 | 139 | @classmethod 140 | def set_default_path(cls, path: str): 141 | """Set default path for loading and saving. 142 | 143 | :param path: Path to file. 144 | """ 145 | cls.__DEFAULT_PATH = path 146 | 147 | @classmethod 148 | def default_path(cls) -> str: 149 | """Return default path.""" 150 | return cls.__DEFAULT_PATH 151 | 152 | @classmethod 153 | def load(cls, path: str = "") -> Profiles: 154 | """Load profiles from file. 155 | 156 | :param path: Path to file. 157 | If not given will use \ 158 | :meth:`default_path() `. 159 | File will be created automatically if it does not exist. 160 | """ 161 | path = cls.__DEFAULT_PATH if not path else path 162 | return cls(get_profiles(path)) 163 | 164 | def new_user(self, redirect_url: str, save=True) -> UserProfile: 165 | """Create New PSN user. 166 | 167 | See :func:`pyremoteplay.oauth.get_login_url() `. 168 | 169 | :param redirect_url: URL from signing in with PSN account at the login url 170 | :param save: Save profiles to file if True 171 | """ 172 | account_data = get_user_account(redirect_url) 173 | if not account_data: 174 | return None 175 | profile = format_user_account(account_data) 176 | if not profile: 177 | return None 178 | self.update_user(profile) 179 | if save: 180 | self.save() 181 | return profile 182 | 183 | def update_user(self, user_profile: UserProfile): 184 | """Update stored User Profile. 185 | 186 | :param user_profile: User Profile 187 | """ 188 | if not isinstance(user_profile, UserProfile): 189 | raise ValueError( 190 | f"Expected instance of {UserProfile}. Got {type(user_profile)}" 191 | ) 192 | # pylint: disable=protected-access 193 | user_profile._verify() 194 | self[user_profile.name] = user_profile.data 195 | 196 | def update_host(self, user_profile: UserProfile, host_profile: HostProfile): 197 | """Update host in User Profile. 198 | 199 | :param user_profile: User Profile 200 | :param host_profile: Host Profile 201 | """ 202 | user_profile.update_host(host_profile) 203 | self.update_user(user_profile) 204 | 205 | def remove_user(self, user: Union[str, UserProfile]): 206 | """Remove user. 207 | 208 | :param user: User profile or user name to remove 209 | """ 210 | if isinstance(user, UserProfile): 211 | user = user.name 212 | if user in self.data: 213 | self.data.pop(user) 214 | 215 | def save(self, path: str = ""): 216 | """Save profiles to file. 217 | 218 | :param path: Path to file. If not given will use default path. 219 | """ 220 | write_profiles(self.data, path) 221 | 222 | def get_users(self, device_id: str) -> list[str]: 223 | """Return all users that are registered with a device. 224 | 225 | :param device_id: Device ID / Device Mac Address 226 | """ 227 | return get_users(device_id, self) 228 | 229 | def get_user_profile(self, user: str) -> UserProfile: 230 | """Return User Profile for user. 231 | 232 | :param user: PSN ID / Username 233 | """ 234 | profile = None 235 | for _profile in self.users: 236 | if _profile.name == user: 237 | profile = _profile 238 | break 239 | return profile 240 | 241 | @property 242 | def usernames(self) -> list[str]: 243 | """Return list of user names.""" 244 | return [name for name in self.data] 245 | 246 | @property 247 | def users(self) -> list[UserProfile]: 248 | """Return User Profiles.""" 249 | return [UserProfile(name, data) for name, data in self.data.items()] 250 | -------------------------------------------------------------------------------- /pyremoteplay/protobuf.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=no-member 2 | """Protobuf methods.""" 3 | from __future__ import annotations 4 | import logging 5 | from typing import TYPE_CHECKING 6 | 7 | from google.protobuf.message import DecodeError 8 | from .takion_pb2 import SenkushaPayload, TakionMessage 9 | from .util import log_bytes 10 | 11 | if TYPE_CHECKING: 12 | from .stream import RPStream 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | class ProtoHandler: 18 | """Handler for Protobuf Messages.""" 19 | 20 | @staticmethod 21 | def message(): 22 | """Return New Protobuf message.""" 23 | msg = TakionMessage() 24 | msg.ClearField("type") 25 | return msg 26 | 27 | @staticmethod 28 | def get_payload_type(proto_msg) -> str: 29 | """Return Payload type.""" 30 | payload_type = proto_msg.type 31 | name = ( 32 | proto_msg.DESCRIPTOR.fields_by_name["type"] 33 | .enum_type.values_by_number[payload_type] 34 | .name 35 | ) 36 | return name 37 | 38 | @staticmethod 39 | def big_payload( 40 | client_version=7, 41 | session_key=b"", 42 | launch_spec=b"", 43 | encrypted_key=b"", 44 | ecdh_pub_key=None, 45 | ecdh_sig=None, 46 | ): 47 | """Big Payload.""" 48 | msg = ProtoHandler.message() 49 | msg.type = msg.PayloadType.BIG 50 | msg.big_payload.client_version = client_version 51 | msg.big_payload.session_key = session_key 52 | msg.big_payload.launch_spec = launch_spec 53 | msg.big_payload.encrypted_key = encrypted_key 54 | if ecdh_pub_key is not None: 55 | msg.big_payload.ecdh_pub_key = ecdh_pub_key 56 | if ecdh_sig is not None: 57 | msg.big_payload.ecdh_sig = ecdh_sig 58 | data = msg.SerializeToString() 59 | return data 60 | 61 | @staticmethod 62 | def corrupt_frame(start: int, end: int): 63 | """Notify of corrupt or missing frame.""" 64 | msg = ProtoHandler.message() 65 | msg.type = msg.PayloadType.CORRUPTFRAME 66 | msg.corrupt_payload.start = start 67 | msg.corrupt_payload.end = end 68 | data = msg.SerializeToString() 69 | return data 70 | 71 | @staticmethod 72 | def disconnect_payload(): 73 | """Disconnect Payload.""" 74 | reason = "Client Disconnecting".encode() 75 | msg = ProtoHandler.message() 76 | msg.type = msg.PayloadType.DISCONNECT 77 | msg.disconnect_payload.reason = reason 78 | data = msg.SerializeToString() 79 | return data 80 | 81 | @staticmethod 82 | def senkusha_echo(enable: bool): 83 | """Senkusha Echo Payload.""" 84 | msg = ProtoHandler.message() 85 | msg.type = msg.PayloadType.SENKUSHA 86 | msg.senkusha_payload.command = SenkushaPayload.Command.ECHO_COMMAND 87 | msg.senkusha_payload.echo_command.state = enable 88 | data = msg.SerializeToString() 89 | return data 90 | 91 | @staticmethod 92 | def senkusha_mtu(req_id: int, mtu_req: int, num: int): 93 | """Senkusha MTU Payload.""" 94 | msg = ProtoHandler.message() 95 | msg.type = msg.PayloadType.SENKUSHA 96 | msg.senkusha_payload.command = SenkushaPayload.Command.MTU_COMMAND 97 | msg.senkusha_payload.mtu_command.id = req_id 98 | msg.senkusha_payload.mtu_command.mtu_req = mtu_req 99 | msg.senkusha_payload.mtu_command.num = num 100 | data = msg.SerializeToString() 101 | return data 102 | 103 | @staticmethod 104 | def senkusha_mtu_client(state: bool, mtu_id: int, mtu_req: int, mtu_down: int): 105 | """Senkusha MTU Client Payload.""" 106 | msg = ProtoHandler.message() 107 | msg.type = msg.PayloadType.SENKUSHA 108 | msg.senkusha_payload.command = SenkushaPayload.Command.CLIENT_MTU_COMMAND 109 | msg.senkusha_payload.client_mtu_command.state = state 110 | msg.senkusha_payload.client_mtu_command.id = mtu_id 111 | msg.senkusha_payload.client_mtu_command.mtu_req = mtu_req 112 | msg.senkusha_payload.client_mtu_command.mtu_down = mtu_down 113 | data = msg.SerializeToString() 114 | return data 115 | 116 | def __init__(self, stream: RPStream): 117 | self._stream = stream 118 | self._recv_bang = False 119 | self._recv_info = False 120 | 121 | def _ack(self, msg, channel: int): 122 | chunk_flag = 1 123 | msg = msg.SerializeToString() 124 | # log_bytes("Proto Send", msg) 125 | self._stream.send_data(msg, chunk_flag, channel, proto=True) 126 | 127 | def _parse_streaminfo(self, msg, video_header: bytes): 128 | info = { 129 | "video_header": video_header, 130 | "audio_header": msg.audio_header, 131 | "start_timeout": msg.start_timeout, 132 | "afk_timeout": msg.afk_timeout, 133 | "afk_timeout_disconnect": msg.afk_timeout_disconnect, 134 | "congestion_control_interval": msg.congestion_control_interval, 135 | } 136 | self._stream.recv_stream_info(info) 137 | 138 | def handle(self, data: bytes): 139 | """Handle message.""" 140 | msg = ProtoHandler.message() 141 | try: 142 | msg.ParseFromString(data) 143 | except (DecodeError, RuntimeWarning) as error: 144 | log_bytes(f"Protobuf Error: {error}", data) 145 | return 146 | p_type = ProtoHandler.get_payload_type(msg) 147 | _LOGGER.debug("RECV Payload Type: %s", p_type) 148 | 149 | if p_type == "STREAMINFO": 150 | if not self._recv_info: 151 | self._recv_info = True 152 | res = msg.stream_info_payload.resolution[0] 153 | v_header = res.video_header 154 | _LOGGER.debug("RECV Stream Info") 155 | self._parse_streaminfo(msg.stream_info_payload, v_header) 156 | channel = 9 157 | msg = ProtoHandler.message() 158 | msg.type = msg.PayloadType.STREAMINFOACK 159 | self._ack(msg, channel) 160 | 161 | elif p_type == "BANG" and not self._recv_bang: 162 | _LOGGER.debug("RECV Bang") 163 | ecdh_pub_key = b"" 164 | ecdh_sig = b"" 165 | accepted = True 166 | if not msg.bang_payload.version_accepted: 167 | _LOGGER.error("Version not accepted") 168 | accepted = False 169 | if not msg.bang_payload.encrypted_key_accepted: 170 | _LOGGER.error("Enrypted Key not accepted") 171 | accepted = False 172 | if accepted: 173 | ecdh_pub_key = msg.bang_payload.ecdh_pub_key 174 | ecdh_sig = msg.bang_payload.ecdh_sig 175 | self._recv_bang = True 176 | self._stream.recv_bang(accepted, ecdh_pub_key, ecdh_sig) 177 | 178 | elif p_type == "BIG": 179 | _LOGGER.debug("RECV BIG") 180 | return 181 | 182 | elif p_type == "HEARTBEAT": 183 | channel = 1 184 | msg = ProtoHandler.message() 185 | msg.type = msg.PayloadType.HEARTBEAT 186 | _LOGGER.debug("Sent HEARTBEAT ACK") 187 | self._ack(msg, channel) 188 | 189 | elif p_type == "DISCONNECT": 190 | _LOGGER.info("Host Disconnected; Reason: %s", msg.disconnect_payload.reason) 191 | # pylint: disable=protected-access 192 | self._stream._session.disconnect_reason = msg.disconnect_payload.reason 193 | self._stream.stop_event.set() 194 | 195 | # Test Packets 196 | elif p_type == "SENKUSHA": 197 | if self._stream.is_test and self._stream.test: 198 | mtu_req = msg.senkusha_payload.mtu_command.mtu_req 199 | mtu_sent = msg.senkusha_payload.mtu_command.mtu_sent 200 | self._stream.test.recv_mtu_in(mtu_req, mtu_sent) 201 | else: 202 | _LOGGER.info("RECV Unhandled Payload Type: %s", p_type) 203 | -------------------------------------------------------------------------------- /pyremoteplay/register.py: -------------------------------------------------------------------------------- 1 | """Register methods for pyremoteplay.""" 2 | import logging 3 | import socket 4 | 5 | from Cryptodome.Random import get_random_bytes 6 | 7 | from .const import ( 8 | RP_PORT, 9 | RP_VERSION_PS4, 10 | RP_VERSION_PS5, 11 | TYPE_PS4, 12 | TYPE_PS5, 13 | USER_AGENT, 14 | ) 15 | from .crypt import SessionCipher 16 | from .ddp import get_host_type, get_status, async_get_status 17 | from .keys import REG_KEY_0_PS4, REG_KEY_1_PS4, REG_KEY_0_PS5, REG_KEY_1_PS5 18 | from .util import log_bytes 19 | from .const import UDP_IP 20 | from .socket import AsyncUDPSocket, AsyncTCPSocket 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | CLIENT_TYPE = "dabfa2ec873de5839bee8d3f4c0239c4282c07c25c6077a2931afcf0adc0d34f" 25 | REG_PATH_PS4 = "/sie/ps4/rp/sess/rgst" 26 | REG_PATH_PS5 = "/sie/ps5/rp/sess/rgst" 27 | REG_INIT_PS4 = b"SRC2" 28 | REG_START_PS4 = b"RES2" 29 | REG_INIT_PS5 = b"SRC3" 30 | REG_START_PS5 = b"RES3" 31 | 32 | HOST_TYPES = { 33 | TYPE_PS4: { 34 | "init": REG_INIT_PS4, 35 | "start": REG_START_PS4, 36 | "path": REG_PATH_PS4, 37 | "version": RP_VERSION_PS4, 38 | "reg_key_0": REG_KEY_0_PS4, 39 | "reg_key_1": REG_KEY_1_PS4, 40 | }, 41 | TYPE_PS5: { 42 | "init": REG_INIT_PS5, 43 | "start": REG_START_PS5, 44 | "path": REG_PATH_PS5, 45 | "version": RP_VERSION_PS5, 46 | "reg_key_0": REG_KEY_0_PS5, 47 | "reg_key_1": REG_KEY_1_PS5, 48 | }, 49 | } 50 | 51 | REG_DATA = bytearray(b"A" * 480) 52 | REG_KEY_SIZE = 16 53 | 54 | 55 | def _gen_key_0(host_type: str, pin: int) -> bytes: 56 | """Generate key from Key 0.""" 57 | reg_key = HOST_TYPES[host_type]["reg_key_0"] 58 | key = bytearray(REG_KEY_SIZE) 59 | for index in range(0, REG_KEY_SIZE): 60 | key[index] = reg_key[index * 32 + 1] 61 | # Encode PIN into last 4 bytes 62 | shift = 0 63 | for index in range(12, REG_KEY_SIZE): 64 | key[index] ^= pin >> (24 - (shift * 8)) & 255 65 | shift += 1 66 | log_bytes("Key 0", key) 67 | return bytes(key) 68 | 69 | 70 | def _gen_key_1(host_type: str, nonce: bytes) -> bytes: 71 | """Generate key from Key 1.""" 72 | reg_key = HOST_TYPES[host_type]["reg_key_1"] 73 | key = bytearray(REG_KEY_SIZE) 74 | nonce = bytearray(nonce) 75 | offset = -45 if host_type == TYPE_PS5 else 41 76 | for index in range(0, REG_KEY_SIZE): 77 | shift = reg_key[index * 32 + 8] 78 | key[index] = ((nonce[index] ^ shift) + offset + index) % 256 79 | log_bytes("Key 1", key) 80 | return bytes(key) 81 | 82 | 83 | def _get_regist_payload(key_1: bytes) -> bytes: 84 | """Return regist payload.""" 85 | payload = b"".join( 86 | [ 87 | bytes(REG_DATA[0:199]), 88 | key_1[8:], 89 | bytes(REG_DATA[207:401]), 90 | key_1[0:8], 91 | bytes(REG_DATA[409:]), 92 | ] 93 | ) 94 | log_bytes("Payload", payload) 95 | return payload 96 | 97 | 98 | def _encrypt_payload(cipher, psn_id: str) -> bytes: 99 | """Return Encrypted Register Payload.""" 100 | payload = (f"Client-Type: {CLIENT_TYPE}\r\n" f"Np-AccountId: {psn_id}\r\n").encode( 101 | "utf-8" 102 | ) 103 | log_bytes("Enc Payload", payload) 104 | enc_payload = cipher.encrypt(payload) 105 | return enc_payload 106 | 107 | 108 | def _get_regist_headers(host_type: str, payload_length: int) -> bytes: 109 | """Get regist headers.""" 110 | path = HOST_TYPES[host_type]["path"] 111 | version = HOST_TYPES[host_type]["version"] 112 | headers = ( 113 | # Appears to use a malformed http request so have to construct it 114 | f"POST {path} HTTP/1.1\r\n HTTP/1.1\r\n" 115 | "HOST: 10.0.2.15\r\n" # Doesn't Matter 116 | f"User-Agent: {USER_AGENT}\r\n" 117 | "Connection: close\r\n" 118 | f"Content-Length: {payload_length}\r\n" 119 | f"RP-Version: {version}\r\n\r\n" 120 | ) 121 | headers = headers.encode("utf-8") 122 | log_bytes("Regist Headers", headers) 123 | return headers 124 | 125 | 126 | def _get_host_type_data(host_type: str) -> dict: 127 | data = HOST_TYPES.get(host_type) 128 | if not data: 129 | _LOGGER.error("Invalid host_type: %s", host_type) 130 | return data 131 | 132 | 133 | def _check_init(data: dict, response: bytes) -> bool: 134 | if not response: 135 | _LOGGER.error( 136 | "Device not in Register Mode;\nGo to Settings -> " 137 | "Remote Play Connection Settings -> Add Device\n" 138 | ) 139 | else: 140 | if bytearray(response)[0:4] == data["start"]: 141 | _LOGGER.info("Register Started") 142 | return True 143 | else: 144 | _LOGGER.error("Unknown Register response") 145 | return False 146 | 147 | 148 | def _regist_init(host: str, host_type: str, timeout: float) -> bool: 149 | """Check if device is accepting registrations.""" 150 | success = False 151 | response = None 152 | data = _get_host_type_data(host_type) 153 | if not data: 154 | return success 155 | 156 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 157 | sock.settimeout(timeout) 158 | sock.sendto(data["init"], (host, RP_PORT)) 159 | 160 | try: 161 | response = sock.recv(32) 162 | except socket.timeout: 163 | pass 164 | success = _check_init(data, response) 165 | sock.close() 166 | return success 167 | 168 | 169 | async def _async_regist_init(host: str, host_type: str, timeout: float) -> bool: 170 | """Check if device is accepting registrations.""" 171 | success = False 172 | response = None 173 | data = _get_host_type_data(host_type) 174 | if not data: 175 | return success 176 | 177 | sock = await AsyncUDPSocket.create( 178 | local_addr=(UDP_IP, 0), remote_addr=(host, RP_PORT) 179 | ) 180 | sock.sendto(data["init"], (host, RP_PORT)) 181 | response = await sock.recv(timeout=timeout) 182 | success = _check_init(data, response) 183 | sock.close() 184 | return success 185 | 186 | 187 | def _get_register_info( 188 | host: str, headers: bytes, payload: bytes, timeout: float 189 | ) -> bytes: 190 | """Send Register Packet and receive register info.""" 191 | response = None 192 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 193 | sock.settimeout(timeout) 194 | sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) 195 | sock.connect((host, RP_PORT)) 196 | sock.sendall(b"".join([headers, payload])) 197 | try: 198 | response = sock.recvfrom(1024) 199 | response = response[0] 200 | except socket.timeout: 201 | _LOGGER.error("No Register Response Received") 202 | finally: 203 | sock.close() 204 | return response 205 | 206 | 207 | async def _async_get_register_info( 208 | host: str, headers: bytes, payload: bytes, timeout: float 209 | ) -> bytes: 210 | """Send Register Packet and receive register info.""" 211 | response = None 212 | sock = await AsyncTCPSocket.create(remote_addr=(host, RP_PORT)) 213 | sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) 214 | sock.send(b"".join([headers, payload])) 215 | response = await sock.recv(timeout) 216 | if not response: 217 | _LOGGER.error("No Register Response Received") 218 | sock.close() 219 | return response 220 | 221 | 222 | def _parse_response(cipher, response: bytes) -> dict: 223 | """Parse Register Response.""" 224 | info = {} 225 | response = response.split(b"\r\n") 226 | if b"200 OK" in response[0]: 227 | _LOGGER.info("Registered successfully") 228 | cipher_text = response[-1] 229 | data = cipher.decrypt(cipher_text).decode() 230 | data = data.split("\r\n") 231 | for item in data: 232 | if item == "": 233 | continue 234 | item = item.split(": ") 235 | info[item[0]] = item[1] 236 | else: 237 | _LOGGER.error("Failed to register, Status: %s", response[0]) 238 | _LOGGER.debug("Register Info: %s", info) 239 | return info 240 | 241 | 242 | def _get_regist_cipher_headers_payload(host_type: str, psn_id: str, pin: str): 243 | nonce = get_random_bytes(16) 244 | key_0 = _gen_key_0(host_type, int(pin)) 245 | key_1 = _gen_key_1(host_type, nonce) 246 | payload = _get_regist_payload(key_1) 247 | cipher = SessionCipher(host_type, key_0, nonce, counter=0) 248 | enc_payload = _encrypt_payload(cipher, psn_id) 249 | payload = b"".join([payload, enc_payload]) 250 | headers = _get_regist_headers(host_type, len(payload)) 251 | return cipher, headers, payload 252 | 253 | 254 | def register(host: str, psn_id: str, pin: str, timeout: float = 2.0) -> dict: 255 | """Return Register info. 256 | Register this client and a PSN Account with a Remote Play Device. 257 | 258 | :param host: IP Address of Remote Play Device 259 | :param psn_id: Base64 encoded PSN ID from completing OAuth login 260 | :param pin: PIN for linking found on Remote Play Host 261 | :param timeout: Timeout to wait for completion 262 | """ 263 | info = {} 264 | status = get_status(host) 265 | if not status: 266 | _LOGGER.error("Host: %s not found", host) 267 | return info 268 | host_type = get_host_type(status).upper() 269 | if not _regist_init(host, host_type, timeout): 270 | return info 271 | 272 | cipher, headers, payload = _get_regist_cipher_headers_payload( 273 | host_type, psn_id, pin 274 | ) 275 | 276 | response = _get_register_info(host, headers, payload, timeout) 277 | if response is not None: 278 | info = _parse_response(cipher, response) 279 | return info 280 | 281 | 282 | async def async_register( 283 | host: str, psn_id: str, pin: str, timeout: float = 2.0 284 | ) -> dict: 285 | """Return Register info. 286 | Register this client and a PSN Account with a Remote Play Device. 287 | 288 | :param host: IP Address of Remote Play Device 289 | :param psn_id: Base64 encoded PSN ID from completing OAuth login 290 | :param pin: PIN for linking found on Remote Play Host 291 | :param timeout: Timeout to wait for completion 292 | """ 293 | info = {} 294 | status = await async_get_status(host) 295 | if not status: 296 | _LOGGER.error("Host: %s not found", host) 297 | return info 298 | host_type = get_host_type(status).upper() 299 | if not await _async_regist_init(host, host_type, timeout): 300 | return info 301 | cipher, headers, payload = _get_regist_cipher_headers_payload( 302 | host_type, psn_id, pin 303 | ) 304 | response = await _async_get_register_info(host, headers, payload, timeout) 305 | if response is not None: 306 | info = _parse_response(cipher, response) 307 | return info 308 | -------------------------------------------------------------------------------- /pyremoteplay/socket.py: -------------------------------------------------------------------------------- 1 | """Async UDP Sockets. Based on asyncudp (https://github.com/eerimoq/asyncudp).""" 2 | from __future__ import annotations 3 | import asyncio 4 | import logging 5 | from typing import Any, Optional, Callable, Union 6 | import socket 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | class AsyncBaseProtocol(asyncio.BaseProtocol): 12 | """Base Protocol. Do not use directly.""" 13 | 14 | def __del__(self): 15 | self.close() 16 | 17 | def __init__(self): 18 | super().__init__() 19 | self._packets = asyncio.Queue() 20 | self._transport = None 21 | self._callback = None 22 | 23 | def connection_made(self, transport: asyncio.BaseTransport): 24 | """Connection Made.""" 25 | self._transport = transport 26 | 27 | def connection_lost(self, exc: Exception): 28 | """Connection Lost.""" 29 | if exc: 30 | _LOGGER.error("Connection Lost: %s", exc) 31 | self.close() 32 | self._packets.put_nowait(None) 33 | 34 | def error_received(self, exc: Exception): 35 | """Error Received.""" 36 | _LOGGER.error("Socket at: %s received error: %s", self.sock.getsockname(), exc) 37 | 38 | async def recvfrom( 39 | self, timeout: float = None 40 | ) -> Optional[tuple[bytes, tuple[str, int]]]: 41 | """Return received data and addr.""" 42 | if self.has_callback: 43 | return None 44 | try: 45 | return await asyncio.wait_for(self._packets.get(), timeout=timeout) 46 | except (asyncio.TimeoutError, asyncio.CancelledError): 47 | pass 48 | return None 49 | 50 | async def recv(self, timeout: float = None) -> Optional[bytes]: 51 | """Return received data.""" 52 | if self.has_callback: 53 | return None 54 | response = await self.recvfrom(timeout=timeout) 55 | if response: 56 | return response[0] 57 | return None 58 | 59 | def sendto(self, data: bytes, *_): 60 | """Send packet.""" 61 | raise NotImplementedError 62 | 63 | def set_callback( 64 | self, callback: Union[Callable[[bytes, tuple[str, int]], None], None] 65 | ): 66 | """Set callback for data received. 67 | 68 | Setting this will flush packet received packet queue. 69 | :meth:`recv() ` 70 | will always return None. 71 | 72 | :param callback: callback for data received 73 | """ 74 | if callback is not None: 75 | if not isinstance(callback, Callable): 76 | raise TypeError(f"Expected callable. Got: {type(callback)}") 77 | self._packets = asyncio.Queue() 78 | self._callback = callback 79 | 80 | def close(self): 81 | """Close transport.""" 82 | if not self.closed: 83 | self._packets.put_nowait(None) 84 | try: 85 | self._transport.close() 86 | # pylint: disable=broad-except 87 | except Exception: 88 | self.sock.close() 89 | 90 | def get_extra_info(self, name: str, default: Any = None) -> Any: 91 | """Return Extra Info.""" 92 | return self._transport.get_extra_info(name, default) 93 | 94 | @property 95 | def has_callback(self): 96 | """Return True if callback is set.""" 97 | return self._callback is not None 98 | 99 | @property 100 | def opened(self) -> bool: 101 | """Return True if opened.""" 102 | return self._transport is not None 103 | 104 | @property 105 | def closed(self) -> bool: 106 | """Return True if closed.""" 107 | if not self._transport: 108 | return False 109 | return self._transport.is_closing() 110 | 111 | @property 112 | def sock(self) -> socket.socket: 113 | """Return sock.""" 114 | return self._transport.get_extra_info("socket") 115 | 116 | 117 | class AsyncTCPProtocol(asyncio.Protocol, AsyncBaseProtocol): 118 | """UDP Protocol.""" 119 | 120 | def connection_made(self, transport: asyncio.Transport): 121 | """Connection Made.""" 122 | self._transport = transport 123 | 124 | def data_received(self, data: bytes): 125 | peername = self._transport.get_extra_info("peername") 126 | item = (data, peername) 127 | if self.has_callback: 128 | self._callback(*item) 129 | else: 130 | self._packets.put_nowait(item) 131 | 132 | def sendto(self, data: bytes, *_): 133 | """Send packet to address.""" 134 | self._transport.write(data) 135 | 136 | 137 | class AsyncUDPProtocol(asyncio.DatagramProtocol, AsyncBaseProtocol): 138 | """UDP Protocol.""" 139 | 140 | def connection_made(self, transport: asyncio.DatagramTransport): 141 | """Connection Made.""" 142 | self._transport = transport 143 | 144 | def datagram_received(self, data: bytes, addr: tuple[str, int]): 145 | """Datagram Received.""" 146 | item = (data, addr) 147 | if self.has_callback: 148 | self._callback(*item) 149 | else: 150 | self._packets.put_nowait(item) 151 | 152 | # pylint: disable=arguments-differ 153 | def sendto(self, data: bytes, addr: tuple[str, int] = None): 154 | """Send packet to address.""" 155 | self._transport.sendto(data, addr) 156 | 157 | 158 | class AsyncBaseSocket: 159 | """Async Base socket. Do not use directly.""" 160 | 161 | @classmethod 162 | async def create( 163 | cls, 164 | local_addr: tuple[str, int] = None, 165 | remote_addr: tuple[str, int] = None, 166 | *, 167 | sock: socket.socket = None, 168 | **kwargs, 169 | ) -> AsyncBaseSocket: 170 | """Create and return Socket.""" 171 | raise NotImplementedError 172 | 173 | async def __aenter__(self): 174 | return self 175 | 176 | async def __aexit__(self, *exc_info): 177 | self.close() 178 | 179 | def __del__(self): 180 | self.close() 181 | 182 | def __init__(self, protocol: AsyncBaseProtocol, local_addr: tuple[str, int] = None): 183 | self._protocol = protocol 184 | self._ip_address = local_addr[0] if local_addr else None 185 | 186 | def close(self): 187 | """Close the socket.""" 188 | self._protocol.close() 189 | 190 | def sendto(self, data: bytes, addr: tuple[str, int] = None): 191 | """Send Packet""" 192 | self._protocol.sendto(data, addr) 193 | 194 | async def recv(self, timeout: float = None) -> Optional[bytes]: 195 | """Receive a packet.""" 196 | 197 | packet = await self._protocol.recv(timeout) 198 | 199 | if packet is None and self._protocol.closed: 200 | raise OSError("Socket is closed") 201 | 202 | return packet 203 | 204 | async def recvfrom( 205 | self, timeout: float = None 206 | ) -> Optional[tuple[bytes, tuple[str, int]]]: 207 | """Receive a packet and address.""" 208 | 209 | response = await self._protocol.recvfrom(timeout) 210 | 211 | if response is None and self._protocol.closed: 212 | raise OSError("Socket is closed") 213 | 214 | return response 215 | 216 | def get_extra_info(self, name: str, default: Any = None) -> Any: 217 | """Return Extra Info.""" 218 | return self._protocol.get_extra_info(name, default) 219 | 220 | def setsockopt(self, __level: int, __optname: int, __value: int | bytes, /): 221 | """Set Sock Opt.""" 222 | self.sock.setsockopt(__level, __optname, __value) 223 | 224 | def set_callback( 225 | self, callback: Union[Callable[[bytes, tuple[str, int]], None], None] 226 | ): 227 | """Set callback for data received. 228 | 229 | Setting this will flush packet received packet queue. 230 | :meth:`recv() ` 231 | will always return None. 232 | 233 | :param callback: callback for data received 234 | """ 235 | self._protocol.set_callback(callback) 236 | 237 | @property 238 | def opened(self) -> bool: 239 | """Return True if opened.""" 240 | return self._protocol.opened 241 | 242 | @property 243 | def closed(self) -> bool: 244 | """Return True if closed.""" 245 | return self._protocol.closed 246 | 247 | @property 248 | def sock(self) -> socket.socket: 249 | """Return socket.""" 250 | return self._protocol.sock 251 | 252 | @property 253 | def local_addr(self) -> tuple[str, int]: 254 | """Return local address.""" 255 | addr = self.sock.getsockname() 256 | if self._ip_address: 257 | return (self._ip_address, addr[1]) 258 | return addr 259 | 260 | 261 | class AsyncTCPSocket(AsyncBaseSocket): 262 | """Async TCP socket.""" 263 | 264 | @classmethod 265 | async def create( 266 | cls, 267 | local_addr: tuple[str, int] = None, 268 | remote_addr: tuple[str, int] = None, 269 | *, 270 | sock: socket.socket = None, 271 | **kwargs, 272 | ) -> AsyncTCPSocket: 273 | """Create and return Socket.""" 274 | _local_addr = local_addr 275 | host = port = None 276 | if sock is not None: 277 | local_addr = remote_addr = None 278 | if remote_addr: 279 | host, port = remote_addr 280 | 281 | if sock is None and not remote_addr: 282 | raise ValueError("Both sock and remote_addr cannot be None") 283 | loop = asyncio.get_running_loop() 284 | _, protocol = await loop.create_connection( 285 | AsyncTCPProtocol, 286 | host=host, 287 | port=port, 288 | sock=sock, 289 | local_addr=local_addr, 290 | **kwargs, 291 | ) 292 | 293 | return cls(protocol, _local_addr) 294 | 295 | def send(self, data: bytes): 296 | """Send Packet.""" 297 | self.sendto(data) 298 | 299 | 300 | class AsyncUDPSocket(AsyncBaseSocket): 301 | """Async UDP socket.""" 302 | 303 | @classmethod 304 | async def create( 305 | cls, 306 | local_addr: tuple[str, int] = None, 307 | remote_addr: tuple[str, int] = None, 308 | *, 309 | sock: socket.socket = None, 310 | reuse_port: bool = None, 311 | allow_broadcast: bool = None, 312 | **kwargs, 313 | ) -> AsyncUDPSocket: 314 | """Create and return UDP Socket.""" 315 | _local_addr = local_addr 316 | if sock is not None: 317 | local_addr = remote_addr = None 318 | loop = asyncio.get_running_loop() 319 | if not hasattr(socket, "SO_REUSEPORT"): 320 | reuse_port = None 321 | _, protocol = await loop.create_datagram_endpoint( 322 | AsyncUDPProtocol, 323 | local_addr=local_addr, 324 | remote_addr=remote_addr, 325 | reuse_port=reuse_port, 326 | allow_broadcast=allow_broadcast, 327 | sock=sock, 328 | **kwargs, 329 | ) 330 | 331 | return cls(protocol, _local_addr) 332 | 333 | def set_broadcast(self, enabled: bool): 334 | """Set Broadcast enabled.""" 335 | self._protocol.sock.setsockopt( 336 | socket.SOL_SOCKET, socket.SO_BROADCAST, int(enabled) 337 | ) 338 | -------------------------------------------------------------------------------- /pyremoteplay/tracker.py: -------------------------------------------------------------------------------- 1 | """Async Device Tracker.""" 2 | from __future__ import annotations 3 | import asyncio 4 | import logging 5 | import time 6 | from typing import Callable 7 | import sys 8 | 9 | from .const import ( 10 | DDP_PORTS, 11 | DEFAULT_UDP_PORT, 12 | BROADCAST_IP, 13 | DEFAULT_POLL_COUNT, 14 | DEFAULT_STANDBY_DELAY, 15 | ) 16 | from .device import RPDevice 17 | from .ddp import ( 18 | get_ddp_search_message, 19 | parse_ddp_response, 20 | async_get_sockets, 21 | async_send_msg, 22 | async_get_status, 23 | STATUS_OK, 24 | STATUS_STANDBY, 25 | ) 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | DEFAULT_PORT = DEFAULT_UDP_PORT + 1 30 | 31 | 32 | class DeviceTracker: 33 | """Async Device Tracker.""" 34 | 35 | def __init__( 36 | self, 37 | default_callback: Callable = None, 38 | max_polls: int = DEFAULT_POLL_COUNT, 39 | local_port: int = DEFAULT_PORT, 40 | directed: bool = False, 41 | ): 42 | super().__init__() 43 | self._socks = [] 44 | self._tasks = set() 45 | self._directed = directed 46 | self._devices_data = {} 47 | self._max_polls = max_polls 48 | self._local_port = local_port 49 | self._default_callback = default_callback 50 | self._message = get_ddp_search_message() 51 | self._event_stop = asyncio.Event() 52 | self._event_shutdown = asyncio.Event() 53 | self._event_shutdown.set() 54 | 55 | def __repr__(self): 56 | return ( 57 | f"<{self.__module__}.{self.__class__.__name__} " 58 | f"local_port={self.local_port} " 59 | f"max_polls={self._max_polls}>" 60 | ) 61 | 62 | def set_max_polls(self, poll_count: int): 63 | """Set number of unreturned polls neeeded to assume no status.""" 64 | self._max_polls = poll_count 65 | 66 | async def send_msg(self, device: RPDevice = None, message: str = ""): 67 | """Send Message.""" 68 | host = BROADCAST_IP 69 | host_type = "" 70 | if not message: 71 | message = self._message 72 | 73 | if device is not None: 74 | host = device.host 75 | host_type = device.host_type 76 | for sock in self._socks: 77 | async_send_msg(sock, host, message, host_type, directed=self._directed) 78 | if device: 79 | break 80 | 81 | def datagram_received(self, data: bytes, addr: tuple): 82 | """When data is received.""" 83 | if data is not None: 84 | self._handle(data, addr) 85 | 86 | def _handle_windows_get_status(self, future: asyncio.Future): 87 | """Custom handler for Windows.""" 88 | # TODO: Fix so that this isn't needed 89 | self._tasks.discard(future) 90 | status = future.result() 91 | if status: 92 | self._update_device(status) 93 | 94 | def _handle(self, data: bytes, addr: tuple): 95 | status = parse_ddp_response(data, addr[0]) 96 | if status: 97 | self._update_device(status) 98 | 99 | def _update_device(self, status: dict): 100 | address = status.get("host-ip") 101 | if not address: 102 | return 103 | if address in self._devices_data: 104 | device_data = self._devices_data[address] 105 | else: 106 | device_data = self.add_device(address, discovered=True) 107 | 108 | device = device_data["device"] 109 | old_status = device.status 110 | # Status changed from OK to Standby/Turned Off 111 | if ( 112 | old_status is not None 113 | and old_status.get("status-code") == STATUS_OK 114 | and device.status.get("status-code") == STATUS_STANDBY 115 | ): 116 | device_data["standby_start"] = time.time() 117 | device_data["poll_count"] = 0 118 | device._set_status(status) # pylint: disable=protected-access 119 | 120 | def close(self): 121 | """Close all sockets.""" 122 | for sock in self._socks: 123 | sock.close() 124 | self._socks = [] 125 | 126 | def add_device( 127 | self, host: str, callback: Callable = None, discovered: bool = False 128 | ): 129 | """Add device to track.""" 130 | if host in self._devices_data: 131 | return None 132 | self._devices_data[host] = { 133 | "device": RPDevice(host), 134 | "discovered": discovered, 135 | "polls_disabled": False, 136 | "poll_count": 0, 137 | "standby_start": 0, 138 | } 139 | if callback is None and self._default_callback is not None: 140 | callback = self._default_callback 141 | self.add_callback(host, callback) 142 | return self._devices_data[host] 143 | 144 | def remove_device(self, host: str): 145 | """Remove device from tracking.""" 146 | if host in self.devices: 147 | self._devices_data.pop(host) 148 | 149 | def add_callback(self, host: str, callback: Callable): 150 | """Add callback. One per host.""" 151 | if host not in self._devices_data: 152 | return 153 | self._devices_data[host]["device"].set_callback(callback) 154 | 155 | def remove_callback(self, host: str): 156 | """Remove callback from list.""" 157 | if host not in self._devices_data: 158 | return 159 | self._devices_data[host]["device"].set_callback(None) 160 | 161 | async def _poll(self): 162 | await self.send_msg() 163 | for device_data in self._devices_data.values(): 164 | device = device_data["device"] 165 | # Device won't respond to polls right after standby 166 | if device_data["polls_disabled"]: 167 | elapsed = time.time() - device_data["standby_start"] 168 | seconds = DEFAULT_STANDBY_DELAY - elapsed 169 | if seconds > 0: 170 | _LOGGER.debug("Polls disabled for %s seconds", round(seconds, 2)) 171 | continue 172 | device_data["polls_disabled"] = False 173 | 174 | # Track polls that were never returned. 175 | device_data["poll_count"] += 1 176 | # Assume Device is not available. 177 | if device_data["poll_count"] > self._max_polls: 178 | device._set_status({}) # pylint: disable=protected-access 179 | device_data["poll_count"] = 0 180 | if device.callback: 181 | device.callback() # pylint: disable=not-callable 182 | if not device_data["discovered"]: 183 | # Explicitly poll device in case it cannot be reached by broadcast. 184 | if not device_data["polls_disabled"]: 185 | if sys.platform == "win32": 186 | # TODO: Hack to avoid Windows closing socket. 187 | task = asyncio.create_task( 188 | async_get_status(device.host, host_type=device.host_type) 189 | ) 190 | task.add_done_callback(self._handle_windows_get_status) 191 | self._tasks.add(task) 192 | else: 193 | await self.send_msg(device) 194 | 195 | async def _setup(self, port: int): 196 | """Setup Tracker.""" 197 | socks = await async_get_sockets(local_port=port, directed=self._directed) 198 | if not socks: 199 | raise RuntimeError("Could not get sockets") 200 | self._socks = socks 201 | for sock in self._socks: 202 | sock.set_callback(self.datagram_received) 203 | sock.set_broadcast(True) 204 | 205 | async def run(self, interval=1): 206 | """Run polling.""" 207 | if not self._event_shutdown.is_set(): 208 | return 209 | if not self._socks: 210 | await self._setup(self._local_port) 211 | self._event_shutdown.clear() 212 | self._event_stop.clear() 213 | await asyncio.sleep(1) # Wait for sockets to get setup 214 | while not self._event_shutdown.is_set(): 215 | if not self._event_stop.is_set(): 216 | await self._poll() 217 | await asyncio.sleep(interval) 218 | self.close() 219 | 220 | def shutdown(self): 221 | """Shutdown protocol.""" 222 | self._event_shutdown.set() 223 | 224 | def stop(self): 225 | """Stop Polling.""" 226 | self._event_stop.set() 227 | 228 | def start(self): 229 | """Start polling.""" 230 | self._event_stop.clear() 231 | 232 | @property 233 | def local_port(self) -> int: 234 | """Return local port.""" 235 | return self._local_port 236 | 237 | @property 238 | def remote_ports(self) -> dict: 239 | """Return remote ports.""" 240 | return DDP_PORTS 241 | 242 | @property 243 | def devices(self) -> dict: 244 | """Return devices that are tracked.""" 245 | return { 246 | ip_address: data["device"] 247 | for ip_address, data in self._devices_data.items() 248 | } 249 | 250 | @property 251 | def device_status(self) -> list: 252 | """Return all device status.""" 253 | return [device["device"].status for device in self._devices_data.values()] 254 | -------------------------------------------------------------------------------- /pyremoteplay/util.py: -------------------------------------------------------------------------------- 1 | """Utility Methods.""" 2 | from __future__ import annotations 3 | import inspect 4 | import json 5 | import logging 6 | import pathlib 7 | import select 8 | import time 9 | from binascii import hexlify 10 | 11 | from .const import CONTROLS_FILE, OPTIONS_FILE, PROFILE_DIR, PROFILE_FILE 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | def check_dir() -> pathlib.Path: 17 | """Return path. Check file dir and create dir if not exists.""" 18 | dir_path = pathlib.Path.home() / PROFILE_DIR 19 | if not dir_path.is_dir(): 20 | dir_path.mkdir(exist_ok=True) 21 | return dir_path 22 | 23 | 24 | def check_file(path: pathlib.Path): 25 | """Check if file exists and create.""" 26 | if not path.is_file(): 27 | with open(path, "w", encoding="utf-8") as _file: 28 | json.dump({}, _file) 29 | 30 | 31 | def get_mapping(path: str = "") -> dict: 32 | """Return dict of key mapping.""" 33 | data = {} 34 | if not path: 35 | dir_path = check_dir() 36 | path = dir_path / CONTROLS_FILE 37 | else: 38 | path = pathlib.Path(path) 39 | check_file(path) 40 | with open(path, "r", encoding="utf-8") as _file: 41 | data = json.load(_file) 42 | return data 43 | 44 | 45 | def write_mapping(mapping: dict, path: str = ""): 46 | """Write mapping.""" 47 | if not path: 48 | path = pathlib.Path.home() / PROFILE_DIR / CONTROLS_FILE 49 | else: 50 | path = pathlib.Path(path) 51 | with open(path, "w", encoding="utf-8") as _file: 52 | json.dump(mapping, _file, indent=2) 53 | 54 | 55 | def get_options(path: str = "") -> dict: 56 | """Return dict of options.""" 57 | data = {} 58 | if not path: 59 | dir_path = check_dir() 60 | path = dir_path / OPTIONS_FILE 61 | else: 62 | path = pathlib.Path(path) 63 | check_file(path) 64 | with open(path, "r", encoding="utf-8") as _file: 65 | data = json.load(_file) 66 | return data 67 | 68 | 69 | def write_options(options: dict, path: str = ""): 70 | """Write options.""" 71 | if not path: 72 | path = pathlib.Path.home() / PROFILE_DIR / OPTIONS_FILE 73 | else: 74 | path = pathlib.Path(path) 75 | with open(path, "w", encoding="utf-8") as _file: 76 | json.dump(options, _file) 77 | 78 | 79 | def get_profiles(path: str = "") -> dict: 80 | """Return Profiles.""" 81 | data = {} 82 | if not path: 83 | dir_path = check_dir() 84 | path = dir_path / PROFILE_FILE 85 | else: 86 | path = pathlib.Path(path) 87 | check_file(path) 88 | with open(path, "r", encoding="utf-8") as _file: 89 | try: 90 | data = json.load(_file) 91 | except json.JSONDecodeError: 92 | _LOGGER.error("Profiles file is corrupt: %s", path) 93 | return data 94 | 95 | 96 | def write_profiles(profiles: dict, path: str = ""): 97 | """Write profile data.""" 98 | if not path: 99 | path = pathlib.Path.home() / PROFILE_DIR / PROFILE_FILE 100 | else: 101 | path = pathlib.Path(path) 102 | with open(path, "w", encoding="utf-8") as _file: 103 | json.dump(profiles, _file) 104 | 105 | 106 | def get_users(device_id: str, profiles: dict = None, path: str = "") -> list[str]: 107 | """Return users for device.""" 108 | users = [] 109 | if not profiles: 110 | profiles = get_profiles(path) 111 | for user, data in profiles.items(): 112 | hosts = data.get("hosts") 113 | if not hosts: 114 | continue 115 | if hosts.get(device_id): 116 | users.append(user) 117 | return users 118 | 119 | 120 | def add_regist_data(profile: dict, host_status: dict, data: dict) -> dict: 121 | """Add regist data to profile and return profile.""" 122 | mac_address = host_status["host-id"] 123 | host_type = host_status["host-type"] 124 | for key in list(data.keys()): 125 | if key.startswith(host_type): 126 | value = data.pop(key) 127 | new_key = key.split("-")[1] 128 | data[new_key] = value 129 | profile["hosts"][mac_address] = {"data": data, "type": host_type} 130 | return profile 131 | 132 | 133 | def format_regist_key(regist_key: str) -> str: 134 | """Format Regist Key for wakeup.""" 135 | return str(int.from_bytes(bytes.fromhex(bytes.fromhex(regist_key).decode()), "big")) 136 | 137 | 138 | def log_bytes(name: str, data: bytes): 139 | """Log bytes.""" 140 | mod = inspect.getmodulename(inspect.stack()[1].filename) 141 | logging.getLogger(f"{__package__}.{mod}").debug( 142 | "Length: %s, %s: %s", len(data), name, hexlify(data) 143 | ) 144 | 145 | 146 | def from_b(_bytes: bytes, order="big") -> int: 147 | """Return int from hex bytes.""" 148 | return int.from_bytes(_bytes, order) 149 | 150 | 151 | def to_b(_int: int, length: int = 2, order="big") -> bytes: 152 | """Return hex bytes from int.""" 153 | return int.to_bytes(_int, length, order) 154 | 155 | 156 | def listener(name: str, sock, handle, stop_event): 157 | """Worker for socket.""" 158 | _LOGGER.debug("Thread Started: %s", name) 159 | stop_event.clear() 160 | while not stop_event.is_set(): 161 | available, _, _ = select.select([sock], [], [], 0.01) 162 | if sock in available: 163 | data = sock.recv(4096) 164 | # log_bytes(f"{name} RECV", data) 165 | if len(data) > 0: 166 | handle(data) 167 | else: 168 | stop_event.set() 169 | time.sleep(0.001) 170 | 171 | sock.close() 172 | _LOGGER.info("%s Stopped", name) 173 | 174 | 175 | def timeit(func): 176 | """Time Function.""" 177 | 178 | def inner(*args, **kwargs): 179 | start = time.time() 180 | result = func(*args, **kwargs) 181 | end = time.time() 182 | elapsed = round(end - start, 8) 183 | _LOGGER.info( 184 | "Timed %s.%s at %s seconds", func.__module__, func.__name__, elapsed 185 | ) 186 | return result 187 | 188 | return inner 189 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest>=6.2.4 2 | -------------------------------------------------------------------------------- /requirements-gui.txt: -------------------------------------------------------------------------------- 1 | pyside6>=6.2.0 2 | av>=8.0.0 3 | PyOpenGL>=3.1.5 4 | sounddevice>=0.4.4 5 | pyjerasure>=1.0.0 6 | pygame>=2.1.2 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | wheel 2 | pyps4-2ndscreen>=1.2.1 3 | cryptography>=3.4.6 4 | protobuf>=4.21.1 5 | requests>=2.25.1 6 | pyee>=8.1.0 7 | pyyaml>=6.0 8 | netifaces>=0.11.0 9 | -------------------------------------------------------------------------------- /script/protoc: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | SRC_FILE="takion.proto" 4 | SRC_DIR="$(pwd)/external" 5 | 6 | DST_DIR="$(pwd)/pyremoteplay" 7 | 8 | protoc -I=$SRC_DIR --python_out=$DST_DIR $SRC_DIR/$SRC_FILE 9 | 10 | if [[ $? == 0 ]]; 11 | then 12 | echo "Protoc build successful" 13 | exit 0 14 | else 15 | echo "Protoc build failed" 16 | exit 1 17 | fi -------------------------------------------------------------------------------- /script/release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Pushes a new version to PyPi. Run from root directory. 3 | DEV_BRANCH="dev" 4 | MASTER_BRANCH="master" 5 | CURRENT="Already up to date." 6 | VERSION="$(python -m pyremoteplay.__version__)" 7 | 8 | latest="$(gh release list | grep 'Latest' | head -c 5)" 9 | if [[ $VERSION == $latest ]]; 10 | then 11 | echo "Version: $VERSION is already latest." 12 | exit 1 13 | fi 14 | 15 | git checkout dev 16 | git reset HEAD 17 | branch="$(git status | head -n 1 | tail -c 4)" 18 | if [[ $branch != $DEV_BRANCH ]]; 19 | then 20 | echo "Branch not on $DEV_BRANCH." 21 | exit 1 22 | fi 23 | echo "Branch on $DEV_BRANCH." 24 | 25 | git_pull="$(git pull)" 26 | if [[ $git_pull != $CURRENT ]]; 27 | then 28 | echo "Branch not up to date." 29 | exit 1 30 | fi 31 | echo "Branch up to date." 32 | 33 | read -p "Push to master?: y> " msg_push 34 | if [ "$msg_push" == "y" ]; 35 | then 36 | git checkout master 37 | git reset HEAD 38 | 39 | branch="$(git status | head -n 1 | tail -c 7)" 40 | if [[ $branch != $MASTER_BRANCH ]]; 41 | then 42 | echo "Branch not on $MASTER_BRANCH." 43 | exit 1 44 | fi 45 | echo "Branch on $MASTER_BRANCH." 46 | git_pull="$(git pull)" 47 | if [[ $git_pull != $CURRENT ]]; 48 | then 49 | echo "Branch not up to date." 50 | exit 1 51 | fi 52 | echo "Branch up to date." 53 | 54 | echo "Rebasing dev into master." 55 | git fetch origin dev 56 | git rebase origin/dev 57 | git push 58 | 59 | read -p "Enter release message: " msg 60 | 61 | gh release create $VERSION -t $VERSION -n "$msg" --target $MASTER_BRANCH 62 | fi 63 | 64 | rm -rf dist 65 | rm -rf build 66 | 67 | read -p "Upload to pypi?: y> " msg_pypi 68 | if [ "$msg_pypi" == "y" ]; 69 | then 70 | echo "Uploading to pypi." 71 | python3 setup.py sdist bdist_wheel 72 | rm dist/*-linux*.whl 73 | python3 -m twine check dist/* 74 | python3 -m twine upload dist/* --skip-existing 75 | echo "Uploaded to pypi." 76 | else 77 | echo "Skipping upload to pypi." 78 | fi 79 | 80 | git checkout dev -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Setup for pyremoteplay.""" 3 | from pathlib import Path 4 | from setuptools import setup 5 | 6 | SRC_DIR = "pyremoteplay" 7 | version_data = {} 8 | version_path = Path.cwd() / SRC_DIR / "__version__.py" 9 | with open(version_path, encoding="utf-8") as fp: 10 | exec(fp.read(), version_data) 11 | 12 | VERSION = version_data["VERSION"] 13 | MIN_PY_VERSION = version_data["MIN_PY_VERSION"] 14 | 15 | REQUIRES = list(open("requirements.txt")) 16 | REQUIRES_GUI = list(open("requirements-gui.txt")) 17 | REQUIRES_DEV = list(open("requirements-dev.txt")) 18 | REQUIRES_DEV.extend(REQUIRES_GUI) 19 | 20 | CLASSIFIERS = [ 21 | "Development Status :: 4 - Beta", 22 | "Environment :: Console", 23 | "Environment :: Console :: Curses", 24 | "Environment :: X11 Applications :: Qt", 25 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 26 | "Natural Language :: English", 27 | "Operating System :: OS Independent", 28 | "Programming Language :: Python :: 3", 29 | "Programming Language :: Python :: 3.8", 30 | "Topic :: Games/Entertainment", 31 | "Topic :: Home Automation", 32 | "Topic :: Software Development :: Libraries :: Python Modules", 33 | "Topic :: System :: Hardware", 34 | ] 35 | 36 | with open("README.md") as f: 37 | README = f.read() 38 | 39 | 40 | setup_kwargs = { 41 | "name": "pyremoteplay", 42 | "version": VERSION, 43 | "description": "Remote Play Library and API", 44 | "long_description": README, 45 | "long_description_content_type": "text/markdown", 46 | "author": "ktnrg45", 47 | "author_email": "ktnrg45dev@gmail.com", 48 | "packages": [ 49 | "pyremoteplay", 50 | "pyremoteplay.gui", 51 | "pyremoteplay.receiver", 52 | "pyremoteplay.gamepad", 53 | ], 54 | "url": "https://github.com/ktnrg45/pyremoteplay", 55 | "license": "GPLv3", 56 | "classifiers": CLASSIFIERS, 57 | "keywords": "playstation sony ps4 ps5 remote play remoteplay rp", 58 | "install_requires": REQUIRES, 59 | "extras_require": {"GUI": REQUIRES_GUI, "DEV": REQUIRES_DEV}, 60 | "python_requires": ">={}".format(MIN_PY_VERSION), 61 | "test_suite": "tests", 62 | # "include_package_data": True, 63 | "entry_points": { 64 | "console_scripts": [ 65 | "pyremoteplay = pyremoteplay.__main__:main", 66 | ], 67 | "gui_scripts": [ 68 | "pyremoteplay-gui = pyremoteplay.gui.__main__:main [GUI]", 69 | ], 70 | }, 71 | } 72 | 73 | setup(**setup_kwargs) 74 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for tests""" 2 | -------------------------------------------------------------------------------- /tests/test_crypt.py: -------------------------------------------------------------------------------- 1 | # fmt: off 2 | """Tests for crypt.py.""" 3 | from pyremoteplay import crypt 4 | 5 | HANDSHAKE_KEY = bytes([ 6 | 0xfc, 0x5d, 0x4b, 0xa0, 0x3a, 0x35, 0x3a, 0xbb, 7 | 0x6a, 0x7f, 0xac, 0x79, 0x1b, 0x17, 0xbb, 0x34, 8 | ]) 9 | SECRET = bytes([ 10 | 0xb8, 0x1c, 0x61, 0x46, 0xe7, 0x49, 0x73, 0x8c, 11 | 0x96, 0x30, 0xca, 0x13, 0xff, 0x71, 0xe5, 0x9b, 12 | 0x3b, 0xf9, 0x41, 0x98, 0xd4, 0x67, 0xa5, 0xa2, 13 | 0xbc, 0x78, 0x4, 0x92, 0x81, 0x43, 0xec, 0x1d, 14 | ]) 15 | PRIVATE_KEY = bytes([ 16 | 0x16, 0xe7, 0x5d, 0xcb, 0xda, 0x98, 0x55, 0xfb, 17 | 0x6b, 0xef, 0xdd, 0x8a, 0xa5, 0xf1, 0x6e, 0x7f, 18 | 0x46, 0xfd, 0xe1, 0xd2, 0x27, 0x97, 0x3, 0x60, 19 | 0x18, 0x72, 0xd8, 0x4b, 0x15, 0x38, 0xd9, 0x0, 20 | ]) 21 | PUBLIC_KEY = bytes([ 22 | 0x4, 0xf4, 0xa, 0xf1, 0x35, 0xa4, 0x88, 0x94, 23 | 0x36, 0xce, 0xe5, 0x2b, 0x5c, 0x73, 0xa3, 0x3e, 24 | 0xc5, 0xad, 0xb, 0xe0, 0x95, 0x2f, 0x57, 0xf4, 25 | 0xf0, 0xed, 0xc, 0x80, 0xb0, 0xbe, 0xda, 0x7c, 26 | 0xa6, 0x43, 0x78, 0x93, 0x93, 0xa5, 0x94, 0x7e, 27 | 0x9f, 0xaa, 0x3f, 0x67, 0x95, 0xc9, 0xaa, 0x9, 28 | 0xa9, 0x63, 0x25, 0xdf, 0xe8, 0x50, 0xbf, 0xc3, 29 | 0xf1, 0xdb, 0x62, 0xa5, 0xa, 0xbf, 0xb0, 0xff, 30 | 0xf7, 31 | ]) 32 | 33 | PUBLIC_SIG = bytes([ 34 | 0x99, 0xb5, 0xcb, 0xb5, 0x37, 0x18, 0xb, 0xfc, 35 | 0x55, 0xda, 0x43, 0x7f, 0x44, 0x76, 0xa8, 0x17, 36 | 0xc9, 0x37, 0xfe, 0x56, 0x1b, 0x8a, 0xbe, 0xc, 37 | 0x41, 0x12, 0xab, 0x71, 0xf5, 0xa6, 0x8d, 0x29, 38 | ]) 39 | 40 | REMOTE_KEY = bytes([ 41 | 0x4, 0xdf, 0xef, 0x8, 0xbb, 0xa8, 0x56, 0xf2, 42 | 0xb4, 0x4b, 0x8a, 0xe, 0x4f, 0x44, 0x20, 0x3f, 43 | 0x8e, 0x49, 0x3f, 0xee, 0xd4, 0x3c, 0xe9, 0x3a, 44 | 0xfe, 0x5c, 0x64, 0x67, 0x77, 0x20, 0x15, 0x7c, 45 | 0x59, 0x10, 0x15, 0x67, 0x94, 0xae, 0x5f, 0x2, 46 | 0x4a, 0xad, 0xc, 0xce, 0xfa, 0x14, 0x15, 0xa, 47 | 0xab, 0xee, 0x8, 0xb, 0x14, 0x12, 0x76, 0xea, 48 | 0x3e, 0xc0, 0xd5, 0x65, 0xf4, 0x68, 0x77, 0xa3, 49 | 0xca, 50 | ]) 51 | 52 | REMOTE_SIG = bytes([ 53 | 0x13, 0xc5, 0x89, 0xe2, 0x3b, 0x72, 0x85, 0x24, 54 | 0xa9, 0x9f, 0x96, 0x80, 0x3, 0xa1, 0x81, 0x30, 55 | 0x59, 0x68, 0xf1, 0xbb, 0xb6, 0x4d, 0xc4, 0xa7, 56 | 0x6c, 0xce, 0xf6, 0x79, 0x4c, 0xeb, 0x2d, 0x98 57 | ]) 58 | 59 | def test_ecdh(): 60 | """Test ECDH.""" 61 | 62 | ecdh = crypt.StreamECDH(HANDSHAKE_KEY, PRIVATE_KEY) 63 | assert ecdh.public_key == PUBLIC_KEY 64 | assert ecdh.public_sig == PUBLIC_SIG 65 | success = ecdh.set_secret(REMOTE_KEY, REMOTE_SIG) 66 | assert ecdh._secret == SECRET 67 | assert success 68 | 69 | 70 | def test_get_gmac_key(): 71 | """Test Generating GMAC Key.""" 72 | base_key = bytes([ 73 | 0xbe, 0xeb, 0xa0, 0xf0, 0x3d, 0x05, 0x70, 0x7d, 74 | 0x3a, 0xc7, 0x3c, 0xd7, 0x32, 0xb9, 0x48, 0x01, 75 | ]) 76 | base_iv = bytes([ 77 | 0xe8, 0x71, 0x87, 0xe7, 0x63, 0xe0, 0xdf, 0x46, 78 | 0x3d, 0xc2, 0x02, 0x4a, 0x2c, 0xd2, 0x9c, 0x45, 79 | ]) 80 | 81 | test_key = bytes([ 82 | 0xe3, 0xdb, 0x92, 0xd9, 0xdd, 0xd3, 0x68, 0x99, 83 | 0xae, 0xfd, 0x9b, 0x15, 0xe1, 0xa6, 0x87, 0x8b, 84 | ]) 85 | 86 | mock_key = crypt.get_gmac_key(1, base_key, base_iv) 87 | assert mock_key == test_key 88 | 89 | 90 | def test_gmac(): 91 | """Test GMAC get and verify.""" 92 | l_data1 = bytes([ 93 | 0x00, 0x10, 0xb4, 0xb3, 0x08, 0x00, 0x00, 0x00, 94 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 95 | 0x10, 0x10, 0xb4, 0xb3, 0x08, 0x00, 0x01, 0x90, 96 | 0x00, 0x00, 0x00, 0x00, 0x00, 97 | ]) 98 | 99 | l_data2 = bytes([ 100 | 0x00, 0x10, 0xb4, 0xb3, 0x08, 0x00, 0x00, 0x00, 101 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 102 | 0x0f, 0x11, 0x72, 0xf4, 0xb3, 0x00, 0x09, 0x00, 103 | 0x00, 0x00, 0x08, 0x0e, 104 | ]) 105 | 106 | l_data3 = bytes([ 107 | 0x00, 0x10, 0xb4, 0xb3, 0x08, 0x00, 0x00, 0x00, 108 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 109 | 0x10, 0x10, 0xb4, 0xb3, 0x09, 0x00, 0x01, 0x90, 110 | 0x00, 0x00, 0x00, 0x00, 0x00, 111 | ]) 112 | 113 | r_data1 = bytes([ 114 | 0x00, 0x11, 0x72, 0xf4, 0xb2, 0x00, 0x00, 0x00, 115 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 116 | 0x72, 0x10, 0xb4, 0xb3, 0x09, 0x00, 0x09, 0x00, 117 | 0x00, 0x00, 0x08, 0x0d, 0x7a, 0x61, 0x0a, 0x30, 118 | 0x08, 0x80, 0x0a, 0x10, 0xd0, 0x05, 0x1a, 0x28, 119 | 0x00, 0x00, 0x00, 0x01, 0x67, 0x4d, 0x40, 0x28, 120 | 0x91, 0x8a, 0x01, 0x40, 0x16, 0xec, 0x05, 0xa8, 121 | 0x08, 0x08, 0x0a, 0x00, 0x00, 0x0f, 0xa4, 0x00, 122 | 0x07, 0x53, 0x01, 0x13, 0x43, 0xc7, 0xc5, 0x40, 123 | 0x00, 0x00, 0x00, 0x01, 0x68, 0xee, 0x3c, 0x80, 124 | 0x12, 0x0e, 0x02, 0x10, 0x00, 0x00, 0xbb, 0x80, 125 | 0x00, 0x00, 0x01, 0xe0, 0x00, 0x00, 0x00, 0x01, 126 | 0x18, 0x64, 0x20, 0x64, 0x28, 0x64, 0x30, 0xc8, 127 | 0x01, 0x3a, 0x14, 0x08, 0x00, 0x12, 0x0e, 0x02, 128 | 0x10, 0x00, 0x00, 0xbb, 0x80, 0x00, 0x00, 0x01, 129 | 0xe0, 0x00, 0x00, 0x00, 0x01, 0x18, 0x00, 130 | ]) 131 | 132 | r_data2 = bytes([ 133 | 0x00, 0x11, 0x72, 0xf4, 0xb2, 0x00, 0x00, 0x00, 134 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 135 | 0x10, 0x11, 0x72, 0xf4, 0xb3, 0x00, 0x01, 0x90, 136 | 0x00, 0x00, 0x00, 0x00, 0x00, 137 | ]) 138 | 139 | r_data3 = bytes([ 140 | 0x00, 0x11, 0x72, 0xf4, 0xb2, 0x00, 0x00, 0x00, 141 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 142 | 0x14, 0x10, 0xb4, 0xb3, 0x0a, 0x00, 0x00, 0x00, 143 | 0x00, 0x09, 0x00, 0x00, 0x00, 0x40, 0x01, 0x00, 144 | 0x00, 145 | ]) 146 | l_tag1 = b'\x7b\x06\xb2\x0a' 147 | l_tag2 = b'\x05\xb7\xaf\x62' 148 | l_tag3 = b'\x8d\x79\xa4\xee' 149 | r_tag1 = b'\x04\x40\x5e\xf4' 150 | r_tag2 = b'\xb9\x5f\x9f\x2c' 151 | r_tag3 = b'\xed\xba\xf7\x61' 152 | 153 | local = crypt.LocalCipher(HANDSHAKE_KEY, SECRET) 154 | remote = crypt.RemoteCipher(HANDSHAKE_KEY, SECRET) 155 | assert local.get_gmac(l_data1) == l_tag1 156 | local.advance_key_pos(len(l_data1)) 157 | assert local.get_gmac(l_data2) == l_tag2 158 | local.advance_key_pos(2) 159 | assert local.get_gmac(l_data3) == l_tag3 160 | 161 | assert remote.get_gmac(r_data1, 0) == r_tag1 162 | assert remote.get_gmac(r_data2, 16) == r_tag2 163 | assert remote.get_gmac(r_data3, 32) == r_tag3 164 | 165 | 166 | def test_encrypt_decrypt(): 167 | """Test Encrypt and Decrypt.""" 168 | key = bytes([ 169 | 0x01, 0x6e, 0x1b, 0xab, 0xd0, 0x3c, 0x00, 0x06, 170 | 0x0d, 0x7e, 0xa9, 0x8e, 0x97, 0xdd, 0xe3, 0x69, 171 | ]) 172 | iv = bytes([ 173 | 0xbc, 0xb7, 0x03, 0x7b, 0x7b, 0x3d, 0xe0, 0x3d, 174 | 0x62, 0x29, 0x47, 0x56, 0x14, 0xae, 0x85, 0x4c, 175 | ]) 176 | data = bytes([ 177 | 0x4e, 0x61, 0x9f, 0x94, 0x5d, 0x4b, 0x8e, 0xbd, 178 | 0x2a, 0x15, 0x4d, 0x03, 0x6a, 0xcd, 0x49, 0x56, 179 | 0x9c, 0xc7, 0x5c, 0xe3, 0xe7, 0x00, 0x17, 0x9a, 180 | 0x38, 0xd9, 0x69, 0x53, 0x45, 0xf9, 0x0c, 0xb5, 181 | 0x8c, 0x05, 0x65, 0x0f, 0x70, 182 | ]) 183 | enc_data = bytes([ 184 | 0x36, 0x8d, 0xd4, 0x21, 0x4b, 0xbe, 0x40, 0xcc, 185 | 0xea, 0x92, 0xfe, 0x77, 0xe4, 0x67, 0x2c, 0x31, 186 | 0x58, 0x89, 0x56, 0x6f, 0x6c, 0xb4, 0x5f, 0x18, 187 | 0x31, 0xa7, 0xa5, 0x2e, 0x38, 0xba, 0x37, 0x25, 188 | 0x67, 0x5c, 0x13, 0x53, 0xb0, 189 | ]) 190 | 191 | key_pos = 1 192 | stream = crypt.StreamCipher(HANDSHAKE_KEY, SECRET) 193 | local = stream._local_cipher 194 | remote = stream._remote_cipher 195 | local._base_index = remote._base_index = 42 196 | local.keystreams = [] 197 | local.keystream_index = 0 198 | remote.keystreams = [] 199 | remote.keystream_index = 0 200 | local._init_cipher() 201 | remote._init_cipher() 202 | assert local.base_key == key 203 | assert remote.base_key == key 204 | assert local.base_iv == iv 205 | assert remote.base_iv == iv 206 | stream.advance_key_pos(key_pos) 207 | mock_enc = stream.encrypt(data) 208 | mock_data = stream.decrypt(enc_data, key_pos) 209 | assert mock_enc == enc_data 210 | assert mock_data == data 211 | 212 | 213 | def test_encrypt(): 214 | """Test Encryption with local cipher.""" 215 | key_pos = 0xa3 216 | payload = bytes([ 217 | 0xa0, 0xff, 0x7f, 0xff, 0x7f, 0xff, 0x7f, 0xff, 218 | 0x7f, 0x99, 0x99, 0xff, 0x7f, 0xfe, 0xf7, 0xef, 219 | 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 220 | 0x00, 221 | ]) 222 | payload_enc = bytes([ 223 | 0x85, 0x20, 0x60, 0xf2, 0x26, 0xbe, 0x5a, 0x09, 224 | 0x79, 0x93, 0x66, 0xb5, 0x4b, 0x47, 0xc1, 0xa4, 225 | 0x44, 0xd0, 0x07, 0x69, 0xf0, 0x16, 0x4f, 0xf8, 226 | 0x29, 227 | ]) 228 | 229 | local_cipher = crypt.LocalCipher(HANDSHAKE_KEY, SECRET) 230 | local_cipher.advance_key_pos(key_pos) 231 | mock_enc = local_cipher.encrypt(payload) 232 | assert mock_enc == payload_enc 233 | 234 | # fmt: on 235 | -------------------------------------------------------------------------------- /tests/test_register.py: -------------------------------------------------------------------------------- 1 | """Tests for register.py.""" 2 | import json 3 | from unittest.mock import MagicMock 4 | 5 | from pyremoteplay import register 6 | from pyremoteplay.const import TYPE_PS4, TYPE_PS5 7 | 8 | # fmt: off 9 | KEY_0 = bytes([ 10 | 0xce, 0xbc, 0xb6, 0x40, 0x08, 0x07, 0x76, 0x04, 11 | 0x7b, 0x85, 0xe8, 0x5b, 0xf3, 0x50, 0xf5, 0x2d, 12 | ]) 13 | 14 | KEY_1 = bytes([ 15 | 0x88, 0x15, 0x29, 0x24, 0xbe, 0xde, 0x71, 0x76, 16 | 0xd7, 0x57, 0xdb, 0xae, 0xa0, 0x02, 0x80, 0x90, 17 | ]) 18 | 19 | NONCE = bytes(16) 20 | 21 | REGIST_PAYLOAD = bytes([ 22 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 23 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 24 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 25 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 26 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 27 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 28 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 29 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 30 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 31 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 32 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 33 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 34 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 35 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 36 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 37 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 38 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 39 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 40 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 41 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 42 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 43 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 44 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 45 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 46 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0xd7, 47 | 0x57, 0xdb, 0xae, 0xa0, 0x02, 0x80, 0x90, 0x41, 48 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 49 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 50 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 51 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 52 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 53 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 54 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 55 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 56 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 57 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 58 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 59 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 60 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 61 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 62 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 63 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 64 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 65 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 66 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 67 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 68 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 69 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 70 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 71 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 72 | 0x41, 0x88, 0x15, 0x29, 0x24, 0xbe, 0xde, 0x71, 73 | 0x76, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 74 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 75 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 76 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 77 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 78 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 79 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 80 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 81 | 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 82 | ]) 83 | 84 | ENC_PAYLOAD = bytes([ 85 | 0x51, 0xcd, 0xfb, 0x33, 0x7d, 0xf8, 0x8b, 0x1a, 86 | 0x62, 0xcc, 0x0c, 0x6f, 0xec, 0xa0, 0xcb, 0x88, 87 | 0xbb, 0x07, 0xdb, 0xf3, 0x56, 0x9a, 0x2c, 0xd4, 88 | 0x7a, 0x99, 0x91, 0x54, 0x6c, 0xd3, 0xa2, 0x8a, 89 | 0xa9, 0x27, 0xb0, 0x21, 0xc0, 0x41, 0x05, 0x40, 90 | 0xe1, 0xa6, 0x97, 0x40, 0x67, 0x6c, 0xd6, 0x2b, 91 | 0x33, 0x6b, 0xaa, 0xb7, 0xfe, 0xa7, 0x0f, 0x85, 92 | 0xfa, 0xb7, 0xcf, 0x31, 0x0c, 0x46, 0x12, 0xdf, 93 | 0x74, 0x11, 0x54, 0xfc, 0xb0, 0x53, 0xc6, 0x43, 94 | 0x38, 0x6a, 0xf8, 0x35, 0x99, 0xc4, 0xe5, 0x7f, 95 | 0x33, 0xa6, 0x64, 0x26, 0x9d, 0x6a, 0xc5, 0xb4, 96 | 0xbf, 0xef, 0x07, 0x4a, 0x82, 0x01, 0x8c, 0xfd, 97 | 0x7c, 0xc1, 0xcc, 0x86, 0x00, 0xdf, 0x2d, 0x22, 98 | 0x25, 0x8c, 0xe5, 99 | ]) 100 | 101 | HEADERS = bytes([ 102 | 0x50, 0x4f, 0x53, 0x54, 0x20, 0x2f, 0x73, 0x69, 103 | 0x65, 0x2f, 0x70, 0x73, 0x34, 0x2f, 0x72, 0x70, 104 | 0x2f, 0x73, 0x65, 0x73, 0x73, 0x2f, 0x72, 0x67, 105 | 0x73, 0x74, 0x20, 0x48, 0x54, 0x54, 0x50, 0x2f, 106 | 0x31, 0x2e, 0x31, 0x0d, 0x0a, 0x20, 0x48, 0x54, 107 | 0x54, 0x50, 0x2f, 0x31, 0x2e, 0x31, 0x0d, 0x0a, 108 | 0x48, 0x4f, 0x53, 0x54, 0x3a, 0x20, 0x31, 0x30, 109 | 0x2e, 0x30, 0x2e, 0x32, 0x2e, 0x31, 0x35, 0x0d, 110 | 0x0a, 0x55, 0x73, 0x65, 0x72, 0x2d, 0x41, 0x67, 111 | 0x65, 0x6e, 0x74, 0x3a, 0x20, 0x72, 0x65, 0x6d, 112 | 0x6f, 0x74, 0x65, 0x70, 0x6c, 0x61, 0x79, 0x20, 113 | 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x73, 0x0d, 114 | 0x0a, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 115 | 0x69, 0x6f, 0x6e, 0x3a, 0x20, 0x63, 0x6c, 0x6f, 116 | 0x73, 0x65, 0x0d, 0x0a, 0x43, 0x6f, 0x6e, 0x74, 117 | 0x65, 0x6e, 0x74, 0x2d, 0x4c, 0x65, 0x6e, 0x67, 118 | 0x74, 0x68, 0x3a, 0x20, 0x35, 0x38, 0x37, 0x0d, 119 | 0x0a, 0x52, 0x50, 0x2d, 0x56, 0x65, 0x72, 0x73, 120 | 0x69, 0x6f, 0x6e, 0x3a, 0x20, 0x31, 0x30, 0x2e, 121 | 0x30, 0x0d, 0x0a, 0x0d, 0x0a, 122 | ]) 123 | 124 | INFO = bytes([ 125 | 0x7b, 0x22, 0x41, 0x50, 0x2d, 0x53, 0x73, 0x69, 126 | 0x64, 0x22, 0x3a, 0x20, 0x22, 0x33, 0x30, 0x33, 127 | 0x30, 0x33, 0x30, 0x33, 0x30, 0x33, 0x30, 0x33, 128 | 0x30, 0x33, 0x30, 0x33, 0x30, 0x22, 0x2c, 0x20, 129 | 0x22, 0x41, 0x50, 0x2d, 0x42, 0x73, 0x73, 0x69, 130 | 0x64, 0x22, 0x3a, 0x20, 0x22, 0x30, 0x30, 0x30, 131 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 132 | 0x30, 0x22, 0x2c, 0x20, 0x22, 0x41, 0x50, 0x2d, 133 | 0x4b, 0x65, 0x79, 0x22, 0x3a, 0x20, 0x22, 0x33, 134 | 0x39, 0x33, 0x39, 0x33, 0x39, 0x33, 0x39, 0x33, 135 | 0x39, 0x33, 0x39, 0x33, 0x39, 0x33, 0x39, 0x22, 136 | 0x2c, 0x20, 0x22, 0x41, 0x50, 0x2d, 0x4e, 0x61, 137 | 0x6d, 0x65, 0x22, 0x3a, 0x20, 0x22, 0x50, 0x4c, 138 | 0x41, 0x59, 0x53, 0x54, 0x41, 0x54, 0x49, 0x4f, 139 | 0x4e, 0x28, 0x52, 0x29, 0x34, 0x22, 0x2c, 0x20, 140 | 0x22, 0x50, 0x53, 0x34, 0x2d, 0x4d, 0x61, 0x63, 141 | 0x22, 0x3a, 0x20, 0x22, 0x30, 0x30, 0x30, 0x30, 142 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 143 | 0x22, 0x2c, 0x20, 0x22, 0x50, 0x53, 0x34, 0x2d, 144 | 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x4b, 0x65, 145 | 0x79, 0x22, 0x3a, 0x20, 0x22, 0x30, 0x30, 0x30, 146 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 147 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x22, 0x2c, 0x20, 148 | 0x22, 0x50, 0x53, 0x34, 0x2d, 0x4e, 0x69, 0x63, 149 | 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x3a, 0x20, 150 | 0x22, 0x50, 0x53, 0x34, 0x2d, 0x33, 0x30, 0x36, 151 | 0x22, 0x2c, 0x20, 0x22, 0x52, 0x50, 0x2d, 0x4b, 152 | 0x65, 0x79, 0x54, 0x79, 0x70, 0x65, 0x22, 0x3a, 153 | 0x20, 0x22, 0x32, 0x22, 0x2c, 0x20, 0x22, 0x52, 154 | 0x50, 0x2d, 0x4b, 0x65, 0x79, 0x22, 0x3a, 0x20, 155 | 0x22, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 156 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 157 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 158 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 159 | 0x30, 0x22, 0x7d, 160 | ]) 161 | 162 | RESPONSE = bytes([ 163 | 0x41, 0x50, 0x2d, 0x53, 0x73, 0x69, 0x64, 0x3a, 164 | 0x20, 0x33, 0x30, 0x33, 0x30, 0x33, 0x30, 0x33, 165 | 0x30, 0x33, 0x30, 0x33, 0x30, 0x33, 0x30, 0x33, 166 | 0x30, 0x0d, 0x0a, 0x41, 0x50, 0x2d, 0x42, 0x73, 167 | 0x73, 0x69, 0x64, 0x3a, 0x20, 0x30, 0x30, 0x30, 168 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 169 | 0x30, 0x0d, 0x0a, 0x41, 0x50, 0x2d, 0x4b, 0x65, 170 | 0x79, 0x3a, 0x20, 0x33, 0x39, 0x33, 0x39, 0x33, 171 | 0x39, 0x33, 0x39, 0x33, 0x39, 0x33, 0x39, 0x33, 172 | 0x39, 0x33, 0x39, 0x0d, 0x0a, 0x41, 0x50, 0x2d, 173 | 0x4e, 0x61, 0x6d, 0x65, 0x3a, 0x20, 0x50, 0x4c, 174 | 0x41, 0x59, 0x53, 0x54, 0x41, 0x54, 0x49, 0x4f, 175 | 0x4e, 0x28, 0x52, 0x29, 0x34, 0x0d, 0x0a, 0x50, 176 | 0x53, 0x34, 0x2d, 0x4d, 0x61, 0x63, 0x3a, 0x20, 177 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 178 | 0x30, 0x30, 0x30, 0x30, 0x0d, 0x0a, 0x50, 0x53, 179 | 0x34, 0x2d, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 180 | 0x4b, 0x65, 0x79, 0x3a, 0x20, 0x30, 0x30, 0x30, 181 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 182 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x0d, 0x0a, 0x50, 183 | 0x53, 0x34, 0x2d, 0x4e, 0x69, 0x63, 0x6b, 0x6e, 184 | 0x61, 0x6d, 0x65, 0x3a, 0x20, 0x50, 0x53, 0x34, 185 | 0x2d, 0x33, 0x30, 0x36, 0x0d, 0x0a, 0x52, 0x50, 186 | 0x2d, 0x4b, 0x65, 0x79, 0x54, 0x79, 0x70, 0x65, 187 | 0x3a, 0x20, 0x32, 0x0d, 0x0a, 0x52, 0x50, 0x2d, 188 | 0x4b, 0x65, 0x79, 0x3a, 0x20, 0x30, 0x30, 0x30, 189 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 190 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 191 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 192 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x0d, 0x0a, 193 | ]) 194 | 195 | RESPONSE_HEADER = bytes([ 196 | 0x48, 0x54, 0x54, 0x50, 0x2f, 0x31, 0x2e, 0x31, 197 | 0x20, 0x32, 0x30, 0x30, 0x20, 0x4f, 0x4b, 0x0d, 198 | 0x0a, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 199 | 0x69, 0x6f, 0x6e, 0x3a, 0x20, 0x63, 0x6c, 0x6f, 200 | 0x73, 0x65, 0x0d, 0x0a, 0x43, 0x6f, 0x6e, 0x74, 201 | 0x65, 0x6e, 0x74, 0x2d, 0x4c, 0x65, 0x6e, 0x67, 202 | 0x74, 0x68, 0x3a, 0x20, 0x20, 0x20, 0x20, 0x20, 203 | 0x20, 0x20, 0x20, 0x32, 0x33, 0x39, 0x0d, 0x0a, 204 | 0x0d, 0x0a, 205 | ]) 206 | # fmt: on 207 | 208 | RAW_RESPONSE = b"".join( 209 | [ 210 | RESPONSE_HEADER, 211 | RESPONSE, 212 | ] 213 | ) 214 | 215 | 216 | def test_key_0(): 217 | """Test Key 0.""" 218 | mock_pin = 12345678 219 | assert register._gen_key_0(TYPE_PS4, mock_pin) == KEY_0 220 | 221 | 222 | def test_key_1(): 223 | """Test Key 1.""" 224 | assert register._gen_key_1(TYPE_PS4, NONCE) 225 | 226 | 227 | def test_regist_payload(): 228 | """Test Regist Payload.""" 229 | assert register._get_regist_payload(KEY_1) == REGIST_PAYLOAD 230 | 231 | 232 | def test_encrypt_payload(): 233 | """Test Encrypt payload.""" 234 | mock_id = "x3HEK6t1aw8=" 235 | cipher = register.SessionCipher(TYPE_PS4, KEY_0, NONCE, counter=0) 236 | mock_payload = register._encrypt_payload(cipher, mock_id) 237 | assert mock_payload == ENC_PAYLOAD 238 | 239 | 240 | def test_get_regist_headers(): 241 | """Test Regist Headers.""" 242 | length = len(REGIST_PAYLOAD) + len(ENC_PAYLOAD) 243 | assert register._get_regist_headers(TYPE_PS4, length) == HEADERS 244 | 245 | 246 | def test_parse_response(): 247 | """Test Parse Response.""" 248 | cipher = MagicMock() 249 | cipher.decrypt = MagicMock(return_value=RESPONSE) 250 | assert register._parse_response(cipher, RAW_RESPONSE) == json.loads(INFO) 251 | -------------------------------------------------------------------------------- /tests/test_stream_packets.py: -------------------------------------------------------------------------------- 1 | """Tests for pyremoteplay/stream_packets.py.""" 2 | from pyremoteplay.stream_packets import Chunk, Header, Packet 3 | 4 | # fmt: off 5 | def test_init(): 6 | """Test build init packet.""" 7 | INIT = bytes([ 8 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 9 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 10 | 0x14, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x90, 11 | 0x00, 0x00, 0x64, 0x00, 0x64, 0x00, 0x00, 0x00, 12 | 0x01, 13 | ]) 14 | TAG_TSN = 1 15 | msg = Packet(Header.Type.CONTROL, Chunk.Type.INIT, tag=TAG_TSN, tsn=TAG_TSN) 16 | mock_result = msg.bytes() 17 | assert mock_result == INIT 18 | 19 | 20 | def test_parse_init_ack(): 21 | """Test parsing init ack packet.""" 22 | INIT_ACK = bytes([ 23 | 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 24 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 25 | 0x34, 0x15, 0xcf, 0x15, 0x4f, 0x00, 0x01, 0x90, 26 | 0x00, 0x00, 0x64, 0x00, 0x64, 0x15, 0xcf, 0x15, 27 | 0x4f, 0xea, 0x54, 0x00, 0x00, 0x00, 0x00, 0x00, 28 | 0x01, 0x15, 0xcf, 0x15, 0x4f, 0x00, 0x01, 0x90, 29 | 0x00, 0x15, 0xcf, 0x15, 0x4f, 0x00, 0x00, 0x00, 30 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x26, 0xa3, 0x12, 31 | 0x05, 32 | ]) 33 | 34 | DATA = bytes([ 35 | 0xea, 0x54, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 36 | 0x15, 0xcf, 0x15, 0x4f, 0x00, 0x01, 0x90, 0x00, 37 | 0x15, 0xcf, 0x15, 0x4f, 0x00, 0x00, 0x00, 0x00, 38 | 0x00, 0x00, 0x00, 0x00, 0x26, 0xa3, 0x12, 0x05, 39 | ]) 40 | 41 | TAG_TSN = 365892943 42 | A_RWND = 102400 43 | STREAMS = 100 44 | 45 | mock_packet = Packet.parse(INIT_ACK) 46 | assert mock_packet.type == Header.Type.CONTROL 47 | assert mock_packet.chunk.type == Chunk.Type.INIT_ACK 48 | params = mock_packet.params 49 | assert params["tag"] == TAG_TSN 50 | assert params["a_rwnd"] == A_RWND 51 | assert params["outbound_streams"] == STREAMS 52 | assert params["inbound_streams"] == STREAMS 53 | assert params["tsn"] == TAG_TSN 54 | assert params["data"] == bytearray(DATA) 55 | 56 | 57 | def test_cookie(): 58 | """Test build cookie packet.""" 59 | COOKIE = bytes([ 60 | 0x00, 0x15, 0xcf, 0x15, 0x4f, 0x00, 0x00, 0x00, 61 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, 62 | 0x24, 0xea, 0x54, 0x00, 0x00, 0x00, 0x00, 0x00, 63 | 0x01, 0x15, 0xcf, 0x15, 0x4f, 0x00, 0x01, 0x90, 64 | 0x00, 0x15, 0xcf, 0x15, 0x4f, 0x00, 0x00, 0x00, 65 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x26, 0xa3, 0x12, 66 | 0x05, 67 | ]) 68 | DATA = bytes([ 69 | 0xea, 0x54, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 70 | 0x15, 0xcf, 0x15, 0x4f, 0x00, 0x01, 0x90, 0x00, 71 | 0x15, 0xcf, 0x15, 0x4f, 0x00, 0x00, 0x00, 0x00, 72 | 0x00, 0x00, 0x00, 0x00, 0x26, 0xa3, 0x12, 0x05, 73 | ]) 74 | TAG = 1 75 | TAG_REMOTE = 365892943 76 | 77 | msg = Packet(Header.Type.CONTROL, Chunk.Type.COOKIE, tag=TAG, tag_remote=TAG_REMOTE, data=DATA) 78 | mock_result = msg.bytes() 79 | assert mock_result == COOKIE 80 | 81 | 82 | def test_parse_cookie_ack(): 83 | """Test parsing cookie ack packet.""" 84 | COOKIE_ACK = bytes([ 85 | 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 86 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x0b, 0x00, 0x00, 87 | 0x04, 88 | ]) 89 | mock_packet = Packet.parse(COOKIE_ACK) 90 | assert mock_packet.type == Header.Type.CONTROL 91 | assert mock_packet.chunk.type == Chunk.Type.COOKIE_ACK 92 | 93 | 94 | def test_data_ack(): 95 | """Test build data ack packet.""" 96 | DATA_ACK = bytes([ 97 | 0x00, 0x15, 0xcf, 0x15, 0x4f, 0xa0, 0x13, 0xad, 98 | 0xbd, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 99 | 0x10, 0x15, 0xcf, 0x15, 0x50, 0x00, 0x01, 0x90, 100 | 0x00, 0x00, 0x00, 0x00, 0x00, 101 | ]) 102 | TAG_REMOTE = 365892943 103 | TAG = 1 104 | GMAC = 2685644221 105 | KEY_POS = 0 106 | TSN = 365892944 107 | 108 | msg = Packet(Header.Type.CONTROL, Chunk.Type.DATA_ACK, tag_remote=TAG_REMOTE, tag=TAG, tsn=TSN) 109 | msg.header.gmac = GMAC 110 | msg.header.key_pos = KEY_POS 111 | mock_result = msg.bytes() 112 | assert mock_result == DATA_ACK 113 | 114 | 115 | def test_parse_data_ack(): 116 | """Test parsing data ack packet.""" 117 | DATA_ACK = bytes([ 118 | 0x00, 0x15, 0xcf, 0x15, 0x4f, 0xa0, 0x13, 0xad, 119 | 0xbd, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 120 | 0x10, 0x15, 0xcf, 0x15, 0x50, 0x00, 0x01, 0x90, 121 | 0x00, 0x00, 0x00, 0x00, 0x00, 122 | ]) 123 | TAG_REMOTE = 365892943 124 | GMAC = 2685644221 125 | KEY_POS = 0 126 | TSN = 365892944 127 | A_RWND = 102400 128 | 129 | mock_packet = Packet.parse(DATA_ACK) 130 | assert mock_packet.type == Header.Type.CONTROL 131 | assert mock_packet.chunk.type == Chunk.Type.DATA_ACK 132 | params = mock_packet.params 133 | assert params["tag_remote"] == TAG_REMOTE 134 | assert params["gmac"] == GMAC 135 | assert params["key_pos"] == KEY_POS 136 | assert params["tsn"] == TSN 137 | assert params["a_rwnd"] == A_RWND 138 | assert params["gap_ack_blocks_count"] == 0 139 | assert params["dup_tsns_count"] == 0 140 | 141 | def test_parse_video(): 142 | """Test Video Parsing.""" 143 | AV_DATA = b"\x12\x00\x01\x00\x01\x00 \x08\x01\x06x\x9c\xef\xe8\x00\x00\x10\xc0\x00<\x00\x00\x00\x05!6F\xd2D\xe7\'<\x7f\xca\xfe\xcf\x8ft\xb5\xb9*\xdfm\xd7\x97\r\x8b\xac\xcee\x05\r\xf8r]Kg\xdf\xde\xda\xd2aT\xf5\xb0" 144 | KEY_POS = 4288 145 | mock_packet = Packet.parse(AV_DATA) 146 | assert mock_packet.type == Header.Type.VIDEO 147 | assert mock_packet.key_pos == KEY_POS 148 | assert mock_packet.index == 1 149 | assert mock_packet.unit_index == 1 150 | assert mock_packet.frame_index == 1 151 | assert mock_packet.frame_length == 3 152 | assert mock_packet.frame_length_src == 2 153 | assert mock_packet.frame_length_fec == 1 154 | 155 | 156 | # fmt: on 157 | --------------------------------------------------------------------------------