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.
@@ -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
+ )