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.
- uplift_ble-0.1.0/LICENSE +21 -0
- uplift_ble-0.1.0/PKG-INFO +266 -0
- uplift_ble-0.1.0/README.md +243 -0
- uplift_ble-0.1.0/pyproject.toml +43 -0
- uplift_ble-0.1.0/src/uplift_ble/__init__.py +7 -0
- uplift_ble-0.1.0/src/uplift_ble/ble_characteristics.py +19 -0
- uplift_ble-0.1.0/src/uplift_ble/ble_services.py +15 -0
- uplift_ble-0.1.0/src/uplift_ble/desk.py +200 -0
- uplift_ble-0.1.0/src/uplift_ble/packet.py +131 -0
- uplift_ble-0.1.0/src/uplift_ble/scanner.py +20 -0
- uplift_ble-0.1.0/src/uplift_ble/units.py +27 -0
uplift_ble-0.1.0/LICENSE
ADDED
|
@@ -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
|
+

|
|
25
|
+

|
|
26
|
+

|
|
27
|
+
[](https://github.com/librick/uplift-ble/issues)
|
|
28
|
+

|
|
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
|
+
[](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
|
+

|
|
2
|
+

|
|
3
|
+

|
|
4
|
+
[](https://github.com/librick/uplift-ble/issues)
|
|
5
|
+

|
|
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
|
+
[](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,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))
|