├── .github ├── dependabot.yml └── workflows │ ├── docs.yml │ ├── pypi.yml │ └── pytest.yml ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── favicon.ico └── logo.svg ├── examples ├── discovering.py └── simple_with_control.py ├── ftms_realtime.ods ├── pyproject.toml ├── src └── pyftms │ ├── __init__.py │ ├── __main__.py │ ├── client │ ├── __init__.py │ ├── backends │ │ ├── __init__.py │ │ ├── controller.py │ │ ├── event.py │ │ └── updater.py │ ├── client.py │ ├── const.py │ ├── errors.py │ ├── machines │ │ ├── __init__.py │ │ ├── cross_trainer.py │ │ ├── indoor_bike.py │ │ ├── rower.py │ │ └── treadmill.py │ ├── manager.py │ └── properties │ │ ├── __init__.py │ │ ├── device_info.py │ │ ├── features.py │ │ └── machine_type.py │ ├── models │ ├── __init__.py │ ├── common.py │ ├── control_point.py │ ├── machine_status.py │ ├── realtime_data │ │ ├── __init__.py │ │ ├── common.py │ │ ├── cross_trainer.py │ │ ├── indoor_bike.py │ │ ├── rower.py │ │ └── treadmill.py │ ├── spin_down.py │ └── training_status.py │ └── serializer │ ├── __init__.py │ ├── list.py │ ├── model.py │ ├── num.py │ └── serializer.py ├── tests ├── __init__.py ├── test_models.py └── test_num_serializer.py └── uv.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | open-pull-requests-limit: 10 13 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish Documentation 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: ["v[0-9].[0-9]+.[0-9]+"] 7 | 8 | # security: restrict permissions for CI jobs. 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | # Build the documentation and upload the static HTML files as an artifact. 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Install uv 21 | uses: astral-sh/setup-uv@v5 22 | with: 23 | enable-cache: true 24 | cache-dependency-glob: "uv.lock" 25 | 26 | - name: Set up environment 27 | run: uv sync --extra docs 28 | 29 | - name: Run pdoc 30 | run: uv run python -m pdoc --logo logo.svg --favicon favicon.ico -o docs/ pyftms 31 | 32 | - uses: actions/upload-pages-artifact@v3 33 | with: 34 | path: docs/ 35 | 36 | # Deploy the artifact to GitHub pages. 37 | # This is a separate job so that only actions/deploy-pages has the necessary permissions. 38 | deploy: 39 | needs: build 40 | runs-on: ubuntu-latest 41 | 42 | permissions: 43 | pages: write 44 | id-token: write 45 | 46 | environment: 47 | name: github-pages 48 | url: ${{ steps.deployment.outputs.page_url }} 49 | 50 | steps: 51 | - id: deployment 52 | uses: actions/deploy-pages@v4 53 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: ["v[0-9].[0-9]+.[0-9]+"] 7 | 8 | # security: restrict permissions for CI jobs. 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | pypi-publish: 14 | name: Upload release to PyPI 15 | runs-on: ubuntu-latest 16 | environment: 17 | name: pypi 18 | url: https://pypi.org/p/pyftms 19 | permissions: 20 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@v5 26 | with: 27 | enable-cache: true 28 | cache-dependency-glob: "uv.lock" 29 | 30 | - name: Set up Python 31 | run: uv python install 32 | 33 | - name: Package build 34 | run: uv build 35 | 36 | - name: Publish package distributions to PyPI 37 | uses: pypa/gh-action-pypi-publish@release/v1 38 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: Run Pytest 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | paths-ignore: 7 | - ".github/**" 8 | pull_request: 9 | paths-ignore: 10 | - ".github/**" 11 | 12 | jobs: 13 | build: 14 | name: Run tests 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | python-version: 20 | - "3.12" 21 | - "3.13" 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Install uv and set the python version 27 | uses: astral-sh/setup-uv@v5 28 | with: 29 | enable-cache: true 30 | cache-dependency-glob: "uv.lock" 31 | python-version: ${{ matrix.python-version }} 32 | 33 | - name: Install the project 34 | run: uv sync --all-extras --dev 35 | 36 | - uses: pavelzw/pytest-action@v2 37 | with: 38 | custom-pytest: uv run pytest 39 | -------------------------------------------------------------------------------- /.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 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | docs/** 163 | !docs/logo.svg 164 | !docs/favicon.ico 165 | 166 | .vscode/** 167 | 168 | # Office temporary files 169 | ~$*.* 170 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Sergey V. DUDANOV 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyFTMS - Bluetooth Fitness Machine Service async client library 2 | 3 | **PyFTMS** is a Python client library for the **FTMS** service, which is a standard for fitness equipment with a Bluetooth interface. **Bleak** is used as the Bluetooth library. Currently four main types of fitness machines are supported: 4 | 1. **Treadmill** 5 | 2. **Cross Trainer** (Elliptical Trainer) 6 | 3. **Rower** (Rowing Machine) 7 | 4. **Indoor Bike** (Spin Bike) 8 | 9 | **Step Climber** and **Stair Climber** machines are **not supported** due to incomplete protocol information and low popularity. 10 | 11 | ## Requirments 12 | 13 | 1. `bleak` 14 | 2. `bleak-retry-connector` 15 | 16 | ## Install it from PyPI 17 | 18 | ```bash 19 | pip install pyftms 20 | ``` 21 | 22 | ## Usage 23 | 24 | Please read API [documentation](https://dudanov.github.io/python-pyftms/pyftms.html). 25 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dudanov/python-pyftms/675c5cb096f97e4872858b0ca079291f3da12942/docs/favicon.ico -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 35 | 36 | 39 | 40 | 50 | 60 | 61 | 64 | 65 | 66 | 71 | PyFTMS 82 | 83 | 86 | 94 | 100 | 106 | 107 | 108 | 115 | 120 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /examples/discovering.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import asyncio 5 | 6 | from pyftms import discover_ftms_devices 7 | 8 | 9 | async def run(): 10 | async for dev, machine_type in discover_ftms_devices(): 11 | print( 12 | f"Found {machine_type.name}: name: {dev.name}, address: {dev.address}" 13 | ) 14 | 15 | 16 | asyncio.run(run()) 17 | -------------------------------------------------------------------------------- /examples/simple_with_control.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import asyncio 5 | import logging 6 | 7 | from pyftms import FitnessMachine, FtmsEvents, get_client_from_address 8 | 9 | ADDRESS = "29:84:5A:22:A4:11" 10 | 11 | logging.basicConfig(level=logging.DEBUG) 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | def on_event(event: FtmsEvents): 17 | print(f"Event received: {event}") 18 | 19 | 20 | def on_disconnect(m: FitnessMachine): 21 | print("Fitness Machine disconnected.") 22 | 23 | 24 | async def run(): 25 | async with await get_client_from_address( 26 | ADDRESS, on_ftms_event=on_event, on_disconnect=on_disconnect 27 | ) as c: 28 | await c.start_resume() 29 | await asyncio.sleep(30) 30 | await c.set_target_speed(5) 31 | await asyncio.sleep(30) 32 | await c.stop() 33 | 34 | 35 | asyncio.run(run()) 36 | -------------------------------------------------------------------------------- /ftms_realtime.ods: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dudanov/python-pyftms/675c5cb096f97e4872858b0ca079291f3da12942/ftms_realtime.ods -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pyftms" 3 | version = "0.4.15" 4 | description = "Bluetooth Fitness Machine Service async client library." 5 | authors = [ 6 | { name = "Sergey Dudanov", email = "sergey.dudanov@gmail.com" }, 7 | ] 8 | maintainers = [ 9 | { name = "Sergey Dudanov", email = "sergey.dudanov@gmail.com" }, 10 | ] 11 | license = "Apache-2.0" 12 | readme = "README.md" 13 | requires-python = ">=3.12,<3.14" 14 | dependencies = [ 15 | "bleak >= 0.21", 16 | "bleak-retry-connector >= 3.5", 17 | ] 18 | keywords = [ 19 | "async", 20 | "bluetooth", 21 | "client", 22 | "fitness", 23 | "fitshow", 24 | "ftms", 25 | ] 26 | classifiers = [ 27 | "Development Status :: 4 - Beta", 28 | "Framework :: AsyncIO", 29 | "Intended Audience :: Developers", 30 | "Natural Language :: English", 31 | "Operating System :: OS Independent", 32 | "Topic :: Software Development :: Libraries :: Python Modules", 33 | "Programming Language :: Python", 34 | "Programming Language :: Python :: 3 :: Only", 35 | "Programming Language :: Python :: 3.12", 36 | "Programming Language :: Python :: 3.13", 37 | ] 38 | 39 | [project.urls] 40 | "Documentation" = "https://github.com/dudanov/pyftms" 41 | "Home Page" = "https://github.com/dudanov/pyftms" 42 | "Issue Tracker" = "https://github.com/dudanov/pyftms/issues" 43 | "Source Code" = "https://github.com/dudanov/pyftms.git" 44 | 45 | [project.optional-dependencies] 46 | docs = [ 47 | "pdoc", 48 | ] 49 | 50 | [tool.uv] 51 | dev-dependencies = [ 52 | "isort", 53 | "pytest-emoji", 54 | "pytest-md", 55 | "pytest", 56 | "ruff", 57 | "tox", 58 | ] 59 | 60 | [build-system] 61 | requires = ["hatchling"] 62 | build-backend = "hatchling.build" 63 | 64 | [tool.ruff] 65 | line-length = 80 66 | 67 | [tool.ruff.format] 68 | docstring-code-format = true 69 | indent-style = "space" 70 | quote-style = "double" 71 | 72 | [tool.isort] 73 | line_length = 80 74 | profile = "black" 75 | -------------------------------------------------------------------------------- /src/pyftms/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | """ 5 | .. include:: ../../README.md 6 | """ 7 | 8 | from .client import ( 9 | ControlEvent, 10 | DeviceInfo, 11 | FitnessMachine, 12 | FtmsCallback, 13 | MachineType, 14 | MovementDirection, 15 | NotFitnessMachineError, 16 | PropertiesManager, 17 | SettingRange, 18 | SetupEvent, 19 | SetupEventData, 20 | SpinDownEvent, 21 | SpinDownEventData, 22 | UpdateEvent, 23 | UpdateEventData, 24 | discover_ftms_devices, 25 | get_client, 26 | get_client_from_address, 27 | get_machine_type_from_service_data, 28 | ) 29 | from .client.backends import FtmsEvents 30 | from .client.machines import CrossTrainer, IndoorBike, Rower, Treadmill 31 | from .models import ( 32 | IndoorBikeSimulationParameters, 33 | ResultCode, 34 | SpinDownControlCode, 35 | SpinDownSpeedData, 36 | SpinDownStatusCode, 37 | TrainingStatusCode, 38 | ) 39 | 40 | __all__ = [ 41 | "discover_ftms_devices", 42 | "get_client", 43 | "get_client_from_address", 44 | "get_machine_type_from_service_data", 45 | "FitnessMachine", 46 | "CrossTrainer", 47 | "IndoorBike", 48 | "Treadmill", 49 | "Rower", 50 | "FtmsCallback", 51 | "FtmsEvents", 52 | "MachineType", 53 | "UpdateEvent", 54 | "SetupEvent", 55 | "ControlEvent", 56 | "NotFitnessMachineError", 57 | "SetupEventData", 58 | "UpdateEventData", 59 | "MovementDirection", 60 | "IndoorBikeSimulationParameters", 61 | "DeviceInfo", 62 | "SettingRange", 63 | "ResultCode", 64 | "PropertiesManager", 65 | "TrainingStatusCode", 66 | # Spin-Down 67 | "SpinDownEvent", 68 | "SpinDownEventData", 69 | "SpinDownSpeedData", 70 | "SpinDownControlCode", 71 | "SpinDownStatusCode", 72 | ] 73 | -------------------------------------------------------------------------------- /src/pyftms/__main__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024-2025, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import asyncio 5 | 6 | from .client import discover_ftms_devices, get_client 7 | 8 | 9 | async def run(): 10 | print("Scanning for available FTMS devices...") 11 | 12 | lst = [] 13 | 14 | async for dev, machine_type in discover_ftms_devices(discover_time=5): 15 | lst.append((dev, machine_type)) 16 | 17 | print( 18 | f"{len(lst)}. {machine_type.name}: name: {dev.name}, address: {dev.address}" 19 | ) 20 | 21 | for dev, machine_type in lst: 22 | print( 23 | f"\nConnection to {machine_type.name}: name: {dev.name}, address: {dev.address}" 24 | ) 25 | 26 | async with get_client(dev, machine_type) as c: 27 | print(f" 1. Device Info: {c.device_info}") 28 | print(f" 2. Supported settings: {c.supported_settings}") 29 | print(f" 3. Supported ranges: {c.supported_ranges}") 30 | print(f" 4. Supported properties: {c.supported_properties}") 31 | print(f" 5. Available properties: {c.available_properties}") 32 | 33 | print("\nDone.") 34 | 35 | 36 | asyncio.run(run()) 37 | -------------------------------------------------------------------------------- /src/pyftms/client/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import asyncio 5 | import logging 6 | from collections.abc import AsyncIterator 7 | from typing import Any 8 | 9 | from bleak import BleakScanner 10 | from bleak.backends.device import BLEDevice 11 | from bleak.backends.scanner import AdvertisementData 12 | from bleak.exc import BleakDeviceNotFoundError 13 | from bleak.uuids import normalize_uuid_str 14 | 15 | from .backends import ( 16 | ControlEvent, 17 | FtmsCallback, 18 | SetupEvent, 19 | SetupEventData, 20 | SpinDownEvent, 21 | SpinDownEventData, 22 | UpdateEvent, 23 | UpdateEventData, 24 | ) 25 | from .client import DisconnectCallback, FitnessMachine 26 | from .const import FTMS_UUID 27 | from .errors import NotFitnessMachineError 28 | from .machines import get_machine 29 | from .manager import PropertiesManager 30 | from .properties import ( 31 | DeviceInfo, 32 | MachineType, 33 | MovementDirection, 34 | SettingRange, 35 | get_machine_type_from_service_data, 36 | ) 37 | 38 | _LOGGER = logging.getLogger(__name__) 39 | 40 | 41 | def get_client( 42 | ble_device: BLEDevice, 43 | adv_or_type: AdvertisementData | MachineType, 44 | *, 45 | timeout: float = 2, 46 | on_ftms_event: FtmsCallback | None = None, 47 | on_disconnect: DisconnectCallback | None = None, 48 | **kwargs: Any, 49 | ) -> FitnessMachine: 50 | """ 51 | Creates an `FitnessMachine` instance from [Bleak](https://bleak.readthedocs.io/) discovered 52 | information: device and advertisement data. Instead of advertisement data, the `MachineType` can be used. 53 | 54 | Parameters: 55 | - `ble_device` - [BLE device](https://bleak.readthedocs.io/en/latest/api/index.html#bleak.backends.device.BLEDevice). 56 | - `adv_or_type` - Service [advertisement data](https://bleak.readthedocs.io/en/latest/backends/index.html#bleak.backends.scanner.AdvertisementData) or `MachineType`. 57 | - `timeout` - Control operation timeout. Defaults to 2.0s. 58 | - `on_ftms_event` - Callback for receiving fitness machine events. 59 | - `on_disconnect` - Disconnection callback. 60 | - `**kwargs` - Additional keyword arguments for backwards compatibility. 61 | 62 | Return: 63 | - `FitnessMachine` instance. 64 | """ 65 | 66 | adv_data = None 67 | 68 | if isinstance(adv_or_type, AdvertisementData): 69 | adv_data = adv_or_type 70 | adv_or_type = get_machine_type_from_service_data(adv_or_type) 71 | 72 | cls = get_machine(adv_or_type) 73 | 74 | return cls( 75 | ble_device, 76 | adv_data, 77 | timeout=timeout, 78 | on_ftms_event=on_ftms_event, 79 | on_disconnect=on_disconnect, 80 | kwargs=kwargs, 81 | ) 82 | 83 | 84 | async def discover_ftms_devices( 85 | discover_time: float = 10, 86 | **kwargs: Any, 87 | ) -> AsyncIterator[tuple[BLEDevice, MachineType]]: 88 | """ 89 | Discover FTMS devices. 90 | 91 | Parameters: 92 | - `discover_time` - Discover time. Defaults to 10s. 93 | - `**kwargs` - Additional keyword arguments for backwards compatibility. 94 | 95 | Return: 96 | - `AsyncIterator[tuple[BLEDevice, MachineType]]` async generator of `BLEDevice` and `MachineType` tuples. 97 | """ 98 | 99 | devices: set[str] = set() 100 | 101 | async with BleakScanner( 102 | service_uuids=[normalize_uuid_str(FTMS_UUID)], 103 | kwargs=kwargs, 104 | ) as scanner: 105 | try: 106 | async with asyncio.timeout(discover_time): 107 | async for dev, adv in scanner.advertisement_data(): 108 | if dev.address in devices: 109 | continue 110 | 111 | try: 112 | machine_type = get_machine_type_from_service_data(adv) 113 | 114 | except NotFitnessMachineError: 115 | continue 116 | 117 | devices.add(dev.address) 118 | 119 | _LOGGER.debug( 120 | " #%d - %s: address='%s', name='%s'", 121 | len(devices), 122 | machine_type.name, 123 | dev.address, 124 | dev.name, 125 | ) 126 | 127 | yield dev, machine_type 128 | 129 | except asyncio.TimeoutError: 130 | pass 131 | 132 | 133 | async def get_client_from_address( 134 | address: str, 135 | *, 136 | scan_timeout: float = 10, 137 | timeout: float = 2, 138 | on_ftms_event: FtmsCallback | None = None, 139 | on_disconnect: DisconnectCallback | None = None, 140 | **kwargs: Any, 141 | ) -> FitnessMachine: 142 | """ 143 | Scans for fitness machine with specified BLE address. On success creates and return an `FitnessMachine` instance. 144 | 145 | Parameters: 146 | - `address` - The Bluetooth address of the device on this machine (UUID on macOS). 147 | - `scan_timeout` - Scanning timeout. Defaults to 10.0s. 148 | - `timeout` - Control operation timeout. Defaults to 2.0s. 149 | - `on_ftms_event` - Callback for receiving fitness machine events. 150 | - `on_disconnect` - Disconnection callback. 151 | - `**kwargs` - Additional keyword arguments for backwards compatibility. 152 | 153 | Return: 154 | - `FitnessMachine` instance if device found successfully. 155 | """ 156 | 157 | async for dev, machine_type in discover_ftms_devices( 158 | scan_timeout, kwargs=kwargs 159 | ): 160 | if dev.address.lower() == address.lower(): 161 | return get_client( 162 | dev, 163 | machine_type, 164 | timeout=timeout, 165 | on_ftms_event=on_ftms_event, 166 | on_disconnect=on_disconnect, 167 | kwargs=kwargs, 168 | ) 169 | 170 | raise BleakDeviceNotFoundError(address) 171 | 172 | 173 | __all__ = [ 174 | "discover_ftms_devices", 175 | "get_client", 176 | "get_client_from_address", 177 | "MachineType", 178 | "NotFitnessMachineError", 179 | "UpdateEvent", 180 | "SetupEvent", 181 | "ControlEvent", 182 | "SpinDownEvent", 183 | "FtmsCallback", 184 | "SetupEventData", 185 | "UpdateEventData", 186 | "SpinDownEventData", 187 | "MovementDirection", 188 | "DeviceInfo", 189 | "SettingRange", 190 | "PropertiesManager", 191 | "DisconnectCallback", 192 | ] 193 | -------------------------------------------------------------------------------- /src/pyftms/client/backends/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from .controller import MachineController 5 | from .event import ( 6 | ControlEvent, 7 | FtmsCallback, 8 | FtmsEvents, 9 | SetupEvent, 10 | SetupEventData, 11 | SpinDownEvent, 12 | SpinDownEventData, 13 | UpdateEvent, 14 | UpdateEventData, 15 | ) 16 | from .updater import DataUpdater 17 | 18 | __all__ = [ 19 | "DataUpdater", 20 | "SetupEventData", 21 | "UpdateEventData", 22 | "MachineController", 23 | "FtmsCallback", 24 | "FtmsEvents", 25 | "UpdateEvent", 26 | "SetupEvent", 27 | "ControlEvent", 28 | "SpinDownEvent", 29 | "SpinDownEventData", 30 | ] 31 | -------------------------------------------------------------------------------- /src/pyftms/client/backends/controller.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024-2025, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import asyncio 5 | import io 6 | import logging 7 | from typing import cast 8 | 9 | from bleak import BleakClient 10 | from bleak.backends.characteristic import BleakGATTCharacteristic 11 | 12 | from ...models import ( 13 | CodeSwitchModel, 14 | ControlCode, 15 | ControlIndicateModel, 16 | ControlModel, 17 | MachineStatusCode, 18 | MachineStatusModel, 19 | ResultCode, 20 | SpinDownSpeedData, 21 | StopPauseCode, 22 | TrainingStatusFlags, 23 | TrainingStatusModel, 24 | ) 25 | from ..const import ( 26 | CONTROL_POINT_UUID, 27 | PAUSE, 28 | STATUS_UUID, 29 | STOP, 30 | TRAINING_STATUS_UUID, 31 | ) 32 | from .event import ( 33 | ControlEvent, 34 | FtmsCallback, 35 | SetupEvent, 36 | SetupEventData, 37 | SpinDownEvent, 38 | SpinDownEventData, 39 | UpdateEvent, 40 | UpdateEventData, 41 | ) 42 | 43 | _LOGGER = logging.getLogger(__name__) 44 | 45 | 46 | def _to_setup_event_data(model: CodeSwitchModel) -> SetupEventData: 47 | result = model._asdict(nested=True) 48 | 49 | result.pop("code") 50 | 51 | if not result: 52 | return {} 53 | 54 | assert len(result) == 1 55 | 56 | k, v = next(iter(result.items())) 57 | 58 | # Handle 'target_time_x' 59 | if k[-1].isdecimal(): 60 | k = k[:-2] 61 | 62 | return cast(SetupEventData, {k: v}) # unsafe cast 63 | 64 | 65 | def _simple_status_events(m: MachineStatusModel) -> ControlEvent | None: 66 | match m.code: 67 | case MachineStatusCode.RESET: 68 | return ControlEvent(event_id="reset", event_source="other") 69 | 70 | case MachineStatusCode.STOP_PAUSE: 71 | value = ( 72 | STOP 73 | if StopPauseCode(m.stop_pause) == StopPauseCode.STOP 74 | else PAUSE 75 | ) 76 | return ControlEvent(event_id=value, event_source="user") 77 | 78 | case MachineStatusCode.STOP_SAFETY: 79 | return ControlEvent(event_id="stop", event_source="safety") 80 | 81 | case MachineStatusCode.START_RESUME: 82 | return ControlEvent(event_id="start", event_source="user") 83 | 84 | 85 | def _simple_control_events(m: ControlModel) -> ControlEvent | None: 86 | """Handle simple control requests after success operation indication""" 87 | match m.code: 88 | case ControlCode.RESET: 89 | return ControlEvent(event_id="reset", event_source="callback") 90 | 91 | case ControlCode.STOP_PAUSE: 92 | value = ( 93 | STOP 94 | if StopPauseCode(m.stop_pause) == StopPauseCode.STOP 95 | else PAUSE 96 | ) 97 | return ControlEvent(event_id=value, event_source="callback") 98 | 99 | case ControlCode.START_RESUME: 100 | return ControlEvent(event_id="start", event_source="callback") 101 | 102 | 103 | class MachineController: 104 | _indicate: asyncio.Future[bytes] 105 | 106 | def __init__(self, callback: FtmsCallback) -> None: 107 | self._subscribed = False 108 | self._auth = False 109 | self._cb = callback 110 | self._write_lock = asyncio.Lock() 111 | 112 | async def subscribe(self, cli: BleakClient) -> None: 113 | """Subscribe for available notifications.""" 114 | if self._subscribed: 115 | return 116 | 117 | if c := cli.services.get_characteristic(TRAINING_STATUS_UUID): 118 | self._on_training_status(c, await cli.read_gatt_char(c)) 119 | await cli.start_notify(c, self._on_training_status) 120 | 121 | if c := cli.services.get_characteristic(STATUS_UUID): 122 | await cli.start_notify(c, self._on_machine_status) 123 | 124 | if c := cli.services.get_characteristic(CONTROL_POINT_UUID): 125 | await cli.start_notify(c, self._on_indicate) 126 | 127 | self._subscribed = True 128 | 129 | def reset(self): 130 | """Resetting state. Call while disconnection event.""" 131 | self._subscribed = False 132 | self._auth = False 133 | 134 | def _on_indicate(self, c: BleakGATTCharacteristic, data: bytes) -> None: 135 | """Control indication callback.""" 136 | if not self._indicate.done(): 137 | self._indicate.set_result(data) 138 | 139 | async def write_command( 140 | self, 141 | cli: BleakClient, 142 | code: ControlCode | None = None, 143 | *, 144 | timeout: float = 2.0, 145 | **kwargs, 146 | ) -> ResultCode: 147 | """Writing command to control point.""" 148 | # Auto-Request control 149 | if not self._auth and code != ControlCode.REQUEST_CONTROL: 150 | await self.write_command( 151 | cli, 152 | ControlCode.REQUEST_CONTROL, 153 | timeout=timeout, 154 | ) 155 | 156 | bio = io.BytesIO() 157 | 158 | request = ControlModel(code=code, **kwargs) 159 | request._serialize(bio) 160 | 161 | # Write to control point 162 | 163 | await self.subscribe(cli) 164 | 165 | async with self._write_lock: 166 | self._indicate = asyncio.Future() 167 | _, resp = await asyncio.wait_for( 168 | asyncio.gather( 169 | cli.write_gatt_char( 170 | CONTROL_POINT_UUID, 171 | bio.getvalue(), 172 | True, 173 | ), 174 | self._indicate, 175 | ), 176 | timeout=timeout, 177 | ) 178 | 179 | bio = io.BytesIO(resp) 180 | 181 | indicate = ControlIndicateModel._deserialize(bio) 182 | 183 | if indicate.request_code != request.code: 184 | raise ValueError("Response on another request?..") 185 | 186 | if indicate.result_code != ResultCode.SUCCESS: 187 | return indicate.result_code 188 | 189 | if request.code == ControlCode.RESET: 190 | self._auth = False 191 | 192 | elif request.code == ControlCode.REQUEST_CONTROL: 193 | self._auth = True 194 | 195 | return ResultCode.SUCCESS 196 | 197 | elif request.spin_down is not None: 198 | data = SpinDownEventData(code=request.spin_down) 199 | 200 | s = SpinDownSpeedData._get_serializer() 201 | 202 | if speed_bytes := bio.read(s.get_size()): 203 | data["target_speed"] = s.deserialize(speed_bytes) 204 | 205 | assert not bio.read(1) 206 | 207 | event = SpinDownEvent(event_id="spin_down", event_data=data) 208 | 209 | self._cb(event) 210 | 211 | return ResultCode.SUCCESS 212 | 213 | # Writing is success. Firing events and update settings data. 214 | 215 | if event := _simple_control_events(request): 216 | # reset, start, stop, pause handled 217 | self._cb(event) 218 | 219 | return ResultCode.SUCCESS 220 | 221 | # Handling setup requests with parameters 222 | 223 | event = SetupEvent( 224 | event_id="setup", 225 | event_data=_to_setup_event_data(request), 226 | event_source="callback", 227 | ) 228 | 229 | self._cb(event) 230 | 231 | return ResultCode.SUCCESS 232 | 233 | def _on_machine_status( 234 | self, c: BleakGATTCharacteristic, data: bytearray 235 | ) -> None: 236 | """Machine Status notification callback.""" 237 | bio = io.BytesIO(data) 238 | status = MachineStatusModel._deserialize(bio) 239 | 240 | # Handle loosing control 241 | if status.code == MachineStatusCode.LOST_CONTROL: 242 | self._auth = False 243 | return 244 | 245 | if status.code == MachineStatusCode.RESET: 246 | self._auth = False 247 | 248 | if p := _simple_status_events(status): 249 | # reset, start, stop (and safety), pause handled 250 | return self._cb(p) 251 | 252 | event = SetupEvent( 253 | event_id="setup", 254 | event_data=_to_setup_event_data(status), 255 | event_source="other", 256 | ) 257 | 258 | self._cb(event) 259 | 260 | def _on_training_status( 261 | self, c: BleakGATTCharacteristic, data: bytearray 262 | ) -> None: 263 | """Training Status notification callback.""" 264 | bio = io.BytesIO(data) 265 | status = TrainingStatusModel._deserialize(bio) 266 | 267 | status_data = UpdateEventData(training_status=status.code) 268 | 269 | if TrainingStatusFlags.STRING_PRESENT in status.flags: 270 | if b := bio.read(): 271 | status_data["training_status_string"] = b.decode( 272 | encoding="utf-8" 273 | ) 274 | 275 | event = UpdateEvent(event_id="update", event_data=status_data) 276 | 277 | self._cb(event) 278 | -------------------------------------------------------------------------------- /src/pyftms/client/backends/event.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from typing import Callable, Literal, NamedTuple, TypedDict 5 | 6 | from ...models import ( 7 | IndoorBikeSimulationParameters, 8 | SpinDownControlCode, 9 | SpinDownSpeedData, 10 | SpinDownStatusCode, 11 | TrainingStatusCode, 12 | ) 13 | from ..properties import MovementDirection 14 | 15 | FtmsNumbers = int | float 16 | ControlSource = Literal["callback", "user", "safety", "other"] 17 | ControlEvents = Literal["start", "stop", "pause", "reset"] 18 | 19 | 20 | class SpinDownEventData(TypedDict, total=False): 21 | """`SpinDownEvent` data.""" 22 | 23 | target_speed: SpinDownSpeedData 24 | """From fitness machine to client. Indicate successfully operation.""" 25 | code: SpinDownControlCode 26 | """From client to fitness machine. START or IGNORE.""" 27 | status: SpinDownStatusCode 28 | """From fitness machine to client.""" 29 | 30 | 31 | class SetupEventData(TypedDict, total=False): 32 | """`SetupEvent` data.""" 33 | 34 | indoor_bike_simulation: IndoorBikeSimulationParameters 35 | """Indoor Bike Simulation Parameters.""" 36 | target_cadence: float 37 | """ 38 | Targeted cadence. 39 | 40 | Units: `rpm`. 41 | """ 42 | target_distance: int 43 | """ 44 | Targeted distance. 45 | 46 | Units: `m`. 47 | """ 48 | target_energy: int 49 | """ 50 | Targeted expended energy. 51 | 52 | Units: `kcal`. 53 | """ 54 | target_heart_rate: int 55 | """ 56 | Targeted heart rate. 57 | 58 | Units: `bpm`. 59 | """ 60 | target_inclination: float 61 | """ 62 | Targeted inclination. 63 | 64 | Units: `%`. 65 | """ 66 | target_power: int 67 | """ 68 | Targeted power. 69 | 70 | Units: `Watt`. 71 | """ 72 | target_resistance: float | int 73 | """ 74 | Targeted resistance level. 75 | 76 | Units: `unitless`. 77 | """ 78 | target_speed: float 79 | """ 80 | Targeted speed. 81 | 82 | Units: `km/h`. 83 | """ 84 | target_steps: int 85 | """ 86 | Targeted number of steps. 87 | 88 | Units: `step`. 89 | """ 90 | target_strides: int 91 | """ 92 | Targeted number of strides. 93 | 94 | Units: `stride`. 95 | """ 96 | target_time: tuple[int, ...] 97 | """ 98 | Targeted training time. 99 | 100 | Units: `s`. 101 | """ 102 | wheel_circumference: float 103 | """ 104 | Wheel circumference. 105 | 106 | Units: `mm`. 107 | """ 108 | 109 | 110 | class UpdateEventData(TypedDict, total=False): 111 | rssi: int 112 | """RSSI.""" 113 | 114 | cadence_average: float 115 | """ 116 | Average Cadence. 117 | 118 | Units: `rpm`. 119 | """ 120 | cadence_instant: float 121 | """ 122 | Instantaneous Cadence. 123 | 124 | Units: `rpm`. 125 | """ 126 | distance_total: int 127 | """ 128 | Total Distance. 129 | 130 | Units: `m`. 131 | """ 132 | elevation_gain_negative: int 133 | """ 134 | Negative Elevation Gain. 135 | 136 | Units: `m`. 137 | """ 138 | elevation_gain_positive: int 139 | """ 140 | Positive Elevation Gain. 141 | 142 | Units: `m`. 143 | """ 144 | energy_per_hour: int 145 | """ 146 | Energy Per Hour. 147 | 148 | Units: `kcal`. 149 | """ 150 | energy_per_minute: int 151 | """ 152 | Energy Per Minute. 153 | 154 | Units: `kcal`. 155 | """ 156 | energy_total: int 157 | """ 158 | Total Energy. 159 | 160 | Units: `kcal`. 161 | """ 162 | force_on_belt: int 163 | """ 164 | Force on Belt. 165 | 166 | Units: `newton`. 167 | """ 168 | heart_rate: int 169 | """ 170 | Heart Rate. 171 | 172 | Units: `bpm`. 173 | """ 174 | inclination: float 175 | """ 176 | Inclination. 177 | 178 | Units: `%`. 179 | """ 180 | metabolic_equivalent: float 181 | """ 182 | Metabolic Equivalent. 183 | 184 | Units: `meta`. 185 | """ 186 | movement_direction: MovementDirection 187 | """ 188 | Movement Direction. 189 | 190 | Units: `MovementDirection`. 191 | """ 192 | pace_average: float 193 | """ 194 | Average Pace. 195 | 196 | Units: `km/m`. 197 | """ 198 | pace_instant: float 199 | """ 200 | Instantaneous Pace. 201 | 202 | Units: `km/m`. 203 | """ 204 | power_average: int 205 | """ 206 | Average Power. 207 | 208 | Units: `Watt`. 209 | """ 210 | power_instant: int 211 | """ 212 | Instantaneous Power. 213 | 214 | Units: `Watt`. 215 | """ 216 | power_output: int 217 | """ 218 | Power Output. 219 | 220 | Units: `Watt`. 221 | """ 222 | ramp_angle: float 223 | """ 224 | Ramp Angle Setting. 225 | 226 | Units: `degree`. 227 | """ 228 | resistance_level: int | float 229 | """ 230 | Resistance Level. 231 | 232 | Units: `unitless`. 233 | """ 234 | speed_average: float 235 | """ 236 | Average Speed. 237 | 238 | Units: `km/h`. 239 | """ 240 | speed_instant: float 241 | """ 242 | Instantaneous Speed. 243 | 244 | Units: `km/h`. 245 | """ 246 | split_time_average: int 247 | """ 248 | Average Split Time. 249 | 250 | Units: `s/500m`. 251 | """ 252 | split_time_instant: int 253 | """ 254 | Instantaneous Split Time. 255 | 256 | Units: `s/500m`. 257 | """ 258 | step_count: int 259 | """ 260 | Step Count. 261 | 262 | Units: `step`. 263 | """ 264 | step_rate_average: int 265 | """ 266 | Average Step Rate. 267 | 268 | Units: `spm`. 269 | """ 270 | step_rate_instant: int 271 | """ 272 | Instantaneous Step Rate. 273 | 274 | Units: `spm`. 275 | """ 276 | stride_count: int 277 | """ 278 | Stride Count. 279 | 280 | Units: `unitless`. 281 | """ 282 | stroke_count: int 283 | """ 284 | Stroke Count. 285 | 286 | Units: `unitless`. 287 | """ 288 | stroke_rate_average: float 289 | """ 290 | Average Stroke Rate. 291 | 292 | Units: `spm`. 293 | """ 294 | stroke_rate_instant: float 295 | """ 296 | Instantaneous Stroke Rate. 297 | 298 | Units: `spm`. 299 | """ 300 | time_elapsed: int 301 | """ 302 | Elapsed Time. 303 | 304 | Units: `s`. 305 | """ 306 | time_remaining: int 307 | """ 308 | Remaining Time. 309 | 310 | Units: `s`. 311 | """ 312 | training_status: TrainingStatusCode 313 | """ 314 | Training Status. 315 | """ 316 | training_status_string: str 317 | """ 318 | Training Status String. 319 | """ 320 | 321 | 322 | class UpdateEvent(NamedTuple): 323 | """Update Event.""" 324 | 325 | event_id: Literal["update"] 326 | """Always `update`.""" 327 | event_data: UpdateEventData 328 | """`UpdateEvent` data.""" 329 | 330 | 331 | class SpinDownEvent(NamedTuple): 332 | """Spin Down Procedure Event.""" 333 | 334 | event_id: Literal["spin_down"] 335 | """Always `spin_down`.""" 336 | event_data: SpinDownEventData 337 | """`SpinDownEvent` data.""" 338 | 339 | 340 | class SetupEvent(NamedTuple): 341 | """Setting Event.""" 342 | 343 | event_id: Literal["setup"] 344 | """Always `setup`.""" 345 | event_data: SetupEventData 346 | """`SetupEvent` data.""" 347 | event_source: ControlSource 348 | """Reason of event.""" 349 | 350 | 351 | class ControlEvent(NamedTuple): 352 | """Control Event.""" 353 | 354 | event_id: ControlEvents 355 | """One of: `start`, `stop`, `pause`, `reset`.""" 356 | event_source: ControlSource 357 | """Reason of event.""" 358 | 359 | 360 | FtmsEvents = UpdateEvent | SetupEvent | ControlEvent | SpinDownEvent 361 | """Tagged union of FTMS events.""" 362 | 363 | FtmsCallback = Callable[[FtmsEvents], None] 364 | """Callback function to receive FTMS events.""" 365 | -------------------------------------------------------------------------------- /src/pyftms/client/backends/updater.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import logging 5 | from typing import Any, cast 6 | 7 | from bleak import BleakClient 8 | from bleak.backends.characteristic import BleakGATTCharacteristic 9 | 10 | from ...models import RealtimeData 11 | from ...serializer import ModelSerializer, get_serializer 12 | from .event import FtmsCallback, UpdateEvent, UpdateEventData 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | class DataUpdater: 18 | _serializer: ModelSerializer[RealtimeData] 19 | 20 | def __init__( 21 | self, model: type[RealtimeData], callback: FtmsCallback 22 | ) -> None: 23 | self._cb = callback 24 | self._serializer = get_serializer(model) 25 | self._prev: dict[str, Any] = {} 26 | self._result: dict[str, Any] = {} 27 | 28 | def reset(self) -> None: 29 | """Resetting state. Call while disconnection event.""" 30 | self._prev.clear() 31 | self._result.clear() 32 | 33 | async def subscribe(self, cli: BleakClient, uuid: str) -> None: 34 | """Subscribe for notification.""" 35 | self.reset() 36 | await cli.start_notify(uuid, self._on_notify) 37 | 38 | def _on_notify(self, c: BleakGATTCharacteristic, data: bytearray) -> None: 39 | _LOGGER.debug("Received notify: %s", data.hex(" ").upper()) 40 | data_ = self._serializer.deserialize(data)._asdict() 41 | _LOGGER.debug("Received notify dict: %s", data_) 42 | self._result |= data_ 43 | 44 | # If `More Data` bit is set - we must wait for other messages. 45 | if data[0] & 1: 46 | _LOGGER.debug("'More Data' bit is set. Waiting for next data.") 47 | return 48 | 49 | # My device sends a lot of null packets during wakeup and sleep mode. 50 | # So I just filter null packets. 51 | if any(self._result.values()): 52 | update = self._result.items() ^ self._prev.items() 53 | 54 | if update := {k: self._result[k] for k, _ in update}: 55 | _LOGGER.debug("Update data: %s", update) 56 | update = cast(UpdateEventData, update) # unsafe casting 57 | update = UpdateEvent(event_id="update", event_data=update) 58 | self._cb(update) 59 | self._prev = self._result.copy() 60 | 61 | self._result.clear() 62 | -------------------------------------------------------------------------------- /src/pyftms/client/client.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024-2025, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from __future__ import annotations 5 | 6 | import logging 7 | from abc import ABC 8 | from functools import cached_property 9 | from types import MappingProxyType 10 | from typing import Any, Callable, ClassVar 11 | 12 | from bleak import BleakClient 13 | from bleak.backends.device import BLEDevice 14 | from bleak.backends.scanner import AdvertisementData 15 | from bleak_retry_connector import close_stale_connections, establish_connection 16 | 17 | from ..models import ( 18 | ControlCode, 19 | ControlModel, 20 | IndoorBikeSimulationParameters, 21 | RealtimeData, 22 | ResultCode, 23 | SpinDownControlCode, 24 | StopPauseCode, 25 | ) 26 | from . import const as c 27 | from .backends import DataUpdater, FtmsCallback, MachineController, UpdateEvent 28 | from .manager import PropertiesManager 29 | from .properties import ( 30 | DeviceInfo, 31 | MachineFeatures, 32 | MachineSettings, 33 | MachineType, 34 | SettingRange, 35 | read_device_info, 36 | read_features, 37 | ) 38 | from .properties.device_info import DIS_UUID 39 | 40 | _LOGGER = logging.getLogger(__name__) 41 | 42 | type DisconnectCallback = Callable[[FitnessMachine], None] 43 | 44 | 45 | class FitnessMachine(ABC, PropertiesManager): 46 | """ 47 | Base FTMS client. 48 | 49 | Supports `async with ...` context manager. 50 | """ 51 | 52 | _machine_type: ClassVar[MachineType] 53 | """Machine type.""" 54 | 55 | _data_model: ClassVar[type[RealtimeData]] 56 | """Model of real-time training data.""" 57 | 58 | _data_uuid: ClassVar[str] 59 | """Notify UUID of real-time training data.""" 60 | 61 | _cli: BleakClient 62 | 63 | _updater: DataUpdater 64 | 65 | _device: BLEDevice 66 | _need_connect: bool 67 | 68 | # Static device info 69 | 70 | _device_info: DeviceInfo = {} 71 | _m_features: MachineFeatures = MachineFeatures(0) 72 | _m_settings: MachineSettings = MachineSettings(0) 73 | _settings_ranges: MappingProxyType[str, SettingRange] = MappingProxyType({}) 74 | 75 | def __init__( 76 | self, 77 | ble_device: BLEDevice, 78 | adv_data: AdvertisementData | None = None, 79 | *, 80 | timeout: float = 2.0, 81 | on_ftms_event: FtmsCallback | None = None, 82 | on_disconnect: DisconnectCallback | None = None, 83 | **kwargs: Any, 84 | ) -> None: 85 | super().__init__(on_ftms_event) 86 | 87 | self._need_connect = False 88 | self._timeout = timeout 89 | self._disconnect_cb = on_disconnect 90 | self._kwargs = kwargs 91 | 92 | self.set_ble_device_and_advertisement_data(ble_device, adv_data) 93 | 94 | # Updaters 95 | self._updater = DataUpdater(self._data_model, self._on_event) 96 | self._controller = MachineController(self._on_event) 97 | 98 | @classmethod 99 | def _get_supported_properties( 100 | cls, features: MachineFeatures = MachineFeatures(~0) 101 | ) -> list[str]: 102 | return cls._data_model._get_features(features) 103 | 104 | async def __aenter__(self): 105 | await self.connect() 106 | return self 107 | 108 | async def __aexit__(self, exc_type, exc, tb): 109 | await self.disconnect() 110 | 111 | # BLE SPECIFIC PROPERTIES 112 | 113 | def set_ble_device_and_advertisement_data( 114 | self, ble_device: BLEDevice, adv_data: AdvertisementData | None 115 | ): 116 | self._device = ble_device 117 | 118 | if adv_data: 119 | self._properties["rssi"] = adv_data.rssi 120 | 121 | if self._cb: 122 | self._cb(UpdateEvent("update", {"rssi": adv_data.rssi})) 123 | 124 | @property 125 | def unique_id(self) -> str: 126 | """Unique ID""" 127 | 128 | return self.device_info.get( 129 | "serial_number", self.address.replace(":", "").lower() 130 | ) 131 | 132 | @property 133 | def need_connect(self) -> bool: 134 | """Connection state latch. `True` if connection is needed.""" 135 | return self._need_connect 136 | 137 | @need_connect.setter 138 | def need_connect(self, value: bool) -> None: 139 | """Connection state latch. `True` if connection is needed.""" 140 | self._need_connect = value 141 | 142 | @property 143 | def rssi(self) -> int | None: 144 | """RSSI.""" 145 | return self.get_property("rssi") 146 | 147 | @property 148 | def name(self) -> str: 149 | """Device name or BLE address""" 150 | 151 | return self._device.name or self._device.address 152 | 153 | def set_disconnect_callback(self, cb: DisconnectCallback): 154 | """Set disconnect callback.""" 155 | self._disconnect_cb = cb 156 | 157 | async def connect(self) -> None: 158 | """ 159 | Opens a connection to the device. Reads necessary static information: 160 | * Device Information (manufacturer, model, serial number, hardware and software versions); 161 | * Supported features; 162 | * Supported settings; 163 | * Ranges of parameters settings. 164 | """ 165 | 166 | self._need_connect = True 167 | 168 | await self._connect() 169 | 170 | async def disconnect(self) -> None: 171 | """Disconnects from device.""" 172 | 173 | self._need_connect = False 174 | 175 | if self.is_connected: 176 | await self._cli.disconnect() 177 | 178 | @property 179 | def address(self) -> str: 180 | """Bluetooth address.""" 181 | 182 | return self._device.address 183 | 184 | @property 185 | def is_connected(self) -> bool: 186 | """Current connection status.""" 187 | 188 | return hasattr(self, "_cli") and self._cli.is_connected 189 | 190 | # COMMON BASE PROPERTIES 191 | 192 | @property 193 | def device_info(self) -> DeviceInfo: 194 | """Device Information.""" 195 | 196 | return self._device_info 197 | 198 | @property 199 | def machine_type(self) -> MachineType: 200 | """Machine type.""" 201 | 202 | return self._machine_type 203 | 204 | @cached_property 205 | def supported_properties(self) -> list[str]: 206 | """ 207 | Properties that supported by this machine. 208 | Based on **Machine Features** report. 209 | 210 | *May contain both meaningless properties and may not contain 211 | some properties that are supported by the machine.* 212 | """ 213 | 214 | x = self._get_supported_properties(self._m_features) 215 | 216 | if self.training_status is not None: 217 | x.append(c.TRAINING_STATUS) 218 | 219 | return x 220 | 221 | @cached_property 222 | def available_properties(self) -> list[str]: 223 | """All properties that *MAY BE* supported by this machine type.""" 224 | 225 | x = self._get_supported_properties() 226 | x.append(c.TRAINING_STATUS) 227 | 228 | return x 229 | 230 | @cached_property 231 | def supported_settings(self) -> list[str]: 232 | """Supported settings.""" 233 | 234 | return ControlModel._get_features(self._m_settings) 235 | 236 | @property 237 | def supported_ranges(self) -> MappingProxyType[str, SettingRange]: 238 | """Ranges of supported settings.""" 239 | 240 | return self._settings_ranges 241 | 242 | def _on_disconnect(self, cli: BleakClient) -> None: 243 | _LOGGER.debug("Client disconnected. Reset updaters states.") 244 | 245 | del self._cli 246 | self._updater.reset() 247 | self._controller.reset() 248 | 249 | if self._disconnect_cb: 250 | self._disconnect_cb(self) 251 | 252 | async def _connect(self) -> None: 253 | if not self._need_connect or self.is_connected: 254 | return 255 | 256 | await close_stale_connections(self._device) 257 | 258 | _LOGGER.debug("Initialization. Trying to establish connection.") 259 | 260 | self._cli = await establish_connection( 261 | client_class=BleakClient, 262 | device=self._device, 263 | name=self.name, 264 | disconnected_callback=self._on_disconnect, 265 | # we needed only two services: `Fitness Machine Service` and `Device Information Service` 266 | services=[c.FTMS_UUID, DIS_UUID], 267 | kwargs=self._kwargs, 268 | ) 269 | 270 | _LOGGER.debug("Connection success.") 271 | 272 | # Reading necessary static fitness machine information 273 | 274 | if not self._device_info: 275 | self._device_info = await read_device_info(self._cli) 276 | 277 | if not self._m_features: 278 | ( 279 | self._m_features, 280 | self._m_settings, 281 | self._settings_ranges, 282 | ) = await read_features(self._cli, self._machine_type) 283 | 284 | await self._controller.subscribe(self._cli) 285 | await self._updater.subscribe(self._cli, self._data_uuid) 286 | 287 | # COMMANDS 288 | 289 | async def _write_command( 290 | self, code: ControlCode | None = None, *args, **kwargs 291 | ): 292 | if self._need_connect: 293 | await self._connect() 294 | return await self._controller.write_command( 295 | self._cli, code, timeout=self._timeout, **kwargs 296 | ) 297 | 298 | return ResultCode.FAILED 299 | 300 | async def reset(self) -> ResultCode: 301 | """Initiates the procedure to reset the controllable settings of a fitness machine.""" 302 | return await self._write_command(ControlCode.RESET) 303 | 304 | async def start_resume(self) -> ResultCode: 305 | """Initiate the procedure to start or resume a training session.""" 306 | return await self._write_command(ControlCode.START_RESUME) 307 | 308 | async def stop(self) -> ResultCode: 309 | """Initiate the procedure to stop a training session.""" 310 | return await self._write_command(stop_pause=StopPauseCode.STOP) 311 | 312 | async def pause(self) -> ResultCode: 313 | """Initiate the procedure to pause a training session.""" 314 | return await self._write_command(stop_pause=StopPauseCode.PAUSE) 315 | 316 | async def set_setting(self, setting_id: str, *args: Any) -> ResultCode: 317 | """ 318 | Generic method of settings by ID. 319 | 320 | **Methods for setting specific parameters.** 321 | """ 322 | 323 | if setting_id not in self.supported_settings: 324 | return ResultCode.NOT_SUPPORTED 325 | 326 | if not args: 327 | raise ValueError("No data to pass.") 328 | 329 | if len(args) == 1: 330 | args = args[0] 331 | 332 | return await self._write_command(code=None, **{setting_id: args}) 333 | 334 | async def set_target_speed(self, value: float) -> ResultCode: 335 | """ 336 | Sets target speed. 337 | 338 | Units: `km/h`. 339 | """ 340 | return await self.set_setting(c.TARGET_SPEED, value) 341 | 342 | async def set_target_inclination(self, value: float) -> ResultCode: 343 | """ 344 | Sets target inclination. 345 | 346 | Units: `%`. 347 | """ 348 | return await self.set_setting(c.TARGET_INCLINATION, value) 349 | 350 | async def set_target_resistance(self, value: float) -> ResultCode: 351 | """ 352 | Sets target resistance level. 353 | 354 | Units: `unitless`. 355 | """ 356 | return await self.set_setting(c.TARGET_RESISTANCE, value) 357 | 358 | async def set_target_power(self, value: int) -> ResultCode: 359 | """ 360 | Sets target power. 361 | 362 | Units: `Watt`. 363 | """ 364 | return await self.set_setting(c.TARGET_POWER, value) 365 | 366 | async def set_target_heart_rate(self, value: int) -> ResultCode: 367 | """ 368 | Sets target heart rate. 369 | 370 | Units: `bpm`. 371 | """ 372 | return await self.set_setting(c.TARGET_HEART_RATE, value) 373 | 374 | async def set_target_energy(self, value: int) -> ResultCode: 375 | """ 376 | Sets target expended energy. 377 | 378 | Units: `kcal`. 379 | """ 380 | return await self.set_setting(c.TARGET_ENERGY, value) 381 | 382 | async def set_target_steps(self, value: int) -> ResultCode: 383 | """ 384 | Sets targeted number of steps. 385 | 386 | Units: `step`. 387 | """ 388 | return await self.set_setting(c.TARGET_STEPS, value) 389 | 390 | async def set_target_strides(self, value: int) -> ResultCode: 391 | """ 392 | Sets targeted number of strides. 393 | 394 | Units: `stride`. 395 | """ 396 | return await self.set_setting(c.TARGET_STRIDES, value) 397 | 398 | async def set_target_distance(self, value: int) -> ResultCode: 399 | """ 400 | Sets targeted distance. 401 | 402 | Units: `m`. 403 | """ 404 | return await self.set_setting(c.TARGET_DISTANCE, value) 405 | 406 | async def set_target_time(self, *value: int) -> ResultCode: 407 | """ 408 | Set targeted training time. 409 | 410 | Units: `s`. 411 | """ 412 | return await self.set_setting(c.TARGET_TIME, *value) 413 | 414 | async def set_indoor_bike_simulation( 415 | self, 416 | value: IndoorBikeSimulationParameters, 417 | ) -> ResultCode: 418 | """Set indoor bike simulation parameters.""" 419 | return await self.set_setting(c.INDOOR_BIKE_SIMULATION, value) 420 | 421 | async def set_wheel_circumference(self, value: float) -> ResultCode: 422 | """ 423 | Set wheel circumference. 424 | 425 | Units: `mm`. 426 | """ 427 | return await self.set_setting(c.WHEEL_CIRCUMFERENCE, value) 428 | 429 | async def spin_down_start(self) -> ResultCode: 430 | """ 431 | Start Spin-Down. 432 | 433 | It can be sent either in response to a request to start Spin-Down, or separately. 434 | """ 435 | return await self.set_setting(c.SPIN_DOWN, SpinDownControlCode.START) 436 | 437 | async def spin_down_ignore(self) -> ResultCode: 438 | """ 439 | Ignore Spin-Down. 440 | 441 | It can be sent in response to a request to start Spin-Down. 442 | """ 443 | return await self.set_setting(c.SPIN_DOWN, SpinDownControlCode.IGNORE) 444 | 445 | async def set_target_cadence(self, value: float) -> ResultCode: 446 | """ 447 | Set targeted cadence. 448 | 449 | Units: `rpm`. 450 | """ 451 | return await self.set_setting(c.TARGET_CADENCE, value) 452 | -------------------------------------------------------------------------------- /src/pyftms/client/const.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024-2025, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | # REAL-TIME TRAINING DATA 5 | 6 | CADENCE_AVERAGE = "cadence_average" 7 | CADENCE_INSTANT = "cadence_instant" 8 | DISTANCE_TOTAL = "distance_total" 9 | ELEVATION_GAIN_NEGATIVE = "elevation_gain_negative" 10 | ELEVATION_GAIN_POSITIVE = "elevation_gain_positive" 11 | ENERGY_PER_HOUR = "energy_per_hour" 12 | ENERGY_PER_MINUTE = "energy_per_minute" 13 | ENERGY_TOTAL = "energy_total" 14 | FORCE_ON_BELT = "force_on_belt" 15 | HEART_RATE = "heart_rate" 16 | INCLINATION = "inclination" 17 | INDOOR_BIKE_SIMULATION = "indoor_bike_simulation" 18 | METABOLIC_EQUIVALENT = "metabolic_equivalent" 19 | MOVEMENT_DIRECTION = "movement_direction" 20 | PACE_AVERAGE = "pace_average" 21 | PACE_INSTANT = "pace_instant" 22 | POWER_AVERAGE = "power_average" 23 | POWER_INSTANT = "power_instant" 24 | POWER_OUTPUT = "power_output" 25 | RAMP_ANGLE = "ramp_angle" 26 | RESISTANCE_LEVEL = "resistance_level" 27 | SPEED_AVERAGE = "speed_average" 28 | SPEED_INSTANT = "speed_instant" 29 | SPLIT_TIME_AVERAGE = "split_time_average" 30 | SPLIT_TIME_INSTANT = "split_time_instant" 31 | STEP_COUNT = "step_count" 32 | STEP_RATE_AVERAGE = "step_rate_average" 33 | STEP_RATE_INSTANT = "step_rate_instant" 34 | STRIDE_COUNT = "stride_count" 35 | STROKE_COUNT = "stroke_count" 36 | STROKE_RATE_AVERAGE = "stroke_rate_average" 37 | STROKE_RATE_INSTANT = "stroke_rate_instant" 38 | TIME_ELAPSED = "time_elapsed" 39 | TIME_REMAINING = "time_remaining" 40 | TRAINING_STATUS = "training_status" 41 | 42 | # TARGET SETTINGS ATTRIBUTES 43 | 44 | # WITH RANGES 45 | 46 | # Optional 47 | 48 | TARGET_SPEED = "target_speed" 49 | TARGET_POWER = "target_power" 50 | TARGET_HEART_RATE = "target_heart_rate" 51 | TARGET_INCLINATION = "target_inclination" 52 | TARGET_RESISTANCE = "target_resistance" 53 | 54 | # WITHOUT RANGES 55 | 56 | # Mandatory 57 | 58 | RESET = "reset" 59 | START = "start" 60 | STOP = "stop" 61 | PAUSE = "pause" 62 | 63 | # Optional 64 | 65 | BIKE_SIMULATION = "bike_simulation" 66 | SPIN_DOWN = "spin_down" 67 | TARGET_CADENCE = "target_cadence" 68 | TARGET_DISTANCE = "target_distance" 69 | TARGET_ENERGY = "target_energy" 70 | TARGET_STEPS = "target_steps" 71 | TARGET_STRIDES = "target_strides" 72 | TARGET_TIME = "target_time" 73 | TARGET_TIME_TWO_ZONES = "target_time_two_zones" 74 | TARGET_TIME_TIME_THREE_ZONES = "target_time_three_zones" 75 | TARGET_TIME_TIME_FIVE_ZONES = "target_time_five_zones" 76 | WHEEL_CIRCUMFERENCE = "wheel_circumference" 77 | 78 | # Bluetooth FTMS UUIDs 79 | 80 | FTMS_UUID = "1826" 81 | """Fitness Machine Service""" 82 | 83 | FEATURE_UUID = "2acc" 84 | """ 85 | `Requirement`: Mandatory. 86 | `Property`: Read. 87 | `Device Type`: Treadmill, walking pad, elliptical machine, rower, and smart bike. 88 | `Description`: Describes the capabilities supported by the device. 89 | """ 90 | 91 | TREADMILL_DATA_UUID = "2acd" 92 | """ 93 | `Requirement`: Optional. 94 | `Property`: Notify. 95 | `Device Type`: Treadmill and walking pad only. 96 | `Description`: Reports real-time workout data. 97 | """ 98 | 99 | CROSS_TRAINER_DATA_UUID = "2ace" 100 | """ 101 | `Requirement`: Optional. 102 | `Property`: Notify. 103 | `Device Type`: Elliptical machines only. 104 | `Description`: Reports real-time workout data. 105 | """ 106 | 107 | ROWER_DATA_UUID = "2ad1" 108 | """ 109 | `Requirement`: Optional. 110 | `Property`: Notify. 111 | `Device Type`: Rower only. 112 | `Description`: Reports real-time workout data. 113 | """ 114 | 115 | INDOOR_BIKE_DATA_UUID = "2ad2" 116 | """ 117 | `Requirement`: Optional. 118 | `Property`: Notify. 119 | `Device Type`: Smart bike only. 120 | `Description`: Reports real-time workout data. 121 | """ 122 | 123 | TRAINING_STATUS_UUID = "2ad3" 124 | """ 125 | `Requirement`: Optional. 126 | `Property`: Read/Notify. 127 | `Device Type`: Treadmill, walking pad, elliptical machine, rower, and smart bike. 128 | `Description`: Reports the device status data. 129 | """ 130 | 131 | SPEED_RANGE_UUID = "2ad4" 132 | """ 133 | `Requirement`: Mandatory if the `Speed Target Setting` feature is supported; otherwise Optional. 134 | `Property`: Read. 135 | `Device Type`: Treadmill, walking pad, and smart bike. 136 | `Description`: Reports the supported speed range. 137 | """ 138 | 139 | INCLINATION_RANGE_UUID = "2ad5" 140 | """ 141 | `Requirement`: Mandatory if the `Inclination Target Setting` feature is supported; otherwise Optional. 142 | `Property`: Read. 143 | `Device Type`: Treadmill and walking pad. 144 | `Description`: Reports the supported inclination range. 145 | """ 146 | 147 | RESISTANCE_LEVEL_RANGE_UUID = "2ad6" 148 | """ 149 | `Requirement`: Mandatory if the `Resistance Target Setting` feature is supported; otherwise Optional. 150 | `Property`: Read. 151 | `Device Type`: Elliptical machine. 152 | `Description`: Reports the supported resistance level range. 153 | """ 154 | 155 | HEART_RATE_RANGE_UUID = "2ad7" 156 | """ 157 | `Requirement`: Mandatory if the `Heart Rate Target Setting` feature is supported; otherwise Optional. 158 | `Property`: Read. 159 | `Device Type`: Treadmill, walking pad, elliptical machine, rower, and smart bike. 160 | `Description`: Reports supported heart rate range. 161 | """ 162 | 163 | POWER_RANGE_UUID = "2ad8" 164 | """ 165 | `Requirement`: Mandatory if the `Power Target Setting` feature is supported; otherwise Optional. 166 | `Property`: Read. 167 | `Device Type`: Elliptical machine, rower, and smart bike. 168 | `Description`: Reports the supported power range. 169 | """ 170 | 171 | CONTROL_POINT_UUID = "2ad9" 172 | """ 173 | `Requirement`: Optional. 174 | `Property`: Write/Indicate. 175 | `Device Type`: Optional support for treadmills and walking pads, and mandatory for elliptical machines, rowers, and smart bikes. 176 | `Description`: Controls the status (paused or resumed) of fitness machine. 177 | """ 178 | 179 | STATUS_UUID = "2ada" 180 | """ 181 | `Requirement`: Mandatory if the `Fitness Machine Control Point` is supported; otherwise Optional. 182 | `Property`: Notify. 183 | `Device Type`: Treadmill, walking pad, elliptical machine, rower, and smart bike. 184 | `Description`: Reports workout status changes of the fitness machine. 185 | """ 186 | -------------------------------------------------------------------------------- /src/pyftms/client/errors.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | 5 | class FtmsError(Exception): 6 | """Base FTMS error""" 7 | 8 | 9 | class CharacteristicNotFound(FtmsError): 10 | def __init__(self, name: str) -> None: 11 | super().__init__(f"Mandatory characteristic '{name}' not found.") 12 | 13 | 14 | class NotFitnessMachineError(FtmsError): 15 | """ 16 | An exception if the FTMS service is not supported by the Bluetooth device. 17 | 18 | May be raised in `get_machine_type_from_service_data` and `get_client` 19 | functions if advertisement data was passed as an argument. 20 | """ 21 | 22 | def __init__(self, data: bytes | None = None) -> None: 23 | if data is None: 24 | reason = "No FTMS service data" 25 | else: 26 | reason = f"Wrong FTMS service data: '{data.hex(" ").upper()}'" 27 | 28 | super().__init__(f"Device is not Fitness Machine. {reason}.") 29 | -------------------------------------------------------------------------------- /src/pyftms/client/machines/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from ..client import FitnessMachine 5 | from ..properties import MachineType 6 | from .cross_trainer import CrossTrainer 7 | from .indoor_bike import IndoorBike 8 | from .rower import Rower 9 | from .treadmill import Treadmill 10 | 11 | 12 | def get_machine(mt: MachineType) -> type[FitnessMachine]: 13 | """Returns Fitness Machine by type.""" 14 | assert len(mt) == 1 15 | 16 | match mt: 17 | case MachineType.TREADMILL: 18 | return Treadmill 19 | 20 | case MachineType.CROSS_TRAINER: 21 | return CrossTrainer 22 | 23 | case MachineType.ROWER: 24 | return Rower 25 | 26 | case MachineType.INDOOR_BIKE: 27 | return IndoorBike 28 | 29 | raise NotImplementedError("This Fitness Machine type is not supported.") 30 | 31 | 32 | __all__ = [ 33 | "CrossTrainer", 34 | "IndoorBike", 35 | "Rower", 36 | "Treadmill", 37 | "get_machine", 38 | ] 39 | -------------------------------------------------------------------------------- /src/pyftms/client/machines/cross_trainer.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from typing import ClassVar 5 | 6 | from ...models import CrossTrainerData, RealtimeData 7 | from ..client import FitnessMachine 8 | from ..const import CROSS_TRAINER_DATA_UUID 9 | from ..properties import MachineType 10 | 11 | 12 | class CrossTrainer(FitnessMachine): 13 | """ 14 | Cross Trainer (Elliptical Trainer). 15 | 16 | Specific class of `FitnessMachine`. 17 | """ 18 | 19 | _machine_type: ClassVar[MachineType] = MachineType.CROSS_TRAINER 20 | 21 | _data_model: ClassVar[type[RealtimeData]] = CrossTrainerData 22 | 23 | _data_uuid: ClassVar[str] = CROSS_TRAINER_DATA_UUID 24 | -------------------------------------------------------------------------------- /src/pyftms/client/machines/indoor_bike.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from typing import ClassVar 5 | 6 | from ...models import IndoorBikeData, RealtimeData 7 | from ..client import FitnessMachine 8 | from ..const import INDOOR_BIKE_DATA_UUID 9 | from ..properties import MachineType 10 | 11 | 12 | class IndoorBike(FitnessMachine): 13 | """ 14 | Indoor Bike (Spin Bike). 15 | 16 | Specific class of `FitnessMachine`. 17 | """ 18 | 19 | _machine_type: ClassVar[MachineType] = MachineType.INDOOR_BIKE 20 | 21 | _data_model: ClassVar[type[RealtimeData]] = IndoorBikeData 22 | 23 | _data_uuid: ClassVar[str] = INDOOR_BIKE_DATA_UUID 24 | -------------------------------------------------------------------------------- /src/pyftms/client/machines/rower.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from typing import ClassVar 5 | 6 | from ...models import RealtimeData, RowerData 7 | from ..client import FitnessMachine 8 | from ..const import ROWER_DATA_UUID 9 | from ..properties import MachineType 10 | 11 | 12 | class Rower(FitnessMachine): 13 | """ 14 | Rower (Rowing Machine). 15 | 16 | Specific class of `FitnessMachine`. 17 | """ 18 | 19 | _machine_type: ClassVar[MachineType] = MachineType.ROWER 20 | 21 | _data_model: ClassVar[type[RealtimeData]] = RowerData 22 | 23 | _data_uuid: ClassVar[str] = ROWER_DATA_UUID 24 | -------------------------------------------------------------------------------- /src/pyftms/client/machines/treadmill.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from typing import ClassVar 5 | 6 | from ...models import RealtimeData, TreadmillData 7 | from ..client import FitnessMachine 8 | from ..const import TREADMILL_DATA_UUID 9 | from ..properties import MachineType 10 | 11 | 12 | class Treadmill(FitnessMachine): 13 | """ 14 | Treadmill. 15 | 16 | Specific class of `FitnessMachine`. 17 | """ 18 | 19 | _machine_type: ClassVar[MachineType] = MachineType.TREADMILL 20 | 21 | _data_model: ClassVar[type[RealtimeData]] = TreadmillData 22 | 23 | _data_uuid: ClassVar[str] = TREADMILL_DATA_UUID 24 | -------------------------------------------------------------------------------- /src/pyftms/client/manager.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from types import MappingProxyType 5 | from typing import Any, cast 6 | 7 | from ..models import IndoorBikeSimulationParameters, TrainingStatusCode 8 | from . import const as c 9 | from .backends import FtmsCallback, FtmsEvents, SetupEventData, UpdateEventData 10 | from .properties import MovementDirection 11 | 12 | 13 | class PropertiesManager: 14 | """ 15 | Based helper class for `FitnessMachine`. Implements access and caching of 16 | properties and settings. 17 | 18 | Do not instantinate it. 19 | """ 20 | 21 | _cb: FtmsCallback | None 22 | """Event Callback function""" 23 | 24 | _properties: UpdateEventData 25 | """Properties dictonary""" 26 | 27 | _live_properties: set[str] 28 | """Properties dictonary""" 29 | 30 | _settings: SetupEventData 31 | """Properties dictonary""" 32 | 33 | def __init__(self, on_ftms_event: FtmsCallback | None = None) -> None: 34 | self._cb = on_ftms_event 35 | self._properties = {} 36 | self._live_properties = set() 37 | self._settings = {} 38 | 39 | def set_callback(self, cb: FtmsCallback): 40 | self._cb = cb 41 | 42 | def _on_event(self, e: FtmsEvents) -> None: 43 | """Real-time training data update handler.""" 44 | if e.event_id == "update": 45 | self._properties |= e.event_data 46 | self._live_properties.update( 47 | k for k, v in e.event_data.items() if v 48 | ) 49 | elif e.event_id == "setup": 50 | self._settings |= e.event_data 51 | 52 | return self._cb and self._cb(e) 53 | 54 | def get_property(self, name: str) -> Any: 55 | """Get property by name.""" 56 | return self._properties.get(name) 57 | 58 | def get_setting(self, name: str) -> Any: 59 | """Get setting by name.""" 60 | return self._settings.get(name) 61 | 62 | @property 63 | def properties(self) -> UpdateEventData: 64 | """Read-only updateable properties mapping.""" 65 | return cast(UpdateEventData, MappingProxyType(self._properties)) 66 | 67 | @property 68 | def live_properties(self) -> tuple[str, ...]: 69 | """ 70 | Living properties. 71 | 72 | Properties that had a value other than zero at least once. 73 | """ 74 | return tuple(self._live_properties) 75 | 76 | @property 77 | def settings(self) -> SetupEventData: 78 | """Read-only updateable settings mapping.""" 79 | return cast(SetupEventData, MappingProxyType(self._settings)) 80 | 81 | @property 82 | def training_status(self) -> TrainingStatusCode: 83 | return self.get_property(c.TRAINING_STATUS) 84 | 85 | # REAL-TIME TRAINING DATA 86 | 87 | @property 88 | def cadence_average(self) -> float: 89 | """ 90 | Average Cadence. 91 | 92 | Units: `rpm`. 93 | """ 94 | return self.get_property(c.CADENCE_AVERAGE) 95 | 96 | @property 97 | def cadence_instant(self) -> float: 98 | """ 99 | Instantaneous Cadence. 100 | 101 | Units: `rpm`. 102 | """ 103 | return self.get_property(c.CADENCE_INSTANT) 104 | 105 | @property 106 | def distance_total(self) -> int: 107 | """ 108 | Total Distance. 109 | 110 | Units: `m`. 111 | """ 112 | return self.get_property(c.DISTANCE_TOTAL) 113 | 114 | @property 115 | def elevation_gain_negative(self) -> float: 116 | """ 117 | Negative Elevation Gain. 118 | 119 | Units: `m`. 120 | """ 121 | return self.get_property(c.ELEVATION_GAIN_NEGATIVE) 122 | 123 | @property 124 | def elevation_gain_positive(self) -> float: 125 | """ 126 | Positive Elevation Gain. 127 | 128 | Units: `m`. 129 | """ 130 | return self.get_property(c.ELEVATION_GAIN_POSITIVE) 131 | 132 | @property 133 | def energy_per_hour(self) -> int: 134 | """ 135 | Energy Per Hour. 136 | 137 | Units: `kcal/h`. 138 | """ 139 | return self.get_property(c.ENERGY_PER_HOUR) 140 | 141 | @property 142 | def energy_per_minute(self) -> int: 143 | """ 144 | Energy Per Minute. 145 | 146 | Units: `kcal/min`. 147 | """ 148 | return self.get_property(c.ENERGY_PER_MINUTE) 149 | 150 | @property 151 | def energy_total(self) -> int: 152 | """ 153 | Total Energy. 154 | 155 | Units: `kcal`. 156 | """ 157 | return self.get_property(c.ENERGY_TOTAL) 158 | 159 | @property 160 | def force_on_belt(self) -> int: 161 | """ 162 | Force on Belt. 163 | 164 | Units: `newton`. 165 | """ 166 | return self.get_property(c.FORCE_ON_BELT) 167 | 168 | @property 169 | def heart_rate(self) -> int: 170 | """ 171 | Heart Rate. 172 | 173 | Units: `bpm`. 174 | """ 175 | return self.get_property(c.HEART_RATE) 176 | 177 | @property 178 | def inclination(self) -> float: 179 | """ 180 | Inclination. 181 | 182 | Units: `%`. 183 | """ 184 | return self.get_property(c.INCLINATION) 185 | 186 | @property 187 | def metabolic_equivalent(self) -> float: 188 | """ 189 | Metabolic Equivalent. 190 | 191 | Units: `meta`. 192 | """ 193 | return self.get_property(c.METABOLIC_EQUIVALENT) 194 | 195 | @property 196 | def movement_direction(self) -> MovementDirection: 197 | """ 198 | Movement Direction. 199 | 200 | Units: `MovementDirection`. 201 | """ 202 | return self.get_property(c.MOVEMENT_DIRECTION) 203 | 204 | @property 205 | def pace_average(self) -> float: 206 | """ 207 | Average Pace. 208 | 209 | Units: `min/km`. 210 | """ 211 | return self.get_property(c.PACE_AVERAGE) 212 | 213 | @property 214 | def pace_instant(self) -> float: 215 | """ 216 | Instantaneous Pace. 217 | 218 | Units: `min/km`. 219 | """ 220 | return self.get_property(c.PACE_INSTANT) 221 | 222 | @property 223 | def power_average(self) -> int: 224 | """ 225 | Average Power. 226 | 227 | Units: `Watt`. 228 | """ 229 | return self.get_property(c.POWER_AVERAGE) 230 | 231 | @property 232 | def power_instant(self) -> int: 233 | """ 234 | Instantaneous Power. 235 | 236 | Units: `Watt`. 237 | """ 238 | return self.get_property(c.POWER_INSTANT) 239 | 240 | @property 241 | def power_output(self) -> int: 242 | """ 243 | Power Output. 244 | 245 | Units: `Watt`. 246 | """ 247 | return self.get_property(c.POWER_OUTPUT) 248 | 249 | @property 250 | def ramp_angle(self) -> float: 251 | """ 252 | Ramp Angle Setting. 253 | 254 | Units: `degree`. 255 | """ 256 | return self.get_property(c.RAMP_ANGLE) 257 | 258 | @property 259 | def resistance_level(self) -> int | float: 260 | """ 261 | Resistance Level. 262 | 263 | Units: `unitless`. 264 | """ 265 | return self.get_property(c.RESISTANCE_LEVEL) 266 | 267 | @property 268 | def speed_average(self) -> float: 269 | """ 270 | Average Speed. 271 | 272 | Units: `km/h`. 273 | """ 274 | return self.get_property(c.SPEED_AVERAGE) 275 | 276 | @property 277 | def speed_instant(self) -> float: 278 | """ 279 | Instantaneous Speed. 280 | 281 | Units: `km/h`. 282 | """ 283 | return self.get_property(c.SPEED_INSTANT) 284 | 285 | @property 286 | def split_time_average(self) -> int: 287 | """ 288 | Average Split Time. 289 | 290 | Units: `s/500m`. 291 | """ 292 | return self.get_property(c.SPLIT_TIME_AVERAGE) 293 | 294 | @property 295 | def split_time_instant(self) -> int: 296 | """ 297 | Instantaneous Split Time. 298 | 299 | Units: `s/500m`. 300 | """ 301 | return self.get_property(c.SPLIT_TIME_INSTANT) 302 | 303 | @property 304 | def step_count(self) -> int: 305 | """ 306 | Step Count. 307 | 308 | Units: `step`. 309 | """ 310 | return self.get_property(c.STEP_COUNT) 311 | 312 | @property 313 | def step_rate_average(self) -> int: 314 | """ 315 | Average Step Rate. 316 | 317 | Units: `step/min`. 318 | """ 319 | return self.get_property(c.STEP_RATE_AVERAGE) 320 | 321 | @property 322 | def step_rate_instant(self) -> int: 323 | """ 324 | Instantaneous Step Rate. 325 | 326 | Units: `step/min`. 327 | """ 328 | return self.get_property(c.STEP_RATE_INSTANT) 329 | 330 | @property 331 | def stride_count(self) -> int: 332 | """ 333 | Stride Count. 334 | 335 | Units: `unitless`. 336 | """ 337 | return self.get_property(c.STRIDE_COUNT) 338 | 339 | @property 340 | def stroke_count(self) -> int: 341 | """ 342 | Stroke Count. 343 | 344 | Units: `unitless`. 345 | """ 346 | return self.get_property(c.STROKE_COUNT) 347 | 348 | @property 349 | def stroke_rate_average(self) -> float: 350 | """ 351 | Average Stroke Rate. 352 | 353 | Units: `stroke/min`. 354 | """ 355 | return self.get_property(c.STROKE_RATE_AVERAGE) 356 | 357 | @property 358 | def stroke_rate_instant(self) -> float: 359 | """ 360 | Instantaneous Stroke Rate. 361 | 362 | Units: `stroke/min`. 363 | """ 364 | return self.get_property(c.STROKE_RATE_INSTANT) 365 | 366 | @property 367 | def time_elapsed(self) -> int: 368 | """ 369 | Elapsed Time. 370 | 371 | Units: `s`. 372 | """ 373 | return self.get_property(c.TIME_ELAPSED) 374 | 375 | @property 376 | def time_remaining(self) -> int: 377 | """ 378 | Remaining Time. 379 | 380 | Units: `s`. 381 | """ 382 | return self.get_property(c.TIME_REMAINING) 383 | 384 | # SETTINGS 385 | 386 | @property 387 | def indoor_bike_simulation(self) -> IndoorBikeSimulationParameters: 388 | """Indoor Bike Simulation Parameters.""" 389 | return self.get_setting(c.INDOOR_BIKE_SIMULATION) 390 | 391 | @property 392 | def target_cadence(self) -> float: 393 | """ 394 | Targeted cadence. 395 | 396 | Units: `rpm`. 397 | """ 398 | return self.get_setting(c.TARGET_CADENCE) 399 | 400 | @property 401 | def target_distance(self) -> int: 402 | """ 403 | Targeted distance. 404 | 405 | Units: `m`. 406 | """ 407 | return self.get_setting(c.TARGET_DISTANCE) 408 | 409 | @property 410 | def target_energy(self) -> int: 411 | """ 412 | Targeted expended energy. 413 | 414 | Units: `kcal`. 415 | """ 416 | return self.get_setting(c.TARGET_ENERGY) 417 | 418 | @property 419 | def target_heart_rate(self) -> int: 420 | """ 421 | Targeted heart rate. 422 | 423 | Units: `bpm`. 424 | """ 425 | return self.get_setting(c.TARGET_HEART_RATE) 426 | 427 | @property 428 | def target_inclination(self) -> float: 429 | """ 430 | Targeted inclination. 431 | 432 | Units: `%`. 433 | """ 434 | return self.get_setting(c.TARGET_INCLINATION) 435 | 436 | @property 437 | def target_power(self) -> int: 438 | """ 439 | Targeted power. 440 | 441 | Units: `Watt`. 442 | """ 443 | return self.get_setting(c.TARGET_POWER) 444 | 445 | @property 446 | def target_resistance(self) -> float: 447 | """ 448 | Targeted resistance level. 449 | 450 | Units: `unitless`. 451 | """ 452 | return self.get_setting(c.TARGET_RESISTANCE) 453 | 454 | @property 455 | def target_speed(self) -> float: 456 | """ 457 | Targeted speed. 458 | 459 | Units: `km/h`. 460 | """ 461 | return self.get_setting(c.TARGET_SPEED) 462 | 463 | @property 464 | def target_steps(self) -> int: 465 | """ 466 | Targeted number of steps. 467 | 468 | Units: `step`. 469 | """ 470 | return self.get_setting(c.TARGET_STEPS) 471 | 472 | @property 473 | def target_strides(self) -> int: 474 | """ 475 | Targeted number of strides. 476 | 477 | Units: `stride`. 478 | """ 479 | return self.get_setting(c.TARGET_STRIDES) 480 | 481 | @property 482 | def target_time(self) -> tuple[int, ...]: 483 | """ 484 | Targeted training time. 485 | 486 | Units: `s`. 487 | """ 488 | return self.get_setting(c.TARGET_TIME) 489 | 490 | @property 491 | def wheel_circumference(self) -> float: 492 | """ 493 | Wheel circumference. 494 | 495 | Units: `mm`. 496 | """ 497 | return self.get_setting(c.WHEEL_CIRCUMFERENCE) 498 | -------------------------------------------------------------------------------- /src/pyftms/client/properties/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from .device_info import DeviceInfo, read_device_info 5 | from .features import ( 6 | MachineFeatures, 7 | MachineSettings, 8 | MovementDirection, 9 | SettingRange, 10 | read_features, 11 | ) 12 | from .machine_type import MachineType, get_machine_type_from_service_data 13 | 14 | __all__ = [ 15 | "DeviceInfo", 16 | "get_machine_type_from_service_data", 17 | "MachineFeatures", 18 | "MachineSettings", 19 | "MachineType", 20 | "MovementDirection", 21 | "read_device_info", 22 | "read_features", 23 | "SettingRange", 24 | ] 25 | -------------------------------------------------------------------------------- /src/pyftms/client/properties/device_info.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024-2025, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import logging 5 | from typing import TypedDict 6 | 7 | from bleak import BleakClient 8 | 9 | DIS_UUID = "180a" 10 | """Device Information Service""" 11 | 12 | _CHARACTERISTICS_MAP = { 13 | "manufacturer": "2a29", 14 | "model": "2a24", 15 | "serial_number": "2a25", 16 | "sw_version": "2a28", 17 | "hw_version": "2a27", 18 | } 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | class DeviceInfo(TypedDict, total=False): 24 | """Device Information""" 25 | 26 | manufacturer: str 27 | """Manufacturer""" 28 | model: str 29 | """Model""" 30 | serial_number: str 31 | """Serial Number""" 32 | sw_version: str 33 | """Software Version""" 34 | hw_version: str 35 | """Hardware Version""" 36 | 37 | 38 | async def read_device_info(cli: BleakClient) -> DeviceInfo: 39 | """Read Device Information""" 40 | 41 | _LOGGER.debug("Reading Device Information...") 42 | 43 | result = DeviceInfo() 44 | 45 | if srv := cli.services.get_service(DIS_UUID): 46 | for k, v in _CHARACTERISTICS_MAP.items(): 47 | if c := srv.get_characteristic(v): 48 | data = await cli.read_gatt_char(c) 49 | result[k] = data.decode() 50 | 51 | _LOGGER.debug("Device Info: %s", result) 52 | 53 | return result 54 | -------------------------------------------------------------------------------- /src/pyftms/client/properties/features.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024-2025, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import io 5 | import logging 6 | from enum import STRICT, IntEnum, IntFlag, auto 7 | from types import MappingProxyType 8 | from typing import NamedTuple 9 | 10 | from bleak import BleakClient 11 | from bleak.backends.characteristic import BleakGATTCharacteristic 12 | 13 | from ...serializer import NumSerializer 14 | from ..const import ( 15 | FEATURE_UUID, 16 | HEART_RATE_RANGE_UUID, 17 | INCLINATION_RANGE_UUID, 18 | POWER_RANGE_UUID, 19 | RESISTANCE_LEVEL_RANGE_UUID, 20 | SPEED_RANGE_UUID, 21 | TARGET_HEART_RATE, 22 | TARGET_INCLINATION, 23 | TARGET_POWER, 24 | TARGET_RESISTANCE, 25 | TARGET_SPEED, 26 | ) 27 | from ..errors import CharacteristicNotFound 28 | from .machine_type import MachineType 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | 33 | class MovementDirection(IntEnum, boundary=STRICT): 34 | """ 35 | Movement direction. Used by `CrossTrainer` machine only. 36 | 37 | Described in section **4.5.1.1 Flags Field**. 38 | """ 39 | 40 | FORWARD = False 41 | """Move Forward""" 42 | BACKWARD = True 43 | """Move Backward""" 44 | 45 | 46 | class MachineFeatures(IntFlag, boundary=STRICT): 47 | """ 48 | Fitness Machine Features. 49 | 50 | Described in section `4.3.1.1: Fitness Machine Features Field`. 51 | """ 52 | 53 | AVERAGE_SPEED = auto() 54 | """Average Speed""" 55 | CADENCE = auto() 56 | """Cadence""" 57 | DISTANCE = auto() 58 | """Total Distance""" 59 | INCLINATION = auto() 60 | """Inclination""" 61 | ELEVATION_GAIN = auto() 62 | """Elevation Gain""" 63 | PACE = auto() 64 | """Pace""" 65 | STEP_COUNT = auto() 66 | """Step Count""" 67 | RESISTANCE = auto() 68 | """Resistance Level""" 69 | STRIDE_COUNT = auto() 70 | """Stride Count""" 71 | EXPENDED_ENERGY = auto() 72 | """Expended Energy""" 73 | HEART_RATE = auto() 74 | """Heart Rate Measurement""" 75 | METABOLIC_EQUIVALENT = auto() 76 | """Metabolic Equivalent""" 77 | ELAPSED_TIME = auto() 78 | """Elapsed Time""" 79 | REMAINING_TIME = auto() 80 | """Remaining Time""" 81 | POWER_MEASUREMENT = auto() 82 | """Power Measurement""" 83 | FORCE_ON_BELT_AND_POWER_OUTPUT = auto() 84 | """Force on Belt and Power Output""" 85 | USER_DATA_RETENTION = auto() 86 | """User Data Retention""" 87 | 88 | 89 | class MachineSettings(IntFlag, boundary=STRICT): 90 | """ 91 | Target Setting Features. 92 | 93 | Described in section `4.3.1.2: Target Setting Features Field`. 94 | """ 95 | 96 | SPEED = auto() 97 | """Speed Target""" 98 | INCLINE = auto() 99 | """Inclination Target""" 100 | RESISTANCE = auto() 101 | """Resistance Target""" 102 | POWER = auto() 103 | """Power Target""" 104 | HEART_RATE = auto() 105 | """Heart Rate Target""" 106 | ENERGY = auto() 107 | """Targeted Expended Energy""" 108 | STEPS = auto() 109 | """Targeted Step Number""" 110 | STRIDES = auto() 111 | """Targeted Stride Number""" 112 | DISTANCE = auto() 113 | """Targeted Distance""" 114 | TIME = auto() 115 | """Targeted Training Time""" 116 | TIME_TWO_ZONES = auto() 117 | """Targeted Time in Two Heart Rate Zones""" 118 | TIME_THREE_ZONES = auto() 119 | """Targeted Time in Three Heart Rate Zones""" 120 | TIME_FIVE_ZONES = auto() 121 | """Targeted Time in Five Heart Rate Zones""" 122 | BIKE_SIMULATION = auto() 123 | """Indoor Bike Simulation Parameters""" 124 | CIRCUMFERENCE = auto() 125 | """Wheel Circumference""" 126 | SPIN_DOWN = auto() 127 | """Spin Down Control""" 128 | CADENCE = auto() 129 | """Targeted Cadence""" 130 | 131 | 132 | class SettingRange(NamedTuple): 133 | """Value range of settings parameter.""" 134 | 135 | min_value: float 136 | """Minimum value. Included in the range.""" 137 | max_value: float 138 | """Maximum value. Included in the range.""" 139 | step: float 140 | """Step value.""" 141 | 142 | 143 | async def _read_range( 144 | cli: BleakClient, c: BleakGATTCharacteristic, format: str 145 | ) -> SettingRange: 146 | data = await cli.read_gatt_char(c) 147 | 148 | bio, serializer = io.BytesIO(data), NumSerializer(format) 149 | result = SettingRange(*(serializer.deserialize(bio) or 0 for _ in range(3))) 150 | 151 | assert not bio.read(1) 152 | return result 153 | 154 | 155 | async def read_features( 156 | cli: BleakClient, 157 | mt: MachineType, 158 | ) -> tuple[ 159 | MachineFeatures, MachineSettings, MappingProxyType[str, SettingRange] 160 | ]: 161 | _LOGGER.debug("Reading features and settings...") 162 | 163 | if (c := cli.services.get_characteristic(FEATURE_UUID)) is None: 164 | raise CharacteristicNotFound("Machine Feature") 165 | 166 | assert len(data := await cli.read_gatt_char(c)) == 8 167 | 168 | bio, u4 = io.BytesIO(data), NumSerializer("u4") 169 | 170 | features = MachineFeatures(u4.deserialize(bio)) 171 | settings = MachineSettings(u4.deserialize(bio)) 172 | 173 | # Remove untypical settings 174 | 175 | if MachineType.TREADMILL in mt: 176 | settings &= ~(MachineSettings.RESISTANCE | MachineSettings.POWER) 177 | 178 | elif MachineType.CROSS_TRAINER in mt: 179 | settings &= ~(MachineSettings.SPEED | MachineSettings.INCLINE) 180 | 181 | elif MachineType.INDOOR_BIKE in mt: 182 | settings &= ~(MachineSettings.SPEED | MachineSettings.INCLINE) 183 | 184 | elif MachineType.ROWER in mt: 185 | settings &= ~(MachineSettings.SPEED | MachineSettings.INCLINE) 186 | 187 | # Remove settings without ranges UUIDs 188 | 189 | ranges: dict[str, SettingRange] = {} 190 | 191 | if MachineSettings.SPEED in settings: 192 | if c := cli.services.get_characteristic(SPEED_RANGE_UUID): 193 | ranges[TARGET_SPEED] = await _read_range(cli, c, "u2.01") 194 | else: 195 | settings &= ~MachineSettings.SPEED 196 | _LOGGER.debug( 197 | "Speed setting has been removed. " 198 | "Characteristic with a range of acceptable values not found." 199 | ) 200 | 201 | if MachineSettings.INCLINE in settings: 202 | if c := cli.services.get_characteristic(INCLINATION_RANGE_UUID): 203 | ranges[TARGET_INCLINATION] = await _read_range(cli, c, "s2.1") 204 | else: 205 | settings &= ~MachineSettings.INCLINE 206 | _LOGGER.debug( 207 | "Inclination setting has been removed. " 208 | "Characteristic with a range of acceptable values not found." 209 | ) 210 | 211 | if MachineSettings.RESISTANCE in settings: 212 | if c := cli.services.get_characteristic(RESISTANCE_LEVEL_RANGE_UUID): 213 | ranges[TARGET_RESISTANCE] = await _read_range(cli, c, "s2.1") 214 | else: 215 | settings &= ~MachineSettings.RESISTANCE 216 | _LOGGER.debug( 217 | "Resistance setting has been removed. " 218 | "Characteristic with a range of acceptable values not found." 219 | ) 220 | 221 | if MachineSettings.POWER in settings: 222 | if c := cli.services.get_characteristic(POWER_RANGE_UUID): 223 | ranges[TARGET_POWER] = await _read_range(cli, c, "s2") 224 | else: 225 | settings &= ~MachineSettings.POWER 226 | _LOGGER.debug( 227 | "Power setting has been removed. " 228 | "Characteristic with a range of acceptable values not found." 229 | ) 230 | 231 | if MachineSettings.HEART_RATE in settings: 232 | if c := cli.services.get_characteristic(HEART_RATE_RANGE_UUID): 233 | ranges[TARGET_HEART_RATE] = await _read_range(cli, c, "u1") 234 | else: 235 | settings &= ~MachineSettings.HEART_RATE 236 | _LOGGER.debug( 237 | "Heart Rate setting has been removed. " 238 | "Characteristic with a range of acceptable values not found." 239 | ) 240 | 241 | _LOGGER.debug("Features: %s", features) 242 | _LOGGER.debug("Settings: %s", settings) 243 | _LOGGER.debug("Settings ranges: %s", ranges) 244 | 245 | return features, settings, MappingProxyType(ranges) 246 | -------------------------------------------------------------------------------- /src/pyftms/client/properties/machine_type.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024-2025, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import functools 5 | import operator 6 | from enum import Flag, auto 7 | 8 | from bleak.backends.scanner import AdvertisementData 9 | from bleak.uuids import normalize_uuid_str 10 | 11 | from ..const import FTMS_UUID 12 | from ..errors import NotFitnessMachineError 13 | 14 | 15 | class MachineFlags(Flag): 16 | """ 17 | Fitness Machine Flags. 18 | 19 | Included in the `Service Data AD Type`. 20 | 21 | Described in section `3.1.1: Flags Field`. 22 | """ 23 | 24 | FITNESS_MACHINE = auto() 25 | """Fitness Machine Available""" 26 | 27 | 28 | class MachineType(Flag): 29 | """ 30 | Fitness Machine Type. 31 | 32 | Included in the Advertisement Service Data. 33 | 34 | Described in section **3.1.2: Fitness Machine Type Field**. 35 | """ 36 | 37 | TREADMILL = auto() 38 | """Treadmill Machine.""" 39 | CROSS_TRAINER = auto() 40 | """Cross Trainer Machine.""" 41 | STEP_CLIMBER = auto() 42 | """Step Climber Machine.""" 43 | STAIR_CLIMBER = auto() 44 | """Stair Climber Machine.""" 45 | ROWER = auto() 46 | """Rower Machine.""" 47 | INDOOR_BIKE = auto() 48 | """Indoor Bike Machine.""" 49 | 50 | 51 | def get_machine_type_from_service_data( 52 | adv_data: AdvertisementData, 53 | ) -> MachineType: 54 | """Returns fitness machine type from Bluetooth advertisement data. 55 | 56 | Parameters: 57 | adv_data: Bluetooth [advertisement data](https://bleak.readthedocs.io/en/latest/backends/index.html#bleak.backends.scanner.AdvertisementData). 58 | 59 | Returns: 60 | Fitness machine type. 61 | """ 62 | 63 | data = adv_data.service_data.get(normalize_uuid_str(FTMS_UUID)) 64 | 65 | if data is None or not (2 <= len(data) <= 3): 66 | raise NotFitnessMachineError(data) 67 | 68 | # Reading mandatory `Flags` and `Machine Type`. 69 | # `Machine Type` bytes may be reversed on some machines or be a just one 70 | # byte (it's bug), so I logically ORed them. 71 | try: 72 | mt = functools.reduce(operator.or_, data[1:]) 73 | mf, mt = MachineFlags(data[0]), MachineType(mt) 74 | 75 | except ValueError: 76 | raise NotFitnessMachineError(data) 77 | 78 | if mf and mt: 79 | return mt 80 | 81 | raise NotFitnessMachineError(data) 82 | -------------------------------------------------------------------------------- /src/pyftms/models/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from .common import ( 5 | CodeSwitchModel, 6 | IndoorBikeSimulationParameters, 7 | StopPauseCode, 8 | ) 9 | from .control_point import ( 10 | ControlCode, 11 | ControlIndicateModel, 12 | ControlModel, 13 | ResultCode, 14 | ) 15 | from .machine_status import MachineStatusCode, MachineStatusModel 16 | from .realtime_data import ( 17 | CrossTrainerData, 18 | IndoorBikeData, 19 | RealtimeData, 20 | RowerData, 21 | TreadmillData, 22 | ) 23 | from .spin_down import ( 24 | SpinDownControlCode, 25 | SpinDownSpeedData, 26 | SpinDownStatusCode, 27 | ) 28 | from .training_status import ( 29 | TrainingStatusCode, 30 | TrainingStatusFlags, 31 | TrainingStatusModel, 32 | ) 33 | 34 | __all__ = [ 35 | "CodeSwitchModel", 36 | "CrossTrainerData", 37 | "IndoorBikeData", 38 | "RowerData", 39 | "TreadmillData", 40 | "ControlCode", 41 | "ControlModel", 42 | "ControlIndicateModel", 43 | "SpinDownSpeedData", 44 | "IndoorBikeSimulationParameters", 45 | "MachineStatusCode", 46 | "MachineStatusModel", 47 | "ResultCode", 48 | "SpinDownControlCode", 49 | "SpinDownStatusCode", 50 | "StopPauseCode", 51 | "TrainingStatusCode", 52 | "TrainingStatusFlags", 53 | "TrainingStatusModel", 54 | "RealtimeData", 55 | ] 56 | -------------------------------------------------------------------------------- /src/pyftms/models/common.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import dataclasses as dc 5 | import io 6 | from enum import STRICT, IntEnum, auto 7 | from typing import Any, cast, override 8 | 9 | from ..serializer import BaseModel, ModelMeta, model_meta 10 | 11 | 12 | class StopPauseCode(IntEnum, boundary=STRICT): 13 | """ 14 | Code of `Stop or Pause` control and status messages. 15 | 16 | Described in section `4.16.2.9: Stop or Pause Procedure`. 17 | """ 18 | 19 | STOP = auto() 20 | """Stop.""" 21 | PAUSE = auto() 22 | """Pause.""" 23 | 24 | 25 | @dc.dataclass(frozen=True) 26 | class IndoorBikeSimulationParameters(BaseModel): 27 | """ 28 | Indoor Bike Simulation Parameters 29 | 30 | Described in section **4.16.2.18: Set Indoor Bike Simulation Parameters Procedure**. 31 | """ 32 | 33 | wind_speed: float = dc.field( 34 | metadata=model_meta( 35 | format="s2.001", 36 | ) 37 | ) 38 | """ 39 | Wind Speed. 40 | 41 | Units: `meters per second (mps)`. 42 | """ 43 | 44 | grade: float = dc.field( 45 | metadata=model_meta( 46 | format="s2.01", 47 | ) 48 | ) 49 | """ 50 | Grade. 51 | 52 | Units: `%`. 53 | """ 54 | 55 | rolling_resistance: float = dc.field( 56 | metadata=model_meta( 57 | format="u1.0001", 58 | ) 59 | ) 60 | """ 61 | Coefficient of Rolling Resistance. 62 | 63 | Units: `unitless`. 64 | """ 65 | 66 | wind_resistance: float = dc.field( 67 | metadata=model_meta( 68 | format="u1.01", 69 | ) 70 | ) 71 | """ 72 | Wind Resistance Coefficient. 73 | 74 | Units: `kilogram per meter (kg/m)`. 75 | """ 76 | 77 | 78 | @dc.dataclass(frozen=True) 79 | class CodeSwitchModel[T: int](BaseModel): 80 | """Base model based on a code attribute and associated parameter attributes.""" 81 | 82 | code: T | None = dc.field( 83 | default=None, 84 | metadata=model_meta( 85 | format="u1", 86 | ), 87 | ) 88 | """Code | Enumeration""" 89 | 90 | @override 91 | @classmethod 92 | def _deserialize_asdict(cls, src: io.IOBase) -> dict[str, Any]: 93 | kwargs, it = {}, cls._iter_fields_serializers() 94 | code = cast(int, next(it)[1].deserialize(src)) 95 | kwargs["code"] = code 96 | 97 | for field, serializer in it: 98 | meta = cast(ModelMeta, field.metadata) 99 | 100 | if meta.get("code") != code: 101 | continue 102 | 103 | name, value = field.name, serializer.deserialize(src) 104 | 105 | # remove digit suffix of 'target_time_x' property 106 | if name[-1].isdecimal(): 107 | name = name[:-2] 108 | 109 | kwargs[name] = value 110 | 111 | break 112 | 113 | assert not src.read() 114 | 115 | return kwargs 116 | -------------------------------------------------------------------------------- /src/pyftms/models/control_point.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import dataclasses as dc 5 | from enum import STRICT, IntEnum, auto 6 | from typing import cast 7 | 8 | from ..serializer import ModelMeta 9 | from .common import ( 10 | BaseModel, 11 | CodeSwitchModel, 12 | IndoorBikeSimulationParameters, 13 | StopPauseCode, 14 | model_meta, 15 | ) 16 | from .spin_down import SpinDownControlCode 17 | 18 | 19 | class ControlCode(IntEnum, boundary=STRICT): 20 | """ 21 | Control Op Codes. 22 | 23 | Described in section `4.16.1: Fitness Machine Control Point Procedure Requirements`. 24 | """ 25 | 26 | REQUEST_CONTROL = 0x00 27 | """Request Control""" 28 | 29 | RESET = auto() 30 | """Reset""" 31 | 32 | SPEED = auto() 33 | """Set Target Speed""" 34 | 35 | INCLINE = auto() 36 | """Set Target Inclination""" 37 | 38 | RESISTANCE = auto() 39 | """Set Target Resistance Level""" 40 | 41 | POWER = auto() 42 | """Set Target Power""" 43 | 44 | HEART_RATE = auto() 45 | """Set Target Heart Rate""" 46 | 47 | START_RESUME = auto() 48 | """Start or Resume""" 49 | 50 | STOP_PAUSE = auto() 51 | """Stop or Pause""" 52 | 53 | ENERGY = auto() 54 | """Set Targeted Expended Energy""" 55 | 56 | STEPS_NUMBER = auto() 57 | """Set Targeted Number of Steps""" 58 | 59 | STRIDES_NUMBER = auto() 60 | """Set Targeted Number of Strides""" 61 | 62 | DISTANCE = auto() 63 | """Set Targeted Distance""" 64 | 65 | TIME_1 = auto() 66 | """Set Targeted Training Time""" 67 | 68 | TIME_2 = auto() 69 | """Set Targeted Time in Two Heart Rate Zones""" 70 | 71 | TIME_3 = auto() 72 | """Set Targeted Time in Three Heart Rate Zones""" 73 | 74 | TIME_5 = auto() 75 | """Set Targeted Time in Five Heart Rate Zones""" 76 | 77 | INDOOR_BIKE_SIMULATION = auto() 78 | """Set Indoor Bike Simulation Parameters""" 79 | 80 | WHEEL_CIRCUMFERENCE = auto() 81 | """Set Wheel Circumference""" 82 | 83 | SPIN_DOWN = auto() 84 | """Spin Down Control""" 85 | 86 | CADENCE = auto() 87 | """Set Targeted Cadence""" 88 | 89 | RESPONSE = 0x80 90 | """Response Code""" 91 | 92 | 93 | class ResultCode(IntEnum, boundary=STRICT): 94 | """ 95 | Result code of control operations. 96 | 97 | Described in section **4.16.2.22 Procedure Complete**. 98 | """ 99 | 100 | SUCCESS = auto() 101 | """Success.""" 102 | 103 | NOT_SUPPORTED = auto() 104 | """Operation Not Supported.""" 105 | 106 | INVALID_PARAMETER = auto() 107 | """Invalid Parameter.""" 108 | 109 | FAILED = auto() 110 | """Operation Failed.""" 111 | 112 | NOT_PERMITTED = auto() 113 | """Control Not Permitted.""" 114 | 115 | 116 | @dc.dataclass(frozen=True) 117 | class ControlIndicateModel(BaseModel): 118 | """ 119 | Fitness Machine Control Point characteristic. 120 | 121 | Parameter Value Format of the Response Indication. 122 | 123 | Described in section `4.16.2.22 Procedure Complete`. 124 | """ 125 | 126 | code: ControlCode = dc.field( 127 | metadata=model_meta( 128 | format="u1", 129 | ) 130 | ) 131 | """Response Code Op Code (0x80)""" 132 | 133 | request_code: ControlCode = dc.field( 134 | metadata=model_meta( 135 | format="u1", 136 | ) 137 | ) 138 | """Request Op Code""" 139 | 140 | result_code: ResultCode = dc.field( 141 | metadata=model_meta( 142 | format="u1", 143 | ) 144 | ) 145 | """Result Code""" 146 | 147 | 148 | @dc.dataclass(frozen=True) 149 | class ControlModel(CodeSwitchModel[ControlCode]): 150 | """ 151 | Fitness Machine Control Point Characteristic Format. 152 | 153 | Described in section `4.16 Fitness Machine Control Point`. 154 | """ 155 | 156 | VALID_TIME_LENGTH = (1, 2, 3, 5) 157 | 158 | target_speed: float | None = dc.field( 159 | default=None, 160 | metadata=model_meta( 161 | format="u2.01", 162 | features_bit=0, 163 | code=ControlCode.SPEED, 164 | ), 165 | ) 166 | """Set Target Speed | Km/h""" 167 | 168 | target_inclination: float | None = dc.field( 169 | default=None, 170 | metadata=model_meta( 171 | format="s2.1", 172 | features_bit=1, 173 | code=ControlCode.INCLINE, 174 | ), 175 | ) 176 | """Set Target Inclination | Percent""" 177 | 178 | target_resistance: float | None = dc.field( 179 | default=None, 180 | metadata=model_meta( 181 | format="s2.1", 182 | features_bit=2, 183 | code=ControlCode.RESISTANCE, 184 | ), 185 | ) 186 | """Set Target Resistance Level | Unitless""" 187 | 188 | target_power: int | None = dc.field( 189 | default=None, 190 | metadata=model_meta( 191 | format="s2", 192 | features_bit=3, 193 | code=ControlCode.POWER, 194 | ), 195 | ) 196 | """Set Target Power | Watt""" 197 | 198 | target_heart_rate: int | None = dc.field( 199 | default=None, 200 | metadata=model_meta( 201 | format="u1", 202 | features_bit=4, 203 | code=ControlCode.HEART_RATE, 204 | ), 205 | ) 206 | """Set Target Heart Rate | BPM""" 207 | 208 | stop_pause: StopPauseCode | None = dc.field( 209 | default=None, 210 | metadata=model_meta( 211 | format="u1", 212 | features_bit=32, # ignoring 213 | code=ControlCode.STOP_PAUSE, 214 | ), 215 | ) 216 | """Stop or Pause | Enumeration""" 217 | 218 | target_energy: int | None = dc.field( 219 | default=None, 220 | metadata=model_meta( 221 | format="u2", 222 | features_bit=5, 223 | code=ControlCode.ENERGY, 224 | ), 225 | ) 226 | """Set Targeted Expended Energy | Calorie""" 227 | 228 | target_steps: int | None = dc.field( 229 | default=None, 230 | metadata=model_meta( 231 | format="u2", 232 | features_bit=6, 233 | code=ControlCode.STEPS_NUMBER, 234 | ), 235 | ) 236 | """Set Targeted Number of Steps | Step""" 237 | 238 | target_strides: int | None = dc.field( 239 | default=None, 240 | metadata=model_meta( 241 | format="u2", 242 | features_bit=7, 243 | code=ControlCode.STRIDES_NUMBER, 244 | ), 245 | ) 246 | """Set Targeted Number of Strides | Stride""" 247 | 248 | target_distance: int | None = dc.field( 249 | default=None, 250 | metadata=model_meta( 251 | format="u3", 252 | features_bit=8, 253 | code=ControlCode.DISTANCE, 254 | ), 255 | ) 256 | """Set Targeted Distance | Meter""" 257 | 258 | target_time: tuple[int, ...] | None = dc.field( 259 | default=None, 260 | ) 261 | """Set Targeted Training Time | Second""" 262 | 263 | target_time_1: tuple[int, ...] | None = dc.field( 264 | default=None, 265 | init=False, 266 | metadata=model_meta( 267 | format="u2", 268 | num=1, 269 | features_bit=9, 270 | code=ControlCode.TIME_1, 271 | ), 272 | ) 273 | """Set Targeted Training Time | Second""" 274 | 275 | target_time_2: tuple[int, ...] | None = dc.field( 276 | default=None, 277 | init=False, 278 | metadata=model_meta( 279 | format="u2", 280 | num=2, 281 | features_bit=10, 282 | code=ControlCode.TIME_2, 283 | ), 284 | ) 285 | """Set Targeted Time in Two Heart Rate Zones""" 286 | 287 | target_time_3: tuple[int, ...] | None = dc.field( 288 | default=None, 289 | init=False, 290 | metadata=model_meta( 291 | format="u2", 292 | num=3, 293 | features_bit=11, 294 | code=ControlCode.TIME_3, 295 | ), 296 | ) 297 | """Set Targeted Time in Three Heart Rate Zones""" 298 | 299 | target_time_5: tuple[int, ...] | None = dc.field( 300 | default=None, 301 | init=False, 302 | metadata=model_meta( 303 | format="u2", 304 | num=5, 305 | features_bit=12, 306 | code=ControlCode.TIME_5, 307 | ), 308 | ) 309 | """Set Targeted Time in Five Heart Rate Zones""" 310 | 311 | indoor_bike_simulation: IndoorBikeSimulationParameters | None = dc.field( 312 | default=None, 313 | metadata=model_meta( 314 | features_bit=13, 315 | code=ControlCode.INDOOR_BIKE_SIMULATION, 316 | ), 317 | ) 318 | """Set Indoor Bike Simulation Parameters""" 319 | 320 | wheel_circumference: float | None = dc.field( 321 | default=None, 322 | metadata=model_meta( 323 | format="u2.1", 324 | features_bit=14, 325 | code=ControlCode.WHEEL_CIRCUMFERENCE, 326 | ), 327 | ) 328 | """Set Wheel Circumference""" 329 | 330 | spin_down: SpinDownControlCode | None = dc.field( 331 | default=None, 332 | metadata=model_meta( 333 | format="u1", 334 | features_bit=15, 335 | code=ControlCode.SPIN_DOWN, 336 | ), 337 | ) 338 | """Spin Down Control | Enumeration""" 339 | 340 | target_cadence: float | None = dc.field( 341 | default=None, 342 | metadata=model_meta( 343 | format="u2.5", 344 | features_bit=16, 345 | code=ControlCode.CADENCE, 346 | ), 347 | ) 348 | """Set Targeted Cadence | 1/minute""" 349 | 350 | def __post_init__(self): 351 | if self.code is not None: 352 | return 353 | 354 | # here only after manual initialization without 'code'. 355 | 356 | if (value := self.target_time) is not None: 357 | if (sz := len(value)) not in self.VALID_TIME_LENGTH: 358 | raise ValueError( 359 | f"Valid number of 'target_time' values are: {self.VALID_TIME_LENGTH}" 360 | ) 361 | 362 | object.__setattr__(self, f"target_time_{sz}", value) 363 | object.__setattr__(self, "target_time", None) 364 | 365 | # find command code by field 366 | for field in dc.fields(self): 367 | if (value := getattr(self, field.name)) is None: 368 | continue 369 | 370 | if meta := cast(ModelMeta, field.metadata): 371 | return object.__setattr__(self, "code", meta.get("code")) 372 | 373 | raise ValueError("Code not found.") 374 | -------------------------------------------------------------------------------- /src/pyftms/models/machine_status.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import dataclasses as dc 5 | from enum import STRICT, IntEnum, auto 6 | 7 | from .common import ( 8 | CodeSwitchModel, 9 | IndoorBikeSimulationParameters, 10 | StopPauseCode, 11 | model_meta, 12 | ) 13 | from .spin_down import SpinDownStatusCode 14 | 15 | 16 | class MachineStatusCode(IntEnum, boundary=STRICT): 17 | """ 18 | Fitness Machine Status. 19 | 20 | Described in section **4.16.1: Fitness Machine Control Point Procedure Requirements**. 21 | """ 22 | 23 | RESET = auto() 24 | """Reset""" 25 | 26 | STOP_PAUSE = auto() 27 | """Fitness Machine Stopped or Paused by the User""" 28 | 29 | STOP_SAFETY = auto() 30 | """Fitness Machine Stopped by Safety Key""" 31 | 32 | START_RESUME = auto() 33 | """Fitness Machine Started or Resumed by the User""" 34 | 35 | SPEED = auto() 36 | """Target Speed Changed""" 37 | 38 | INCLINE = auto() 39 | """Target Incline Changed""" 40 | 41 | RESISTANCE = auto() 42 | """Target Resistance Level Changed""" 43 | 44 | POWER = auto() 45 | """Target Power Changed""" 46 | 47 | HEART_RATE = auto() 48 | """Target Heart Rate Changed""" 49 | 50 | ENERGY = auto() 51 | """Targeted Expended Energy Changed""" 52 | 53 | STEPS_NUMBER = auto() 54 | """Targeted Number of Steps Changed""" 55 | 56 | STRIDES_NUMBER = auto() 57 | """Targeted Number of Strides Changed""" 58 | 59 | DISTANCE = auto() 60 | """Targeted Distance Changed""" 61 | 62 | TIME_1 = auto() 63 | """Targeted Training Time Changed""" 64 | 65 | TIME_2 = auto() 66 | """Targeted Time in Two Heart Rate Zones Changed""" 67 | 68 | TIME_3 = auto() 69 | """Targeted Time in Three Heart Rate Zones Changed""" 70 | 71 | TIME_5 = auto() 72 | """Targeted Time in Five Heart Rate Zones Changed""" 73 | 74 | INDOOR_BIKE_SIMULATION = auto() 75 | """Indoor Bike Simulation Parameters Changed""" 76 | 77 | WHEEL_CIRCUMFERENCE = auto() 78 | """Wheel Circumference Changed""" 79 | 80 | SPIN_DOWN = auto() 81 | """Spin Down Status""" 82 | 83 | CADENCE = auto() 84 | """Targeted Cadence Changed""" 85 | 86 | LOST_CONTROL = 0xFF 87 | """Control Permission Lost""" 88 | 89 | 90 | @dc.dataclass(frozen=True) 91 | class MachineStatusModel(CodeSwitchModel[MachineStatusCode]): 92 | """ 93 | Structure of the Fitness Machine Status characteristic. 94 | 95 | Described in section **4.17 Fitness Machine Status**. 96 | """ 97 | 98 | stop_pause: StopPauseCode | None = dc.field( 99 | default=None, 100 | metadata=model_meta( 101 | format="u1", 102 | code=MachineStatusCode.STOP_PAUSE, 103 | ), 104 | ) 105 | """Stopped or Paused Event | Enumeration""" 106 | 107 | target_speed: float | None = dc.field( 108 | default=None, 109 | metadata=model_meta( 110 | format="u2.01", 111 | code=MachineStatusCode.SPEED, 112 | ), 113 | ) 114 | """New Target Speed | Km/h""" 115 | 116 | target_inclination: float | None = dc.field( 117 | default=None, 118 | metadata=model_meta( 119 | format="s2.1", 120 | code=MachineStatusCode.INCLINE, 121 | ), 122 | ) 123 | """New Target Inclination | Percent""" 124 | 125 | target_resistance: float | None = dc.field( 126 | default=None, 127 | metadata=model_meta( 128 | format="u1.1", 129 | code=MachineStatusCode.RESISTANCE, 130 | ), 131 | ) 132 | """New Target Resistance Level | Unitless""" 133 | 134 | target_power: int | None = dc.field( 135 | default=None, 136 | metadata=model_meta( 137 | format="s2", 138 | code=MachineStatusCode.POWER, 139 | ), 140 | ) 141 | """New Target Power | Watt""" 142 | 143 | target_heart_rate: int | None = dc.field( 144 | default=None, 145 | metadata=model_meta( 146 | format="u1", 147 | code=MachineStatusCode.HEART_RATE, 148 | ), 149 | ) 150 | """New Target Heart Rate | BPM""" 151 | 152 | target_energy: int | None = dc.field( 153 | default=None, 154 | metadata=model_meta( 155 | format="u2", 156 | code=MachineStatusCode.ENERGY, 157 | ), 158 | ) 159 | """New Targeted Expended Energy | Calorie""" 160 | 161 | target_steps: int | None = dc.field( 162 | default=None, 163 | metadata=model_meta( 164 | format="u2", 165 | code=MachineStatusCode.STEPS_NUMBER, 166 | ), 167 | ) 168 | """New Targeted Number of Steps | Step""" 169 | 170 | target_strides: int | None = dc.field( 171 | default=None, 172 | metadata=model_meta( 173 | format="u2", 174 | code=MachineStatusCode.STRIDES_NUMBER, 175 | ), 176 | ) 177 | """New Targeted Number of Strides | Stride""" 178 | 179 | target_distance: int | None = dc.field( 180 | default=None, 181 | metadata=model_meta( 182 | format="u3", 183 | code=MachineStatusCode.DISTANCE, 184 | ), 185 | ) 186 | """New Targeted Distance | Meter""" 187 | 188 | target_time_1: tuple[int, ...] | None = dc.field( 189 | default=None, 190 | metadata=model_meta( 191 | format="u2", 192 | num=1, 193 | code=MachineStatusCode.TIME_1, 194 | ), 195 | ) 196 | """New Targeted Training Time | Second""" 197 | 198 | target_time_2: tuple[int, ...] | None = dc.field( 199 | default=None, 200 | metadata=model_meta( 201 | format="u2", 202 | num=2, 203 | code=MachineStatusCode.TIME_2, 204 | ), 205 | ) 206 | """New Targeted Time in Two Heart Rate Zones""" 207 | 208 | target_time_3: tuple[int, ...] | None = dc.field( 209 | default=None, 210 | metadata=model_meta( 211 | format="u2", 212 | num=3, 213 | code=MachineStatusCode.TIME_3, 214 | ), 215 | ) 216 | """New Targeted Time in Three Heart Rate Zones""" 217 | 218 | target_time_5: tuple[int, ...] | None = dc.field( 219 | default=None, 220 | metadata=model_meta( 221 | format="u2", 222 | num=5, 223 | code=MachineStatusCode.TIME_5, 224 | ), 225 | ) 226 | """New Targeted Time in Five Heart Rate Zones""" 227 | 228 | indoor_bike_simulation: IndoorBikeSimulationParameters | None = dc.field( 229 | default=None, 230 | metadata=model_meta( 231 | code=MachineStatusCode.INDOOR_BIKE_SIMULATION, 232 | ), 233 | ) 234 | """New Indoor Bike Simulation Parameters""" 235 | 236 | wheel_circumference: float | None = dc.field( 237 | default=None, 238 | metadata=model_meta( 239 | format="u2.1", 240 | code=MachineStatusCode.WHEEL_CIRCUMFERENCE, 241 | ), 242 | ) 243 | """New Wheel Circumference""" 244 | 245 | spin_down_status: SpinDownStatusCode | None = dc.field( 246 | default=None, 247 | metadata=model_meta( 248 | format="u1", 249 | code=MachineStatusCode.SPIN_DOWN, 250 | ), 251 | ) 252 | """Spin Down Control | Enumeration""" 253 | 254 | target_cadence: float | None = dc.field( 255 | default=None, 256 | metadata=model_meta( 257 | format="u2.5", 258 | code=MachineStatusCode.CADENCE, 259 | ), 260 | ) 261 | """New Targeted Cadence | 1/minute""" 262 | -------------------------------------------------------------------------------- /src/pyftms/models/realtime_data/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from .common import RealtimeData 5 | from .cross_trainer import CrossTrainerData 6 | from .indoor_bike import IndoorBikeData 7 | from .rower import RowerData 8 | from .treadmill import TreadmillData 9 | 10 | __all__ = [ 11 | "CrossTrainerData", 12 | "IndoorBikeData", 13 | "RowerData", 14 | "TreadmillData", 15 | "RealtimeData", 16 | ] 17 | -------------------------------------------------------------------------------- /src/pyftms/models/realtime_data/common.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import dataclasses as dc 5 | import io 6 | from typing import Any, cast, override 7 | 8 | from ...serializer import BaseModel, get_serializer, model_meta 9 | 10 | 11 | @dc.dataclass(frozen=True) 12 | class RealtimeData(BaseModel): 13 | mask: dc.InitVar[int] 14 | 15 | @override 16 | @classmethod 17 | def _deserialize_asdict(cls, src: io.IOBase) -> dict[str, Any]: 18 | mask, kwargs = cast(int, get_serializer("u2").deserialize(src)), {} 19 | kwargs["mask"] = mask 20 | mask ^= 1 21 | 22 | for field, serializer in cls._iter_fields_serializers(): 23 | if mask & 1: 24 | kwargs[field.name] = serializer.deserialize(src) 25 | 26 | mask >>= 1 27 | 28 | if not mask: 29 | break 30 | 31 | assert not src.read() 32 | 33 | return kwargs 34 | 35 | @override 36 | @classmethod 37 | def _calc_size(cls) -> int: 38 | return super()._calc_size() + 2 39 | 40 | 41 | @dc.dataclass(frozen=True) 42 | class RealtimeSpeedData(RealtimeData): 43 | speed_instant: float | None = dc.field( 44 | default=None, 45 | metadata=model_meta( 46 | format="u2.01", 47 | ), 48 | ) 49 | """Instantaneous Speed""" 50 | 51 | speed_average: float | None = dc.field( 52 | default=None, 53 | metadata=model_meta( 54 | format="u2.01", 55 | features_bit=0, 56 | ), 57 | ) 58 | """Average Speed""" 59 | 60 | 61 | @dc.dataclass(frozen=True) 62 | class InclinationData(BaseModel): 63 | inclination: float | None = dc.field( 64 | default=None, 65 | metadata=model_meta( 66 | format="s2.1", 67 | ), 68 | ) 69 | """Inclination""" 70 | 71 | ramp_angle: float | None = dc.field( 72 | default=None, 73 | metadata=model_meta( 74 | format="s2.1", 75 | ), 76 | ) 77 | """Ramp Angle""" 78 | 79 | 80 | @dc.dataclass(frozen=True) 81 | class EnergyData(BaseModel): 82 | energy_total: int | None = dc.field( 83 | default=None, 84 | metadata=model_meta( 85 | format="u2", 86 | ), 87 | ) 88 | """Total Energy""" 89 | 90 | energy_per_hour: int | None = dc.field( 91 | default=None, 92 | metadata=model_meta( 93 | format="u2", 94 | ), 95 | ) 96 | """Per Hour Energy""" 97 | 98 | energy_per_minute: int | None = dc.field( 99 | default=None, 100 | metadata=model_meta( 101 | format="u1", 102 | ), 103 | ) 104 | """Per Minute Energy""" 105 | -------------------------------------------------------------------------------- /src/pyftms/models/realtime_data/cross_trainer.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import dataclasses as dc 5 | 6 | from ...client.properties import MovementDirection 7 | from .common import ( 8 | BaseModel, 9 | EnergyData, 10 | InclinationData, 11 | RealtimeSpeedData, 12 | model_meta, 13 | ) 14 | 15 | 16 | @dc.dataclass(frozen=True) 17 | class ElevationGainData(BaseModel): 18 | elevation_gain_positive: int = dc.field( 19 | metadata=model_meta( 20 | format="u2", 21 | ), 22 | ) 23 | """Elevation Gain Positive""" 24 | 25 | elevation_gain_negative: int = dc.field( 26 | metadata=model_meta( 27 | format="u2", 28 | ), 29 | ) 30 | """Elevation Gain Negative""" 31 | 32 | 33 | @dc.dataclass(frozen=True) 34 | class StepRateData(BaseModel): 35 | step_rate_instant: int | None = dc.field( 36 | default=None, 37 | metadata=model_meta( 38 | format="u2", 39 | ), 40 | ) 41 | """Step Rate Instant""" 42 | 43 | step_rate_average: int | None = dc.field( 44 | default=None, 45 | metadata=model_meta( 46 | format="u2", 47 | ), 48 | ) 49 | """Step Rate Average""" 50 | 51 | 52 | @dc.dataclass(frozen=True) 53 | class CrossTrainerData(RealtimeSpeedData): 54 | distance_total: int | None = dc.field( 55 | default=None, 56 | metadata=model_meta( 57 | format="u3", 58 | features_bit=2, 59 | ), 60 | ) 61 | """Total Distance""" 62 | 63 | step_rate: StepRateData | None = dc.field( 64 | default=None, 65 | metadata=model_meta( 66 | features_bit=6, 67 | ), 68 | ) 69 | """Step Rate Data""" 70 | 71 | stride_count: int | None = dc.field( 72 | default=None, 73 | metadata=model_meta( 74 | format="u2", 75 | features_bit=8, 76 | ), 77 | ) 78 | """Stride Count""" 79 | 80 | elevation_gain: ElevationGainData | None = dc.field( 81 | default=None, 82 | metadata=model_meta( 83 | features_bit=4, 84 | ), 85 | ) 86 | """Elevation Gain Data""" 87 | 88 | inclination: InclinationData | None = dc.field( 89 | default=None, 90 | metadata=model_meta( 91 | features_bit=3, 92 | ), 93 | ) 94 | """Inclination and Ramp Angle Data""" 95 | 96 | resistance_level: float | None = dc.field( 97 | default=None, 98 | metadata=model_meta( 99 | format="s2.1", 100 | features_bit=7, 101 | ), 102 | ) 103 | """Resistance Level""" 104 | 105 | power_instant: int | None = dc.field( 106 | default=None, 107 | metadata=model_meta( 108 | format="s2", 109 | features_bit=14, 110 | ), 111 | ) 112 | """Instantaneous Power""" 113 | 114 | power_average: int | None = dc.field( 115 | default=None, 116 | metadata=model_meta( 117 | format="s2", 118 | features_bit=14, 119 | ), 120 | ) 121 | """Average Power""" 122 | 123 | energy: EnergyData | None = dc.field( 124 | default=None, 125 | metadata=model_meta( 126 | features_bit=9, 127 | ), 128 | ) 129 | """Energy Data""" 130 | 131 | heart_rate: int | None = dc.field( 132 | default=None, 133 | metadata=model_meta( 134 | format="u1", 135 | features_bit=10, 136 | ), 137 | ) 138 | """Heart Rate""" 139 | 140 | metabolic_equivalent: float | None = dc.field( 141 | default=None, 142 | metadata=model_meta( 143 | format="u1.1", 144 | features_bit=11, 145 | ), 146 | ) 147 | """Metabolic Equivalent""" 148 | 149 | time_elapsed: int | None = dc.field( 150 | default=None, 151 | metadata=model_meta( 152 | format="u2", 153 | features_bit=12, 154 | ), 155 | ) 156 | """Elapsed Time""" 157 | 158 | time_remaining: int | None = dc.field( 159 | default=None, 160 | metadata=model_meta( 161 | format="u2", 162 | features_bit=13, 163 | ), 164 | ) 165 | """Remaining Time""" 166 | 167 | movement_direction: MovementDirection = dc.field(init=False) 168 | """Movement Direction""" 169 | 170 | def __post_init__(self, mask: int): 171 | md = ( 172 | MovementDirection.BACKWARD 173 | if mask & 0x8000 174 | else MovementDirection.FORWARD 175 | ) 176 | object.__setattr__(self, "movement_direction", md) 177 | -------------------------------------------------------------------------------- /src/pyftms/models/realtime_data/indoor_bike.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import dataclasses as dc 5 | 6 | from .common import EnergyData, RealtimeSpeedData, model_meta 7 | 8 | 9 | @dc.dataclass(frozen=True) 10 | class IndoorBikeData(RealtimeSpeedData): 11 | cadence_instant: float | None = dc.field( 12 | default=None, 13 | metadata=model_meta( 14 | format="u2.5", 15 | features_bit=1, 16 | ), 17 | ) 18 | """Instantaneous Cadence""" 19 | 20 | cadence_average: float | None = dc.field( 21 | default=None, 22 | metadata=model_meta( 23 | format="u2.5", 24 | features_bit=1, 25 | ), 26 | ) 27 | """Average Cadence""" 28 | 29 | distance_total: int | None = dc.field( 30 | default=None, 31 | metadata=model_meta( 32 | format="u3", 33 | features_bit=2, 34 | ), 35 | ) 36 | """Total Distance""" 37 | 38 | resistance_level: int | None = dc.field( 39 | default=None, 40 | metadata=model_meta( 41 | format="s2", 42 | features_bit=7, 43 | ), 44 | ) 45 | """Resistance Level""" 46 | 47 | power_instant: int | None = dc.field( 48 | default=None, 49 | metadata=model_meta( 50 | format="s2", 51 | features_bit=14, 52 | ), 53 | ) 54 | """Instantaneous Power""" 55 | 56 | power_average: int | None = dc.field( 57 | default=None, 58 | metadata=model_meta( 59 | format="s2", 60 | features_bit=14, 61 | ), 62 | ) 63 | """Average Power""" 64 | 65 | energy: EnergyData | None = dc.field( 66 | default=None, 67 | metadata=model_meta( 68 | features_bit=9, 69 | ), 70 | ) 71 | """Energy Data""" 72 | 73 | heart_rate: int | None = dc.field( 74 | default=None, 75 | metadata=model_meta( 76 | format="u1", 77 | features_bit=10, 78 | ), 79 | ) 80 | """Heart Rate""" 81 | 82 | metabolic_equivalent: float | None = dc.field( 83 | default=None, 84 | metadata=model_meta( 85 | format="u1.1", 86 | features_bit=11, 87 | ), 88 | ) 89 | """Metabolic Equivalent""" 90 | 91 | time_elapsed: int | None = dc.field( 92 | default=None, 93 | metadata=model_meta( 94 | format="u2", 95 | features_bit=12, 96 | ), 97 | ) 98 | """Elapsed Time""" 99 | 100 | time_remaining: int | None = dc.field( 101 | default=None, 102 | metadata=model_meta( 103 | format="u2", 104 | features_bit=13, 105 | ), 106 | ) 107 | """Remaining Time""" 108 | -------------------------------------------------------------------------------- /src/pyftms/models/realtime_data/rower.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import dataclasses as dc 5 | 6 | from .common import BaseModel, EnergyData, RealtimeData, model_meta 7 | 8 | 9 | @dc.dataclass(frozen=True) 10 | class StrokeRateData(BaseModel): 11 | stroke_rate_instant: float = dc.field( 12 | metadata=model_meta( 13 | format="u1.5", 14 | ), 15 | ) 16 | """Stroke Rate""" 17 | 18 | stroke_count: int = dc.field( 19 | metadata=model_meta( 20 | format="u2", 21 | ), 22 | ) 23 | """Stroke Count""" 24 | 25 | 26 | @dc.dataclass(frozen=True) 27 | class RowerData(RealtimeData): 28 | stroke_rate: StrokeRateData | None = dc.field( 29 | default=None, 30 | metadata=model_meta(), 31 | ) 32 | """Stroke Rate Data""" 33 | 34 | stroke_rate_average: float | None = dc.field( 35 | default=None, 36 | metadata=model_meta( 37 | format="u1.5", 38 | features_bit=1, 39 | ), 40 | ) 41 | """Average Stroke Rate""" 42 | 43 | distance_total: int | None = dc.field( 44 | default=None, 45 | metadata=model_meta( 46 | format="u3", 47 | features_bit=2, 48 | ), 49 | ) 50 | """Total Distance""" 51 | 52 | split_time_instant: int | None = dc.field( 53 | default=None, 54 | metadata=model_meta( 55 | format="u2", 56 | features_bit=5, 57 | ), 58 | ) 59 | """Instantaneous Split Time""" 60 | 61 | split_time_average: int | None = dc.field( 62 | default=None, 63 | metadata=model_meta( 64 | format="u2", 65 | features_bit=5, 66 | ), 67 | ) 68 | """Average Split Time""" 69 | 70 | power_instant: int | None = dc.field( 71 | default=None, 72 | metadata=model_meta( 73 | format="s2", 74 | features_bit=14, 75 | ), 76 | ) 77 | """Instantaneous Power""" 78 | 79 | power_average: int | None = dc.field( 80 | default=None, 81 | metadata=model_meta( 82 | format="s2", 83 | features_bit=14, 84 | ), 85 | ) 86 | """Average Power""" 87 | 88 | resistance_level: int | None = dc.field( 89 | default=None, 90 | metadata=model_meta( 91 | format="s2", 92 | features_bit=7, 93 | ), 94 | ) 95 | """Resistance Level""" 96 | 97 | energy: EnergyData | None = dc.field( 98 | default=None, 99 | metadata=model_meta( 100 | features_bit=9, 101 | ), 102 | ) 103 | """Energy Data""" 104 | 105 | heart_rate: int | None = dc.field( 106 | default=None, 107 | metadata=model_meta( 108 | format="u1", 109 | features_bit=10, 110 | ), 111 | ) 112 | """Heart Rate""" 113 | 114 | metabolic_equivalent: float | None = dc.field( 115 | default=None, 116 | metadata=model_meta( 117 | format="u1.1", 118 | features_bit=11, 119 | ), 120 | ) 121 | """Metabolic Equivalent""" 122 | 123 | time_elapsed: int | None = dc.field( 124 | default=None, 125 | metadata=model_meta( 126 | format="u2", 127 | features_bit=12, 128 | ), 129 | ) 130 | """Elapsed Time""" 131 | 132 | time_remaining: int | None = dc.field( 133 | default=None, 134 | metadata=model_meta( 135 | format="u2", 136 | features_bit=13, 137 | ), 138 | ) 139 | """Remaining Time""" 140 | -------------------------------------------------------------------------------- /src/pyftms/models/realtime_data/treadmill.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import dataclasses as dc 5 | 6 | from .common import ( 7 | BaseModel, 8 | EnergyData, 9 | InclinationData, 10 | RealtimeSpeedData, 11 | model_meta, 12 | ) 13 | 14 | 15 | @dc.dataclass(frozen=True) 16 | class ElevationGainData(BaseModel): 17 | elevation_gain_positive: int = dc.field( 18 | metadata=model_meta( 19 | format="u2.1", 20 | ), 21 | ) 22 | """Elevation Gain Positive""" 23 | 24 | elevation_gain_negative: int = dc.field( 25 | metadata=model_meta( 26 | format="u2.1", 27 | ), 28 | ) 29 | """Elevation Gain Negative""" 30 | 31 | 32 | @dc.dataclass(frozen=True) 33 | class ForceOnBeltData(BaseModel): 34 | force_on_belt: int | None = dc.field( 35 | default=None, 36 | metadata=model_meta( 37 | format="s2", 38 | ), 39 | ) 40 | """Force On Belt""" 41 | 42 | power_output: int | None = dc.field( 43 | default=None, 44 | metadata=model_meta( 45 | format="s2", 46 | ), 47 | ) 48 | """Output Power""" 49 | 50 | 51 | @dc.dataclass(frozen=True) 52 | class TreadmillData(RealtimeSpeedData): 53 | distance_total: int | None = dc.field( 54 | default=None, 55 | metadata=model_meta( 56 | format="u3", 57 | features_bit=2, 58 | ), 59 | ) 60 | """Total Distance""" 61 | 62 | inclination: InclinationData | None = dc.field( 63 | default=None, 64 | metadata=model_meta( 65 | features_bit=3, 66 | ), 67 | ) 68 | """Inclination and Ramp Angle Data""" 69 | 70 | elevation_gain: ElevationGainData | None = dc.field( 71 | default=None, 72 | metadata=model_meta( 73 | features_bit=4, 74 | ), 75 | ) 76 | """Elevation Gain Data""" 77 | 78 | pace_instant: float | None = dc.field( 79 | default=None, 80 | metadata=model_meta( 81 | format="u1.1", 82 | features_bit=5, 83 | ), 84 | ) 85 | """Instantaneous Speed""" 86 | 87 | pace_average: float | None = dc.field( 88 | default=None, 89 | metadata=model_meta( 90 | format="u1.1", 91 | features_bit=5, 92 | ), 93 | ) 94 | """Average Speed""" 95 | 96 | energy: EnergyData | None = dc.field( 97 | default=None, 98 | metadata=model_meta( 99 | features_bit=9, 100 | ), 101 | ) 102 | """Energy Data""" 103 | 104 | heart_rate: int | None = dc.field( 105 | default=None, 106 | metadata=model_meta( 107 | format="u1", 108 | features_bit=10, 109 | ), 110 | ) 111 | """Heart Rate""" 112 | 113 | metabolic_equivalent: float | None = dc.field( 114 | default=None, 115 | metadata=model_meta( 116 | format="u1.1", 117 | features_bit=11, 118 | ), 119 | ) 120 | """Metabolic Equivalent""" 121 | 122 | time_elapsed: int | None = dc.field( 123 | default=None, 124 | metadata=model_meta( 125 | format="u2", 126 | features_bit=12, 127 | ), 128 | ) 129 | """Elapsed Time""" 130 | 131 | time_remaining: int | None = dc.field( 132 | default=None, 133 | metadata=model_meta( 134 | format="u2", 135 | features_bit=13, 136 | ), 137 | ) 138 | """Remaining Time""" 139 | 140 | force_on_belt: ForceOnBeltData | None = dc.field( 141 | default=None, 142 | metadata=model_meta( 143 | features_bit=15, 144 | ), 145 | ) 146 | """Force On Belt and Power Output""" 147 | 148 | step_count: int | None = dc.field( 149 | default=None, 150 | metadata=model_meta( 151 | format="u3", 152 | features_bit=6, 153 | ), 154 | ) 155 | """Steps Count""" 156 | -------------------------------------------------------------------------------- /src/pyftms/models/spin_down.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import dataclasses as dc 5 | from enum import STRICT, IntEnum, auto 6 | 7 | from .common import BaseModel, model_meta 8 | 9 | 10 | class SpinDownStatusCode(IntEnum, boundary=STRICT): 11 | """ 12 | Spin Down Status. 13 | 14 | Described in section **4.17 Fitness Machine Status. Table 4.27**. 15 | """ 16 | 17 | REQUESTED = auto() 18 | """Spin Down Requested""" 19 | 20 | SUCCESS = auto() 21 | """Success""" 22 | 23 | ERROR = auto() 24 | """Error""" 25 | 26 | STOP_PEDALING = auto() 27 | """Stop Pedaling""" 28 | 29 | 30 | class SpinDownControlCode(IntEnum, boundary=STRICT): 31 | """ 32 | Spin Down Control Code. 33 | 34 | Described in section **4.16.2.20 Spin Down Control Procedure. Table 4.21**. 35 | """ 36 | 37 | START = auto() 38 | """Spin Down Start.""" 39 | 40 | IGNORE = auto() 41 | """Spin Down Ignore.""" 42 | 43 | 44 | @dc.dataclass(frozen=True) 45 | class SpinDownSpeedData(BaseModel): 46 | """ 47 | Response Parameter when the Spin Down Procedure succeeds. 48 | 49 | Described in section `4.16.2.20 Spin Down Control Procedure. Table 4.22`. 50 | """ 51 | 52 | low: float = dc.field( 53 | metadata=model_meta( 54 | format="u2.01", 55 | ) 56 | ) 57 | """ 58 | Target Speed Low. 59 | 60 | Units: `km/h`. 61 | """ 62 | 63 | high: float = dc.field( 64 | metadata=model_meta( 65 | format="u2.01", 66 | ) 67 | ) 68 | """ 69 | Target Speed High. 70 | 71 | Units: `km/h`. 72 | """ 73 | -------------------------------------------------------------------------------- /src/pyftms/models/training_status.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import dataclasses as dc 5 | from enum import STRICT, IntEnum, IntFlag, auto 6 | 7 | from .common import BaseModel, model_meta 8 | 9 | 10 | class TrainingStatusFlags(IntFlag, boundary=STRICT): 11 | """ 12 | Training Status. 13 | 14 | Represents the current training state while a user is exercising. 15 | 16 | Described in section **4.10.1.2: Training Status Field**. 17 | """ 18 | 19 | STRING_PRESENT = auto() 20 | """Other.""" 21 | 22 | EXTENDED_STRING = auto() 23 | """Idle.""" 24 | 25 | 26 | class TrainingStatusCode(IntEnum, boundary=STRICT): 27 | """ 28 | Training Status. 29 | 30 | Represents the current training state while a user is exercising. 31 | 32 | Described in section **4.10.1.2: Training Status Field**. 33 | """ 34 | 35 | OTHER = 0 36 | """Other.""" 37 | 38 | IDLE = auto() 39 | """Idle.""" 40 | 41 | WARMING_UP = auto() 42 | """Warming Up.""" 43 | 44 | LOW_INTENSITY_INTERVAL = auto() 45 | """Low Intensity Interval.""" 46 | 47 | HIGH_INTENSITY_INTERVAL = auto() 48 | """High Intensity Interval.""" 49 | 50 | RECOVERY_INTERVAL = auto() 51 | """Recovery Interval.""" 52 | 53 | ISOMETRIC = auto() 54 | """Isometric.""" 55 | 56 | HEART_RATE_CONTROL = auto() 57 | """Heart Rate Control.""" 58 | 59 | FITNESS_TEST = auto() 60 | """Fitness Test.""" 61 | 62 | SPEED_TOO_LOW = auto() 63 | """Speed Outside of Control Region - Low (increase speed to return to controllable region).""" 64 | 65 | SPEED_TOO_HIGH = auto() 66 | """Speed Outside of Control Region - High (decrease speed to return to controllable region).""" 67 | 68 | COOL_DOWN = auto() 69 | """Cool Down.""" 70 | 71 | WATT_CONTROL = auto() 72 | """Watt Control.""" 73 | 74 | MANUAL_MODE = auto() 75 | """Manual Mode (Quick Start).""" 76 | 77 | PRE_WORKOUT = auto() 78 | """Pre-Workout.""" 79 | 80 | POST_WORKOUT = auto() 81 | """Post-Workout.""" 82 | 83 | 84 | @dc.dataclass(frozen=True) 85 | class TrainingStatusModel(BaseModel): 86 | """ 87 | Structure of the Training Status Characteristic. 88 | 89 | Described in section **4.10 Training Status**. 90 | """ 91 | 92 | flags: TrainingStatusFlags = dc.field( 93 | metadata=model_meta( 94 | format="u1", 95 | ) 96 | ) 97 | """Flags Field.""" 98 | 99 | code: TrainingStatusCode = dc.field( 100 | metadata=model_meta( 101 | format="u1", 102 | ) 103 | ) 104 | """Training Status Field.""" 105 | -------------------------------------------------------------------------------- /src/pyftms/serializer/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from .list import ListSerializer 5 | from .model import ( 6 | BaseModel, 7 | ModelMeta, 8 | ModelSerializer, 9 | get_serializer, 10 | model_meta, 11 | ) 12 | from .num import FtmsNumbers, NumSerializer 13 | from .serializer import Serializer 14 | 15 | __all__ = [ 16 | "BaseModel", 17 | "FtmsNumbers", 18 | "get_serializer", 19 | "ListSerializer", 20 | "model_meta", 21 | "ModelMeta", 22 | "ModelSerializer", 23 | "NumSerializer", 24 | "Serializer", 25 | ] 26 | -------------------------------------------------------------------------------- /src/pyftms/serializer/list.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import io 5 | from typing import Iterator, override 6 | 7 | from .serializer import Serializer 8 | 9 | 10 | class ListSerializer[T](Serializer[Iterator[T]]): 11 | def __init__(self, serializer: Serializer[T], n: int) -> None: 12 | assert n > 0 13 | self._serializer = serializer 14 | self._len = n 15 | 16 | @override 17 | def serialize(self, dst: io.IOBase, value: Iterator[T]) -> int: 18 | return sum(self._serializer.serialize(dst, x) for x in value) 19 | 20 | @override 21 | def _deserialize(self, src: io.IOBase) -> Iterator[T]: 22 | return (self._serializer._deserialize(src) for _ in range(self._len)) 23 | 24 | @override 25 | def get_size(self) -> int: 26 | return self._serializer.get_size() * self._len 27 | -------------------------------------------------------------------------------- /src/pyftms/serializer/model.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import dataclasses as dc 5 | import io 6 | from types import GenericAlias, MappingProxyType, UnionType 7 | from typing import ( 8 | Any, 9 | ClassVar, 10 | Iterator, 11 | Optional, 12 | Self, 13 | TypedDict, 14 | TypeVar, 15 | Union, 16 | cast, 17 | get_args, 18 | get_origin, 19 | overload, 20 | override, 21 | ) 22 | 23 | from .list import ListSerializer 24 | from .num import NumSerializer 25 | from .serializer import Serializer 26 | 27 | 28 | class ModelMeta(TypedDict, total=False): 29 | format: str 30 | features_bit: int 31 | code: int 32 | num: int 33 | 34 | 35 | def model_meta( 36 | *, 37 | format: str = "", 38 | features_bit: int | None = None, 39 | code: int | None = None, 40 | num: int | None = None, 41 | ) -> ModelMeta: 42 | result = ModelMeta(format=format) 43 | 44 | if features_bit is not None: 45 | result["features_bit"] = features_bit 46 | 47 | if code is not None: 48 | result["code"] = code 49 | 50 | if num is not None: 51 | result["num"] = num 52 | 53 | return result 54 | 55 | 56 | def _get_model_field_serializer(field: dc.Field): 57 | meta, type_ = cast(ModelMeta, field.metadata), field.type 58 | 59 | if get_origin(type_) in (Optional, Union, UnionType): 60 | if (type_ := get_args(type_)[0]) is None: 61 | raise TypeError("Failed to get first type.") 62 | 63 | if isinstance(type_, TypeVar): 64 | if (type_ := type_.__bound__) is None: 65 | raise TypeError("'TypeVar' must have bound type.") 66 | 67 | def _get_serializer(type_) -> Serializer: 68 | if isinstance(type_, GenericAlias): 69 | if (num := meta.get("num")) is None: 70 | raise TypeError("Number of elements is required.") 71 | 72 | serializer = _get_serializer(get_args(type_)[0]) 73 | 74 | return ListSerializer(serializer, num) 75 | 76 | if issubclass(type_, (int, float)): 77 | if fmt := meta.get("format"): 78 | return get_serializer(fmt) 79 | 80 | raise TypeError( 81 | f"Format string for field '{field.name}' is required." 82 | ) 83 | 84 | if issubclass(type_, BaseModel): 85 | return get_serializer(type_) 86 | 87 | raise TypeError("Unsupported type.") 88 | 89 | return _get_serializer(type_) 90 | 91 | 92 | def _get_model_serializers( 93 | cls: type["BaseModel"], 94 | ) -> MappingProxyType[str, Serializer]: 95 | """Generate tuples of Fields and its Serializers.""" 96 | if (serializers_ := getattr(cls, "_model_serializers", None)) is None: 97 | result: dict[str, Serializer] = {} 98 | 99 | for field in dc.fields(cls): 100 | if field.metadata: 101 | result[field.name] = _get_model_field_serializer(field) 102 | 103 | setattr( 104 | cls, "_model_serializers", serializers_ := MappingProxyType(result) 105 | ) 106 | 107 | return serializers_ 108 | 109 | 110 | @dc.dataclass(frozen=True) 111 | class BaseModel: 112 | _model_serializers: ClassVar[MappingProxyType[str, Serializer]] 113 | 114 | @classmethod 115 | def _iter_fields_serializers(cls) -> Iterator[tuple[dc.Field, Serializer]]: 116 | """Generate tuples of Fields and its Serializers.""" 117 | serializers = _get_model_serializers(cls) 118 | 119 | for field in dc.fields(cls): 120 | if (serializer := serializers.get(field.name)) is not None: 121 | yield field, serializer 122 | 123 | @classmethod 124 | def _deserialize_asdict(cls, src: io.IOBase) -> dict[str, Any]: 125 | kwargs = {} 126 | 127 | for field, serializer in cls._iter_fields_serializers(): 128 | value, type_ = serializer._deserialize(src), field.type 129 | 130 | if value is None: 131 | continue 132 | 133 | if get_origin(type_) in (Optional, Union, UnionType): 134 | if (type_ := get_args(type_)[0]) is None: 135 | raise TypeError("Failed to get first type.") 136 | 137 | if isinstance(serializer, (NumSerializer, ListSerializer)): 138 | value = type_(value) 139 | 140 | kwargs[field.name] = value 141 | 142 | return kwargs 143 | 144 | @classmethod 145 | def _deserialize(cls, src: io.IOBase) -> Self: 146 | return cls(**cls._deserialize_asdict(src)) 147 | 148 | def _serialize(self, dst: io.IOBase) -> int: 149 | written = 0 150 | 151 | for field, serializer in self._iter_fields_serializers(): 152 | if (val := getattr(self, field.name)) is not None: 153 | written += serializer.serialize(dst, val) 154 | 155 | return written 156 | 157 | @classmethod 158 | def _calc_size(cls) -> int: 159 | return sum(s.get_size() for _, s in cls._iter_fields_serializers()) 160 | 161 | def _asdict(self, *, nested: bool = False): 162 | def _transform(input: dict): 163 | for key in tuple(input.keys()): 164 | if (value := input[key]) is None: 165 | input.pop(key) 166 | 167 | elif isinstance(value, dict): 168 | _transform(value) 169 | 170 | if not nested: 171 | input.pop(key) 172 | input |= value 173 | 174 | _transform(result := dc.asdict(self)) 175 | 176 | return result 177 | 178 | @classmethod 179 | def _get_features(cls, features: int) -> list[str]: 180 | result = [] 181 | 182 | def _get_cls_features(cls): 183 | for field in dc.fields(cls): 184 | meta, tp = cast(ModelMeta, field.metadata), field.type 185 | 186 | if not meta or field.name == "code": 187 | continue 188 | 189 | if get_origin(tp) in (Optional, Union, UnionType): 190 | if (tp := get_args(tp)[0]) is None: 191 | raise TypeError("Failed to get first type.") 192 | 193 | if (bit := meta.get("features_bit")) is None or features & ( 194 | 1 << bit 195 | ): 196 | if isinstance(tp, type) and issubclass(tp, BaseModel): 197 | _get_cls_features(tp) 198 | continue 199 | 200 | result.append(field.name) 201 | 202 | _get_cls_features(cls) 203 | 204 | return result 205 | 206 | def __post_init__(self, *args, **kwargs): 207 | for field in dc.fields(self): 208 | if (val := getattr(self, field.name)) is None: 209 | continue 210 | 211 | meta = cast(ModelMeta, field.metadata) 212 | 213 | if (num := meta.get("num")) and len(val) != num: 214 | raise ValueError( 215 | f"Length of field '{field.name}' must be {num}." 216 | ) 217 | 218 | @classmethod 219 | def _get_serializer(cls) -> Serializer[Self]: 220 | return get_serializer(cls) 221 | 222 | 223 | # MODEL SERIALIZER 224 | 225 | 226 | class ModelSerializer[T: BaseModel](Serializer[T]): 227 | """Model Serializer""" 228 | 229 | def __init__(self, cls: type[T]) -> None: 230 | self._cls = cls 231 | 232 | @override 233 | def _deserialize(self, src: io.IOBase) -> T: 234 | return self._cls._deserialize(src) 235 | 236 | @override 237 | def serialize(self, writer: io.IOBase, value: T) -> int: 238 | return value._serialize(writer) 239 | 240 | @override 241 | def get_size(self) -> int: 242 | return self._cls._calc_size() 243 | 244 | 245 | # SERIALIZERS REGISTRY 246 | 247 | _registry: dict[str | type[BaseModel], Serializer] = {} 248 | """Serializers Registry""" 249 | 250 | 251 | @overload 252 | def get_serializer(arg: str, num: None = None) -> NumSerializer: ... 253 | 254 | 255 | @overload 256 | def get_serializer[T: BaseModel]( 257 | arg: type[T], num: None = None 258 | ) -> ModelSerializer[T]: ... 259 | 260 | 261 | @overload 262 | def get_serializer(arg: str, num: int) -> ListSerializer[NumSerializer]: ... 263 | 264 | 265 | @overload 266 | def get_serializer[T: BaseModel]( 267 | arg: type[T], num: int 268 | ) -> ListSerializer[ModelSerializer[T]]: ... 269 | 270 | 271 | def get_serializer[T: BaseModel](arg: str | type[T], num: int | None = None): 272 | if (serializer := _registry.get(arg)) is None: 273 | if isinstance(arg, str): 274 | serializer = NumSerializer(arg) 275 | 276 | elif isinstance(arg, type) and issubclass(arg, BaseModel): 277 | serializer = ModelSerializer(arg) 278 | 279 | else: 280 | raise TypeError(f"Unsupported type {arg}.") 281 | 282 | _registry[arg] = serializer 283 | 284 | return serializer if num is None else ListSerializer(serializer, num) 285 | -------------------------------------------------------------------------------- /src/pyftms/serializer/num.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024-2025, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import io 5 | import re 6 | from typing import override 7 | 8 | from .serializer import Serializer 9 | 10 | type FtmsNumbers = float | int | None 11 | 12 | 13 | class NumSerializer(Serializer[FtmsNumbers]): 14 | """ 15 | A simple class for reading/writing numbers from/to a stream. 16 | 17 | Stores the necessary parameters, including those for autoscaling during operations. 18 | """ 19 | 20 | __slots__ = ( 21 | "factor", 22 | "none", 23 | "sign", 24 | "size", 25 | ) 26 | 27 | def __init__(self, format: str) -> None: 28 | """Initialize serializer. 29 | 30 | Parameters: 31 | format: Number format string. 32 | 33 | Raises: 34 | ValueError: If format string is wrong. 35 | """ 36 | 37 | if (m := re.fullmatch(r"[us][1-4](\.\d{1,4})?", format)) is None: 38 | raise ValueError(f"Wrong serializer format: '{format}'.") 39 | 40 | self.factor = float(m.group(1) or "0") 41 | self.sign, self.size = format.startswith("s"), int(format[1]) 42 | self.none = (1 << (8 * self.size - self.sign)) - 1 43 | 44 | @override 45 | def _deserialize(self, stream: io.IOBase) -> FtmsNumbers: 46 | """Deserialize number from stream. 47 | 48 | Deserialize number from stream, scaling it if necessary. 49 | 50 | Parameters: 51 | stream: IO stream. 52 | 53 | Returns: 54 | Readed value. 55 | 56 | Raises: 57 | EOFError: If the stream ends unexpectedly. 58 | """ 59 | 60 | if len(data := stream.read(self.size)) != self.size: 61 | raise EOFError("Unexpected end of stream.") 62 | 63 | value = int.from_bytes(data, "little", signed=self.sign) 64 | 65 | if value == self.none: 66 | return 67 | 68 | if self.factor: 69 | value *= self.factor 70 | 71 | return value 72 | 73 | @override 74 | def serialize(self, stream: io.IOBase, value: FtmsNumbers) -> int: 75 | """Serialize number. 76 | 77 | Serialize number to a stream, pre-scaling if necessary. 78 | 79 | Parameters: 80 | stream: IO stream. 81 | value: Number to serialize. 82 | 83 | Returns: 84 | Number of written bytes. 85 | """ 86 | 87 | if value is None: 88 | value = self.none 89 | 90 | elif self.factor: 91 | value /= self.factor 92 | 93 | data = int(value).to_bytes(self.size, "little", signed=self.sign) 94 | 95 | return stream.write(data) 96 | 97 | @override 98 | def get_size(self) -> int: 99 | return self.size 100 | -------------------------------------------------------------------------------- /src/pyftms/serializer/serializer.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024, Sergey Dudanov 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import io 5 | from abc import ABC, abstractmethod 6 | 7 | 8 | class Serializer[T](ABC): 9 | def deserialize(self, src: io.IOBase | bytes) -> T: 10 | if not isinstance(src, io.IOBase): 11 | src = io.BytesIO(src) 12 | 13 | return self._deserialize(src) 14 | 15 | @abstractmethod 16 | def _deserialize(self, src: io.IOBase) -> T: ... 17 | 18 | @abstractmethod 19 | def serialize(self, dst: io.IOBase, value: T) -> int: ... 20 | 21 | @abstractmethod 22 | def get_size(self) -> int: ... 23 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dudanov/python-pyftms/675c5cb096f97e4872858b0ca079291f3da12942/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pyftms.models import MachineStatusModel, TreadmillData 4 | from pyftms.serializer import BaseModel, ModelSerializer, get_serializer 5 | 6 | 7 | @pytest.mark.parametrize( 8 | "model,data,result", 9 | [ 10 | ( 11 | TreadmillData, 12 | b"\x00\x00\x00\x00", 13 | {"speed_instant": 0}, 14 | ), 15 | # Testing `hassio-ftms` issue #3: https://github.com/dudanov/hassio-ftms/issues/3 16 | ( 17 | TreadmillData, 18 | b"\x9c\x25\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", 19 | { 20 | "speed_instant": 0, 21 | "distance_total": 0, 22 | "heart_rate": 0, 23 | "time_elapsed": 0, 24 | "step_count": 0, 25 | "inclination": 0, 26 | "ramp_angle": 0, 27 | "elevation_gain_positive": 0, 28 | "elevation_gain_negative": 0, 29 | "energy_total": 0, 30 | "energy_per_hour": 0, 31 | "energy_per_minute": 0, 32 | }, 33 | ), 34 | ( 35 | MachineStatusModel, 36 | b"\x05\x69\x00", 37 | {"code": 5, "target_speed": 1.05}, 38 | ), 39 | ], 40 | ) 41 | def test_realtime_data(model: type[BaseModel], data: bytes, result: dict): 42 | s = get_serializer(model) 43 | 44 | assert isinstance(s, ModelSerializer) 45 | assert s.deserialize(data)._asdict() == result 46 | -------------------------------------------------------------------------------- /tests/test_num_serializer.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | import pytest 4 | 5 | from pyftms.serializer import FtmsNumbers, NumSerializer, get_serializer 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "format,number,result", 10 | [ 11 | ("u1", 128, b"\x80"), 12 | ("u2", 128, b"\x80\x00"), 13 | ("u3", 128, b"\x80\x00\x00"), 14 | ("u1.1", 12.8, b"\x80"), 15 | ("u2.1", 12.8, b"\x80\x00"), 16 | ("u3.1", 12.8, b"\x80\x00\x00"), 17 | ("s1", -128, b"\x80"), 18 | ("s2", -128, b"\x80\xff"), 19 | ("s3", -128, b"\x80\xff\xff"), 20 | ("s1.1", -12.8, b"\x80"), 21 | ("s2.1", -12.8, b"\x80\xff"), 22 | ("s3.1", -12.8, b"\x80\xff\xff"), 23 | ("u2", None, b"\xff\xff"), 24 | ("u2.1", None, b"\xff\xff"), 25 | ("s2", None, b"\xff\x7f"), 26 | ("s2.1", None, b"\xff\x7f"), 27 | ], 28 | ) 29 | def test_num_serializer(format: str, number: FtmsNumbers, result: bytes): 30 | serializer = get_serializer(format) 31 | 32 | assert isinstance(serializer, NumSerializer) 33 | assert serializer.deserialize(result) == number 34 | 35 | bio = io.BytesIO() 36 | 37 | size = serializer.serialize(bio, number) 38 | assert size == serializer.get_size() and size == len(result) 39 | assert bio.getvalue() == result 40 | --------------------------------------------------------------------------------