uplift-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) 2025 Eric McDonald
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,266 @@
1
+ Metadata-Version: 2.4
2
+ Name: uplift-ble
3
+ Version: 0.1.0
4
+ Summary: Python library for controlling Uplift standing desks over Bluetooth Low Energy via the official Uplift BLE adapter
5
+ Author: Eric McDonald
6
+ Author-email: Eric McDonald <106356608+librick@users.noreply.github.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: AsyncIO
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: Microsoft :: Windows :: Windows 10
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Operating System :: MacOS :: MacOS X
15
+ Requires-Dist: bleak>=1.0.1
16
+ Requires-Dist: typer>=0.16.0
17
+ Requires-Dist: pytest>=8.4.1 ; extra == 'test'
18
+ Requires-Python: >=3.11
19
+ Project-URL: Homepage, https://github.com/librick/uplift_ble
20
+ Project-URL: Issues, https://github.com/librick/uplift_ble/issues
21
+ Provides-Extra: test
22
+ Description-Content-Type: text/markdown
23
+
24
+ ![Made with Python](https://img.shields.io/badge/Made%20with-Python-3776AB.svg)
25
+ ![PyPI - Version](https://img.shields.io/pypi/v/uplift-ble)
26
+ ![GitHub License](https://img.shields.io/github/license/librick/uplift-ble)
27
+ [![GitHub issues](https://img.shields.io/github/issues/librick/uplift-ble.svg)](https://github.com/librick/uplift-ble/issues)
28
+ ![GitHub Repo stars](https://img.shields.io/github/stars/librick/uplift-ble)
29
+
30
+ # uplift-ble
31
+
32
+ Unofficial Python library for controlling Uplift standing desks over Bluetooth Low Energy via the [Uplift BLE adapter](https://www.upliftdesk.com/bluetooth-adapter-for-uplift-desk/).
33
+
34
+ Benefits:
35
+ - Cross platform (made possible by [Bleak](https://github.com/hbldh/bleak))
36
+ - Asynchronous API
37
+ - Many supported commands, reverse-engineered directly from the Uplift Android app
38
+ - Modern logging via Python's built-in logging module
39
+ - Minimal dependencies
40
+
41
+ *This library is unofficial and is NOT affiliated with the company that makes UPLIFT desks.*
42
+
43
+ ⚠️ **WARNING** ⚠️
44
+
45
+ This software controls the movement of large heavy things. Do NOT run this code if you are in the vicinity of a desk that you do NOT want to move, or even *suspect* that you *could* be in the vicinity of such a desk. No authentication is required to send commands to an Uplift BLE adapter; any person (or machine) with this code who is within range of an adapter can issue commands to the adapter and move the attached desk. That means if, for example, you run this code in your office full of standing desks, you could injure people or damage property.
46
+
47
+ **This software is provided “as‑is” without warranties of any kind, express or implied. The authors and maintainers are not responsible for any damage, injury, or malfunction that may result from using this software to control your desk or any other hardware. By using this tool, you agree to assume all risks and liabilities.**
48
+
49
+ ## Usage
50
+
51
+ The uplift-ble module includes two main classes, `DeskScanner` and `Desk`. Usage example:
52
+
53
+ ```python
54
+ import asyncio
55
+ import logging
56
+ import sys
57
+
58
+ from uplift_ble.desk import Desk
59
+ from uplift_ble.scanner import DeskScanner
60
+
61
+
62
+ async def main():
63
+ # The `DeskScanner` class is used to discover nearby desk adapters and their Bluetooth MAC addresses.
64
+ addresses = await DeskScanner.discover()
65
+
66
+ if len(addresses) == 0:
67
+ print("Error: no desks found.", file=sys.stderr)
68
+ sys.exit(1)
69
+ elif len(addresses) > 1:
70
+ print("Error: multiple desks found. Exiting for safety.", file=sys.stderr)
71
+ sys.exit(1)
72
+
73
+ # The `Desk` class represents a desk and supports the async context manager API.
74
+ async with Desk(address=addresses[0]) as desk:
75
+ height_before_move = await desk.get_current_height()
76
+ await desk.move_up()
77
+ height_after_move = await desk.get_current_height()
78
+ print(
79
+ f"Moved desk from height of {height_before_move} mm to {height_after_move} mm"
80
+ )
81
+
82
+ print("Done.")
83
+
84
+
85
+ def _configure_logging() -> None:
86
+ logging.basicConfig(
87
+ level=logging.INFO,
88
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
89
+ datefmt="%H:%M:%S",
90
+ stream=sys.stdout,
91
+ )
92
+
93
+
94
+ if __name__ == "__main__":
95
+ _configure_logging()
96
+ asyncio.run(main())
97
+
98
+ ```
99
+
100
+
101
+ ## CLI Utility (uplift_ble_cli.py)
102
+
103
+ ### Prerequisites
104
+
105
+ * Python 3.11 or higher
106
+ * Git
107
+
108
+ ### Installation
109
+
110
+ These instructions are intended for Linux users but are likely broadly applicable.
111
+
112
+ 1. **Clone the Repository**
113
+ ```bash
114
+ git clone git@github.com:librick/uplift-ble.git
115
+ cd uplift-ble
116
+ ```
117
+ 2. **Create & Activate Virtual Environment**
118
+ ```bash
119
+ python3 -m venv venv
120
+ source venv/bin/activate
121
+ ```
122
+ 3. **Verify Virtual Environment**
123
+ ```bash
124
+ which python3
125
+ ```
126
+ 4. **Install Dependencies**
127
+ ```bash
128
+ python3 -m pip install .
129
+ ```
130
+ 5. **Install Test Dependencies and Run Tests (Optional)**
131
+ ```bash
132
+ python3 -m pip install -e ".[test]"
133
+ python3 -m pytest -v
134
+ ```
135
+ 6. **Run the CLI**
136
+ ```bash
137
+ python3 scripts/uplift_ble_cli.py --help
138
+ ```
139
+
140
+ ### CLI Examples
141
+
142
+ Display help and available commands:
143
+ ```bash
144
+ python3 scripts/uplift_ble_cli.py --help
145
+ ```
146
+
147
+ Find nearby desks:
148
+ ```bash
149
+ python3 scripts/uplift_ble_cli.py discover
150
+ ```
151
+
152
+ Get the current desk height:
153
+ ```bash
154
+ python3 scripts/uplift_ble_cli.py get-current-height
155
+ ```
156
+
157
+ Move desk to a specified height (e.g., 22 inches, approximately 610 millimeters):
158
+ ```bash
159
+ python3 scripts/uplift_ble_cli.py move-to-specified-height 610
160
+ ```
161
+
162
+ ## Reverse Engineering
163
+ Valid desk commands were discovered by some combination of the following techniques:
164
+ - Reverse-engineered from the source code of the [Uplift Desk App](https://play.google.com/store/apps/details?id=app.android.uplifts&hl=en_US) on Google Play
165
+ - Discovered by brute-force search of vendor-specific opcodes against an actual desk
166
+ - Referenced from existing work from Bennet Wendorf's [uplift-desk-controller](https://github.com/Bennett-Wendorf/uplift-desk-controller) repo
167
+
168
+ ## Protocol
169
+
170
+ The [Uplift Desk Bluetooth adapter](https://www.upliftdesk.com/bluetooth-adapter-for-uplift-desk/) uses a proprietary byte-oriented protocol over the Bluetooth Low Energy (BLE) Generic Attribute Profile (GATT). There are two vendor-defined characteristics: one for sending commands to the Bluetooth adapter (`0xFE61`) and one on which notifications are raised such that clients can receive information from the Bluetooth adapter (`0xFE62`).
171
+
172
+ | GATT Characteristic | Purpose |
173
+ | ------------------- | ------------------------------------------------------------------- |
174
+ | 0xFE61 | Desk control. Clients write to this to send commands to the server. |
175
+ | 0xFE62 | Desk output. The server sends notifications on this for clients. |
176
+
177
+ ### Attribute Value Format
178
+ All attribute values sent to `0xFE61` (commands) and received from `0xFE62` (notifications) follow the same byte-oriented format. Each attribute value consists of two sync bytes (`0xF1F1` for commands, `0xF2F2` for notifications), an opcode byte, a length byte, an optional payload, a checksum byte, and a terminator byte (always `0x7E`).
179
+
180
+ #### Attribute Value Format, Commands:
181
+ ```txt
182
+ 0xF1 → sync byte, command packet, (1 of 2 bytes)
183
+ 0xF1 → sync byte, command packet, (2 of 2 bytes)
184
+ 0xXX → opcode (1 byte)
185
+ 0xYY → length (1 byte)
186
+ 0x.. → payload (0xYY byte(s))
187
+ 0xZZ → (opcode + length + sum of all payload bytes) mod 256
188
+ 0x7E → terminator (1 byte)
189
+ ```
190
+
191
+ #### Attribute Value Format, Notifications:
192
+ ```txt
193
+ 0xF2 → sync byte, command packet, (1 of 2 bytes)
194
+ 0xF2 → sync byte, command packet, (2 of 2 bytes)
195
+ 0xXX → opcode (1 byte)
196
+ 0xYY → length (1 byte)
197
+ 0x.. → payload (0xYY byte(s))
198
+ 0xZZ → (opcode + length + sum of all payload bytes) mod 256
199
+ 0x7E → terminator (1 byte)
200
+ ```
201
+
202
+ ### Known Commands
203
+
204
+ | Opcode | Length | Attribute Value | Purpose |
205
+ | ------ | ------ | ----------------------------------------- | -------------------------------------- |
206
+ | 0x01 | 0 | `0xF1,0xF1,0x01,0x00,0x01,0x7E` | Move desk up |
207
+ | 0x02 | 0 | `0xF1,0xF1,0x02,0x00,0x02,0x7E` | Move desk down |
208
+ | 0x07 | 0 | `0xF1,0xF1,0x07,0x00,0x07,0x7E` | Request height limits |
209
+ | 0x10 | 2 | `0xF1,0xF1,0x10,0x02,0xCA,0xFE,0xDB,0x7E` | Set calibration offset |
210
+ | 0x11 | 2 | `0xF1,0xF1,0x11,0x02,0xCA,0xFE,0xDC,0x7E` | Set height limit max |
211
+ | 0x1B | 2 | `0xF1,0xF1,0x1B,0x02,0xCA,0xFE,0xE6,0x7E` | Move to specified height |
212
+ | 0x21 | 0 | `0xF1,0xF1,0x21,0x00,0x21,0x7E` | Set current height as height limit max |
213
+ | 0x22 | 0 | `0xF1,0xF1,0x22,0x00,0x22,0x7E` | Set current height as height limit min |
214
+ | 0x23 | 1 | `0xF1,0xF1,0x23,0x01,0x01,0x25,0x7E` | Clear height limit max |
215
+ | 0x23 | 1 | `0xF1,0xF1,0x23,0x01,0x02,0x26,0x7E` | Clear height limit min |
216
+ | 0x2B | 0 | `0xF1,0xF1,0x2B,0x00,0x2B,0x7E` | Stop movement |
217
+ | 0x0E | 1 | `0xF1,0xF1,0x0E,0x01,0x00,0x0F,0x7E` | Set units to centimeters |
218
+ | 0x0E | 1 | `0xF1,0xF1,0x0E,0x01,0x01,0x10,0x7E` | Set units to inches |
219
+ | 0xFE | 0 | `0xF1,0xF1,0xFE,0x00,0xFE,0x7E` | Reset |
220
+
221
+ Some of commands above were found by reverse-engineering the Uplift app (v1.1.0) using tools such as JADX. Specifically, the authors read through the .java code for the activities within the `com.jiecang.app.android.aidesk` namespace. Other commands were found by exhaustive search over the range of all opcodes.
222
+
223
+ ### Known Notifications
224
+
225
+ | Opcode | Payload Length | Purpose | Factory Value (taken from V2-Commercial model) |
226
+ |--------|----------------|-------------------------------------------------------------------------|------------------------------------------------|
227
+ | 0x01 | 3 | Reports the height of the desk in 0.01 mm (10 µm) increments. | |
228
+ | 0x10 | 2 | Reports the calibration offset in millimeters (2‑byte, big‑endian). | `572` |
229
+ | 0x04 | 0 | Seen when the desk is in an error state and the display shows **ASR**. | N/A |
230
+ | 0x11 | 2 | Reports the max height limit in millimeters (2‑byte, big‑endian). | `671` |
231
+
232
+ Most notification packets seem to have opcodes that match the opcode of an associated command packet.
233
+ For example, sending the command packet with opcode=0x01 triggers a notification packet with the same opcode,
234
+ containing the desk's current height.
235
+
236
+ **There are many notification packets whose opcodes and payload structures are unknown. PRs are welcome!**
237
+
238
+ ### Explaining the Calibration Offset
239
+ The calibration offset adds a fixed offset to the height of the desk.
240
+ This table summarizes some examples of what the Desk's display shows for various calibration offsets when the desk is at its lowest point.
241
+
242
+ | Calibration Offset (mm) | Desk Unit | Display Reading |
243
+ |-------------------------|-----------|----------------------------------------------------------------------|
244
+ | 0 | inches | 0.01 |
245
+ | 254 | inches | 10.1 |
246
+ | 508 | inches | 20.1 |
247
+ | 762 | inches | 30.1 |
248
+ | 2537 | inches | 100 |
249
+ | 25396 | inches | 999 |
250
+ | 25397 | inches | *Weird state!* Display shows **ASR** but desk can still move. |
251
+ | 65535 | inches | *Weird state!* Display shows **ASR** but desk can still move. |
252
+
253
+
254
+ ## Security of the Uplift BLE Adapter
255
+ The Bluetooth adapter allows unauthenticated GATT commands to be sent to it (no pairing or encryption required). The Uplift app itself allows you to discover and connect to nearby desks without, for example, a pairing code.
256
+
257
+ The author thinks this is a bad idea. A malicious actor could easily write code (or use a library such as this one) to scan for nearby desks, connect to them without any explicit authorization, and either soft-brick them through a series of commands designed to make the desk impossible to move via the app or physical controller (see example below), or move desks when people do not intend for them to be moved.
258
+
259
+ ## Contributors
260
+ [![Contributors](https://contrib.rocks/image?repo=librick/uplift-ble)](https://github.com/librick/uplift-ble/graphs/contributors)
261
+
262
+ ## Prior Work
263
+ This project builds on the prior work of Bennett Wendorf's [uplift-desk-controller](https://github.com/Bennett-Wendorf/uplift-desk-controller) repo. In addition to publishing a Python library, they also authored a Home Assistant integration, [hass-uplift-desk](https://github.com/Bennett-Wendorf/hass-uplift-desk). The uplift-ble library was originally intended to be a fork of Bennett's repo, but grew in scope to be a standalone library. I am thankful for Bennett's work and contributions to open source software.
264
+
265
+ ## Legal
266
+ This project is an **unofficial project** and is **NOT** endorsed by nor affiliated with the company that makes UPLIFT desks. We make no claims to the trademarks or intellectual property of the UPLIFT company. All code in this repo is written independently of UPLIFT and is MIT licensed. Any vendor-specific information used in this code is discovered through reverse-engineering publicly available information.
@@ -0,0 +1,243 @@
1
+ ![Made with Python](https://img.shields.io/badge/Made%20with-Python-3776AB.svg)
2
+ ![PyPI - Version](https://img.shields.io/pypi/v/uplift-ble)
3
+ ![GitHub License](https://img.shields.io/github/license/librick/uplift-ble)
4
+ [![GitHub issues](https://img.shields.io/github/issues/librick/uplift-ble.svg)](https://github.com/librick/uplift-ble/issues)
5
+ ![GitHub Repo stars](https://img.shields.io/github/stars/librick/uplift-ble)
6
+
7
+ # uplift-ble
8
+
9
+ Unofficial Python library for controlling Uplift standing desks over Bluetooth Low Energy via the [Uplift BLE adapter](https://www.upliftdesk.com/bluetooth-adapter-for-uplift-desk/).
10
+
11
+ Benefits:
12
+ - Cross platform (made possible by [Bleak](https://github.com/hbldh/bleak))
13
+ - Asynchronous API
14
+ - Many supported commands, reverse-engineered directly from the Uplift Android app
15
+ - Modern logging via Python's built-in logging module
16
+ - Minimal dependencies
17
+
18
+ *This library is unofficial and is NOT affiliated with the company that makes UPLIFT desks.*
19
+
20
+ ⚠️ **WARNING** ⚠️
21
+
22
+ This software controls the movement of large heavy things. Do NOT run this code if you are in the vicinity of a desk that you do NOT want to move, or even *suspect* that you *could* be in the vicinity of such a desk. No authentication is required to send commands to an Uplift BLE adapter; any person (or machine) with this code who is within range of an adapter can issue commands to the adapter and move the attached desk. That means if, for example, you run this code in your office full of standing desks, you could injure people or damage property.
23
+
24
+ **This software is provided “as‑is” without warranties of any kind, express or implied. The authors and maintainers are not responsible for any damage, injury, or malfunction that may result from using this software to control your desk or any other hardware. By using this tool, you agree to assume all risks and liabilities.**
25
+
26
+ ## Usage
27
+
28
+ The uplift-ble module includes two main classes, `DeskScanner` and `Desk`. Usage example:
29
+
30
+ ```python
31
+ import asyncio
32
+ import logging
33
+ import sys
34
+
35
+ from uplift_ble.desk import Desk
36
+ from uplift_ble.scanner import DeskScanner
37
+
38
+
39
+ async def main():
40
+ # The `DeskScanner` class is used to discover nearby desk adapters and their Bluetooth MAC addresses.
41
+ addresses = await DeskScanner.discover()
42
+
43
+ if len(addresses) == 0:
44
+ print("Error: no desks found.", file=sys.stderr)
45
+ sys.exit(1)
46
+ elif len(addresses) > 1:
47
+ print("Error: multiple desks found. Exiting for safety.", file=sys.stderr)
48
+ sys.exit(1)
49
+
50
+ # The `Desk` class represents a desk and supports the async context manager API.
51
+ async with Desk(address=addresses[0]) as desk:
52
+ height_before_move = await desk.get_current_height()
53
+ await desk.move_up()
54
+ height_after_move = await desk.get_current_height()
55
+ print(
56
+ f"Moved desk from height of {height_before_move} mm to {height_after_move} mm"
57
+ )
58
+
59
+ print("Done.")
60
+
61
+
62
+ def _configure_logging() -> None:
63
+ logging.basicConfig(
64
+ level=logging.INFO,
65
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
66
+ datefmt="%H:%M:%S",
67
+ stream=sys.stdout,
68
+ )
69
+
70
+
71
+ if __name__ == "__main__":
72
+ _configure_logging()
73
+ asyncio.run(main())
74
+
75
+ ```
76
+
77
+
78
+ ## CLI Utility (uplift_ble_cli.py)
79
+
80
+ ### Prerequisites
81
+
82
+ * Python 3.11 or higher
83
+ * Git
84
+
85
+ ### Installation
86
+
87
+ These instructions are intended for Linux users but are likely broadly applicable.
88
+
89
+ 1. **Clone the Repository**
90
+ ```bash
91
+ git clone git@github.com:librick/uplift-ble.git
92
+ cd uplift-ble
93
+ ```
94
+ 2. **Create & Activate Virtual Environment**
95
+ ```bash
96
+ python3 -m venv venv
97
+ source venv/bin/activate
98
+ ```
99
+ 3. **Verify Virtual Environment**
100
+ ```bash
101
+ which python3
102
+ ```
103
+ 4. **Install Dependencies**
104
+ ```bash
105
+ python3 -m pip install .
106
+ ```
107
+ 5. **Install Test Dependencies and Run Tests (Optional)**
108
+ ```bash
109
+ python3 -m pip install -e ".[test]"
110
+ python3 -m pytest -v
111
+ ```
112
+ 6. **Run the CLI**
113
+ ```bash
114
+ python3 scripts/uplift_ble_cli.py --help
115
+ ```
116
+
117
+ ### CLI Examples
118
+
119
+ Display help and available commands:
120
+ ```bash
121
+ python3 scripts/uplift_ble_cli.py --help
122
+ ```
123
+
124
+ Find nearby desks:
125
+ ```bash
126
+ python3 scripts/uplift_ble_cli.py discover
127
+ ```
128
+
129
+ Get the current desk height:
130
+ ```bash
131
+ python3 scripts/uplift_ble_cli.py get-current-height
132
+ ```
133
+
134
+ Move desk to a specified height (e.g., 22 inches, approximately 610 millimeters):
135
+ ```bash
136
+ python3 scripts/uplift_ble_cli.py move-to-specified-height 610
137
+ ```
138
+
139
+ ## Reverse Engineering
140
+ Valid desk commands were discovered by some combination of the following techniques:
141
+ - Reverse-engineered from the source code of the [Uplift Desk App](https://play.google.com/store/apps/details?id=app.android.uplifts&hl=en_US) on Google Play
142
+ - Discovered by brute-force search of vendor-specific opcodes against an actual desk
143
+ - Referenced from existing work from Bennet Wendorf's [uplift-desk-controller](https://github.com/Bennett-Wendorf/uplift-desk-controller) repo
144
+
145
+ ## Protocol
146
+
147
+ The [Uplift Desk Bluetooth adapter](https://www.upliftdesk.com/bluetooth-adapter-for-uplift-desk/) uses a proprietary byte-oriented protocol over the Bluetooth Low Energy (BLE) Generic Attribute Profile (GATT). There are two vendor-defined characteristics: one for sending commands to the Bluetooth adapter (`0xFE61`) and one on which notifications are raised such that clients can receive information from the Bluetooth adapter (`0xFE62`).
148
+
149
+ | GATT Characteristic | Purpose |
150
+ | ------------------- | ------------------------------------------------------------------- |
151
+ | 0xFE61 | Desk control. Clients write to this to send commands to the server. |
152
+ | 0xFE62 | Desk output. The server sends notifications on this for clients. |
153
+
154
+ ### Attribute Value Format
155
+ All attribute values sent to `0xFE61` (commands) and received from `0xFE62` (notifications) follow the same byte-oriented format. Each attribute value consists of two sync bytes (`0xF1F1` for commands, `0xF2F2` for notifications), an opcode byte, a length byte, an optional payload, a checksum byte, and a terminator byte (always `0x7E`).
156
+
157
+ #### Attribute Value Format, Commands:
158
+ ```txt
159
+ 0xF1 → sync byte, command packet, (1 of 2 bytes)
160
+ 0xF1 → sync byte, command packet, (2 of 2 bytes)
161
+ 0xXX → opcode (1 byte)
162
+ 0xYY → length (1 byte)
163
+ 0x.. → payload (0xYY byte(s))
164
+ 0xZZ → (opcode + length + sum of all payload bytes) mod 256
165
+ 0x7E → terminator (1 byte)
166
+ ```
167
+
168
+ #### Attribute Value Format, Notifications:
169
+ ```txt
170
+ 0xF2 → sync byte, command packet, (1 of 2 bytes)
171
+ 0xF2 → sync byte, command packet, (2 of 2 bytes)
172
+ 0xXX → opcode (1 byte)
173
+ 0xYY → length (1 byte)
174
+ 0x.. → payload (0xYY byte(s))
175
+ 0xZZ → (opcode + length + sum of all payload bytes) mod 256
176
+ 0x7E → terminator (1 byte)
177
+ ```
178
+
179
+ ### Known Commands
180
+
181
+ | Opcode | Length | Attribute Value | Purpose |
182
+ | ------ | ------ | ----------------------------------------- | -------------------------------------- |
183
+ | 0x01 | 0 | `0xF1,0xF1,0x01,0x00,0x01,0x7E` | Move desk up |
184
+ | 0x02 | 0 | `0xF1,0xF1,0x02,0x00,0x02,0x7E` | Move desk down |
185
+ | 0x07 | 0 | `0xF1,0xF1,0x07,0x00,0x07,0x7E` | Request height limits |
186
+ | 0x10 | 2 | `0xF1,0xF1,0x10,0x02,0xCA,0xFE,0xDB,0x7E` | Set calibration offset |
187
+ | 0x11 | 2 | `0xF1,0xF1,0x11,0x02,0xCA,0xFE,0xDC,0x7E` | Set height limit max |
188
+ | 0x1B | 2 | `0xF1,0xF1,0x1B,0x02,0xCA,0xFE,0xE6,0x7E` | Move to specified height |
189
+ | 0x21 | 0 | `0xF1,0xF1,0x21,0x00,0x21,0x7E` | Set current height as height limit max |
190
+ | 0x22 | 0 | `0xF1,0xF1,0x22,0x00,0x22,0x7E` | Set current height as height limit min |
191
+ | 0x23 | 1 | `0xF1,0xF1,0x23,0x01,0x01,0x25,0x7E` | Clear height limit max |
192
+ | 0x23 | 1 | `0xF1,0xF1,0x23,0x01,0x02,0x26,0x7E` | Clear height limit min |
193
+ | 0x2B | 0 | `0xF1,0xF1,0x2B,0x00,0x2B,0x7E` | Stop movement |
194
+ | 0x0E | 1 | `0xF1,0xF1,0x0E,0x01,0x00,0x0F,0x7E` | Set units to centimeters |
195
+ | 0x0E | 1 | `0xF1,0xF1,0x0E,0x01,0x01,0x10,0x7E` | Set units to inches |
196
+ | 0xFE | 0 | `0xF1,0xF1,0xFE,0x00,0xFE,0x7E` | Reset |
197
+
198
+ Some of commands above were found by reverse-engineering the Uplift app (v1.1.0) using tools such as JADX. Specifically, the authors read through the .java code for the activities within the `com.jiecang.app.android.aidesk` namespace. Other commands were found by exhaustive search over the range of all opcodes.
199
+
200
+ ### Known Notifications
201
+
202
+ | Opcode | Payload Length | Purpose | Factory Value (taken from V2-Commercial model) |
203
+ |--------|----------------|-------------------------------------------------------------------------|------------------------------------------------|
204
+ | 0x01 | 3 | Reports the height of the desk in 0.01 mm (10 µm) increments. | |
205
+ | 0x10 | 2 | Reports the calibration offset in millimeters (2‑byte, big‑endian). | `572` |
206
+ | 0x04 | 0 | Seen when the desk is in an error state and the display shows **ASR**. | N/A |
207
+ | 0x11 | 2 | Reports the max height limit in millimeters (2‑byte, big‑endian). | `671` |
208
+
209
+ Most notification packets seem to have opcodes that match the opcode of an associated command packet.
210
+ For example, sending the command packet with opcode=0x01 triggers a notification packet with the same opcode,
211
+ containing the desk's current height.
212
+
213
+ **There are many notification packets whose opcodes and payload structures are unknown. PRs are welcome!**
214
+
215
+ ### Explaining the Calibration Offset
216
+ The calibration offset adds a fixed offset to the height of the desk.
217
+ This table summarizes some examples of what the Desk's display shows for various calibration offsets when the desk is at its lowest point.
218
+
219
+ | Calibration Offset (mm) | Desk Unit | Display Reading |
220
+ |-------------------------|-----------|----------------------------------------------------------------------|
221
+ | 0 | inches | 0.01 |
222
+ | 254 | inches | 10.1 |
223
+ | 508 | inches | 20.1 |
224
+ | 762 | inches | 30.1 |
225
+ | 2537 | inches | 100 |
226
+ | 25396 | inches | 999 |
227
+ | 25397 | inches | *Weird state!* Display shows **ASR** but desk can still move. |
228
+ | 65535 | inches | *Weird state!* Display shows **ASR** but desk can still move. |
229
+
230
+
231
+ ## Security of the Uplift BLE Adapter
232
+ The Bluetooth adapter allows unauthenticated GATT commands to be sent to it (no pairing or encryption required). The Uplift app itself allows you to discover and connect to nearby desks without, for example, a pairing code.
233
+
234
+ The author thinks this is a bad idea. A malicious actor could easily write code (or use a library such as this one) to scan for nearby desks, connect to them without any explicit authorization, and either soft-brick them through a series of commands designed to make the desk impossible to move via the app or physical controller (see example below), or move desks when people do not intend for them to be moved.
235
+
236
+ ## Contributors
237
+ [![Contributors](https://contrib.rocks/image?repo=librick/uplift-ble)](https://github.com/librick/uplift-ble/graphs/contributors)
238
+
239
+ ## Prior Work
240
+ This project builds on the prior work of Bennett Wendorf's [uplift-desk-controller](https://github.com/Bennett-Wendorf/uplift-desk-controller) repo. In addition to publishing a Python library, they also authored a Home Assistant integration, [hass-uplift-desk](https://github.com/Bennett-Wendorf/hass-uplift-desk). The uplift-ble library was originally intended to be a fork of Bennett's repo, but grew in scope to be a standalone library. I am thankful for Bennett's work and contributions to open source software.
241
+
242
+ ## Legal
243
+ This project is an **unofficial project** and is **NOT** endorsed by nor affiliated with the company that makes UPLIFT desks. We make no claims to the trademarks or intellectual property of the UPLIFT company. All code in this repo is written independently of UPLIFT and is MIT licensed. Any vendor-specific information used in this code is discovered through reverse-engineering publicly available information.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.7.19,<0.8.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "uplift-ble"
7
+ version = "0.1.0"
8
+ authors = [
9
+ { name = "Eric McDonald", email = "106356608+librick@users.noreply.github.com" },
10
+ ]
11
+ dependencies = [
12
+ "bleak>=1.0.1",
13
+ "typer>=0.16.0",
14
+ ]
15
+
16
+ description = "Python library for controlling Uplift standing desks over Bluetooth Low Energy via the official Uplift BLE adapter"
17
+ readme = "README.md"
18
+ requires-python = ">=3.11"
19
+ classifiers = [
20
+ "Development Status :: 3 - Alpha",
21
+ "Framework :: AsyncIO",
22
+ "Programming Language :: Python :: 3",
23
+ "Operating System :: Microsoft :: Windows :: Windows 10",
24
+ "Operating System :: POSIX :: Linux",
25
+ "Operating System :: MacOS :: MacOS X",
26
+ ]
27
+ license = "MIT"
28
+ license-files = ["LICENSE*"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/librick/uplift_ble"
32
+ Issues = "https://github.com/librick/uplift_ble/issues"
33
+
34
+ [project.optional-dependencies]
35
+ test = [
36
+ "pytest>=8.4.1",
37
+ ]
38
+
39
+ [[tool.uv.index]]
40
+ name = "testpypi"
41
+ url = "https://test.pypi.org/simple/"
42
+ publish-url = "https://test.pypi.org/legacy/"
43
+ explicit = true
@@ -0,0 +1,7 @@
1
+ # src/uplift_ble/__init__.py
2
+ from .desk import Desk
3
+ from .ble_characteristics import *
4
+
5
+ __all__ = [
6
+ "Desk",
7
+ ]
@@ -0,0 +1,19 @@
1
+ """
2
+ BLE GATT Characteristic UUIDs for the Uplift adapter.
3
+
4
+ The Uplift BLE adapter's characteristic IDs are 16-bit values chosen from the Bluetooth SIG's
5
+ vendor-specific block (0xFE00-0xFEFF). SIG reserves this range for all vendor-assigned
6
+ attributes (services, characteristics, and descriptors). Each 16-bit ID is embedded
7
+ into the Base UUID template (0000XXXX-0000-1000-8000-00805F9B34FB) to create a
8
+ full 128-bit UUID. See Bluetooth SIG Assigned Numbers for details:
9
+ https://www.bluetooth.com/specifications/assigned-numbers/
10
+ """
11
+
12
+ from bleak.uuids import normalize_uuid_16
13
+
14
+
15
+ # UUID for sending control commands to the Uplift BLE adapter.
16
+ BLE_CHAR_UUID_UPLIFT_DESK_CONTROL: str = normalize_uuid_16(0xFE61)
17
+
18
+ # UUID for receiving status and output values from the Uplift BLE adapter.
19
+ BLE_CHAR_UUID_UPLIFT_DESK_OUTPUT: str = normalize_uuid_16(0xFE62)
@@ -0,0 +1,15 @@
1
+ """
2
+ BLE GATT Service UUIDs for the Uplift adapter.
3
+
4
+ The Uplift BLE adapter's service IDs are 16-bit values chosen from the Bluetooth SIG's
5
+ vendor-specific block (0xFE00-0xFEFF). SIG reserves this range for all vendor-assigned
6
+ attributes (services, characteristics, and descriptors). Each 16-bit ID is embedded
7
+ into the Base UUID template (0000XXXX-0000-1000-8000-00805F9B34FB) to create a
8
+ full 128-bit UUID. See Bluetooth SIG Assigned Numbers for details:
9
+ https://www.bluetooth.com/specifications/assigned-numbers/
10
+ """
11
+
12
+ from bleak.uuids import normalize_uuid_16
13
+
14
+ # UUID for a service which allows clients to discover nearby Uplift adapters.
15
+ BLE_SERVICE_UUID_UPLIFT_DISCOVERY: str = normalize_uuid_16(0xFE60)
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Module: uplift_ble.desk
4
+ Handles connecting to an Uplift BLE desk, sending commands, and logging notifications.
5
+ """
6
+ import asyncio
7
+ from contextlib import suppress
8
+ import functools
9
+ import logging
10
+ from bleak import BleakClient
11
+
12
+ from uplift_ble.ble_characteristics import (
13
+ BLE_CHAR_UUID_UPLIFT_DESK_CONTROL,
14
+ BLE_CHAR_UUID_UPLIFT_DESK_OUTPUT,
15
+ )
16
+ from uplift_ble.packet import (
17
+ PacketNotification,
18
+ create_command_packet,
19
+ parse_notification_packets,
20
+ )
21
+ from uplift_ble.units import convert_mm_to_inches, convert_tenths_of_mm_to_mm
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def command_writer(func):
27
+ @functools.wraps(func)
28
+ async def wrapper(self, *args, **kwargs):
29
+ # Ensure BLE client is connected.
30
+ if not self._connected:
31
+ await self.connect()
32
+
33
+ # Build and send packet.
34
+ packet: bytes = func(self, *args, **kwargs)
35
+ logger.info(f"{func.__name__}(): sending {len(packet)} bytes: {packet.hex()}")
36
+ await self._client.write_gatt_char(self.char_uuid_control, packet)
37
+
38
+ # Allow time for any notifications to arrive.
39
+ logger.info(f"Waiting up to {self._notification_timeout}s for notifications...")
40
+ await asyncio.sleep(self._notification_timeout)
41
+ return packet
42
+
43
+ return wrapper
44
+
45
+
46
+ class Desk:
47
+ def __init__(
48
+ self,
49
+ address: str,
50
+ char_uuid_control: str = BLE_CHAR_UUID_UPLIFT_DESK_CONTROL,
51
+ char_uuid_output: str = BLE_CHAR_UUID_UPLIFT_DESK_OUTPUT,
52
+ notification_timeout: float = 5.0,
53
+ ):
54
+ self.address = address
55
+ self.char_uuid_control = char_uuid_control
56
+ self.char_uuid_output = char_uuid_output
57
+ self._client = BleakClient(address)
58
+ self._connected = False
59
+ self._notification_timeout = notification_timeout
60
+ self._last_known_height_mm: float | None = None
61
+
62
+ async def connect(self):
63
+ if not self._connected:
64
+ logger.info(f"Connecting to {self.address}…")
65
+ await self._client.connect()
66
+ self._connected = True
67
+ logger.info("Connected.")
68
+ logger.info(f"Subscribing to notifications on {self.char_uuid_output}")
69
+ await self._client.start_notify(
70
+ self.char_uuid_output, self._notification_handler
71
+ )
72
+
73
+ async def disconnect(self):
74
+ if self._connected:
75
+ logger.info("Disconnecting…")
76
+ # BlueZ adapters have been known to throw an EOFError exception when the bus closes.
77
+ with suppress(EOFError):
78
+ await self._client.disconnect()
79
+ self._connected = False
80
+ logger.info("Disconnected.")
81
+
82
+ async def __aenter__(self):
83
+ await self.connect()
84
+ return self
85
+
86
+ async def __aexit__(self, exc_type, exc, tb):
87
+ await self.disconnect()
88
+
89
+ def _notification_handler(self, sender: int, data: bytearray):
90
+ """
91
+ Handler for incoming notifications. Logs raw data, updates state, and parses packets.
92
+ """
93
+ logger.debug(f"Received notification from {sender}: {data.hex()}")
94
+ packets = parse_notification_packets(data)
95
+ logger.info(f"Received {len(packets)} notification packet(s).")
96
+ for p in packets:
97
+ self._process_notification_packet(p)
98
+
99
+ @command_writer
100
+ def move_up(self) -> bytes:
101
+ return create_command_packet(opcode=0x01, payload=b"")
102
+
103
+ @command_writer
104
+ def move_down(self) -> bytes:
105
+ return create_command_packet(opcode=0x02, payload=b"")
106
+
107
+ @command_writer
108
+ def request_height_limits(self) -> bytes:
109
+ return create_command_packet(opcode=0x07, payload=b"")
110
+
111
+ @command_writer
112
+ def set_calibration_offset(self, calibration_offset: int) -> bytes:
113
+ if not 0 <= calibration_offset <= 0xFFFF:
114
+ raise ValueError("calibration_offset not in range [0,65535]")
115
+ payload = calibration_offset.to_bytes(2, "big")
116
+ return create_command_packet(opcode=0x10, payload=payload)
117
+
118
+ @command_writer
119
+ def set_height_limit_max(self, max_height: int) -> bytes:
120
+ if not 0 <= max_height <= 0xFFFF:
121
+ raise ValueError("max_height not in range [0,65535]")
122
+ payload = max_height.to_bytes(2, "big")
123
+ return create_command_packet(opcode=0x11, payload=payload)
124
+
125
+ @command_writer
126
+ def move_to_specified_height(self, height: int) -> bytes:
127
+ if not isinstance(height, int) or not 0 <= height <= 0xFFFF:
128
+ raise ValueError("height must be an integer in range [0,65535]")
129
+ payload = height.to_bytes(2, "big")
130
+ return create_command_packet(opcode=0x1B, payload=payload)
131
+
132
+ @command_writer
133
+ def set_current_height_as_height_limit_max(self) -> bytes:
134
+ return create_command_packet(opcode=0x21, payload=b"")
135
+
136
+ @command_writer
137
+ def set_current_height_as_height_limit_min(self) -> bytes:
138
+ return create_command_packet(opcode=0x22, payload=b"")
139
+
140
+ @command_writer
141
+ def clear_height_limit_max(self) -> bytes:
142
+ return create_command_packet(opcode=0x23, payload=bytes([0x01]))
143
+
144
+ @command_writer
145
+ def clear_height_limit_min(self) -> bytes:
146
+ return create_command_packet(opcode=0x23, payload=bytes([0x02]))
147
+
148
+ @command_writer
149
+ def stop_movement(self) -> bytes:
150
+ return create_command_packet(opcode=0x2B, payload=b"")
151
+
152
+ @command_writer
153
+ def set_units_to_centimeters(self) -> bytes:
154
+ return create_command_packet(opcode=0x0E, payload=bytes([0x00]))
155
+
156
+ @command_writer
157
+ def set_units_to_inches(self) -> bytes:
158
+ return create_command_packet(opcode=0x0E, payload=bytes([0x01]))
159
+
160
+ @command_writer
161
+ def reset(self) -> bytes:
162
+ return create_command_packet(opcode=0xFE, payload=b"")
163
+
164
+ async def get_current_height(self) -> float | None:
165
+ """
166
+ Requests a height update and returns the last observed desk height in mm.
167
+ """
168
+ await self.request_height_limits()
169
+ return self._last_known_height_mm
170
+
171
+ def _process_notification_packet(self, p: PacketNotification):
172
+ if p.opcode == 0x01:
173
+ tenths = int.from_bytes(p.payload, byteorder="big", signed=False)
174
+ mm = convert_tenths_of_mm_to_mm(tenths)
175
+ inches = convert_mm_to_inches(mm)
176
+ logger.info(
177
+ f"- Received packet, opcode=0x{p.opcode:02X}, current height: {mm} mm (~{inches} in)"
178
+ )
179
+ # Important! Update the class state with this most-recently reported height.
180
+ self._last_known_height_mm = mm
181
+ elif p.opcode == 0x04:
182
+ logger.info(
183
+ f"- Received packet, opcode=0x{p.opcode:02X}, desk is reporting an error state (ASR) and likely needs to be manually reset"
184
+ )
185
+ elif p.opcode == 0x10:
186
+ mm = int.from_bytes(p.payload, byteorder="big", signed=False)
187
+ inches = convert_mm_to_inches(mm)
188
+ logger.info(
189
+ f"- Received packet, opcode=0x{p.opcode:02X}, calibration height: {mm} mm (~{inches} in)"
190
+ )
191
+ elif p.opcode == 0x11:
192
+ mm = int.from_bytes(p.payload, byteorder="big", signed=False)
193
+ inches = convert_mm_to_inches(mm)
194
+ logger.info(
195
+ f"- Received packet, opcode=0x{p.opcode:02X}, height limit max: {mm} mm (~{inches} in)"
196
+ )
197
+ else:
198
+ logger.info(
199
+ f"- Received packet, opcode=0x{p.opcode:02X}, unknown opcode. Please make a PR if you know what this is."
200
+ )
@@ -0,0 +1,131 @@
1
+ from dataclasses import dataclass
2
+ from typing import List
3
+
4
+
5
+ @dataclass
6
+ class PacketNotification:
7
+ """
8
+ Represents a notification packet received from the Uplift BLE adapter.
9
+ """
10
+
11
+ opcode: int
12
+ payload: bytes
13
+ checksum: int
14
+
15
+ def __str__(self) -> str:
16
+ payload_hex = self.payload.hex().upper() if self.payload else ""
17
+ return (
18
+ f"PacketNotification(opcode=0x{self.opcode:02X}, "
19
+ f"payload=0x{payload_hex}, "
20
+ f"checksum=0x{self.checksum:02X})"
21
+ )
22
+
23
+
24
+ def create_command_packet(opcode: int, payload: bytes) -> bytes:
25
+ """
26
+ Creates a packet that represents a command which can be sent to the Uplift BLE adapter.
27
+
28
+ The packet format is a special vendor-defined format.
29
+
30
+ Frame:
31
+ [0xF1,0xF1] [len] [payload...] [checksum] [0x7E]
32
+ """
33
+ if not 0 <= opcode <= 0xFF:
34
+ raise ValueError("opcode not in range [0,255]")
35
+ payload_len = len(payload)
36
+ if not 0 <= payload_len <= 0xFF:
37
+ raise ValueError("payload length not in range [0,255]")
38
+
39
+ checksum = _compute_checksum(opcode, payload)
40
+
41
+ return bytes([0xF1, 0xF1, opcode, payload_len, *payload, checksum, 0x7E])
42
+
43
+
44
+ def parse_notification_packets(data: bytes) -> List[PacketNotification]:
45
+ """
46
+ Scan data, which can contain multiple packets, for back-to-back notification packets,
47
+ parsing and returning all valid packets.
48
+
49
+ Notification packets are very similar to command packets, except that they
50
+ use the header byte sequence 0xF2F2 instead of 0xF1F1.
51
+ """
52
+ packets: List[PacketNotification] = []
53
+ i = 0
54
+ length = len(data)
55
+ while i + 6 <= length:
56
+ # Look for header
57
+ if data[i : i + 2] != b"\xf2\xf2":
58
+ i += 1
59
+ continue
60
+
61
+ # Read length byte
62
+ if i + 4 > length:
63
+ break
64
+ payload_len = data[i + 3]
65
+ total_len = 2 + 1 + 1 + payload_len + 1 + 1
66
+
67
+ # Check if full packet present
68
+ if i + total_len > length:
69
+ break
70
+
71
+ packet_bytes = data[i : i + total_len]
72
+ p = _parse_notification_packet(packet_bytes)
73
+ if p is not None:
74
+ packets.append(p)
75
+ i += total_len
76
+ else:
77
+ # Skip this header and continue
78
+ i += 1
79
+
80
+ return packets
81
+
82
+
83
+ def _parse_notification_packet(data: bytes) -> PacketNotification | None:
84
+ """
85
+ Try to parse a single PacketNotification from exactly one packet in 'data'.
86
+ Returns PacketNotification or None if invalid.
87
+ """
88
+ # Minimum packet length: header(2) + opcode(1) + length(1) + checksum(1) + trailer(1)
89
+ if len(data) < 6:
90
+ return None
91
+
92
+ # Header
93
+ if data[0:2] != b"\xf2\xf2":
94
+ return None
95
+
96
+ # Trailer
97
+ if data[-1] != 0x7E:
98
+ return None
99
+
100
+ opcode = data[2]
101
+ payload_len = data[3]
102
+ expected_total = 2 + 1 + 1 + payload_len + 1 + 1
103
+ if len(data) != expected_total:
104
+ return None
105
+
106
+ # Extract payload and checksum
107
+ payload = data[4 : 4 + payload_len]
108
+ checksum = data[-2]
109
+
110
+ # Validate checksum
111
+ if _compute_checksum(opcode, payload) != checksum:
112
+ return None
113
+
114
+ return PacketNotification(opcode=opcode, payload=payload, checksum=checksum)
115
+
116
+
117
+ def _compute_checksum(opcode: int, payload: bytes) -> int:
118
+ """
119
+ Compute an 8-bit checksum over:
120
+ - opcode byte
121
+ - payload length byte
122
+ - each byte in payload
123
+
124
+ Returns (opcode + len(payload) + sum(payload)) mod 256.
125
+ """
126
+ if not 0 <= opcode <= 0xFF:
127
+ raise ValueError("opcode not in range [0,255]")
128
+ total = opcode + len(payload)
129
+ for b in payload:
130
+ total += b
131
+ return total & 0xFF
@@ -0,0 +1,20 @@
1
+ from bleak import BleakScanner
2
+
3
+ from uplift_ble.ble_services import BLE_SERVICE_UUID_UPLIFT_DISCOVERY
4
+
5
+
6
+ class DeskScanner:
7
+ @staticmethod
8
+ async def discover(timeout: float = 5.0) -> list[str]:
9
+ """
10
+ Returns a list of BLE addresses for any discovered desks.
11
+ """
12
+ # Currently this scanner only discovers Uplift desks by service UUID.
13
+ # To support other desk makes/models, refactor to accept a list of service UUIDs or
14
+ # subclass/configure different desk scanners.
15
+ target_services = [BLE_SERVICE_UUID_UPLIFT_DISCOVERY]
16
+ devices = await BleakScanner.discover(
17
+ timeout=timeout, service_uuids=target_services
18
+ )
19
+
20
+ return [d.address for d in devices]
@@ -0,0 +1,27 @@
1
+ from decimal import ROUND_HALF_UP, Decimal
2
+
3
+
4
+ def convert_mm_to_inches(mm: int | float) -> float:
5
+ """
6
+ Converts a value in millimeters to an approximate value in inches, rounding using half-up to one decimal place.
7
+ """
8
+ # The conversion factor used is intentionally inexact to match the hardware behavior.
9
+ return round_half_up(mm * 0.039, num_digits=1)
10
+
11
+
12
+ def convert_tenths_of_mm_to_mm(tenths_of_mm: int | float) -> float:
13
+ """
14
+ Converts a value in tenths of a millimeter to millimeters, rounding using half-up to the nearest whole number.
15
+ """
16
+ # 1 tenth of a millimeter = 0.1 mm
17
+ return round_half_up(tenths_of_mm * 0.1)
18
+
19
+
20
+ def round_half_up(value: int | float, num_digits=0):
21
+ """
22
+ By default, the Python 3 built-in round() function uses "banker's rounding",
23
+ or "round-half-to-even" rather than rounding up, for example, 0.5.
24
+ We use round-half-up to better approximate the Uplift hardware.
25
+ """
26
+ quant = Decimal("1e{}".format(-num_digits))
27
+ return float(Decimal(value).quantize(quant, rounding=ROUND_HALF_UP))