vitafit-ble 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vitafit_ble-0.1.0/LICENSE +21 -0
- vitafit_ble-0.1.0/PKG-INFO +59 -0
- vitafit_ble-0.1.0/README.md +34 -0
- vitafit_ble-0.1.0/pyproject.toml +97 -0
- vitafit_ble-0.1.0/pyproject.toml.orig +69 -0
- vitafit_ble-0.1.0/src/vitafit_ble/__init__.py +29 -0
- vitafit_ble-0.1.0/src/vitafit_ble/const.py +17 -0
- vitafit_ble-0.1.0/src/vitafit_ble/parser.py +87 -0
- vitafit_ble-0.1.0/src/vitafit_ble/protocol.py +206 -0
- vitafit_ble-0.1.0/src/vitafit_ble/py.typed +0 -0
- vitafit_ble-0.1.0/src/vitafit_ble/session.py +139 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 James Myatt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vitafit-ble
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bluetooth library for Vitafit devices
|
|
5
|
+
Author: James Myatt
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Home Automation
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Dist: bleak>=1.0
|
|
14
|
+
Requires-Dist: bleak-retry-connector>=4.0
|
|
15
|
+
Requires-Dist: bluetooth-data-tools>=1.28
|
|
16
|
+
Requires-Dist: bluetooth-sensor-state-data>=1.9
|
|
17
|
+
Requires-Dist: habluetooth>=3.42.0
|
|
18
|
+
Requires-Dist: sensor-state-data>=2.20
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Project-URL: Documentation, https://vitafit-ble.readthedocs.io
|
|
21
|
+
Project-URL: Repository, https://github.com/jamesmyatt/vitafit-ble
|
|
22
|
+
Project-URL: Bug Tracker, https://github.com/jamesmyatt/vitafit-ble/issues
|
|
23
|
+
Project-URL: Changelog, https://github.com/jamesmyatt/vitafit-ble/blob/main/CHANGELOG.md
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# vitafit-ble
|
|
27
|
+
|
|
28
|
+
Bluetooth library for Vitafit devices. See [supported devices](docs/supported_devices.md) for the models it works with.
|
|
29
|
+
|
|
30
|
+
Unofficial; not affiliated with Vitafit.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
pip install vitafit-ble
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Use
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from bleak import BleakClient
|
|
42
|
+
from vitafit_ble import async_measure
|
|
43
|
+
|
|
44
|
+
async with BleakClient(address) as client:
|
|
45
|
+
measurement = await async_measure(client)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
It returns a `Measurement` with `weight_kg`, `impedance_ohm` and `display_unit`, or `None` if no stable weight arrives within 30 s. Weight is always in kg. `impedance_ohm` is `None` if impedance isn't measured, for example through socks or if you step off early.
|
|
49
|
+
|
|
50
|
+
Pass `weight_only=True` to skip impedance (the Vitafit app's "Weight Only Mode"). The scale keeps this mode for later offline weigh-ins.
|
|
51
|
+
|
|
52
|
+
`VitafitBluetoothDeviceData` wraps the same session for Home Assistant; its `async_poll` passes `weight_only` through.
|
|
53
|
+
|
|
54
|
+
See [usage](docs/usage.md) for details.
|
|
55
|
+
|
|
56
|
+
## Licence
|
|
57
|
+
|
|
58
|
+
MIT. The protocol is based on openScale's
|
|
59
|
+
[VT701 handler](https://github.com/oliexdev/openScale/pull/1423).
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# vitafit-ble
|
|
2
|
+
|
|
3
|
+
Bluetooth library for Vitafit devices. See [supported devices](docs/supported_devices.md) for the models it works with.
|
|
4
|
+
|
|
5
|
+
Unofficial; not affiliated with Vitafit.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install vitafit-ble
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from bleak import BleakClient
|
|
17
|
+
from vitafit_ble import async_measure
|
|
18
|
+
|
|
19
|
+
async with BleakClient(address) as client:
|
|
20
|
+
measurement = await async_measure(client)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
It returns a `Measurement` with `weight_kg`, `impedance_ohm` and `display_unit`, or `None` if no stable weight arrives within 30 s. Weight is always in kg. `impedance_ohm` is `None` if impedance isn't measured, for example through socks or if you step off early.
|
|
24
|
+
|
|
25
|
+
Pass `weight_only=True` to skip impedance (the Vitafit app's "Weight Only Mode"). The scale keeps this mode for later offline weigh-ins.
|
|
26
|
+
|
|
27
|
+
`VitafitBluetoothDeviceData` wraps the same session for Home Assistant; its `async_poll` passes `weight_only` through.
|
|
28
|
+
|
|
29
|
+
See [usage](docs/usage.md) for details.
|
|
30
|
+
|
|
31
|
+
## Licence
|
|
32
|
+
|
|
33
|
+
MIT. The protocol is based on openScale's
|
|
34
|
+
[VT701 handler](https://github.com/oliexdev/openScale/pull/1423).
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.12.21,<0.13"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vitafit-ble"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Bluetooth library for Vitafit devices"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Framework :: AsyncIO",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Home Automation",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"bleak>=1.0",
|
|
22
|
+
"bleak-retry-connector>=4.0",
|
|
23
|
+
"bluetooth-data-tools>=1.28",
|
|
24
|
+
"bluetooth-sensor-state-data>=1.9",
|
|
25
|
+
"habluetooth>=3.42.0",
|
|
26
|
+
"sensor-state-data>=2.20",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[[project.authors]]
|
|
30
|
+
name = "James Myatt"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Documentation = "https://vitafit-ble.readthedocs.io"
|
|
34
|
+
Repository = "https://github.com/jamesmyatt/vitafit-ble"
|
|
35
|
+
"Bug Tracker" = "https://github.com/jamesmyatt/vitafit-ble/issues"
|
|
36
|
+
Changelog = "https://github.com/jamesmyatt/vitafit-ble/blob/main/CHANGELOG.md"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"mypy",
|
|
41
|
+
"pre-commit",
|
|
42
|
+
"pytest",
|
|
43
|
+
"pytest-asyncio",
|
|
44
|
+
"pytest-cov",
|
|
45
|
+
"ruff",
|
|
46
|
+
]
|
|
47
|
+
docs = [
|
|
48
|
+
"furo",
|
|
49
|
+
"myst-parser",
|
|
50
|
+
"sphinx",
|
|
51
|
+
"sphinx-autobuild",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
addopts = "-v -Wdefault --cov=vitafit_ble --cov-report=term-missing:skip-covered"
|
|
56
|
+
asyncio_mode = "auto"
|
|
57
|
+
|
|
58
|
+
[tool.coverage.run]
|
|
59
|
+
branch = true
|
|
60
|
+
|
|
61
|
+
[tool.coverage.report]
|
|
62
|
+
exclude_lines = [
|
|
63
|
+
"pragma: no cover",
|
|
64
|
+
"@overload",
|
|
65
|
+
"if TYPE_CHECKING",
|
|
66
|
+
"raise NotImplementedError",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint]
|
|
70
|
+
select = ["ALL"]
|
|
71
|
+
ignore = [
|
|
72
|
+
"COM812",
|
|
73
|
+
"CPY001",
|
|
74
|
+
"D203",
|
|
75
|
+
"D213",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
[tool.ruff.lint.per-file-ignores]
|
|
79
|
+
"tests/**" = [
|
|
80
|
+
"D",
|
|
81
|
+
"S101",
|
|
82
|
+
"PLR2004",
|
|
83
|
+
"SLF001",
|
|
84
|
+
]
|
|
85
|
+
"docs/conf.py" = [
|
|
86
|
+
"A001",
|
|
87
|
+
"D100",
|
|
88
|
+
"INP001",
|
|
89
|
+
]
|
|
90
|
+
"scripts/**" = ["INP001"]
|
|
91
|
+
|
|
92
|
+
[tool.ruff.lint.isort]
|
|
93
|
+
force-sort-within-sections = true
|
|
94
|
+
combine-as-imports = true
|
|
95
|
+
|
|
96
|
+
[tool.mypy]
|
|
97
|
+
strict = true
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.12.21,<0.13"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vitafit-ble"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Bluetooth library for Vitafit devices"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "James Myatt" }]
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Home Automation",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"bleak>=1.0",
|
|
23
|
+
"bleak-retry-connector>=4.0",
|
|
24
|
+
"bluetooth-data-tools>=1.28",
|
|
25
|
+
"bluetooth-sensor-state-data>=1.9",
|
|
26
|
+
"habluetooth>=3.42.0",
|
|
27
|
+
"sensor-state-data>=2.20",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
"Documentation" = "https://vitafit-ble.readthedocs.io"
|
|
32
|
+
"Repository" = "https://github.com/jamesmyatt/vitafit-ble"
|
|
33
|
+
"Bug Tracker" = "https://github.com/jamesmyatt/vitafit-ble/issues"
|
|
34
|
+
"Changelog" = "https://github.com/jamesmyatt/vitafit-ble/blob/main/CHANGELOG.md"
|
|
35
|
+
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
dev = ["mypy", "pre-commit", "pytest", "pytest-asyncio", "pytest-cov", "ruff"]
|
|
38
|
+
docs = ["furo", "myst-parser", "sphinx", "sphinx-autobuild"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
addopts = "-v -Wdefault --cov=vitafit_ble --cov-report=term-missing:skip-covered"
|
|
42
|
+
asyncio_mode = "auto"
|
|
43
|
+
|
|
44
|
+
[tool.coverage.run]
|
|
45
|
+
branch = true
|
|
46
|
+
|
|
47
|
+
[tool.coverage.report]
|
|
48
|
+
exclude_lines = [
|
|
49
|
+
"pragma: no cover",
|
|
50
|
+
"@overload",
|
|
51
|
+
"if TYPE_CHECKING",
|
|
52
|
+
"raise NotImplementedError",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["ALL"]
|
|
57
|
+
ignore = ["COM812", "CPY001", "D203", "D213"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint.per-file-ignores]
|
|
60
|
+
"tests/**" = ["D", "S101", "PLR2004", "SLF001"]
|
|
61
|
+
"docs/conf.py" = ["A001", "D100", "INP001"]
|
|
62
|
+
"scripts/**" = ["INP001"]
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint.isort]
|
|
65
|
+
force-sort-within-sections = true
|
|
66
|
+
combine-as-imports = true
|
|
67
|
+
|
|
68
|
+
[tool.mypy]
|
|
69
|
+
strict = true
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Bluetooth library for Vitafit devices."""
|
|
2
|
+
|
|
3
|
+
from sensor_state_data import (
|
|
4
|
+
DeviceClass,
|
|
5
|
+
DeviceKey,
|
|
6
|
+
SensorDescription,
|
|
7
|
+
SensorDeviceInfo,
|
|
8
|
+
SensorUpdate,
|
|
9
|
+
SensorValue,
|
|
10
|
+
Units,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from .parser import VitafitBluetoothDeviceData
|
|
14
|
+
from .protocol import DisplayUnit
|
|
15
|
+
from .session import Measurement, async_measure
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DeviceClass",
|
|
19
|
+
"DeviceKey",
|
|
20
|
+
"DisplayUnit",
|
|
21
|
+
"Measurement",
|
|
22
|
+
"SensorDescription",
|
|
23
|
+
"SensorDeviceInfo",
|
|
24
|
+
"SensorUpdate",
|
|
25
|
+
"SensorValue",
|
|
26
|
+
"Units",
|
|
27
|
+
"VitafitBluetoothDeviceData",
|
|
28
|
+
"async_measure",
|
|
29
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Constants for the Vitafit VT701."""
|
|
2
|
+
|
|
3
|
+
#: The scale is recognised by a local name starting with this, e.g. "Vitafit Body Fat".
|
|
4
|
+
LOCAL_NAME_PREFIX = "Vitafit"
|
|
5
|
+
MANUFACTURER = "Vitafit"
|
|
6
|
+
MODEL = "VT701"
|
|
7
|
+
|
|
8
|
+
NOTIFY_CHARACTERISTIC_UUID = "0000fff1-0000-1000-8000-00805f9b34fb"
|
|
9
|
+
WRITE_CHARACTERISTIC_UUID = "0000fff2-0000-1000-8000-00805f9b34fb"
|
|
10
|
+
|
|
11
|
+
#: Seconds to wait for a stable weight.
|
|
12
|
+
WEIGHT_TIMEOUT = 30.0
|
|
13
|
+
#: Seconds to wait for impedance after acknowledging the stable weight.
|
|
14
|
+
IMPEDANCE_TIMEOUT = 10.0
|
|
15
|
+
|
|
16
|
+
#: Minimum seconds between connections, so one weigh-in is read once.
|
|
17
|
+
POLL_INTERVAL = 60.0
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Home Assistant-style device data for the Vitafit VT701."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from bleak.exc import BleakError
|
|
9
|
+
from bleak_retry_connector import BleakClientWithServiceCache, establish_connection
|
|
10
|
+
from bluetooth_data_tools import short_address
|
|
11
|
+
from bluetooth_sensor_state_data import BluetoothData
|
|
12
|
+
from sensor_state_data import SensorLibrary, SensorUpdate
|
|
13
|
+
|
|
14
|
+
from .const import LOCAL_NAME_PREFIX, MANUFACTURER, MODEL, POLL_INTERVAL
|
|
15
|
+
from .session import async_measure
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from bleak.backends.device import BLEDevice
|
|
19
|
+
from habluetooth import BluetoothServiceInfo
|
|
20
|
+
|
|
21
|
+
_LOGGER = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class VitafitBluetoothDeviceData(BluetoothData):
|
|
25
|
+
"""Data for a Vitafit VT701 scale."""
|
|
26
|
+
|
|
27
|
+
def _start_update(self, service_info: BluetoothServiceInfo) -> None:
|
|
28
|
+
"""Update from a BLE advertisement."""
|
|
29
|
+
if not (service_info.name or "").startswith(LOCAL_NAME_PREFIX):
|
|
30
|
+
return
|
|
31
|
+
self.set_device_manufacturer(MANUFACTURER)
|
|
32
|
+
self.set_device_type(MODEL)
|
|
33
|
+
name = f"{MANUFACTURER} {MODEL} {short_address(service_info.address)}"
|
|
34
|
+
self.set_device_name(name)
|
|
35
|
+
self.set_title(name)
|
|
36
|
+
|
|
37
|
+
def poll_needed(
|
|
38
|
+
self,
|
|
39
|
+
service_info: BluetoothServiceInfo, # noqa: ARG002
|
|
40
|
+
last_poll: float | None,
|
|
41
|
+
) -> bool:
|
|
42
|
+
"""Return True if the scale should be connected to.
|
|
43
|
+
|
|
44
|
+
True for the first advertisement, then at most once every
|
|
45
|
+
``POLL_INTERVAL`` (60 s), so one weigh-in is read once. Safe to call on
|
|
46
|
+
every advertisement. The scale only advertises while awake, i.e. after
|
|
47
|
+
being stepped on.
|
|
48
|
+
"""
|
|
49
|
+
return last_poll is None or last_poll > POLL_INTERVAL
|
|
50
|
+
|
|
51
|
+
async def async_poll(
|
|
52
|
+
self,
|
|
53
|
+
ble_device: BLEDevice,
|
|
54
|
+
**measure_kwargs: Any, # noqa: ANN401
|
|
55
|
+
) -> SensorUpdate:
|
|
56
|
+
"""Connect, read one weigh-in and disconnect.
|
|
57
|
+
|
|
58
|
+
The update has ``mass`` and ``impedance`` (``None`` if the scale
|
|
59
|
+
couldn't measure it). If no stable weight arrives, or a Bluetooth error
|
|
60
|
+
occurs, the update has no readings; the error is logged as a warning.
|
|
61
|
+
|
|
62
|
+
``measure_kwargs`` are passed to ``async_measure``, for example
|
|
63
|
+
``weight_only=True``.
|
|
64
|
+
"""
|
|
65
|
+
_LOGGER.debug("Polling Vitafit scale: %s", ble_device.address)
|
|
66
|
+
client = await establish_connection(
|
|
67
|
+
BleakClientWithServiceCache, ble_device, ble_device.address
|
|
68
|
+
)
|
|
69
|
+
try:
|
|
70
|
+
measurement = await async_measure(client, **measure_kwargs)
|
|
71
|
+
except BleakError as err:
|
|
72
|
+
_LOGGER.warning("%s: weigh-in failed: %s", ble_device.address, err)
|
|
73
|
+
return self._finish_update()
|
|
74
|
+
finally:
|
|
75
|
+
await client.disconnect()
|
|
76
|
+
_LOGGER.debug("%s: disconnected", ble_device.address)
|
|
77
|
+
|
|
78
|
+
if measurement is None:
|
|
79
|
+
_LOGGER.debug("%s: no stable weight received", ble_device.address)
|
|
80
|
+
else:
|
|
81
|
+
self.update_predefined_sensor(
|
|
82
|
+
SensorLibrary.MASS__MASS_KILOGRAMS, measurement.weight_kg
|
|
83
|
+
)
|
|
84
|
+
self.update_predefined_sensor(
|
|
85
|
+
SensorLibrary.IMPEDANCE__OHM, measurement.impedance_ohm
|
|
86
|
+
)
|
|
87
|
+
return self._finish_update()
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Frame codec for the Vitafit VT701 GATT protocol.
|
|
2
|
+
|
|
3
|
+
Frames in both directions are ``[header][length][0x26][type][data…][checksum][0xAA]``:
|
|
4
|
+
|
|
5
|
+
- ``header`` is 0x5A from the scale and 0xA5 from the client.
|
|
6
|
+
- ``length`` counts every byte after itself.
|
|
7
|
+
- ``checksum`` is the XOR of ``length`` through the last data byte.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from enum import IntEnum
|
|
14
|
+
from functools import reduce
|
|
15
|
+
import logging
|
|
16
|
+
from operator import xor
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Iterable, Iterator
|
|
21
|
+
|
|
22
|
+
_LOGGER = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
HEADER_SCALE = 0x5A
|
|
25
|
+
HEADER_CLIENT = 0xA5
|
|
26
|
+
PRODUCT_ID = 0x26
|
|
27
|
+
TRAILER = 0xAA
|
|
28
|
+
|
|
29
|
+
MODE_NORMAL = 0x00
|
|
30
|
+
# The Vitafit app's weight-only mode: the scale passes no current and sends
|
|
31
|
+
# impedance 0x55AA.
|
|
32
|
+
MODE_WEIGHT_ONLY = 0x01
|
|
33
|
+
|
|
34
|
+
# Weight frame byte 7 is UNIT_BASE + the current display unit, but the weight
|
|
35
|
+
# itself is always sent in 0.01 kg.
|
|
36
|
+
UNIT_BASE = 0x20
|
|
37
|
+
|
|
38
|
+
STABLE_FLAG = 0x02
|
|
39
|
+
|
|
40
|
+
MIN_FRAME_LENGTH = 6
|
|
41
|
+
|
|
42
|
+
# Impedance outside this range is treated as no reading, as openScale does.
|
|
43
|
+
IMPEDANCE_MIN_OHM = 1
|
|
44
|
+
IMPEDANCE_MAX_OHM = 1499
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FrameType(IntEnum):
|
|
48
|
+
"""Frame type byte, the same in both directions."""
|
|
49
|
+
|
|
50
|
+
WEIGHT = 0x10
|
|
51
|
+
IMPEDANCE = 0x11
|
|
52
|
+
UNIT = 0x17
|
|
53
|
+
"""Sets the scale's display unit; the data byte is a DisplayUnit.
|
|
54
|
+
|
|
55
|
+
It isn't needed for a weigh-in, so the session only sends it when asked to
|
|
56
|
+
set the unit.
|
|
57
|
+
"""
|
|
58
|
+
MODE = 0x33
|
|
59
|
+
"""Sets the measurement mode; the data byte is MODE_NORMAL or MODE_WEIGHT_ONLY.
|
|
60
|
+
|
|
61
|
+
The scale keeps the mode until the next mode command, including for
|
|
62
|
+
weigh-ins without a connection. openScale calls this the first hello.
|
|
63
|
+
"""
|
|
64
|
+
HELLO = 0x44
|
|
65
|
+
"""Unknown request, referred to as the hello command.
|
|
66
|
+
|
|
67
|
+
The scale replies with one data byte of unknown meaning, 0x00 so far.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Total length of each scale-to-client frame type that decode() reads.
|
|
72
|
+
FRAME_LENGTHS: dict[int, int] = {FrameType.WEIGHT: 12, FrameType.IMPEDANCE: 13}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ImpedanceCode(IntEnum):
|
|
76
|
+
"""Impedance values that mean the scale didn't measure it."""
|
|
77
|
+
|
|
78
|
+
FAILED = 0xFFFF
|
|
79
|
+
"""No contact, for example through socks."""
|
|
80
|
+
WEIGHT_ONLY = 0x55AA
|
|
81
|
+
"""The scale is in weight-only mode, so it passed no current."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DisplayUnit(IntEnum):
|
|
85
|
+
"""Unit shown on the scale's display. Readings are always sent in kg."""
|
|
86
|
+
|
|
87
|
+
KG = 1
|
|
88
|
+
LB = 2
|
|
89
|
+
ST = 3
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class WeightFrame:
|
|
94
|
+
"""Live weight reading."""
|
|
95
|
+
|
|
96
|
+
weight_kg: float
|
|
97
|
+
"""Weight in kg, to 0.01 kg, whatever the display unit."""
|
|
98
|
+
stable: bool
|
|
99
|
+
"""True once the reading has settled."""
|
|
100
|
+
display_unit: DisplayUnit | None
|
|
101
|
+
"""Unit the scale is displaying; None if the unit code is unknown."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class ImpedanceFrame:
|
|
106
|
+
"""Whole-body impedance reading."""
|
|
107
|
+
|
|
108
|
+
impedance_ohm: int | None
|
|
109
|
+
"""Impedance in Ω; None if the scale couldn't measure it, e.g. through socks."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _checksum(data: Iterable[int]) -> int:
|
|
113
|
+
return reduce(xor, data, 0)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def encode_command(frame_type: FrameType, data: bytes = b"") -> bytes:
|
|
117
|
+
"""Build a client-to-scale command frame."""
|
|
118
|
+
body = (len(data) + 4, PRODUCT_ID, frame_type, *data)
|
|
119
|
+
return bytes((HEADER_CLIENT, *body, _checksum(body), TRAILER))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def unit_command(unit: DisplayUnit) -> bytes:
|
|
123
|
+
"""Build the command that sets the scale's display unit."""
|
|
124
|
+
return encode_command(FrameType.UNIT, bytes((unit,)))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def start_commands(
|
|
128
|
+
*, weight_only: bool = False, display_unit: DisplayUnit | None = None
|
|
129
|
+
) -> Iterator[bytes]:
|
|
130
|
+
"""Yield the commands sent at the start of a weigh-in.
|
|
131
|
+
|
|
132
|
+
The scale starts the weigh-in by itself when it wakes; these set it up.
|
|
133
|
+
|
|
134
|
+
The mode command, then the hello command, then the unit command if
|
|
135
|
+
``display_unit`` is given.
|
|
136
|
+
"""
|
|
137
|
+
mode = MODE_WEIGHT_ONLY if weight_only else MODE_NORMAL
|
|
138
|
+
yield encode_command(FrameType.MODE, bytes((mode,)))
|
|
139
|
+
yield encode_command(FrameType.HELLO)
|
|
140
|
+
if display_unit is not None:
|
|
141
|
+
yield unit_command(display_unit)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
ACK_STABLE_WEIGHT = encode_command(FrameType.WEIGHT, bytes((STABLE_FLAG,)))
|
|
145
|
+
ACK_IMPEDANCE = encode_command(FrameType.IMPEDANCE, b"\x00")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _is_valid(frame: bytes) -> bool:
|
|
149
|
+
if not (
|
|
150
|
+
len(frame) >= MIN_FRAME_LENGTH
|
|
151
|
+
and frame[0] == HEADER_SCALE
|
|
152
|
+
and frame[1] == len(frame) - 2
|
|
153
|
+
and frame[2] == PRODUCT_ID
|
|
154
|
+
and frame[-1] == TRAILER
|
|
155
|
+
and _checksum(frame[1:-2]) == frame[-2]
|
|
156
|
+
):
|
|
157
|
+
return False
|
|
158
|
+
expected = FRAME_LENGTHS.get(frame[3])
|
|
159
|
+
if expected is not None and len(frame) != expected:
|
|
160
|
+
_LOGGER.debug(
|
|
161
|
+
"%s frame has wrong length: %s",
|
|
162
|
+
FrameType(frame[3]).name.capitalize(),
|
|
163
|
+
frame.hex(" "),
|
|
164
|
+
)
|
|
165
|
+
return False
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _decode_weight(frame: bytes) -> WeightFrame:
|
|
170
|
+
raw = int.from_bytes(frame[8:10], "big")
|
|
171
|
+
try:
|
|
172
|
+
display_unit: DisplayUnit | None = DisplayUnit(frame[7] - UNIT_BASE)
|
|
173
|
+
except ValueError:
|
|
174
|
+
display_unit = None
|
|
175
|
+
return WeightFrame(
|
|
176
|
+
weight_kg=raw / 100,
|
|
177
|
+
stable=frame[4] == STABLE_FLAG,
|
|
178
|
+
display_unit=display_unit,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _decode_impedance(frame: bytes) -> ImpedanceFrame:
|
|
183
|
+
ohm = int.from_bytes(frame[9:11], "big")
|
|
184
|
+
match ohm:
|
|
185
|
+
case ImpedanceCode.FAILED:
|
|
186
|
+
_LOGGER.debug("Impedance not measured: no contact, e.g. socks")
|
|
187
|
+
case ImpedanceCode.WEIGHT_ONLY:
|
|
188
|
+
_LOGGER.debug("Impedance not measured: weight-only mode")
|
|
189
|
+
case _ if IMPEDANCE_MIN_OHM <= ohm <= IMPEDANCE_MAX_OHM:
|
|
190
|
+
return ImpedanceFrame(impedance_ohm=ohm)
|
|
191
|
+
case _:
|
|
192
|
+
_LOGGER.debug("Impedance out of range: %s Ω", ohm)
|
|
193
|
+
# Still a valid frame, unlike None, so the session acks it and stops waiting.
|
|
194
|
+
return ImpedanceFrame(impedance_ohm=None)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def decode(frame: bytes) -> WeightFrame | ImpedanceFrame | None:
|
|
198
|
+
"""Decode a scale-to-client frame; return None if invalid or unrecognised."""
|
|
199
|
+
if not _is_valid(frame):
|
|
200
|
+
return None
|
|
201
|
+
match frame[3]:
|
|
202
|
+
case FrameType.WEIGHT:
|
|
203
|
+
return _decode_weight(frame)
|
|
204
|
+
case FrameType.IMPEDANCE:
|
|
205
|
+
return _decode_impedance(frame)
|
|
206
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Measurement session over an open GATT connection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import logging
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from .const import (
|
|
11
|
+
IMPEDANCE_TIMEOUT,
|
|
12
|
+
NOTIFY_CHARACTERISTIC_UUID,
|
|
13
|
+
WEIGHT_TIMEOUT,
|
|
14
|
+
WRITE_CHARACTERISTIC_UUID,
|
|
15
|
+
)
|
|
16
|
+
from .protocol import (
|
|
17
|
+
ACK_IMPEDANCE,
|
|
18
|
+
ACK_STABLE_WEIGHT,
|
|
19
|
+
ImpedanceFrame,
|
|
20
|
+
WeightFrame,
|
|
21
|
+
decode,
|
|
22
|
+
start_commands,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from bleak import BleakClient
|
|
27
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
|
28
|
+
|
|
29
|
+
from .protocol import DisplayUnit
|
|
30
|
+
|
|
31
|
+
_LOGGER = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class Measurement:
|
|
36
|
+
"""Result of one weigh-in."""
|
|
37
|
+
|
|
38
|
+
weight_kg: float
|
|
39
|
+
"""Stable weight in kg, to 0.01 kg."""
|
|
40
|
+
impedance_ohm: int | None
|
|
41
|
+
"""Whole-body impedance in Ω.
|
|
42
|
+
|
|
43
|
+
None if the scale couldn't measure it, for example through socks, or didn't
|
|
44
|
+
report it in time, for example when the user stepped off early.
|
|
45
|
+
"""
|
|
46
|
+
display_unit: DisplayUnit | None
|
|
47
|
+
"""Unit the scale was displaying, from the stable weight frame.
|
|
48
|
+
|
|
49
|
+
None if the unit code is unknown.
|
|
50
|
+
|
|
51
|
+
``weight_kg`` is in kg whatever the display unit.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _async_next_frame(
|
|
56
|
+
frames: asyncio.Queue[bytes],
|
|
57
|
+
) -> WeightFrame | ImpedanceFrame | None:
|
|
58
|
+
data = await frames.get()
|
|
59
|
+
frame = decode(data)
|
|
60
|
+
_LOGGER.debug("Received %s: %s", data.hex(" "), frame)
|
|
61
|
+
return frame
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _async_stable_weight(frames: asyncio.Queue[bytes]) -> WeightFrame:
|
|
65
|
+
while True:
|
|
66
|
+
frame = await _async_next_frame(frames)
|
|
67
|
+
if isinstance(frame, WeightFrame) and frame.stable:
|
|
68
|
+
return frame
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _async_impedance(frames: asyncio.Queue[bytes]) -> ImpedanceFrame:
|
|
72
|
+
while True:
|
|
73
|
+
frame = await _async_next_frame(frames)
|
|
74
|
+
if isinstance(frame, ImpedanceFrame):
|
|
75
|
+
return frame
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def async_measure(
|
|
79
|
+
client: BleakClient,
|
|
80
|
+
*,
|
|
81
|
+
weight_timeout: float = WEIGHT_TIMEOUT,
|
|
82
|
+
impedance_timeout: float = IMPEDANCE_TIMEOUT,
|
|
83
|
+
display_unit: DisplayUnit | None = None,
|
|
84
|
+
weight_only: bool = False,
|
|
85
|
+
) -> Measurement | None:
|
|
86
|
+
"""Run one weigh-in; return None if no stable weight arrives in time.
|
|
87
|
+
|
|
88
|
+
Impedance is None if the scale can't measure it, for example through socks,
|
|
89
|
+
or doesn't report it in time, for example when the user steps off early.
|
|
90
|
+
|
|
91
|
+
With ``display_unit``, set the scale's display unit after the mode and hello
|
|
92
|
+
commands. Without it, the scale keeps its display unit. The weight is always
|
|
93
|
+
in kg.
|
|
94
|
+
|
|
95
|
+
With ``weight_only``, select the scale's weight-only mode, which passes no
|
|
96
|
+
current, so impedance is None. Without it, select the normal mode. The
|
|
97
|
+
scale keeps the mode for later weigh-ins without a connection.
|
|
98
|
+
"""
|
|
99
|
+
frames: asyncio.Queue[bytes] = asyncio.Queue()
|
|
100
|
+
|
|
101
|
+
def _on_notify(_: BleakGATTCharacteristic, data: bytearray) -> None:
|
|
102
|
+
frames.put_nowait(bytes(data))
|
|
103
|
+
|
|
104
|
+
async def _async_write(command: bytes) -> None:
|
|
105
|
+
_LOGGER.debug("Sending %s", command.hex(" "))
|
|
106
|
+
await client.write_gatt_char(WRITE_CHARACTERISTIC_UUID, command, response=True)
|
|
107
|
+
|
|
108
|
+
await client.start_notify(NOTIFY_CHARACTERISTIC_UUID, _on_notify)
|
|
109
|
+
for command in start_commands(weight_only=weight_only, display_unit=display_unit):
|
|
110
|
+
await _async_write(command)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
async with asyncio.timeout(weight_timeout):
|
|
114
|
+
weight = await _async_stable_weight(frames)
|
|
115
|
+
except TimeoutError:
|
|
116
|
+
_LOGGER.debug("No stable weight within %s s", weight_timeout)
|
|
117
|
+
return None
|
|
118
|
+
else:
|
|
119
|
+
# openScale says acknowledging the stable weight starts the impedance
|
|
120
|
+
# measurement. Untested: no normal-mode weigh-in has skipped it.
|
|
121
|
+
await _async_write(ACK_STABLE_WEIGHT)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
async with asyncio.timeout(impedance_timeout):
|
|
125
|
+
impedance = await _async_impedance(frames)
|
|
126
|
+
except TimeoutError:
|
|
127
|
+
_LOGGER.debug(
|
|
128
|
+
"No impedance within %s s; returning weight only", impedance_timeout
|
|
129
|
+
)
|
|
130
|
+
impedance_ohm = None
|
|
131
|
+
else:
|
|
132
|
+
await _async_write(ACK_IMPEDANCE)
|
|
133
|
+
impedance_ohm = impedance.impedance_ohm
|
|
134
|
+
|
|
135
|
+
return Measurement(
|
|
136
|
+
weight_kg=weight.weight_kg,
|
|
137
|
+
impedance_ohm=impedance_ohm,
|
|
138
|
+
display_unit=weight.display_unit,
|
|
139
|
+
)
|