├── .devcontainer └── devcontainer.json ├── .gitattributes ├── .github └── dependabot.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── doc ├── ChangeLog.md ├── Dev_notes.md ├── Install_en.md └── UsageNotes_en.md ├── resources ├── License.md ├── MainIcons.ico ├── Screenshot.png ├── WPLogo150.png ├── WPLogo44.png └── racing-game-steering-wheel-svgrepo-com.svg └── src ├── ESP32SimWheelConfig ├── __main__.py ├── __pixels_test__.py ├── esp32simwheel.py ├── lang_en.py ├── lang_es.py ├── lang_zh.py └── rename_devices.py ├── build.py └── requirements.txt /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/python 3 | { 4 | "name": "Python 3", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/python:1-3.11-buster" 7 | 8 | // Features to add to the dev container. More info: https://containers.dev/features. 9 | // "features": {}, 10 | 11 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 12 | // "forwardPorts": [], 13 | 14 | // Use 'postCreateCommand' to run commands after the container is created. 15 | // "postCreateCommand": "pip3 install --user -r requirements.txt", 16 | 17 | // Configure tool-specific properties. 18 | // "customizations": {}, 19 | 20 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 21 | // "remoteUser": "root" 22 | } 23 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for more information: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | # https://containers.dev/guide/dependabot 6 | 7 | version: 2 8 | updates: 9 | - package-ecosystem: "devcontainers" 10 | directory: "/" 11 | schedule: 12 | interval: weekly 13 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 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 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | #.idea/ 153 | 154 | # Custom 155 | draft/ 156 | .vscode/launch.json 157 | 158 | # Deployment 159 | pyz_build/ 160 | *.pyz 161 | *.pyzw -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "gamepad", 4 | "hidapi", 5 | "nicegui", 6 | "Recalibrar" 7 | ], 8 | "cSpell.ignoreWords": [ 9 | "Aalexz", 10 | "BBBBBHH", 11 | "NOSONAR", 12 | "Zauberzeug", 13 | "aggrid", 14 | "appstrings", 15 | "autorenew", 16 | "dpad", 17 | "hidapi", 18 | "msgid", 19 | "msgstr", 20 | "onefile", 21 | "pyinstaller", 22 | "swjson", 23 | "udev", 24 | "udevadm", 25 | "uvicorn" 26 | ] 27 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | EUROPEAN UNION PUBLIC LICENCE v. 1.2 2 | EUPL © the European Union 2007, 2016 3 | 4 | This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined 5 | below) which is provided under the terms of this Licence. Any use of the Work, 6 | other than as authorised under this Licence is prohibited (to the extent such 7 | use is covered by a right of the copyright holder of the Work). 8 | 9 | The Work is provided under the terms of this Licence when the Licensor (as 10 | defined below) has placed the following notice immediately following the 11 | copyright notice for the Work: 12 | 13 | Licensed under the EUPL 14 | 15 | or has expressed by any other means his willingness to license under the EUPL. 16 | 17 | 1. Definitions 18 | 19 | In this Licence, the following terms have the following meaning: 20 | 21 | - ‘The Licence’: this Licence. 22 | 23 | - ‘The Original Work’: the work or software distributed or communicated by the 24 | Licensor under this Licence, available as Source Code and also as Executable 25 | Code as the case may be. 26 | 27 | - ‘Derivative Works’: the works or software that could be created by the 28 | Licensee, based upon the Original Work or modifications thereof. This Licence 29 | does not define the extent of modification or dependence on the Original Work 30 | required in order to classify a work as a Derivative Work; this extent is 31 | determined by copyright law applicable in the country mentioned in Article 15. 32 | 33 | - ‘The Work’: the Original Work or its Derivative Works. 34 | 35 | - ‘The Source Code’: the human-readable form of the Work which is the most 36 | convenient for people to study and modify. 37 | 38 | - ‘The Executable Code’: any code which has generally been compiled and which is 39 | meant to be interpreted by a computer as a program. 40 | 41 | - ‘The Licensor’: the natural or legal person that distributes or communicates 42 | the Work under the Licence. 43 | 44 | - ‘Contributor(s)’: any natural or legal person who modifies the Work under the 45 | Licence, or otherwise contributes to the creation of a Derivative Work. 46 | 47 | - ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of 48 | the Work under the terms of the Licence. 49 | 50 | - ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, 51 | renting, distributing, communicating, transmitting, or otherwise making 52 | available, online or offline, copies of the Work or providing access to its 53 | essential functionalities at the disposal of any other natural or legal 54 | person. 55 | 56 | 2. Scope of the rights granted by the Licence 57 | 58 | The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, 59 | sublicensable licence to do the following, for the duration of copyright vested 60 | in the Original Work: 61 | 62 | - use the Work in any circumstance and for all usage, 63 | - reproduce the Work, 64 | - modify the Work, and make Derivative Works based upon the Work, 65 | - communicate to the public, including the right to make available or display 66 | the Work or copies thereof to the public and perform publicly, as the case may 67 | be, the Work, 68 | - distribute the Work or copies thereof, 69 | - lend and rent the Work or copies thereof, 70 | - sublicense rights in the Work or copies thereof. 71 | 72 | Those rights can be exercised on any media, supports and formats, whether now 73 | known or later invented, as far as the applicable law permits so. 74 | 75 | In the countries where moral rights apply, the Licensor waives his right to 76 | exercise his moral right to the extent allowed by law in order to make effective 77 | the licence of the economic rights here above listed. 78 | 79 | The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to 80 | any patents held by the Licensor, to the extent necessary to make use of the 81 | rights granted on the Work under this Licence. 82 | 83 | 3. Communication of the Source Code 84 | 85 | The Licensor may provide the Work either in its Source Code form, or as 86 | Executable Code. If the Work is provided as Executable Code, the Licensor 87 | provides in addition a machine-readable copy of the Source Code of the Work 88 | along with each copy of the Work that the Licensor distributes or indicates, in 89 | a notice following the copyright notice attached to the Work, a repository where 90 | the Source Code is easily and freely accessible for as long as the Licensor 91 | continues to distribute or communicate the Work. 92 | 93 | 4. Limitations on copyright 94 | 95 | Nothing in this Licence is intended to deprive the Licensee of the benefits from 96 | any exception or limitation to the exclusive rights of the rights owners in the 97 | Work, of the exhaustion of those rights or of other applicable limitations 98 | thereto. 99 | 100 | 5. Obligations of the Licensee 101 | 102 | The grant of the rights mentioned above is subject to some restrictions and 103 | obligations imposed on the Licensee. Those obligations are the following: 104 | 105 | Attribution right: The Licensee shall keep intact all copyright, patent or 106 | trademarks notices and all notices that refer to the Licence and to the 107 | disclaimer of warranties. The Licensee must include a copy of such notices and a 108 | copy of the Licence with every copy of the Work he/she distributes or 109 | communicates. The Licensee must cause any Derivative Work to carry prominent 110 | notices stating that the Work has been modified and the date of modification. 111 | 112 | Copyleft clause: If the Licensee distributes or communicates copies of the 113 | Original Works or Derivative Works, this Distribution or Communication will be 114 | done under the terms of this Licence or of a later version of this Licence 115 | unless the Original Work is expressly distributed only under this version of the 116 | Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee 117 | (becoming Licensor) cannot offer or impose any additional terms or conditions on 118 | the Work or Derivative Work that alter or restrict the terms of the Licence. 119 | 120 | Compatibility clause: If the Licensee Distributes or Communicates Derivative 121 | Works or copies thereof based upon both the Work and another work licensed under 122 | a Compatible Licence, this Distribution or Communication can be done under the 123 | terms of this Compatible Licence. For the sake of this clause, ‘Compatible 124 | Licence’ refers to the licences listed in the appendix attached to this Licence. 125 | Should the Licensee's obligations under the Compatible Licence conflict with 126 | his/her obligations under this Licence, the obligations of the Compatible 127 | Licence shall prevail. 128 | 129 | Provision of Source Code: When distributing or communicating copies of the Work, 130 | the Licensee will provide a machine-readable copy of the Source Code or indicate 131 | a repository where this Source will be easily and freely available for as long 132 | as the Licensee continues to distribute or communicate the Work. 133 | 134 | Legal Protection: This Licence does not grant permission to use the trade names, 135 | trademarks, service marks, or names of the Licensor, except as required for 136 | reasonable and customary use in describing the origin of the Work and 137 | reproducing the content of the copyright notice. 138 | 139 | 6. Chain of Authorship 140 | 141 | The original Licensor warrants that the copyright in the Original Work granted 142 | hereunder is owned by him/her or licensed to him/her and that he/she has the 143 | power and authority to grant the Licence. 144 | 145 | Each Contributor warrants that the copyright in the modifications he/she brings 146 | to the Work are owned by him/her or licensed to him/her and that he/she has the 147 | power and authority to grant the Licence. 148 | 149 | Each time You accept the Licence, the original Licensor and subsequent 150 | Contributors grant You a licence to their contributions to the Work, under the 151 | terms of this Licence. 152 | 153 | 7. Disclaimer of Warranty 154 | 155 | The Work is a work in progress, which is continuously improved by numerous 156 | Contributors. It is not a finished work and may therefore contain defects or 157 | ‘bugs’ inherent to this type of development. 158 | 159 | For the above reason, the Work is provided under the Licence on an ‘as is’ basis 160 | and without warranties of any kind concerning the Work, including without 161 | limitation merchantability, fitness for a particular purpose, absence of defects 162 | or errors, accuracy, non-infringement of intellectual property rights other than 163 | copyright as stated in Article 6 of this Licence. 164 | 165 | This disclaimer of warranty is an essential part of the Licence and a condition 166 | for the grant of any rights to the Work. 167 | 168 | 8. Disclaimer of Liability 169 | 170 | Except in the cases of wilful misconduct or damages directly caused to natural 171 | persons, the Licensor will in no event be liable for any direct or indirect, 172 | material or moral, damages of any kind, arising out of the Licence or of the use 173 | of the Work, including without limitation, damages for loss of goodwill, work 174 | stoppage, computer failure or malfunction, loss of data or any commercial 175 | damage, even if the Licensor has been advised of the possibility of such damage. 176 | However, the Licensor will be liable under statutory product liability laws as 177 | far such laws apply to the Work. 178 | 179 | 9. Additional agreements 180 | 181 | While distributing the Work, You may choose to conclude an additional agreement, 182 | defining obligations or services consistent with this Licence. However, if 183 | accepting obligations, You may act only on your own behalf and on your sole 184 | responsibility, not on behalf of the original Licensor or any other Contributor, 185 | and only if You agree to indemnify, defend, and hold each Contributor harmless 186 | for any liability incurred by, or claims asserted against such Contributor by 187 | the fact You have accepted any warranty or additional liability. 188 | 189 | 10. Acceptance of the Licence 190 | 191 | The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ 192 | placed under the bottom of a window displaying the text of this Licence or by 193 | affirming consent in any other similar way, in accordance with the rules of 194 | applicable law. Clicking on that icon indicates your clear and irrevocable 195 | acceptance of this Licence and all of its terms and conditions. 196 | 197 | Similarly, you irrevocably accept this Licence and all of its terms and 198 | conditions by exercising any rights granted to You by Article 2 of this Licence, 199 | such as the use of the Work, the creation by You of a Derivative Work or the 200 | Distribution or Communication by You of the Work or copies thereof. 201 | 202 | 11. Information to the public 203 | 204 | In case of any Distribution or Communication of the Work by means of electronic 205 | communication by You (for example, by offering to download the Work from a 206 | remote location) the distribution channel or media (for example, a website) must 207 | at least provide to the public the information requested by the applicable law 208 | regarding the Licensor, the Licence and the way it may be accessible, concluded, 209 | stored and reproduced by the Licensee. 210 | 211 | 12. Termination of the Licence 212 | 213 | The Licence and the rights granted hereunder will terminate automatically upon 214 | any breach by the Licensee of the terms of the Licence. 215 | 216 | Such a termination will not terminate the licences of any person who has 217 | received the Work from the Licensee under the Licence, provided such persons 218 | remain in full compliance with the Licence. 219 | 220 | 13. Miscellaneous 221 | 222 | Without prejudice of Article 9 above, the Licence represents the complete 223 | agreement between the Parties as to the Work. 224 | 225 | If any provision of the Licence is invalid or unenforceable under applicable 226 | law, this will not affect the validity or enforceability of the Licence as a 227 | whole. Such provision will be construed or reformed so as necessary to make it 228 | valid and enforceable. 229 | 230 | The European Commission may publish other linguistic versions or new versions of 231 | this Licence or updated versions of the Appendix, so far this is required and 232 | reasonable, without reducing the scope of the rights granted by the Licence. New 233 | versions of the Licence will be published with a unique version number. 234 | 235 | All linguistic versions of this Licence, approved by the European Commission, 236 | have identical value. Parties can take advantage of the linguistic version of 237 | their choice. 238 | 239 | 14. Jurisdiction 240 | 241 | Without prejudice to specific agreement between parties, 242 | 243 | - any litigation resulting from the interpretation of this License, arising 244 | between the European Union institutions, bodies, offices or agencies, as a 245 | Licensor, and any Licensee, will be subject to the jurisdiction of the Court 246 | of Justice of the European Union, as laid down in article 272 of the Treaty on 247 | the Functioning of the European Union, 248 | 249 | - any litigation arising between other parties and resulting from the 250 | interpretation of this License, will be subject to the exclusive jurisdiction 251 | of the competent court where the Licensor resides or conducts its primary 252 | business. 253 | 254 | 15. Applicable Law 255 | 256 | Without prejudice to specific agreement between parties, 257 | 258 | - this Licence shall be governed by the law of the European Union Member State 259 | where the Licensor has his seat, resides or has his registered office, 260 | 261 | - this licence shall be governed by Belgian law if the Licensor has no seat, 262 | residence or registered office inside a European Union Member State. 263 | 264 | Appendix 265 | 266 | ‘Compatible Licences’ according to Article 5 EUPL are: 267 | 268 | - GNU General Public License (GPL) v. 2, v. 3 269 | - GNU Affero General Public License (AGPL) v. 3 270 | - Open Software License (OSL) v. 2.1, v. 3.0 271 | - Eclipse Public License (EPL) v. 1.0 272 | - CeCILL v. 2.0, v. 2.1 273 | - Mozilla Public Licence (MPL) v. 2 274 | - GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 275 | - Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for 276 | works other than software 277 | - European Union Public Licence (EUPL) v. 1.1, v. 1.2 278 | - Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong 279 | Reciprocity (LiLiQ-R+). 280 | 281 | The European Commission may update this Appendix to later versions of the above 282 | licences without producing a new version of the EUPL, as long as they provide 283 | the rights granted in Article 2 of this Licence and protect the covered Source 284 | Code from exclusive appropriation. 285 | 286 | All other changes or additions to this Appendix require the production of a new 287 | EUPL version. 288 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Configuration app for open source ESP32-based sim wheels / button boxes 2 | 3 | A companion app for the 4 | [open source ESP32-based wireless steering wheel or button box](https://github.com/afpineda/OpenSourceSimWheelESP32). 5 | 6 | ![Screenshot](./resources/Screenshot.png) 7 | 8 | ## Features 9 | 10 | - Load/save from/to file. 11 | - Handle multiple connected devices. 12 | - Choose working mode of clutch paddles, "ALT" buttons and/or directional pad. 13 | - Set clutch bite point. 14 | - Force auto-calibration of analog clutch paddles. 15 | - Force battery auto-calibration (not available if the battery was "factory-calibrated" previously). 16 | - For rotary encoders, set the pulse width multiplier. 17 | - Map hardware buttons to user-defined HID buttons. 18 | - Supports all firmware versions to date (data versions 1.0 to 1.6). 19 | - Rename device (Windows and BLE, only) 20 | - Set a custom VID and/or PID (BLE only) 21 | - Reverse axis polarity (in analog clutch paddles only) 22 | - Windows / Linux / Mac 23 | - UI languages (automatically detected): 24 | - English (default) 25 | - Español (Spanish) 26 | - 中国 (Chinese) 27 | 28 | ## User documentation 29 | 30 | [Installing and running](./doc/Install_en.md) 31 | 32 | [Usage notes](./doc/UsageNotes_en.md) 33 | 34 | ## Known issues / Troubleshooting 35 | 36 | ### User interface not showing 37 | 38 | As a workaround, open your web browser and try one of these URL: `localhost:8000` or `localhost:8080`. 39 | The user interface will show in your browser. 40 | 41 | If your operating system is Windows 11, this issue is caused by some kind 42 | of security measure for files downloaded from the Internet. 43 | Download the ZIP file again and follow the [installation notes](./doc/Install_en.md) carefully. 44 | 45 | ## Other 46 | 47 | [Change log](./doc/ChangeLog.md) 48 | 49 | [Notes for software developers](./doc/Dev_notes.md) 50 | 51 | ### Licensed work 52 | 53 | This project uses the following libraries under a compatible license: 54 | 55 | - ["hidapi"](https://github.com/trezor/cython-hidapi) from Gary Bishop: 56 | 57 | Available under several licenses, including public domain. 58 | 59 | - ["nicegui"](https://nicegui.io/) from Zauberzeug GmbH: 60 | 61 | Available under [MIT license](https://mit-license.org/). 62 | 63 | - ["appstrings"](ttps://github.com/afpineda/appstrings-python) from this author: 64 | 65 | Available under EUPL license. 66 | -------------------------------------------------------------------------------- /doc/ChangeLog.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## 2.7.2 4 | 5 | - Minor UI styling for legibility. 6 | 7 | ## 2.7.1 8 | 9 | - Up to date with data version 1.6 (firmware version 6.11.3). 10 | 11 | ## 2.7.0 12 | 13 | - Support for custom pulse width multipliers for rotary encoders. 14 | - Up to date with data version 1.5 (firmware version 6.11.0). 15 | 16 | ## 2.6.0 17 | 18 | - Support for "Launch control" (firmware version 6.10.0). 19 | 20 | ## 2.5.0 21 | 22 | - Updated to work with data version 1.4 (firmware version 6.4.0) 23 | - No new features. 24 | - From now on, this changelog is written in reverse order. 25 | 26 | ## 2.4.0 27 | 28 | - Show current VID and PID on device listing. 29 | - Updated to work with data version 1.3. 30 | 31 | ## 2.3.0 32 | 33 | - Added two buttons in order to reverse axis polarity (in analog clutch paddles only). 34 | 35 | ## 2.2.0 36 | 37 | - Updated to work with data version 1.2. 38 | - On BLE devices you may set a custom VID, PID (Windows/Linux/Mac) and/or display name (Windows only). 39 | - Bug fixes. 40 | - Updated documentation. 41 | 42 | ## 2.1.2 43 | 44 | - Fixed bug which caused an app crash at startup when using certain character encodings in the text terminal 45 | (used for logging purposes). No new functionalities. 46 | - Fixed incomplete installation instructions for Linux users. 47 | 48 | ## 2.1.1 49 | 50 | - Rebuild due to a [bug](https://github.com/afpineda/appstrings-python/issues/1) in library "appstrings". 51 | Thanks to user [Aalexz](https://github.com/Aalexz).No new functionalities. 52 | - Minor changes for [SonarLint](https://docs.sonarsource.com/sonarlint/vs-code/) compliance. 53 | 54 | ## 2.1.0 55 | 56 | - Added Chinese language. 57 | - Added command-line parameter to force any available user language. 58 | - Code revision with SonarLint. 59 | 60 | ## 2.0.1 61 | 62 | - License has changed to EUPL 1.2 63 | - Implementation now uses `appstrings` library from this author. 64 | - No changes in functionality, so there is no release for this version. 65 | 66 | ## 2.0.0 67 | 68 | - Moving to Python implementation. 69 | - A change in the settings through the hardware itself is 70 | automatically reflected in the app without a explicit refresh. 71 | - Backwards-compatible: support for 1.0 and 1.1 data versions. 72 | 73 | ## 1.3.0 74 | 75 | - Handle multiple devices available at the same time. 76 | - Minor bug fixes. 77 | 78 | ## 1.2.0 79 | 80 | - Updated to data version 1.1. 81 | - Select DPAD working mode. 82 | - User-defined buttons map. 83 | 84 | ## 1.1.0 85 | 86 | - Ignore devices with no available configuration options. 87 | - Added some usage notes. 88 | 89 | ## 1.0.0 90 | 91 | - First release with basic functionality. 92 | - Windows only. Delphi implementation. 93 | -------------------------------------------------------------------------------- /doc/Dev_notes.md: -------------------------------------------------------------------------------- 1 | # Notes and commentaries for software developers 2 | 3 | ## Fundamentals 4 | 5 | This app interfaces hardware devices through HID feature reports. 6 | The binary format of those reports is called "data version", which is reported by the device itself. 7 | See [HID notes](https://github.com/afpineda/OpenSourceSimWheelESP32/blob/main/doc/firmware/HID_notes.md) 8 | for a complete description. 9 | 10 | The source code includes a "module" which can be reused for custom applications. 11 | Implements all the details to enumerate and interface supported devices. 12 | 13 | ## Source code 14 | 15 | In fact, there are two different implementations for this app, 16 | as described bellow. 17 | 18 | ### Python implementation 19 | 20 | Currently active and maintained. 21 | 22 | It was tested successfully with Python 3.12, however, PyInstaller does not work with that version. 23 | Use Python 3.11.7 for freezing. 24 | 25 | Direct release dependencies are: 26 | 27 | - [hidapi](https://pypi.org/project/hidapi/) 28 | - [NiceGUI](https://pypi.org/project/nicegui/) 29 | - [appstrings](https://pypi.org/project/appstrings/) 30 | 31 | There is a "requirements.txt" file for all dependencies. 32 | 33 | Development dependencies includes [PyInstaller](https://pypi.org/project/pyinstaller/). 34 | 35 | At the time of writing, it was not possible to freeze using "zipapp" with success. 36 | 37 | ### Delphi implementation 38 | 39 | Can be found in the "delphi-implementation" branch of this project. 40 | Currently not active nor maintained. 41 | However, this situation could be reverted in the future. 42 | This implementation works with data version 1.1, but not with previous ones. 43 | 44 | ## Future work 45 | 46 | **The decision to continue development with one technology or another 47 | depends on the drawbacks of those technologies and may 48 | change in the future.** 49 | -------------------------------------------------------------------------------- /doc/Install_en.md: -------------------------------------------------------------------------------- 1 | # Installing and running 2 | 3 | There are some choices as described below. 4 | 5 | ## Windows bundle 6 | 7 | For Windows 64-bits, only. 8 | 9 | - Go to the [Releases](https://github.com/afpineda/SimWheelESP32Config/releases) page 10 | and download the latest file named "**ESP32SimWheel-windows.zip**" (or similar). 11 | - **IMPORTANT note** for Windows 11 users: 12 | - Right-click on the ZIP file and select *Properties* 13 | - At the bottom of the properties page, look for a security notice 14 | - Check the "unblock" box next to it. 15 | 16 | See [this tutorial](https://www.elevenforum.com/t/unblock-file-downloaded-from-internet-in-windows-11.1125/) 17 | for detailed instructions. 18 | 19 | - Unzip to a folder of your choice. 20 | 21 | Run the executable in that folder. 22 | 23 | ## Running from source code 24 | 25 | For all platforms with Python support (Windows / Linux / Mac). 26 | 27 | To be done only once: 28 | 29 | - Python version 3 is required. At the time of writing, this application works fine with **version 3.12.1**. 30 | If not done yet, download and install [Python](https://www.python.org/downloads/). 31 | - Ensure the python interpreter can be found in your "PATH" environment variable. 32 | If you have several Python versions installed, ensure the proper one is found first 33 | in the "PATH" environment variable. 34 | Alternatively, you may configure a [virtual environment](https://realpython.com/python-virtual-environments-a-primer/) 35 | ("venv" or "conda") in the installation folder. 36 | - Go to the [Releases](https://github.com/afpineda/SimWheelESP32Config/releases) page 37 | and download the latest **source code** (in ZIP format). 38 | - Unzip to a folder of your choice. 39 | - Open a shell terminal. 40 | - Place the prompt in the root directory of that folder, 41 | where the files "LICENSE.md" and "README.md" are found. 42 | - Install library dependencies: 43 | 44 | ```shell 45 | pip install -r src/requirements.txt 46 | ``` 47 | 48 | or: 49 | 50 | ```shell 51 | pip install hidapi 52 | pip install nicegui 53 | pip install appstrings 54 | ``` 55 | 56 | - If something goes wrong, try an older Python version. 57 | - Note for **Linux** users: 58 | 59 | As stated in the [hidapi page](https://github.com/trezor/cython-hidapi?tab=readme-ov-file#udev-rules): 60 | *"For correct functionality under Linux, you need to create a rule file similar 61 | to this one in your [udev](https://raw.githubusercontent.com/trezor/trezor-common/master/udev/51-trezor.rules) 62 | rules directory. 63 | Also you might need to call `udevadm control --reload-rules` to reload the rules."* 64 | 65 | In order to run the application, type: 66 | 67 | ```shell 68 | python /src/ESP32SimWheelConfig/__main__.py 69 | ``` 70 | 71 | You may want to put that command in a shell script. 72 | If you configured a virtual (python) environment, don't forget to activate it previously. 73 | 74 | ## Forcing a specific user language 75 | 76 | User language is automatically detected from your operating system environment. 77 | However, you may run the application in a specific user language just by passing 78 | the language code as a command-line argument. For example: 79 | 80 | ```shell 81 | ESP32SimWheel es 82 | ``` 83 | 84 | or 85 | 86 | ```shell 87 | python /src/ESP32SimWheelConfig/__main__.py es 88 | ``` 89 | 90 | Available language codes are: 91 | 92 | | Code | Language | 93 | | ---- | ----------------- | 94 | | en | English | 95 | | es | Spanish (Español) | 96 | | zh | Chinese (中国) | 97 | -------------------------------------------------------------------------------- /doc/UsageNotes_en.md: -------------------------------------------------------------------------------- 1 | # Usage notes 2 | 3 | ## Startup 4 | 5 | At startup, connected devices will be automatically scanned. 6 | When found, the app will connect to the first available one. 7 | 8 | Note that Bluetooth devices **must be paired** first 9 | in order for this app to detect them. 10 | 11 | ## Select another sim wheel / button box 12 | 13 | Click on the "drawer" button on the top-left corner. 14 | Available devices will be automatically scanned and listed. 15 | If your device is not connected yet, connect it, wait a second, and 16 | click on the `🔄 Refresh` button. 17 | 18 | Note that devices with no user-configurable settings will never show up. 19 | 20 | Click on the `✅ Select` button in order to connect to that device. 21 | 22 | ## User settings 23 | 24 | Only **user-configurable** settings will show up, 25 | which depends on your device capabilities. 26 | 27 | ## Security lock 28 | 29 | You can activate or deactivate the security lock on your device 30 | by pressing a specific combination of buttons. 31 | This program will display a warning, 32 | and any attempt to modify the configuration will be unsuccessful 33 | if the security lock is activated. 34 | 35 | ## Pulse width multiplier for rotary encoders 36 | 37 | A high value reduces the likelihood of missed rotations on the host computer, 38 | but increases the likelihood of missed rotations on the device 39 | if several consecutive rotations are accumulated. 40 | A low value has the opposite effect. 41 | 42 | Increase the pulse width multiplier if the host computer occasionally misses rotations, 43 | but keep it as low as possible. 44 | 45 | Changes are saved to flash memory. 46 | 47 | ## Button map 48 | 49 | - Click `🔄 Reload` to download the current map from the device (may take a few seconds). 50 | - Changes are applied immediately, 51 | but **not** saved automatically. 52 | - Click `💾 Save` to make any changes available after power off. 53 | - Click `🏭 Defaults` to revert to "factory defaults". 54 | - Button #0 is the first button. 55 | Note that the following button numbers have a special meaning in Windows: 56 | - *00*: "A" button 57 | - *01*: "B" button 58 | - *02*: "X" button 59 | - *03*: "Y" button 60 | - *04*: "LB" button (should be reserved for the left shift paddle) 61 | - *05*: "RB" button (should be reserved for the right shift paddle) 62 | - *06*: "Back" button 63 | - *07*: "Start" button. 64 | - The "Firmware-defined" button numbers are fixed and depend on the hardware. 65 | - The "user-defined" button number is sent to the hosting PC when the 66 | corresponding "firmware-defined" button is pressed. 67 | By default, this is the same as the "firmware-defined" button number. 68 | - The "user-defined" button number in "alt mode" is reported to the hosting PC when the 69 | corresponding "firmware-defined" button is pressed and the *alternate mode* is engaged. 70 | By default, this is equal to the "firmware-defined" button number plus 64. 71 | - Each user-defined button number ranges from 0 to 127 (inclusive). 72 | 73 | ## Custom hardware ID and display name 74 | 75 | Available on Bluetooth devices only. 76 | 77 | If you have two or more BLE devices using ESP32 open simwheel firmware, 78 | **all of them will show the same display name, because they share the same hardware ID**. 79 | 80 | If you need two or more devices to exhibit a different display name, 81 | provide a custom PID (**recommended**), VID, or both. 82 | You may use the existing VID and PID from another non-related device. 83 | However, this is not recommended. 84 | 85 | You may set a custom **display name** for all devices sharing the given VID and PID. 86 | If you clear the display name, a generic one will show on the next computer reboot. 87 | **Be warned: you could rename other non-related devices**. 88 | -------------------------------------------------------------------------------- /resources/License.md: -------------------------------------------------------------------------------- 1 | # Racing Game Steering Wheel SVG 2 | 3 | Icon is in the **Public Domain**. 4 | Downloaded from [SVG repo](https://www.svgrepo.com/svg/276256/racing-game-steering-wheel) 5 | -------------------------------------------------------------------------------- /resources/MainIcons.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afpineda/SimWheelESP32Config/737a2222d1843228c8e4f2c346ca5695f0f1639c/resources/MainIcons.ico -------------------------------------------------------------------------------- /resources/Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afpineda/SimWheelESP32Config/737a2222d1843228c8e4f2c346ca5695f0f1639c/resources/Screenshot.png -------------------------------------------------------------------------------- /resources/WPLogo150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afpineda/SimWheelESP32Config/737a2222d1843228c8e4f2c346ca5695f0f1639c/resources/WPLogo150.png -------------------------------------------------------------------------------- /resources/WPLogo44.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afpineda/SimWheelESP32Config/737a2222d1843228c8e4f2c346ca5695f0f1639c/resources/WPLogo44.png -------------------------------------------------------------------------------- /resources/racing-game-steering-wheel-svgrepo-com.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 11 | 16 | 18 | 20 | 22 | 24 | 25 | 26 | 28 | 29 | 30 | 32 | 33 | 40 | -------------------------------------------------------------------------------- /src/ESP32SimWheelConfig/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # **************************************************************************** 3 | # @file __main__.py 4 | # 5 | # @author Ángel Fernández Pineda. Madrid. Spain. 6 | # @date 2024-01-21 7 | # @brief Configuration app for ESP32-based open source sim wheels 8 | # @copyright 2024 Ángel Fernández Pineda. Madrid. Spain. 9 | # @license Licensed under the EUPL 10 | # ***************************************************************************** 11 | 12 | if __package__: 13 | from . import esp32simwheel 14 | else: 15 | import esp32simwheel 16 | 17 | from nicegui import ui, app, run 18 | import webview 19 | from json import dumps, loads 20 | from appstrings import gettext, set_translation_locale 21 | from lang_en import EN 22 | from lang_es import ES # NOSONAR 23 | from lang_zh import ZH # NOSONAR 24 | from rename_devices import get_display_name_from_registry, set_display_name_in_registry 25 | import os 26 | import sys 27 | 28 | ## NOTE: Must avoid non-ASCII characters at print() 29 | 30 | ################################################################################################## 31 | 32 | print("ESP32SimWheelConfig --------------------------------------------") 33 | 34 | for arg in sys.argv: 35 | arg_cf = arg.casefold() 36 | if arg_cf == "en": 37 | print("Language: English") 38 | set_translation_locale("en") 39 | if arg_cf == "es": 40 | print("Language: Espanol") 41 | set_translation_locale("es") 42 | if arg_cf == "zh": 43 | print("Language: Chinese") 44 | set_translation_locale("zh") 45 | 46 | ################################################################################################## 47 | 48 | _ = gettext 49 | 50 | STR = EN 51 | MAX_DISPLAY_NAME_LENGTH = 72 52 | DEFAULT_GROUP_CLASSES = "text-h6 w-full" 53 | 54 | ################################################################################################## 55 | 56 | device = esp32simwheel.SimWheel() 57 | 58 | PROFILE_FILE_TYPE = ("Device profiles (*.swjson)",) 59 | 60 | ################################################################################################## 61 | 62 | buttons_map_data = [] 63 | 64 | __buttons_map_columns = [ 65 | { 66 | "headerName": _(STR.FIRMWARE_DEFINED), 67 | "field": "firmware", 68 | "wrapHeaderText": True, 69 | "autoHeaderHeight": True, 70 | }, 71 | { 72 | "headerName": _(STR.USER_DEFINED), 73 | "field": "user", 74 | "editable": True, 75 | "wrapHeaderText": True, 76 | "autoHeaderHeight": True, 77 | }, 78 | { 79 | "headerName": _(STR.USER_DEFINED_ALT), 80 | "field": "userAltMode", 81 | "editable": True, 82 | "wrapHeaderText": True, 83 | "autoHeaderHeight": True, 84 | }, 85 | ] 86 | 87 | 88 | def is_running_in_windows() -> bool: 89 | return os.name == "nt" 90 | 91 | 92 | def get_16bit_value(value_as_string: str) -> int | None: 93 | try: 94 | v = int(value_as_string) 95 | except Exception: 96 | try: 97 | v = int(value_as_string, 16) 98 | except Exception: 99 | return None 100 | if (v > 0) and (v <= 0xFFFF): 101 | return v 102 | else: 103 | return None 104 | 105 | 106 | def hardware_id_validate(value: str) -> bool: 107 | return get_16bit_value(value) != None 108 | 109 | 110 | def please_wait(): 111 | notification = ui.notification(timeout=None) 112 | notification.message = _(STR.WAIT) 113 | notification.spinner = True 114 | return notification 115 | 116 | 117 | def notify_done(result: bool = True): 118 | if result: 119 | ui.notify(_(STR.DONE)) 120 | else: 121 | ui.notify(_(STR.ERROR), type="negative") 122 | 123 | 124 | def _refresh_available_devices(): 125 | print("Refreshing device list") 126 | available_devices_ph.clear() 127 | count = 0 128 | for sim_wheel in esp32simwheel.enumerate(): 129 | count += 1 130 | with available_devices_ph: 131 | with ui.card() as card: 132 | card.classes(add="w-full q-ma-xs") 133 | ui.label(sim_wheel.product_name).classes("text-overline") 134 | ui.label(sim_wheel.manufacturer).classes("font-thin") 135 | ui.label(f"S/N: {sim_wheel.device_id:X}").classes("font-thin") 136 | ui.label(f"VID: {sim_wheel.vid:X}, PID: {sim_wheel.pid:X}").classes( 137 | "font-thin" 138 | ) 139 | ui.button(_(STR.SELECT), icon="task_alt").classes("self-center").on( 140 | "click", lambda path=sim_wheel.path: select_device(path) 141 | ) 142 | sim_wheel.close() 143 | if count == 0: 144 | with available_devices_ph: 145 | ui.label(_(STR.NO_DEVICES_FOUND)).classes( 146 | replace="text-negative", add="text-weight-bold" 147 | ) 148 | 149 | 150 | async def refresh_available_devices(): 151 | notification = please_wait() 152 | await run.io_bound(_refresh_available_devices) 153 | notification.dismiss() 154 | 155 | 156 | def select_device(path: str): 157 | device.path = path 158 | print(f"Selecting {device.path}") 159 | drawer.toggle() 160 | 161 | 162 | def auto_select_device(): 163 | for sim_wheel in esp32simwheel.enumerate(): 164 | device.path = sim_wheel.path 165 | break 166 | 167 | 168 | def device_refresh(): 169 | device.is_alive 170 | 171 | 172 | async def on_app_startup(): 173 | print("Starting") 174 | await refresh_available_devices() 175 | ui.timer(2.0, device_refresh) 176 | 177 | 178 | def set_bite_point(value): 179 | device.bite_point = value 180 | 181 | 182 | def buttons_group_enable(value: bool): 183 | # buttons_map_group.set_enabled(value) 184 | btn_map_reload.set_enabled(value) 185 | btn_map_save.set_enabled(value) 186 | btn_map_defaults.set_enabled(value) 187 | buttons_map_grid.set_visibility(value) 188 | 189 | 190 | def buttons_map_value_change(changes): 191 | row_index = changes.args["rowIndex"] 192 | column_key = changes.args["colId"] 193 | value = changes.args["value"] 194 | if (value < 0) or (value > 127): 195 | ui.notify( 196 | _(STR.INVALID_BTN), 197 | type="negative", 198 | multi_line=True, 199 | ) 200 | else: 201 | buttons_map_data[row_index][column_key] = value 202 | device.set_button_map_tuple(buttons_map_data[row_index]) 203 | # Ensure that the new value was accepted by the device 204 | try: 205 | btn_map = device.get_button_map(buttons_map_data[row_index]["firmware"]) 206 | if btn_map != {}: 207 | buttons_map_data[row_index] = btn_map 208 | except Exception: 209 | pass 210 | buttons_map_grid.update() 211 | 212 | 213 | def _reload_buttons_map(): 214 | print("Loading buttons map") 215 | buttons_map_data.clear() 216 | try: 217 | for map in device.enumerate_buttons_map(): 218 | buttons_map_data.append(map) 219 | print(f"Buttons map: {len(buttons_map_data)} items") 220 | print("Buttons map: Done!") 221 | except Exception: 222 | buttons_map_data.clear() 223 | print("Buttons map: Failed !") 224 | 225 | 226 | async def reload_buttons_map(): 227 | buttons_group_enable(False) 228 | notification = please_wait() 229 | await run.io_bound(_reload_buttons_map) 230 | buttons_map_grid.update() 231 | buttons_group_enable(True) 232 | notification.dismiss() 233 | 234 | 235 | async def save_now(): 236 | buttons_group_enable(False) 237 | device.save_now() 238 | notify_done() 239 | buttons_group_enable(True) 240 | 241 | 242 | async def buttons_map_factory_defaults(): 243 | device.reset_buttons_map() 244 | await reload_buttons_map() 245 | await save_now() 246 | 247 | 248 | def profile_group_enable(enabled: bool = True): 249 | profile_group.enabled = enabled 250 | btn_load_profile.enabled = enabled 251 | btn_save_profile.enabled = enabled 252 | check_profile_buttons_map.enabled = enabled 253 | check_profile_same_device.enabled = enabled 254 | 255 | 256 | def _load_profile(filename: str) -> bool: 257 | print("Loading profile from file") 258 | try: 259 | f = open(filename, "r", encoding="utf-8") 260 | try: 261 | json = f.read() 262 | content = loads(json) 263 | if check_profile_same_device.value: 264 | id_check_ok = ("deviceID" in content) and ( 265 | content["deviceID"] == device.device_id 266 | ) 267 | else: 268 | id_check_ok = True 269 | if not id_check_ok: 270 | f.close() 271 | return False 272 | if not check_profile_buttons_map.value: 273 | content.pop("ButtonsMap", None) 274 | device.deserialize(content) 275 | finally: 276 | f.close() 277 | return True 278 | except Exception: 279 | return False 280 | 281 | 282 | async def load_profile(): 283 | filename = await app.native.main_window.create_file_dialog( 284 | allow_multiple=False, file_types=PROFILE_FILE_TYPE 285 | ) 286 | if filename: 287 | notification = please_wait() 288 | profile_group_enable(False) 289 | done = await run.io_bound(_load_profile, filename[0]) 290 | notification.dismiss() 291 | profile_group_enable(True) 292 | notify_done(done) 293 | 294 | 295 | def _save_profile(filename: str) -> bool: 296 | print("Saving profile to file") 297 | try: 298 | content = device.serialize() 299 | content["deviceID"] = device.device_id 300 | if not check_profile_buttons_map.value: 301 | content.pop("ButtonsMap", None) 302 | json = dumps(content) 303 | f = open(filename, "w", encoding="utf-8") 304 | try: 305 | f.write(json) 306 | finally: 307 | f.close() 308 | return True 309 | except Exception: 310 | return False 311 | 312 | 313 | async def save_profile(): 314 | filename = await app.native.main_window.create_file_dialog( 315 | allow_multiple=False, 316 | dialog_type=webview.SAVE_DIALOG, 317 | file_types=PROFILE_FILE_TYPE, 318 | ) 319 | if filename: 320 | notification = please_wait() 321 | profile_group_enable(False) 322 | done = await run.io_bound(_save_profile, filename) 323 | notification.dismiss() 324 | profile_group_enable(True) 325 | notify_done(done) 326 | 327 | 328 | def on_update_hardware_id(): 329 | vid = device.custom_vid 330 | pid = device.custom_pid 331 | custom_vid_input.value = vid 332 | custom_pid_input.value = pid 333 | display_name_input.value = get_display_name_from_registry(vid, pid) 334 | 335 | 336 | async def hardware_id_factory_defaults(): 337 | try: 338 | device.reset_custom_hardware_id() 339 | vid = device.custom_vid 340 | pid = device.custom_pid 341 | if is_running_in_windows(): 342 | set_display_name_in_registry(vid, pid, None) 343 | on_update_hardware_id() 344 | notify_done() 345 | except Exception: 346 | notify_done(False) 347 | 348 | 349 | async def hardware_id_set(): 350 | try: 351 | display_name = display_name_input.value 352 | if (display_name != None) and (len(display_name) > MAX_DISPLAY_NAME_LENGTH): 353 | raise RuntimeError("Display name is too long") 354 | vid = get_16bit_value(custom_vid_input.value) 355 | pid = get_16bit_value(custom_pid_input.value) 356 | device.set_custom_hardware_id(vid, pid) 357 | if is_running_in_windows(): 358 | set_display_name_in_registry(vid, pid, display_name) 359 | on_update_hardware_id() 360 | notify_done() 361 | except Exception: 362 | notify_done(False) 363 | 364 | 365 | async def reverse_left_axis_click(): 366 | try: 367 | device.reverse_left_axis() 368 | notify_done() 369 | except Exception: 370 | notify_done(False) 371 | 372 | 373 | async def reverse_right_axis_click(): 374 | try: 375 | device.reverse_right_axis() 376 | notify_done() 377 | except Exception: 378 | notify_done(False) 379 | 380 | ################################################################################################## 381 | 382 | # Top header 383 | 384 | with ui.header(): 385 | with ui.row(): 386 | ui.button(icon="menu").on("click", lambda: drawer.toggle()).props( 387 | "flat color=white dense" 388 | ) 389 | headerLabel = ui.label().classes("text-h3 align-middle tracking-wide ellipsis") 390 | headerLabel.bind_text_from( 391 | device, 392 | "is_alive", 393 | backward=lambda is_alive_value: ( 394 | device.product_name if is_alive_value else _(STR.NO_DEVICE) 395 | ), 396 | ) 397 | 398 | # Drawer 399 | 400 | drawer = ui.left_drawer(value=False).props("behavior=desktop") 401 | drawer.on("show", refresh_available_devices) 402 | with drawer: 403 | with ui.row().classes("w-full"): 404 | ui.label(_(STR.AVAILABLE_DEVICES)).classes("text-h7") 405 | ui.space() 406 | ui.button(icon="refresh").props("flat dense").on( 407 | "click", refresh_available_devices, throttle=1 408 | ) 409 | ui.separator() 410 | available_devices_ph = ui.column().classes("w-full justify-center") 411 | 412 | # Main content 413 | 414 | ## Security lock 415 | 416 | read_only_notice = ui.label(_(STR.READ_ONLY_NOTICE)) 417 | read_only_notice.classes("self-center").tailwind.font_size("lg").text_color("red-600") 418 | read_only_notice.bind_visibility_from(device, "is_read_only") 419 | 420 | ## ALT buttons group 421 | 422 | alt_buttons_group = ui.expansion(_(STR.ALT_BUTTONS), value=True, icon="touch_app") 423 | alt_buttons_group.classes(DEFAULT_GROUP_CLASSES) 424 | alt_buttons_group.tailwind.font_weight("bold") 425 | alt_buttons_group.bind_visibility_from(device, "has_alt_buttons") 426 | with alt_buttons_group: 427 | ui.toggle({True: _(STR.ALT_MODE), False: _(STR.REGULAR_BUTTON)}).bind_value( 428 | device, "alt_buttons_working_mode" 429 | ).classes("self-center") 430 | 431 | ## DPAD group 432 | 433 | dpad_group = ui.expansion(_(STR.DPAD), value=True, icon="gamepad") 434 | dpad_group.classes(DEFAULT_GROUP_CLASSES) 435 | dpad_group.tailwind.font_weight("bold") 436 | dpad_group.bind_visibility_from(device, "has_dpad") 437 | with dpad_group: 438 | ui.toggle({True: _(STR.NAV), False: _(STR.REGULAR_BUTTON)}).bind_value( 439 | device, "dpad_working_mode" 440 | ).classes("self-center") 441 | 442 | ## Clutch paddles group 443 | 444 | clutch_paddles_group = ui.expansion(_(STR.CLUTCH_PADDLES), value=True, icon="garage") 445 | clutch_paddles_group.classes(DEFAULT_GROUP_CLASSES) 446 | clutch_paddles_group.tailwind.font_weight("bold") 447 | clutch_paddles_group.bind_visibility_from(device, "has_clutch") 448 | with clutch_paddles_group: 449 | ui.radio( 450 | { 451 | 0: _(STR.CLUTCH), 452 | 1: _(STR.AXIS), 453 | 2: _(STR.ALT_MODE), 454 | 3: _(STR.BUTTON), 455 | 4: _(STR.LAUNCH_CTRL_LEFT_MASTER), 456 | 5: _(STR.LAUNCH_CTRL_RIGHT_MASTER), 457 | } 458 | ).classes("self-center").bind_value(device, "clutch_working_mode").style("font-size: 75%").props("size=xs") 459 | ui.label(_(STR.BITE_POINT)).classes("self-center").tailwind.font_size("sm") 460 | bite_point_slider = ui.slider(min=0, max=254, step=1) 461 | bite_point_slider.bind_value_from(device, "bite_point") 462 | bite_point_slider.bind_enabled_from( 463 | device, 464 | "clutch_working_mode", 465 | backward=lambda value: (value == esp32simwheel.ClutchPaddlesWorkingMode.CLUTCH) 466 | or (value == esp32simwheel.ClutchPaddlesWorkingMode.LAUNCH_CONTROL_LEFT) 467 | or (value == esp32simwheel.ClutchPaddlesWorkingMode.LAUNCH_CONTROL_RIGHT), 468 | ) 469 | bite_point_slider.on( 470 | "update:model-value", 471 | lambda e: set_bite_point(e.args), 472 | throttle=0.25, 473 | leading_events=False, 474 | ) 475 | ui.label(_(STR.ANALOG_AXES)).classes("self-center").bind_visibility_from( 476 | device, "has_analog_clutch_paddles" 477 | ).tailwind.font_size("sm") 478 | ui.button( 479 | _(STR.RECALIBRATE), 480 | icon="autorenew", 481 | on_click=lambda: device.recalibrate_analog_axes(), 482 | ).bind_visibility_from(device, "has_analog_clutch_paddles").classes("self-center") 483 | with ui.row().classes("self-center"): 484 | btn_reverse_left_axis = ui.button( 485 | _(STR.REVERSE_LEFT_AXIS), 486 | icon="invert_colors", 487 | on_click=reverse_left_axis_click, 488 | ).bind_visibility_from(device, "has_analog_clutch_paddles") 489 | btn_reverse_right_axis = ui.button( 490 | _(STR.REVERSE_RIGHT_AXIS), 491 | icon="invert_colors", 492 | on_click=reverse_right_axis_click, 493 | ).bind_visibility_from(device, "has_analog_clutch_paddles") 494 | 495 | ## Battery group 496 | 497 | battery_group = ui.expansion(_(STR.BATTERY), value=True, icon="battery_full") 498 | battery_group.classes(DEFAULT_GROUP_CLASSES) 499 | battery_group.tailwind.font_weight("bold") 500 | battery_group.bind_visibility_from(device, "has_battery") 501 | with battery_group: 502 | ui.label(_(STR.SOC)).classes("self-center").tailwind.font_size("sm") 503 | ui.linear_progress(show_value=False).bind_value_from( 504 | device, "battery_soc", backward=lambda v: v / 100 505 | ) 506 | ui.button( 507 | _(STR.RECALIBRATE), 508 | icon="autorenew", 509 | on_click=lambda: device.recalibrate_battery(), 510 | ).bind_visibility_from(device, "battery_calibration_available").classes( 511 | "self-center" 512 | ) 513 | 514 | ## Buttons map group 515 | 516 | buttons_map_group = ui.expansion(_(STR.BUTTONS_MAP), value=False, icon="map") 517 | buttons_map_group.classes(DEFAULT_GROUP_CLASSES) 518 | buttons_map_group.tailwind.font_weight("bold") 519 | buttons_map_group.bind_visibility_from(device, "has_buttons_map") 520 | with buttons_map_group: 521 | with ui.row().classes("self-center"): 522 | btn_map_reload = ui.button( 523 | _(STR.RELOAD), icon="sync", on_click=reload_buttons_map 524 | ) 525 | btn_map_save = ui.button(_(STR.SAVE), icon="save", on_click=save_now) 526 | btn_map_defaults = ui.button( 527 | _(STR.DEFAULTS), icon="factory", on_click=buttons_map_factory_defaults 528 | ) 529 | 530 | buttons_map_grid = ui.aggrid( 531 | { 532 | "columnDefs": __buttons_map_columns, 533 | "rowData": buttons_map_data, 534 | "rowSelection": "single", 535 | "stopEditingWhenCellsLoseFocus": True, 536 | } 537 | ).on("cellValueChanged", buttons_map_value_change) 538 | 539 | ## Rotary encoders group 540 | 541 | rotary_encoders_group = ui.expansion(_(STR.ROTARY_ENCODERS), value=False, icon="360") 542 | rotary_encoders_group.classes(DEFAULT_GROUP_CLASSES) 543 | rotary_encoders_group.tailwind.font_weight("bold") 544 | rotary_encoders_group.bind_visibility_from(device, "has_rotary_encoders") 545 | with rotary_encoders_group: 546 | with ui.row().classes("self-center"): 547 | ui.label(_(STR.PULSE_WIDTH)).classes("self-center").tailwind.font_size("sm") 548 | ui.number( 549 | min=1, 550 | max=254, 551 | step=1, 552 | precision=0, 553 | prefix="x", 554 | ).bind_value(device, "pulse_width_multiplier") 555 | 556 | ## Profile group 557 | 558 | profile_group = ui.expansion(_(STR.LOCAL_PROFILE), value=False, icon="inventory_2") 559 | profile_group.classes(DEFAULT_GROUP_CLASSES) 560 | profile_group.tailwind.font_weight("bold") 561 | profile_group.bind_visibility_from(device, "is_alive") 562 | with profile_group: 563 | check_profile_same_device = ui.checkbox(_(STR.CHECK_ID), value=True) 564 | check_profile_same_device.tailwind.font_size("sm") 565 | with check_profile_same_device: 566 | ui.tooltip(_(STR.PROFILE_CHECK_TOOLTIP)) 567 | check_profile_buttons_map = ui.checkbox( 568 | _(STR.INCLUDE_BTN_MAP), value=False 569 | ).bind_visibility_from(device, "has_buttons_map") 570 | with ui.row().classes("self-center"): 571 | btn_load_profile = ui.button( 572 | _(STR.LOAD), icon="file_upload", on_click=load_profile 573 | ) 574 | btn_save_profile = ui.button( 575 | _(STR.SAVE), icon="file_download", on_click=save_profile 576 | ) 577 | 578 | ## Hardware ID group 579 | 580 | hardware_id_group = ui.expansion( 581 | _(STR.CUSTOM_HARDWARE_ID), value=False, icon="fingerprint" 582 | ) 583 | hardware_id_group.classes(DEFAULT_GROUP_CLASSES) 584 | hardware_id_group.tailwind.font_weight("bold") 585 | hardware_id_group.bind_visibility_from(device, "has_custom_hw_id") 586 | with hardware_id_group: 587 | read_only_notice = ( 588 | ui.label(_(STR.DANGER_ZONE)) 589 | .classes("self-center") 590 | .tailwind.font_size("lg") 591 | .text_color("red-600") 592 | ) 593 | check_not_an_asshole = ui.checkbox(_(STR.I_AM_NOT_AN_ASSHOLE), value=False) 594 | check_not_an_asshole.style("font-size: 50%") 595 | check_not_an_asshole.on("update:model-value", on_update_hardware_id) 596 | display_name_input = ui.input( 597 | label=_(STR.CUSTOM_DISPLAY_NAME), 598 | validation={ 599 | _(STR.ERROR): lambda value: (value == None) 600 | or (len(value) <= MAX_DISPLAY_NAME_LENGTH) 601 | }, 602 | ) 603 | display_name_input.classes("w-full") 604 | if is_running_in_windows(): 605 | display_name_input.bind_enabled_from(check_not_an_asshole, "value") 606 | else: 607 | display_name_input.enabled = False 608 | custom_vid_input = ui.input( 609 | label=_(STR.CUSTOM_VID), 610 | placeholder=_(STR.VID_PID_FORMAT), 611 | validation={_(STR.ERROR): hardware_id_validate}, 612 | ) 613 | custom_vid_input.classes("w-full") 614 | custom_vid_input.bind_enabled_from(check_not_an_asshole, "value") 615 | custom_pid_input = ui.input( 616 | label=_(STR.CUSTOM_PID), 617 | placeholder=_(STR.VID_PID_FORMAT), 618 | validation={_(STR.ERROR): hardware_id_validate}, 619 | ) 620 | custom_pid_input.classes("w-full") 621 | custom_pid_input.bind_enabled_from(check_not_an_asshole, "value") 622 | with ui.row().classes("self-center"): 623 | btn_hardware_id_update = ui.button( 624 | _(STR.SAVE), icon="file_download", on_click=hardware_id_set 625 | ) 626 | btn_hardware_id_update.bind_enabled_from(check_not_an_asshole, "value") 627 | btn_hardware_id_defaults = ui.button( 628 | _(STR.DEFAULTS), icon="factory", on_click=hardware_id_factory_defaults 629 | ) 630 | btn_hardware_id_defaults.bind_enabled_from(check_not_an_asshole, "value") 631 | 632 | ################################################################################################## 633 | 634 | auto_select_device() 635 | app.on_connect(on_app_startup) 636 | ui.run( 637 | native=True, 638 | reload=False, 639 | title="ESP32 open-source sim wheel / button box", 640 | window_size=(800, 600), 641 | uvicorn_logging_level="error", 642 | binding_refresh_interval=0.3, 643 | ) 644 | -------------------------------------------------------------------------------- /src/ESP32SimWheelConfig/__pixels_test__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # **************************************************************************** 3 | # @file __pixels_test__.py 4 | # 5 | # @author Ángel Fernández Pineda. Madrid. Spain. 6 | # @date 2025-02-13 7 | # @brief Configuration app for ESP32-based open source sim wheels 8 | # @copyright 2024 Ángel Fernández Pineda. Madrid. Spain. 9 | # @license Licensed under the EUPL 10 | # ***************************************************************************** 11 | 12 | if __package__: 13 | from . import esp32simwheel 14 | else: 15 | import esp32simwheel 16 | 17 | import time 18 | import sys 19 | 20 | ############################################################################### 21 | 22 | 23 | def pixel_control_enumerate(): 24 | for device in esp32simwheel.enumerate(configurable_only=False): 25 | if device.has_pixel_control: 26 | yield device 27 | 28 | 29 | def set_pixel(device, group, index): 30 | for i in range(device.pixel_count(group)): 31 | device.pixel_set(group, i, 0, 0, 0) 32 | device.pixel_set(group, index, 255, 255, 255) 33 | 34 | 35 | def next_index(device): 36 | for group in esp32simwheel.PixelGroup: 37 | device.pixel_index[group] = device.pixel_index[group] + 1 38 | if device.pixel_index[group] >= device.pixel_count(group): 39 | device.pixel_index[group] = 0 40 | # print(f"GRP: {group} IDX: {device.pixel_index[group]}") 41 | 42 | 43 | ############################################################################### 44 | 45 | if len(sys.argv) > 1: 46 | fps = int(sys.argv[1]) 47 | if fps < 0: 48 | fps = 50 49 | else: 50 | fps = 50 51 | 52 | print("------------------") 53 | print("Pixel control test") 54 | print(f"FPS limit: {fps}") 55 | print("------------------") 56 | 57 | if fps==0: 58 | frame_limit_time = 0.0 59 | else: 60 | frame_limit_time = 1 / fps 61 | 62 | devices = list(pixel_control_enumerate()) 63 | for sim_wheel in devices: 64 | print(f"Found : '{sim_wheel.manufacturer}' / '{sim_wheel.product_name}'") 65 | sim_wheel.pixel_index = [0, 0, 0] 66 | sim_wheel.pixel_reset() 67 | 68 | print("-------") 69 | print("Running") 70 | print("-------") 71 | 72 | while True: 73 | for device in devices: 74 | for group in esp32simwheel.PixelGroup: 75 | set_pixel(device, group, device.pixel_index[group]) 76 | # print(f"GRP: {group} IDX: {device.pixel_index[group]}") 77 | next_index(device) 78 | device.pixel_show() 79 | if frame_limit_time>0.0: 80 | time.sleep(frame_limit_time) 81 | -------------------------------------------------------------------------------- /src/ESP32SimWheelConfig/esp32simwheel.py: -------------------------------------------------------------------------------- 1 | # **************************************************************************** 2 | # @file esp32simwheel.py 3 | # 4 | # @author Ángel Fernández Pineda. Madrid. Spain. 5 | # @date 2024-01-21 6 | # @brief Configuration app for ESP32-based open source sim wheels 7 | # @copyright 2024 Ángel Fernández Pineda. Madrid. Spain. 8 | # @license Licensed under the EUPL 9 | # ***************************************************************************** 10 | 11 | """ 12 | Retrieve instances of connected ESP32 open sim wheel devices 13 | 14 | Classes: 15 | 16 | SimWheel 17 | 18 | Enumerations: 19 | 20 | ClutchPaddlesWorkingMode 21 | 22 | Functions: 23 | 24 | enumerate() 25 | 26 | Exceptions: 27 | 28 | AnotherAppInterferesError 29 | """ 30 | ############################################################################### 31 | 32 | import hid 33 | import struct 34 | from enum import IntEnum 35 | 36 | ############################################################################### 37 | 38 | _CONTROLLER_TYPE = 5 39 | 40 | _RID_CAPABILITIES = 2 41 | _RID_CONFIG = 3 42 | _RID_BUTTONS_MAP = 4 43 | _RID_HARDWARE_ID = 5 44 | _RID_PIXEL_CONTROL_ID = 30 45 | 46 | # Note: must increase data size in 1 to make room for the report-ID field 47 | _REPORT2_SIZE_V1_0 = 8 + 1 48 | _REPORT2_SIZE_V1_1 = _REPORT2_SIZE_V1_0 + 8 49 | _REPORT2_SIZE_V1_3 = _REPORT2_SIZE_V1_1 + 1 50 | _REPORT2_SIZE_V1_4 = _REPORT2_SIZE_V1_3 + 3 51 | 52 | _REPORT3_SIZE_V1_0 = 4 + 1 53 | _REPORT3_SIZE_V1_1 = _REPORT3_SIZE_V1_0 + 1 54 | _REPORT3_SIZE_V1_2 = _REPORT3_SIZE_V1_1 + 1 55 | _REPORT3_SIZE_V1_5 = _REPORT3_SIZE_V1_2 + 1 56 | 57 | _REPORT4_SIZE_V1_1 = 3 + 1 58 | 59 | _REPORT5_SIZE_V1_2 = 6 + 1 60 | 61 | _REPORT30_SIZE_V1_4 = 6 + 1 62 | 63 | _MAX_REPORT_SIZE = 25 64 | 65 | # Capability flags 66 | _CAP_CLUTCH_BUTTON = 0 # has digital clutch paddles (switches) 67 | _CAP_CLUTCH_ANALOG = 1 # has analog clutch paddles (potentiometers) 68 | _CAP_ALT = 2 # has "ALT" buttons 69 | _CAP_DPAD = 3 # has a directional pad 70 | _CAP_BATTERY = 4 # battery-operated 71 | _CAP_BATTERY_CALIBRATION_AVAILABLE = 5 # has battery calibration data 72 | _CAP_ROTARY_ENCODERS = 10 # has rotary encoders 73 | 74 | # Data version 75 | _SUPPORTED_DATA_MAJOR_VERSION = 1 76 | _SUPPORTED_DATA_MINOR_VERSION = 6 77 | 78 | # Simple commands 79 | _CMD_AXIS_RECALIBRATE = 1 80 | _CMD_BATT_RECALIBRATE = 2 81 | _CMD_RESET_BUTTONS_MAP = 3 82 | _CMD_SAVE_NOW = 4 83 | _CMD_REVERSE_LEFT_AXIS = 5 84 | _CMD_REVERSE_RIGHT_AXIS = 6 85 | _CMD_SHOW_PIXELS = 7 86 | _CMD_RESET_PIXELS = 8 87 | 88 | ############################################################################### 89 | 90 | 91 | class AnotherAppInterferesError(Exception): 92 | """Raised when another application is interfering in the reading of buttons maps.""" 93 | 94 | pass 95 | 96 | 97 | ############################################################################### 98 | 99 | 100 | class ClutchPaddlesWorkingMode(IntEnum): 101 | """Working modes of clutch paddles. 102 | 103 | CLUTCH : F1-style clutch 104 | AXIS : Independent analog axes 105 | ALT : Alternate mode 106 | BUTTON : Regular buttons 107 | LAUNCH_CONTROL_LEFT : Launch control (left paddle is master) 108 | LAUNCH_CONTROL_RIGHT : Launch control (right paddle is master) 109 | """ 110 | 111 | CLUTCH = 0 112 | AXIS = 1 113 | ALT = 2 114 | BUTTON = 3 115 | LAUNCH_CONTROL_LEFT = 4 116 | LAUNCH_CONTROL_RIGHT = 5 117 | 118 | 119 | ############################################################################### 120 | 121 | 122 | class PixelGroup(IntEnum): 123 | """Pixel groups""" 124 | 125 | GRP_TELEMETRY = 0 126 | GRP_BACKLIGHTS = 1 127 | GRP_INDIVIDUAL = 2 128 | 129 | 130 | ############################################################################### 131 | 132 | 133 | class SimWheel: 134 | """A class to represent an ESP32 open-source sim wheel or button box.""" 135 | 136 | def __init__(self, path: str = "", vid: int = 0, pid: int = 0): 137 | """Create a representation of an ESP32 open-source sim wheel or button box.""" 138 | self._hid = hid.device() 139 | self.__path = path 140 | self.__is_open = False 141 | self.__is_sim_wheel = None 142 | self.__data_minor_version = None 143 | self.__data_major_version = None 144 | self._capability_flags = 0 145 | self.__vid = vid 146 | self.__pid = pid 147 | self.__pixel_count = [0, 0, 0] 148 | 149 | def __del__(self): 150 | self.close() 151 | 152 | def _open(self): 153 | if ( 154 | (self.__is_sim_wheel != False) 155 | and (not self.__is_open) 156 | and (self.__path != "") 157 | ): 158 | try: 159 | self._hid.open_path(self.__path) 160 | self.__is_open = True 161 | if self.__is_sim_wheel == None: 162 | self.__is_sim_wheel = self._check_is_sim_wheel() 163 | except Exception: 164 | self.__is_open = False 165 | 166 | # noinspection python:S3776 167 | def _check_is_sim_wheel(self) -> bool: # NOSONAR 168 | """Determine if this device is a supported ESP32 open-source sim wheel or not.""" 169 | # Supported data versions: 1.0, 1.1, 1.2 170 | try: 171 | # Get "capabilities" report (ID #2) 172 | report2 = bytes( 173 | self._hid.get_feature_report(_RID_CAPABILITIES, _MAX_REPORT_SIZE) 174 | ) 175 | 176 | # Get magic number, data version and flags 177 | data = struct.unpack(" _SUPPORTED_DATA_MINOR_VERSION 184 | ) # Check minor version 185 | if check_failed: 186 | return False 187 | self.__data_major_version = data[1] 188 | self.__data_minor_version = data[2] 189 | self._capability_flags = data[3] 190 | 191 | # At data version 1.1, get device ID 192 | if len(report2) >= _REPORT2_SIZE_V1_1: 193 | data = struct.unpack( 194 | "= _REPORT2_SIZE_V1_3: 202 | data = struct.unpack( 203 | "= _REPORT2_SIZE_V1_3: 211 | data = struct.unpack( 212 | "= 1): 223 | self._hid.get_feature_report(_RID_BUTTONS_MAP, _REPORT4_SIZE_V1_1) 224 | 225 | # At data version 1.2, confirm that additional reports are available 226 | if (self.__data_major_version == 1) and (self.__data_minor_version >= 2): 227 | self._hid.get_feature_report(_RID_HARDWARE_ID, _REPORT5_SIZE_V1_2) 228 | 229 | # Check availability of custom hardware ID 230 | if (self.__data_major_version == 1) and (self.__data_minor_version >= 2): 231 | report5 = self._get_hardware_id_report() 232 | self._custom_hw_id_enabled = (report5[0] != 0) or (report5[1] != 0) 233 | else: 234 | self._custom_hw_id_enabled = False 235 | 236 | return True 237 | except Exception: 238 | return False 239 | 240 | def _get_config_report(self): 241 | """Read a device configuration feature report (id #3).""" 242 | report3 = bytes(self._hid.get_feature_report(_RID_CONFIG, _MAX_REPORT_SIZE)) 243 | if self.__data_minor_version >= 5: 244 | return struct.unpack("= 2: 246 | return struct.unpack("= 1: 261 | aux[5] = data[4] 262 | if self.__data_minor_version >= 2: 263 | aux[6] = data[5] 264 | if self.__data_minor_version >= 5: 265 | aux[7] = data[6] 266 | if self.__data_minor_version >= 5: 267 | self._hid.send_feature_report(aux[0:_REPORT3_SIZE_V1_5]) 268 | elif self.__data_minor_version >= 2: 269 | self._hid.send_feature_report(aux[0:_REPORT3_SIZE_V1_2]) 270 | elif self.__data_minor_version == 1: 271 | self._hid.send_feature_report(aux[0:_REPORT3_SIZE_V1_1]) 272 | elif self.__data_minor_version == 0: 273 | self._hid.send_feature_report(aux[0:_REPORT3_SIZE_V1_0]) 274 | else: 275 | raise RuntimeError("Unsupported data version") 276 | 277 | def _send_simple_command(self, command: int): 278 | """Send a simple command to the device.""" 279 | if self._is_ready(): 280 | try: 281 | self._send_config_report( 282 | bytes([0xFF, 0xFF, 0xFF, command, 0xFF, 0xFF, 0xFF]) 283 | ) 284 | except Exception: 285 | self.close() 286 | 287 | def _get_buttons_map_report(self): 288 | """Read a buttons map feature report (id #4).""" 289 | if self.__data_minor_version >= 1: 290 | data = bytes( 291 | self._hid.get_feature_report(_RID_BUTTONS_MAP, _REPORT4_SIZE_V1_1) 292 | ) 293 | return struct.unpack("= 2: 321 | data = bytes( 322 | self._hid.get_feature_report(_RID_HARDWARE_ID, _REPORT5_SIZE_V1_2) 323 | ) 324 | return struct.unpack(" bool: 355 | """Returns True if this device is still connected""" 356 | self._open() 357 | try: 358 | self._get_config_report() 359 | except Exception: 360 | self.close() 361 | return self.__is_open 362 | 363 | @property 364 | def is_sim_wheel(self) -> bool: 365 | """Returns True if this device is an ESP32 open-source sim wheel or button box.""" 366 | self._open() 367 | return bool(self.__is_sim_wheel) 368 | 369 | @property 370 | def has_buttons_map(self) -> bool: 371 | """Returns True if this device supports user-defined button maps.""" 372 | if self._is_ready(): 373 | return self.__data_minor_version >= 1 374 | else: 375 | return False 376 | 377 | @property 378 | def has_clutch(self) -> bool: 379 | """Returns True if this device has clutch paddles (any kind).""" 380 | if self._is_ready(): 381 | return bool( 382 | ((1 << _CAP_CLUTCH_ANALOG) | (1 << _CAP_CLUTCH_BUTTON)) 383 | & self._capability_flags 384 | ) 385 | else: 386 | return False 387 | 388 | @property 389 | def has_analog_clutch_paddles(self) -> bool: 390 | """Returns True if this device has analog clutch paddles.""" 391 | if self._is_ready(): 392 | return bool((1 << _CAP_CLUTCH_ANALOG) & self._capability_flags) 393 | else: 394 | return False 395 | 396 | @property 397 | def has_dpad(self) -> bool: 398 | """Returns True if this device has navigational controls.""" 399 | if self._is_ready(): 400 | return bool((1 << _CAP_DPAD) & self._capability_flags) 401 | else: 402 | return False 403 | 404 | @property 405 | def has_alt_buttons(self) -> bool: 406 | """Returns True if this device has ALT buttons.""" 407 | if self._is_ready(): 408 | return bool((1 << _CAP_ALT) & self._capability_flags) 409 | else: 410 | return False 411 | 412 | @property 413 | def has_pixel_control(self) -> bool: 414 | """Returns True if this device has pixels.""" 415 | self._open() 416 | return ( 417 | (self.__pixel_count[PixelGroup.GRP_TELEMETRY] > 0) 418 | or (self.__pixel_count[PixelGroup.GRP_BACKLIGHTS] > 0) 419 | or (self.__pixel_count[PixelGroup.GRP_INDIVIDUAL] > 0) 420 | ) 421 | 422 | @property 423 | def has_battery(self) -> bool: 424 | """Returns True if this device is powered by batteries.""" 425 | if self._is_ready(): 426 | return bool((1 << _CAP_BATTERY) & self._capability_flags) 427 | else: 428 | return False 429 | 430 | @property 431 | def has_rotary_encoders(self) -> bool: 432 | """Returns True if this device has configurable rotary encoders.""" 433 | if self._is_ready(): 434 | return bool((1 << _CAP_ROTARY_ENCODERS) & self._capability_flags) 435 | else: 436 | return False 437 | 438 | @property 439 | def battery_calibration_available(self) -> bool: 440 | """Returns True if this device is able to auto-calibrate battery's state of charge.""" 441 | if self._is_ready(): 442 | return bool( 443 | (1 << _CAP_BATTERY_CALIBRATION_AVAILABLE) & self._capability_flags 444 | ) 445 | else: 446 | return False 447 | 448 | @property 449 | def batter_soc(self) -> int | None: 450 | """Returns a percentage (0..100) of current battery charge""" 451 | if self._is_ready(): 452 | try: 453 | report = self._get_config_report() 454 | return report[3] 455 | except Exception: 456 | self.close() 457 | return None 458 | 459 | @property 460 | def data_major_version(self) -> int | None: 461 | """Major version of the data interchange specification supported by this device.""" 462 | return self.__data_major_version 463 | 464 | @property 465 | def data_minor_version(self) -> int | None: 466 | """Minor version of the data interchange specification supported by this device.""" 467 | return self.__data_minor_version 468 | 469 | @property 470 | def clutch_working_mode(self) -> ClutchPaddlesWorkingMode | None: 471 | """Returns the current working mode of clutch paddles. 472 | 473 | This value has no meaning if there are no clutch paddles. 474 | Check has_clutch_paddles first. 475 | """ 476 | if self._is_ready(): 477 | try: 478 | report = self._get_config_report() 479 | return ClutchPaddlesWorkingMode(report[0]) 480 | except Exception: 481 | self.close() 482 | return None 483 | 484 | @clutch_working_mode.setter 485 | def clutch_working_mode(self, mode: ClutchPaddlesWorkingMode): 486 | """Set the working mode of clutch paddles. 487 | 488 | No effect if there are no clutch paddles. 489 | """ 490 | if self._is_ready(): 491 | try: 492 | self._send_config_report( 493 | bytes([int(mode), 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) 494 | ) 495 | except Exception: 496 | self.close() 497 | 498 | @property 499 | def alt_buttons_working_mode(self) -> bool | None: 500 | """Returns the working mode of ALT buttons. 501 | 502 | True for alternate mode, False for regular buttons mode. 503 | This value has no meaning if there are no ALT buttons. 504 | Check has_alt_buttons first. 505 | """ 506 | if self._is_ready(): 507 | try: 508 | report = self._get_config_report() 509 | return bool(report[1]) 510 | except Exception: 511 | self.close() 512 | return None 513 | 514 | @alt_buttons_working_mode.setter 515 | def alt_buttons_working_mode(self, mode: bool): 516 | """Set the working mode of ALT buttons. 517 | 518 | No effect if there are no ALT buttons. 519 | """ 520 | if self._is_ready(): 521 | try: 522 | self._send_config_report( 523 | bytes([0xFF, int(mode), 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) 524 | ) 525 | except Exception: 526 | self.close() 527 | 528 | @property 529 | def bite_point(self) -> int | None: 530 | """Returns the current clutch's bite point. 531 | 532 | A value in the range from 0 to 254, inclusive. 533 | Non-meaningful if there are no clutch paddles. 534 | """ 535 | if self._is_ready(): 536 | try: 537 | report = self._get_config_report() 538 | return report[2] 539 | except Exception: 540 | self.close() 541 | return None 542 | 543 | @bite_point.setter 544 | def bite_point(self, value: int): 545 | """Sets the clutch's bite point. 546 | 547 | No effect if there are no clutch paddles. 548 | """ 549 | if (value < 0) or (value > 254): 550 | raise ValueError("Bite point not in the range 0..254") 551 | if self._is_ready(): 552 | try: 553 | self._send_config_report( 554 | bytes([0xFF, 0xFF, value, 0xFF, 0xFF, 0xFF, 0xFF]) 555 | ) 556 | except Exception: 557 | self.close() 558 | 559 | @property 560 | def dpad_working_mode(self) -> bool | None: 561 | """Returns the working mode of navigational controls. 562 | 563 | True for navigation, False for regular buttons mode. 564 | This value has no meaning if there are no navigational controls. 565 | Check has_dpad first. 566 | """ 567 | if self._is_ready(): 568 | try: 569 | report = self._get_config_report() 570 | return bool(report[4]) 571 | except Exception: 572 | self.close() 573 | return None 574 | 575 | @dpad_working_mode.setter 576 | def dpad_working_mode(self, mode: bool): 577 | """Set the working mode of navigational controls. 578 | 579 | No effect if there are no navigational controls. 580 | """ 581 | if self._is_ready(): 582 | try: 583 | self._send_config_report( 584 | bytes([0xFF, 0xFF, 0xFF, 0xFF, int(mode), 0xFF, 0xFF]) 585 | ) 586 | except Exception: 587 | self.close() 588 | 589 | @property 590 | def vid(self) -> str: 591 | """Current Vendor ID.""" 592 | return self.__vid 593 | 594 | @property 595 | def pid(self) -> str: 596 | """Current Product ID.""" 597 | return self.__pid 598 | 599 | @property 600 | def path(self) -> str: 601 | """OS path to this device.""" 602 | return self.__path 603 | 604 | @path.setter 605 | def path(self, path: str): 606 | if self.__path != path: 607 | self.close() 608 | self.__path = path 609 | self.__is_sim_wheel = None 610 | 611 | @property 612 | def manufacturer(self) -> str: 613 | """Name of the manufacturer of this device.""" 614 | self._open() 615 | if self.__is_open: 616 | return self._hid.get_manufacturer_string() 617 | else: 618 | return "" 619 | 620 | @property 621 | def product_name(self) -> str: 622 | """Product name of this device.""" 623 | self._open() 624 | if self.__is_open: 625 | return self._hid.get_product_string() 626 | else: 627 | return "" 628 | 629 | @property 630 | def is_user_configurable(self) -> bool: 631 | """Returns True if this device has any setting available for user configuration.""" 632 | if self._is_ready(): 633 | return ( 634 | self.has_buttons_map 635 | or self.has_custom_hw_id 636 | or bool( 637 | ( 638 | (1 << _CAP_ALT) 639 | | (1 << _CAP_CLUTCH_BUTTON) 640 | | (1 << _CAP_CLUTCH_ANALOG) 641 | | (1 << _CAP_BATTERY_CALIBRATION_AVAILABLE) 642 | | (1 << _CAP_DPAD) 643 | ) 644 | & self._capability_flags 645 | ) 646 | ) 647 | else: 648 | return False 649 | 650 | @property 651 | def is_read_only(self) -> bool: 652 | """Security lock on this device.""" 653 | if self._is_ready() and (self.__data_minor_version >= 2): 654 | try: 655 | report3 = bytes( 656 | self._hid.get_feature_report(_RID_CONFIG, _MAX_REPORT_SIZE) 657 | ) 658 | return report3[6] != 0 659 | except Exception: 660 | self.close() 661 | return False 662 | 663 | @property 664 | def has_custom_hw_id(self) -> bool: 665 | """Check if this device can set a custom hardware ID""" 666 | if self._is_ready(): 667 | return self._custom_hw_id_enabled 668 | else: 669 | return False 670 | 671 | @property 672 | def custom_vid(self) -> int | None: 673 | """Custom VID for this device. 674 | 675 | Effective after next reboot. 676 | """ 677 | if self._is_ready(): 678 | try: 679 | report = self._get_hardware_id_report() 680 | return report[0] 681 | except Exception: 682 | self.close() 683 | return None 684 | 685 | @property 686 | def custom_pid(self) -> int | None: 687 | """Custom PID for this device. 688 | 689 | Effective after next reboot. 690 | """ 691 | if self._is_ready(): 692 | try: 693 | report = self._get_hardware_id_report() 694 | return report[1] 695 | except Exception: 696 | self.close() 697 | return None 698 | 699 | @property 700 | def pulse_width_multiplier(self) -> int: 701 | """Pulse width multiplier for rotary encoders.""" 702 | try: 703 | report = self._get_config_report() 704 | if len(report) >= 7: 705 | return int(report[6]) 706 | else: 707 | return 1 708 | except Exception: 709 | self.close() 710 | return 1 711 | 712 | @pulse_width_multiplier.setter 713 | def pulse_width_multiplier(self, value: int): 714 | if self._is_ready(): 715 | try: 716 | self._send_config_report( 717 | bytes([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, int(value)]) 718 | ) 719 | except Exception: 720 | self.close() 721 | 722 | def recalibrate_analog_axes(self): 723 | """Force auto-calibration of analog clutch paddles (if available).""" 724 | self._send_simple_command(_CMD_AXIS_RECALIBRATE) 725 | 726 | def recalibrate_battery(self): 727 | """Force auto-calibration of battery's state of charge (if available).""" 728 | self._send_simple_command(_CMD_BATT_RECALIBRATE) 729 | 730 | def reset_buttons_map(self): 731 | """Return user-defined buttons map to factory defaults. 732 | 733 | No effect if this feature is not supported. 734 | """ 735 | self._send_simple_command(_CMD_RESET_BUTTONS_MAP) 736 | 737 | def save_now(self): 738 | """Save all user settings to the device's internal flash memory.""" 739 | self._send_simple_command(_CMD_SAVE_NOW) 740 | 741 | def get_button_map(self, raw_input_number: int): 742 | """Returns a user-defined button mapping. 743 | 744 | Parameters: 745 | 746 | raw_input_number : A firmware-defined button number in the range from 0 to 63, inclusive. 747 | 748 | Returns an empty dictionary if the requested button number does not exist. 749 | Otherwise, returns a dictionary with the following keys: 750 | 751 | firmware : The same as the parameter "rawInputNumber". 752 | user : An user-defined button number in the range from 0 to 127, which will be 753 | reported when the button is pressed. 754 | userAltMode: The same as "user"", but in alternate mode. 755 | 756 | Raises AnotherAppInterferesError if another application is trying to do the same thing. 757 | The user should try later in such a case. 758 | """ 759 | if (raw_input_number < 0) or (raw_input_number >= 64): 760 | raise ValueError("rawInputNumber not in the range 0..63") 761 | if self._is_ready(): 762 | if self.__data_minor_version == 0: 763 | return {} 764 | try: 765 | self._send_buttons_map_report(bytes([raw_input_number, 0xFF, 0xFF])) 766 | report = self._get_buttons_map_report() 767 | except Exception: 768 | self.close() 769 | return {} 770 | 771 | if report[0] != raw_input_number: 772 | raise AnotherAppInterferesError("Try later") 773 | elif ( 774 | (report[1] >= 0) 775 | and (report[1] < 127) 776 | and (report[2] >= 0) 777 | and (report[2] < 127) 778 | ): 779 | return { 780 | "firmware": report[0], 781 | "user": report[1], 782 | "userAltMode": report[2], 783 | } 784 | return {} 785 | 786 | def set_button_map( 787 | self, 788 | raw_input_number: int, 789 | user_input_number: int, 790 | user_input_number_alt_mode: int, 791 | ): 792 | """Sets an user-defined button mapping""" 793 | if (raw_input_number < 0) or (raw_input_number >= 64): 794 | raise ValueError("raw_input_number not in the range 0..63") 795 | if (user_input_number < 0) or (user_input_number >= 128): 796 | raise ValueError("user_input_number not in the range 0..127") 797 | if (user_input_number_alt_mode < 0) or (user_input_number_alt_mode >= 128): 798 | raise ValueError("user_input_number_alt_mode not in the range 0..127") 799 | if self._is_ready(): 800 | try: 801 | self._send_buttons_map_report( 802 | bytes( 803 | [ 804 | raw_input_number, 805 | user_input_number, 806 | user_input_number_alt_mode, 807 | ] 808 | ) 809 | ) 810 | except Exception: 811 | self.close() 812 | 813 | def set_button_map_tuple(self, tuple_or_list_or_dict): 814 | """Sets an user-defined button mapping 815 | 816 | Args: 817 | tuple_or_list_or_dict (tuple/list/dict): User-defined button mapping as described below. 818 | 819 | Three items are expected in the given argument: 820 | Index 0 or key \"firmware\": A firmware-defined button number in the range from 0 to 63, inclusive. 821 | Index 1 or key \"user\": An user-defined button number in the range from 0 to 127, inclusive. 822 | Index 2 or key \"userAltMode\": the same as index 1, but for alternate mode. 823 | """ 824 | if isinstance(tuple_or_list_or_dict, (tuple, list)): 825 | if len(tuple_or_list_or_dict) == 3: 826 | self.set_button_map( 827 | tuple_or_list_or_dict[0], 828 | tuple_or_list_or_dict[1], 829 | tuple_or_list_or_dict[2], 830 | ) 831 | elif isinstance(tuple_or_list_or_dict, dict): 832 | self.set_button_map( 833 | tuple_or_list_or_dict["firmware"], 834 | tuple_or_list_or_dict["user"], 835 | tuple_or_list_or_dict["userAltMode"], 836 | ) 837 | 838 | def enumerate_buttons_map(self): 839 | """Enumerates all available firmware-defined button numbers and their current user-defined map. 840 | 841 | Remarks: 842 | May take a few seconds to run. 843 | """ 844 | for raw in range(64): 845 | btn_map = self.get_button_map(raw) 846 | if btn_map != {}: 847 | yield btn_map 848 | 849 | def reset_custom_hardware_id(self): 850 | """Reset custom hardware ID to factory defaults after next reboot (BLE only)""" 851 | if self._is_ready(): 852 | try: 853 | self._send_hardware_id_report( 854 | bytes( 855 | [ 856 | 0x00, 857 | 0x00, 858 | 0x00, 859 | 0x00, 860 | 0x96, 861 | 0xAA, 862 | ] 863 | ) 864 | ) 865 | except Exception: 866 | self.close() 867 | 868 | def set_custom_hardware_id(self, vid: int, pid: int): 869 | """Force a custom hardware ID after next reboot (BLE only) 870 | 871 | Parameters: 872 | 873 | vid : Vendor ID in the range 1..0xFFFF. 874 | pid : Product ID in the range 1..0xFFFF. 875 | """ 876 | if (pid <= 0) or (pid > 0xFFFF) or (vid <= 0) or (vid > 0xFFFF): 877 | raise ValueError("Custom PID/VID not in the range 1..0xFFFF") 878 | if self._is_ready(): 879 | try: 880 | control = (vid * pid) % 65536 881 | vid_bytes = vid.to_bytes(2, byteorder="little") 882 | pid_bytes = pid.to_bytes(2, byteorder="little") 883 | control_bytes = control.to_bytes(2, byteorder="little") 884 | # for debug: print( 885 | # f"Request for custom hardware ID: VID = {vid} PID = {pid}, control = {control} " 886 | # ) 887 | # print(f"(bytes): VID = {vid_bytes[0]},{vid_bytes[1]}") 888 | # print(f"(bytes): PID = {pid_bytes[0]},{pid_bytes[1]}") 889 | # print(f"(bytes): control = {control_bytes[0]},{control_bytes[1]}") 890 | self._send_hardware_id_report( 891 | bytes( 892 | [ 893 | vid_bytes[0], 894 | vid_bytes[1], 895 | pid_bytes[0], 896 | pid_bytes[1], 897 | control_bytes[0], 898 | control_bytes[1], 899 | ] 900 | ) 901 | ) 902 | except Exception: 903 | self.close() 904 | 905 | def reverse_left_axis(self): 906 | """Reverse the polarity of the left analog axis.""" 907 | self._send_simple_command(_CMD_REVERSE_LEFT_AXIS) 908 | 909 | def reverse_right_axis(self): 910 | """Reverse the polarity of the right analog axis.""" 911 | self._send_simple_command(_CMD_REVERSE_RIGHT_AXIS) 912 | 913 | def pixel_count(self, group: PixelGroup) -> int: 914 | """Number of pixels in a group""" 915 | return self.__pixel_count[group] 916 | 917 | def pixel_set( 918 | self, group: PixelGroup, index: int, red: int, green: int, blue: int 919 | ) -> None: 920 | """Set pixel color in a group""" 921 | if ( 922 | self._is_ready() 923 | and (group < 3) 924 | and (index >= 0) 925 | and (index < self.__pixel_count[group]) 926 | ): 927 | try: 928 | self._send_pixel_control_report( 929 | bytes([group, index, blue, green, red, 0x00]) 930 | ) 931 | except Exception: 932 | self.close() 933 | 934 | def pixel_show(self) -> None: 935 | """Show all pixels (in all groups) at once""" 936 | if self._is_ready(): 937 | try: 938 | if self.data_minor_version >= 6: 939 | self._send_pixel_control_report( 940 | bytes([0xFF, 0x00, 0x00, 0x00, 0x00, 0x00]) 941 | ) 942 | else: 943 | self._send_simple_command(_CMD_SHOW_PIXELS) 944 | 945 | except Exception: 946 | self.close() 947 | 948 | def pixel_reset(self) -> None: 949 | """Turn off all pixels (in all groups) at once""" 950 | if self._is_ready(): 951 | try: 952 | if self.data_minor_version >= 6: 953 | self._send_pixel_control_report( 954 | bytes([0xFE, 0x00, 0x00, 0x00, 0x00, 0x00]) 955 | ) 956 | else: 957 | self._send_simple_command(_CMD_RESET_PIXELS) 958 | 959 | except Exception: 960 | self.close() 961 | 962 | def serialize(self, all: bool = False) -> dict: 963 | """Returns a dictionary containing current device settings 964 | 965 | Args: 966 | all (bool, optional): When False, not user-configurable settings are omitted. 967 | Otherwise, default values are given for those. 968 | 969 | Returns: 970 | dict: A representation of current user settings 971 | 972 | Remarks: 973 | May take a few seconds to run. 974 | """ 975 | result = {} 976 | if all or self.has_alt_buttons: 977 | result["AltWorkingMode"] = self.alt_buttons_working_mode 978 | if all or self.has_dpad: 979 | result["DpadWorkingMode"] = self.dpad_working_mode 980 | if all or self.has_clutch: 981 | result["Clutch"] = [self.clutch_working_mode, self.bite_point] 982 | if self.has_buttons_map: 983 | try: 984 | result["ButtonsMap"] = list(self.enumerate_buttons_map()) 985 | except Exception: 986 | pass 987 | elif all: 988 | result["ButtonsMap"] = [ 989 | { 990 | "firmware": i, 991 | "user": i, 992 | "userAltMode": i + 64, 993 | } 994 | for i in range(0, 64) 995 | ] 996 | 997 | return result 998 | 999 | def deserialize(self, source: dict): 1000 | """Updates device user settings from the given dictionary 1001 | 1002 | Args: 1003 | source (dict): A dictionary object as returned by serialize() 1004 | """ 1005 | if "AltWorkingMode" in source: 1006 | self.alt_buttons_working_mode = source["AltWorkingMode"] 1007 | if "DpadWorkingMode" in source: 1008 | self.dpad_working_mode = source["DpadWorkingMode"] 1009 | if "Clutch" in source: 1010 | self.clutch_working_mode = source["Clutch"][0] 1011 | self.bite_point = source["Clutch"][1] 1012 | if "ButtonsMap" in source: 1013 | buttons_map = source["ButtonsMap"] 1014 | if isinstance(buttons_map, list): 1015 | for m in buttons_map: 1016 | self.set_button_map_tuple(m) 1017 | 1018 | 1019 | ############################################################################### 1020 | 1021 | 1022 | def enumerate(configurable_only: bool = True): 1023 | """Retrieve all connected ESP32 open-source sim wheels or button boxes. 1024 | 1025 | Args: 1026 | configurable_only (bool, optional): 1027 | if True, devices with no user-configurable settings will be ignored. 1028 | Defaults to True. 1029 | 1030 | Yields: 1031 | SimWheel: A connected ESP32 open-source sim wheel or button box. 1032 | """ 1033 | for device_dict in hid.enumerate(): 1034 | usage_page = device_dict["usage_page"] 1035 | page = device_dict["usage"] 1036 | path = device_dict["path"] 1037 | vid = device_dict["vendor_id"] 1038 | pid = device_dict["product_id"] 1039 | if (usage_page == 1) and (page == _CONTROLLER_TYPE): 1040 | a_wheel = SimWheel(path, vid, pid) 1041 | test = a_wheel.is_sim_wheel and ( 1042 | (not configurable_only) or a_wheel.is_user_configurable 1043 | ) 1044 | if test: 1045 | yield a_wheel 1046 | 1047 | 1048 | ############################################################################### 1049 | 1050 | if __name__ == "__main__": 1051 | for sim_wheel in enumerate(configurable_only=False): 1052 | print("***********************************************************") 1053 | print(f"Path: {sim_wheel.path}") 1054 | print(f"Current VID: {sim_wheel.vid}") 1055 | print(f"Current PID: {sim_wheel.pid}") 1056 | print(f"Manufacturer: {sim_wheel.manufacturer}") 1057 | print(f"Product: {sim_wheel.product_name}") 1058 | print(f"Device ID: {sim_wheel.device_id}") 1059 | print(f"Allow custom HW ID: {sim_wheel.has_custom_hw_id}") 1060 | print(f"Custom VID: {sim_wheel.custom_vid}") 1061 | print(f"Custom PID: {sim_wheel.custom_pid}") 1062 | print(f"Security lock: {sim_wheel.is_read_only}") 1063 | print(f"Max FPS: {sim_wheel.max_fps}") 1064 | tel_pc = sim_wheel.pixel_count(PixelGroup.GRP_TELEMETRY) 1065 | bck_pc = sim_wheel.pixel_count(PixelGroup.GRP_BACKLIGHTS) 1066 | ind_pc = sim_wheel.pixel_count(PixelGroup.GRP_INDIVIDUAL) 1067 | print(f"Led count {tel_pc} / {bck_pc} / {ind_pc}") 1068 | sim_wheel.pixel_set(PixelGroup.GRP_TELEMETRY,0,255,0,0); 1069 | sim_wheel.pixel_set(PixelGroup.GRP_BACKLIGHTS,0,255,0,0); 1070 | sim_wheel.pixel_set(PixelGroup.GRP_INDIVIDUAL,0,255,0,0); 1071 | sim_wheel.pixel_show() 1072 | print(f"Pulse width multiplier: {sim_wheel.pulse_width_multiplier}") 1073 | print("Please, wait while loading user settings...") 1074 | print(sim_wheel.serialize(all=True)) 1075 | sim_wheel.pixel_reset() 1076 | print("Done.") 1077 | -------------------------------------------------------------------------------- /src/ESP32SimWheelConfig/lang_en.py: -------------------------------------------------------------------------------- 1 | # **************************************************************************** 2 | # @file lang_en.py 3 | # 4 | # @author Ángel Fernández Pineda. Madrid. Spain. 5 | # @date 2024-01-25 6 | # @brief Configuration app for ESP32-based open source sim wheels 7 | # @copyright 2024 Ángel Fernández Pineda. Madrid. Spain. 8 | # @license Licensed under the EUPL 9 | # ***************************************************************************** 10 | 11 | from enum import Enum 12 | from appstrings import install 13 | 14 | 15 | class EN(Enum): 16 | _lang = "en" 17 | _domain = "ESP32SimWheelConfig" 18 | ALT_BUTTONS = "ALT buttons" 19 | ALT_MODE = "Alternate mode" 20 | ANALOG_AXES = "Analog axes" 21 | AVAILABLE_DEVICES = "Available devices" 22 | AXIS = "Axis" 23 | BATTERY = "Battery" 24 | BITE_POINT = "Bite point" 25 | BUTTON = "Button" 26 | BUTTONS_MAP = "Buttons map" 27 | CHECK_ID = "Check device identity" 28 | CLUTCH = "Clutch" 29 | CLUTCH_PADDLES = "Clutch paddles" 30 | DEFAULTS = "Defaults" 31 | DONE = "Done!" 32 | DPAD = "Directional pad" 33 | ERROR = "Error!" 34 | FIRMWARE_DEFINED = "Firmware-defined" 35 | INCLUDE_BTN_MAP = "Include buttons map" 36 | INVALID_BTN = "Invalid button number (valid numbers are in the range 0-127)" 37 | LOAD = "Load" 38 | LOCAL_PROFILE = "Local profile" 39 | NAV = "Navigation" 40 | NO_DEVICE = "No device" 41 | NO_DEVICES_FOUND = "No devices found" 42 | PROFILE_CHECK_TOOLTIP = "Uncheck to load settings from another sim wheel/button box" 43 | RECALIBRATE = "Recalibrate" 44 | REGULAR_BUTTON = "Regular button" 45 | RELOAD = "Reload" 46 | SAVE = "Save" 47 | SELECT = "Select" 48 | SOC = "State of charge" 49 | USER_DEFINED = "User-defined" 50 | USER_DEFINED_ALT = "User-defined Alt Mode" 51 | WAIT = "Please, wait..." 52 | READ_ONLY_NOTICE = "Security lock. Device is read-only." 53 | DANGER_ZONE = "Danger zoner" 54 | CUSTOM_HARDWARE_ID = "Custom hardware ID" 55 | I_AM_NOT_AN_ASSHOLE = "I know what I am doing" 56 | CUSTOM_VID = "Custom vendor ID (VID)" 57 | CUSTOM_PID = "Custom product ID (PID)" 58 | VID_PID_FORMAT = "16-bit unsigned integer" 59 | CUSTOM_DISPLAY_NAME = "Custom display name (Windows only)" 60 | REVERSE_LEFT_AXIS = "Reverse left axis" 61 | REVERSE_RIGHT_AXIS = "Reverse right axis" 62 | LAUNCH_CTRL_LEFT_MASTER = "Launch control (master left paddle)" 63 | LAUNCH_CTRL_RIGHT_MASTER = "Launch control (master right paddle)" 64 | ROTARY_ENCODERS = "Rotary encoders" 65 | PULSE_WIDTH = "Pulse width" 66 | 67 | install(EN) 68 | -------------------------------------------------------------------------------- /src/ESP32SimWheelConfig/lang_es.py: -------------------------------------------------------------------------------- 1 | # **************************************************************************** 2 | # @file lang_es.py 3 | # 4 | # @author Ángel Fernández Pineda. Madrid. Spain. 5 | # @date 2024-01-25 6 | # @brief Configuration app for ESP32-based open source sim wheels 7 | # @copyright 2024 Ángel Fernández Pineda. Madrid. Spain. 8 | # @license Licensed under the EUPL 9 | # ***************************************************************************** 10 | 11 | from enum import Enum 12 | from appstrings import install 13 | 14 | 15 | class ES(Enum): 16 | _lang = "es" 17 | _domain = "ESP32SimWheelConfig" 18 | ALT_BUTTONS = "Botones ALT" 19 | ALT_MODE = "Modo alternativo" 20 | ANALOG_AXES = "Ejes analógicos" 21 | AVAILABLE_DEVICES = "Dispositivos disponibles" 22 | AXIS = "Eje" 23 | BATTERY = "Batería" 24 | BITE_POINT = "Punto de mordida" 25 | BUTTON = "Botón" 26 | BUTTONS_MAP = "Mapa de botones" 27 | CHECK_ID = "Comprobar identidad del dispositivo" 28 | CLUTCH = "Embrague" 29 | CLUTCH_PADDLES = "Paletas de embrague" 30 | DEFAULTS = "Por defecto" 31 | DONE = "¡Hecho!" 32 | DPAD = "Cruceta direccional" 33 | ERROR = "¡Error!" 34 | FIRMWARE_DEFINED = "Del firmware" 35 | INCLUDE_BTN_MAP = "Incluir mapa de botones" 36 | INVALID_BTN = "Número de botón inválido (los válidos están en el rango 0-127)" 37 | LOAD = "Cargar" 38 | LOCAL_PROFILE = "Perfil local" 39 | NAV = "Navegación" 40 | NO_DEVICE = "Sin dispositivo" 41 | NO_DEVICES_FOUND = "No se halló dispositivo alguno" 42 | PROFILE_CHECK_TOOLTIP = ( 43 | "Desmarcar para cargar los ajustes de otro volante / caja de botones" 44 | ) 45 | RECALIBRATE = "Recalibrar" 46 | REGULAR_BUTTON = "Botón normal" 47 | RELOAD = "Recargar" 48 | SAVE = "Salvar" 49 | SELECT = "Seleccionar" 50 | SOC = "Estado de carga" 51 | USER_DEFINED = "Del usuario" 52 | USER_DEFINED_ALT = "Del usuario modo ALT" 53 | WAIT = "Por favor, espere..." 54 | READ_ONLY_NOTICE = "Bloqueo de seguridad. Sólo lectura." 55 | DANGER_ZONE = "Zona de peligro" 56 | CUSTOM_HARDWARE_ID = "ID a medida" 57 | I_AM_NOT_AN_ASSHOLE = "Sé lo que hago" 58 | CUSTOM_VID = "ID de fabricante a medida (VID)" 59 | CUSTOM_PID = "ID de producto a medida (PID)" 60 | VID_PID_FORMAT = "Entero sin signo de 16 bits" 61 | CUSTOM_DISPLAY_NAME = "Nombre en pantalla a medida (solo Windows)" 62 | REVERSE_LEFT_AXIS = "Invertir eje izquierdo" 63 | REVERSE_RIGHT_AXIS = "Invertir eje derecho" 64 | LAUNCH_CTRL_LEFT_MASTER = "Control de lanzada (leva maestra izquierda)" 65 | LAUNCH_CTRL_RIGHT_MASTER = "Control de lanzada (leva maestra derecha)" 66 | ROTARY_ENCODERS = "Codificadores rotativos" 67 | PULSE_WIDTH = "Ancho de pulso" 68 | 69 | 70 | install(ES) 71 | -------------------------------------------------------------------------------- /src/ESP32SimWheelConfig/lang_zh.py: -------------------------------------------------------------------------------- 1 | # **************************************************************************** 2 | # @file lang_zh.py 3 | # 4 | # @author Ángel Fernández Pineda. Madrid. Spain. 5 | # @date 2024-04-03 6 | # @brief Configuration app for ESP32-based open source sim wheels 7 | # @copyright 2024 Ángel Fernández Pineda. Madrid. Spain. 8 | # @license Licensed under the EUPL 9 | # ***************************************************************************** 10 | 11 | from enum import Enum 12 | from appstrings import install 13 | 14 | 15 | class ZH(Enum): 16 | _lang = "zh" 17 | _domain = "ESP32SimWheelConfig" 18 | ALT_BUTTONS = "ALT 按钮" 19 | ALT_MODE = "备用模式" 20 | ANALOG_AXES = "模拟轴" 21 | AVAILABLE_DEVICES = "可用设备" 22 | AXIS = "轴" 23 | BATTERY = "电池" 24 | BITE_POINT = "咬合点" 25 | BUTTON = "按钮" 26 | BUTTONS_MAP = "按钮地图" 27 | CHECK_ID = "检查设备身份" 28 | CLUTCH = "离合器" 29 | CLUTCH_PADDLES = "离合器拨片" 30 | DEFAULTS = "默认值" 31 | DONE = "完成" 32 | DPAD = "定向垫" 33 | ERROR = "错误" 34 | FIRMWARE_DEFINED = "固件定义" 35 | INCLUDE_BTN_MAP = "包括按钮地图" 36 | INVALID_BTN = "按钮编号无效(有效编号范围为 0-127)" 37 | LOAD = "载荷" 38 | LOCAL_PROFILE = "当地概况" 39 | NAV = "导航" 40 | NO_DEVICE = "无设备" 41 | NO_DEVICES_FOUND = "未找到设备" 42 | PROFILE_CHECK_TOOLTIP = "取消选中可从另一个模拟滚轮或按钮盒加载设置" 43 | RECALIBRATE = "重新校准" 44 | REGULAR_BUTTON = "常规按钮" 45 | RELOAD = "重新加载" 46 | SAVE = "节省" 47 | SELECT = "选择" 48 | SOC = "充电状态" 49 | USER_DEFINED = "用户自定义" 50 | USER_DEFINED_ALT = "用户自定义 Alt 模式" 51 | WAIT = "请稍候..." 52 | READ_ONLY_NOTICE = "安全锁。设备是只读的。" 53 | DANGER_ZONE = "危险区域" 54 | CUSTOM_HARDWARE_ID = "自定义硬件 ID" 55 | I_AM_NOT_AN_ASSHOLE = "我知道我在做什么" 56 | CUSTOM_VID = "自定义供应商 ID (VID)" 57 | CUSTOM_PID = "自定义产品 ID (PID)" 58 | VID_PID_FORMAT = "16 位无符号整数" 59 | CUSTOM_DISPLAY_NAME = "自定义显示名称(仅限 Windows)" 60 | REVERSE_LEFT_AXIS = "反转左轴" 61 | REVERSE_RIGHT_AXIS = "反转右轴" 62 | LAUNCH_CTRL_LEFT_MASTER = "發射控制(左主凸輪)" 63 | LAUNCH_CTRL_RIGHT_MASTER = "發射控制(右主凸輪)" 64 | ROTARY_ENCODERS = "旋转编码器" 65 | PULSE_WIDTH = "脈衝寬" 66 | 67 | 68 | install(ZH) 69 | -------------------------------------------------------------------------------- /src/ESP32SimWheelConfig/rename_devices.py: -------------------------------------------------------------------------------- 1 | # **************************************************************************** 2 | # @file rename_devices.py 3 | # 4 | # @author Ángel Fernández Pineda. Madrid. Spain. 5 | # @date 2024-08-04 6 | # @brief Access specific keys in the windows registry related to device names 7 | # @copyright 2024 Ángel Fernández Pineda. Madrid. Spain. 8 | # @license Licensed under the EUPL 9 | # ***************************************************************************** 10 | 11 | """ 12 | Access specific keys in the windows registry related to device names 13 | 14 | Functions: 15 | 16 | get_display_name_from_registry() 17 | set_display_name_in_registry() 18 | 19 | """ 20 | 21 | ############################################################################### 22 | 23 | import winreg 24 | from sys import platform 25 | 26 | __all__ = [ 27 | "get_display_name_from_registry", 28 | "set_display_name_in_registry", 29 | ] 30 | 31 | ############################################################################### 32 | 33 | __KEY_ROOT = r"System\\CurrentControlSet\\Control\\MediaProperties\\PrivateProperties\\Joystick\\OEM\\VID_" 34 | __VALUE_OEM_NAME = "OEMName" 35 | 36 | ############################################################################### 37 | 38 | 39 | def _compute_key(vid: int, pid: int) -> str: 40 | vid_hex = f"{vid:#0{6}x}"[2:].upper() 41 | pid_hex = f"{pid:#0{6}x}"[2:].upper() 42 | return __KEY_ROOT + vid_hex + "&PID_" + pid_hex 43 | 44 | 45 | def get_display_name_from_registry(vid: int, pid: int) -> str | None: 46 | """Get the device display name from the Windows registry""" 47 | if platform != "win32": 48 | return None 49 | key = _compute_key(vid, pid) 50 | try: 51 | key_handle = winreg.OpenKeyEx(winreg.HKEY_CURRENT_USER, key) 52 | try: 53 | return winreg.QueryValueEx(key_handle, __VALUE_OEM_NAME)[0] 54 | finally: 55 | key_handle.Close() 56 | except FileNotFoundError: 57 | return None 58 | 59 | 60 | def set_display_name_in_registry( 61 | vid: int, pid: int, new_name: str | None 62 | ) -> str | None: 63 | """Set the device display name in the Windows registry""" 64 | if platform == "win32": 65 | key = _compute_key(vid, pid) 66 | key_handle = winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, key) 67 | try: 68 | if (new_name == None) or (new_name == ""): 69 | try: 70 | winreg.DeleteValue(key_handle, __VALUE_OEM_NAME) 71 | except FileNotFoundError: 72 | pass 73 | else: 74 | winreg.SetValueEx(key_handle, __VALUE_OEM_NAME, 0, winreg.REG_SZ, new_name) 75 | finally: 76 | key_handle.Close() 77 | -------------------------------------------------------------------------------- /src/build.py: -------------------------------------------------------------------------------- 1 | # **************************************************************************** 2 | # @file build.py 3 | # 4 | # @author Ángel Fernández Pineda. Madrid. Spain. 5 | # @date 2024-01-21 6 | # @brief Configuration app for ESP32-based open source sim wheels 7 | # @copyright 2024 Ángel Fernández Pineda. Madrid. Spain. 8 | # @license Licensed under the EUPL 9 | # ***************************************************************************** 10 | 11 | # -------------------------- IMPORTANT NOTE -------------------------------- 12 | # Your antivirus may get in the way 13 | # Add an exception rule 14 | # -------------------------- IMPORTANT NOTE -------------------------------- 15 | 16 | import os 17 | import subprocess 18 | from pathlib import Path 19 | import nicegui 20 | from os.path import exists 21 | 22 | if exists(".gitignore") and exists(".gitattributes"): 23 | print("--------------------------------------------------------------") 24 | print("ESP32SimWheelConfig: freezing for distribution ") 25 | print("--------------------------------------------------------------") 26 | 27 | main_file = Path("src/ESP32SimWheelConfig/__main__.py") 28 | icons_file = Path("resources/MainIcons.ico") 29 | sim_wheel_api_file = Path("src/ESP32SimWheelConfig/esp32simwheel.py") 30 | license_file = Path("./LICENSE") 31 | 32 | cmd = [ 33 | "pyinstaller", 34 | f"{main_file}", # your main file with ui.run() 35 | "--name", 36 | "ESP32SimWheel", # name of your app 37 | #'--onefile', 38 | "--windowed", # prevent console appearing, only use with ui.run(native=True, ...) 39 | "--add-data", 40 | f"{Path(nicegui.__file__).parent}{os.pathsep}nicegui", 41 | "--add-data", 42 | f"{license_file}{os.pathsep}.", 43 | "-i", 44 | f"{icons_file}", 45 | "--hidden-import", 46 | f"{sim_wheel_api_file}", 47 | ] 48 | print("Launching freezer: ") 49 | for s in cmd: 50 | print(s, end=" ") 51 | print("") 52 | subprocess.call(cmd) 53 | 54 | else: 55 | print("ERROR: this script must run in the project root...") 56 | exit(-1) 57 | 58 | # Reminder: to make a zip in linux 59 | # cd dist 60 | # zip -r -q linux ESP32SimWheel/ -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afpineda/SimWheelESP32Config/737a2222d1843228c8e4f2c346ca5695f0f1639c/src/requirements.txt --------------------------------------------------------------------------------