desky-ble 0.1.2__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.
- desky_ble-0.1.2/.gitignore +10 -0
- desky_ble-0.1.2/PKG-INFO +118 -0
- desky_ble-0.1.2/README.md +99 -0
- desky_ble-0.1.2/pyproject.toml +33 -0
- desky_ble-0.1.2/src/desky_ble/__init__.py +90 -0
- desky_ble-0.1.2/src/desky_ble/ble_client.py +363 -0
- desky_ble-0.1.2/src/desky_ble/protocol.py +269 -0
desky_ble-0.1.2/PKG-INFO
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: desky-ble
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Unofficial Python library for local Bluetooth LE control of Desky® Standing Desks.
|
|
5
|
+
Project-URL: Homepage, https://github.com/vakintosh/ha-desky-ble
|
|
6
|
+
Project-URL: Bug-Tracker, https://github.com/vakintosh/ha-desky-ble/issues
|
|
7
|
+
Author: vakintosh
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: ble,bluetooth,desky,home-assistant,iot,standing-desk
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Home Automation
|
|
15
|
+
Requires-Python: >=3.13
|
|
16
|
+
Requires-Dist: bleak-retry-connector>=4.5.0
|
|
17
|
+
Requires-Dist: bleak>=2.1.1
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# desky-ble
|
|
21
|
+
|
|
22
|
+
Unofficial Python library for local Bluetooth LE control of Desky® Standing Desks.
|
|
23
|
+
|
|
24
|
+
This standalone client library provides a robust, asynchronous interface for interacting with Desky desk controllers. It handles all low-level BLE communication, including real-time height monitoring, preset management, and automatic detection of common controller variants (specifically those utilizing Lierda and Peilin chipsets).
|
|
25
|
+
|
|
26
|
+
Designed for interoperability, this library is built to integrate seamlessly with platforms like Home Assistant, enabling local, cloud-free automation of your workspace.
|
|
27
|
+
|
|
28
|
+
## Supported controllers
|
|
29
|
+
|
|
30
|
+
| Variant | BLE Service UUID |
|
|
31
|
+
|----------|------------------------------------------|
|
|
32
|
+
| Lierda 1 | `0000ff12-0000-1000-8000-00805f9b34fb` |
|
|
33
|
+
| Lierda 2 | `0000fe60-0000-1000-8000-00805f9b34fb` |
|
|
34
|
+
| Peilin | `88121427-11e2-52a2-4615-ff00dec16800` |
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install desky-ble
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Requires Python 3.13+.
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
from bleak import BleakScanner
|
|
49
|
+
from desky_ble import DeskyBleClient, DeskState
|
|
50
|
+
|
|
51
|
+
async def main():
|
|
52
|
+
device = await BleakScanner.find_device_by_name("Desky")
|
|
53
|
+
|
|
54
|
+
def on_state(state: DeskState):
|
|
55
|
+
print(f"Height: {state.height_cm} cm, Moving: {state.is_moving}")
|
|
56
|
+
|
|
57
|
+
client = DeskyBleClient(device, state_callback=on_state)
|
|
58
|
+
await client.connect()
|
|
59
|
+
await client.request_status()
|
|
60
|
+
await client.move_up()
|
|
61
|
+
await asyncio.sleep(2)
|
|
62
|
+
await client.stop()
|
|
63
|
+
await client.disconnect()
|
|
64
|
+
|
|
65
|
+
asyncio.run(main())
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Features
|
|
69
|
+
|
|
70
|
+
### Motion control
|
|
71
|
+
|
|
72
|
+
- `move_up()` / `move_down()` — continuous movement
|
|
73
|
+
- `stop()` — stop any movement
|
|
74
|
+
- `move_to_height(raw)` — move to a specific height (raw value = cm × 10)
|
|
75
|
+
|
|
76
|
+
### Memory presets
|
|
77
|
+
|
|
78
|
+
- `recall_memory(slot)` — recall a saved preset (slots 1–4)
|
|
79
|
+
- `save_memory(slot)` — save current height to a preset
|
|
80
|
+
|
|
81
|
+
### Desk settings
|
|
82
|
+
|
|
83
|
+
- `set_brightness(value)` — LED display brightness (0–100)
|
|
84
|
+
- `set_led_color(value)` — LED colour (1–7)
|
|
85
|
+
- `set_lighting(value)` — under-desk lighting (0=off, 1=on)
|
|
86
|
+
- `set_vibration(value)` — vibration feedback (0=off, 1=on)
|
|
87
|
+
- `set_lock(value)` — child lock (0=off, 1=on)
|
|
88
|
+
- `set_anti_collision(value)` — anti-collision sensitivity (1–3)
|
|
89
|
+
- `set_touch_mode(value)` — touch mode (0=one-press, 1=hold)
|
|
90
|
+
- `set_unit(value)` — display unit
|
|
91
|
+
- `set_reminder(minutes)` — sit/stand reminder interval
|
|
92
|
+
- `clear_limits()` — clear upper/lower height limits
|
|
93
|
+
|
|
94
|
+
### State tracking
|
|
95
|
+
|
|
96
|
+
The `DeskState` object is updated in real-time via BLE notifications:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
state = client.state
|
|
100
|
+
state.height_cm # Current height in cm
|
|
101
|
+
state.height_raw # Raw height value
|
|
102
|
+
state.is_moving # Whether the desk is moving
|
|
103
|
+
state.lock_status # Child lock (0=off, 1=on)
|
|
104
|
+
state.brightness # Display brightness (0-100)
|
|
105
|
+
state.led_color # LED colour (1-7)
|
|
106
|
+
state.vibration # Vibration feedback (0=off, 1=on)
|
|
107
|
+
state.lighting # Under-desk lighting (0=off, 1=on)
|
|
108
|
+
state.anti_collision # Anti-collision sensitivity (1-3)
|
|
109
|
+
state.has_limits # Whether height limits are set
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Settings persistence
|
|
113
|
+
|
|
114
|
+
After reconnect, call `restore_settings()` to re-apply any settings that the desk may have reset.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# desky-ble
|
|
2
|
+
|
|
3
|
+
Unofficial Python library for local Bluetooth LE control of Desky® Standing Desks.
|
|
4
|
+
|
|
5
|
+
This standalone client library provides a robust, asynchronous interface for interacting with Desky desk controllers. It handles all low-level BLE communication, including real-time height monitoring, preset management, and automatic detection of common controller variants (specifically those utilizing Lierda and Peilin chipsets).
|
|
6
|
+
|
|
7
|
+
Designed for interoperability, this library is built to integrate seamlessly with platforms like Home Assistant, enabling local, cloud-free automation of your workspace.
|
|
8
|
+
|
|
9
|
+
## Supported controllers
|
|
10
|
+
|
|
11
|
+
| Variant | BLE Service UUID |
|
|
12
|
+
|----------|------------------------------------------|
|
|
13
|
+
| Lierda 1 | `0000ff12-0000-1000-8000-00805f9b34fb` |
|
|
14
|
+
| Lierda 2 | `0000fe60-0000-1000-8000-00805f9b34fb` |
|
|
15
|
+
| Peilin | `88121427-11e2-52a2-4615-ff00dec16800` |
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install desky-ble
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Requires Python 3.13+.
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import asyncio
|
|
29
|
+
from bleak import BleakScanner
|
|
30
|
+
from desky_ble import DeskyBleClient, DeskState
|
|
31
|
+
|
|
32
|
+
async def main():
|
|
33
|
+
device = await BleakScanner.find_device_by_name("Desky")
|
|
34
|
+
|
|
35
|
+
def on_state(state: DeskState):
|
|
36
|
+
print(f"Height: {state.height_cm} cm, Moving: {state.is_moving}")
|
|
37
|
+
|
|
38
|
+
client = DeskyBleClient(device, state_callback=on_state)
|
|
39
|
+
await client.connect()
|
|
40
|
+
await client.request_status()
|
|
41
|
+
await client.move_up()
|
|
42
|
+
await asyncio.sleep(2)
|
|
43
|
+
await client.stop()
|
|
44
|
+
await client.disconnect()
|
|
45
|
+
|
|
46
|
+
asyncio.run(main())
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
### Motion control
|
|
52
|
+
|
|
53
|
+
- `move_up()` / `move_down()` — continuous movement
|
|
54
|
+
- `stop()` — stop any movement
|
|
55
|
+
- `move_to_height(raw)` — move to a specific height (raw value = cm × 10)
|
|
56
|
+
|
|
57
|
+
### Memory presets
|
|
58
|
+
|
|
59
|
+
- `recall_memory(slot)` — recall a saved preset (slots 1–4)
|
|
60
|
+
- `save_memory(slot)` — save current height to a preset
|
|
61
|
+
|
|
62
|
+
### Desk settings
|
|
63
|
+
|
|
64
|
+
- `set_brightness(value)` — LED display brightness (0–100)
|
|
65
|
+
- `set_led_color(value)` — LED colour (1–7)
|
|
66
|
+
- `set_lighting(value)` — under-desk lighting (0=off, 1=on)
|
|
67
|
+
- `set_vibration(value)` — vibration feedback (0=off, 1=on)
|
|
68
|
+
- `set_lock(value)` — child lock (0=off, 1=on)
|
|
69
|
+
- `set_anti_collision(value)` — anti-collision sensitivity (1–3)
|
|
70
|
+
- `set_touch_mode(value)` — touch mode (0=one-press, 1=hold)
|
|
71
|
+
- `set_unit(value)` — display unit
|
|
72
|
+
- `set_reminder(minutes)` — sit/stand reminder interval
|
|
73
|
+
- `clear_limits()` — clear upper/lower height limits
|
|
74
|
+
|
|
75
|
+
### State tracking
|
|
76
|
+
|
|
77
|
+
The `DeskState` object is updated in real-time via BLE notifications:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
state = client.state
|
|
81
|
+
state.height_cm # Current height in cm
|
|
82
|
+
state.height_raw # Raw height value
|
|
83
|
+
state.is_moving # Whether the desk is moving
|
|
84
|
+
state.lock_status # Child lock (0=off, 1=on)
|
|
85
|
+
state.brightness # Display brightness (0-100)
|
|
86
|
+
state.led_color # LED colour (1-7)
|
|
87
|
+
state.vibration # Vibration feedback (0=off, 1=on)
|
|
88
|
+
state.lighting # Under-desk lighting (0=off, 1=on)
|
|
89
|
+
state.anti_collision # Anti-collision sensitivity (1-3)
|
|
90
|
+
state.has_limits # Whether height limits are set
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Settings persistence
|
|
94
|
+
|
|
95
|
+
After reconnect, call `restore_settings()` to re-apply any settings that the desk may have reset.
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "desky-ble"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "Unofficial Python library for local Bluetooth LE control of Desky® Standing Desks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "vakintosh"}
|
|
14
|
+
]
|
|
15
|
+
keywords = ["home-assistant", "bluetooth", "ble", "desky", "standing-desk", "iot"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Home Automation",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"bleak>=2.1.1",
|
|
25
|
+
"bleak-retry-connector>=4.5.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/vakintosh/ha-desky-ble"
|
|
30
|
+
Bug-Tracker = "https://github.com/vakintosh/ha-desky-ble/issues"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/desky_ble"]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""desky-ble – standalone BLE protocol and client library for Desky standing desks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .ble_client import ControllerVariant, DeskyBleClient
|
|
6
|
+
from .protocol import (
|
|
7
|
+
CMD_CLEAR_LIMIT,
|
|
8
|
+
CMD_GET_ANTI_COLLISION,
|
|
9
|
+
CMD_GET_BRIGHTNESS,
|
|
10
|
+
CMD_GET_CURRENT_LIMITATION,
|
|
11
|
+
CMD_GET_LED_COLOR,
|
|
12
|
+
CMD_GET_LIGHTING,
|
|
13
|
+
CMD_GET_LOCK,
|
|
14
|
+
CMD_GET_STATUS,
|
|
15
|
+
CMD_GET_VIBRATION,
|
|
16
|
+
CMD_HANDSHAKE,
|
|
17
|
+
CMD_MOVE_DOWN,
|
|
18
|
+
CMD_MOVE_UP,
|
|
19
|
+
CMD_RECALL_MEMORY_1,
|
|
20
|
+
CMD_RECALL_MEMORY_2,
|
|
21
|
+
CMD_RECALL_MEMORY_3,
|
|
22
|
+
CMD_RECALL_MEMORY_4,
|
|
23
|
+
CMD_SAVE_MEMORY_1,
|
|
24
|
+
CMD_SAVE_MEMORY_2,
|
|
25
|
+
CMD_SAVE_MEMORY_3,
|
|
26
|
+
CMD_SAVE_MEMORY_4,
|
|
27
|
+
CMD_STOP,
|
|
28
|
+
DeskState,
|
|
29
|
+
Opcode,
|
|
30
|
+
build_frame,
|
|
31
|
+
cmd_move_to_height,
|
|
32
|
+
cmd_set_anti_collision,
|
|
33
|
+
cmd_set_brightness,
|
|
34
|
+
cmd_set_led_color,
|
|
35
|
+
cmd_set_lighting,
|
|
36
|
+
cmd_set_lock,
|
|
37
|
+
cmd_set_reminder,
|
|
38
|
+
cmd_set_touch_mode,
|
|
39
|
+
cmd_set_unit,
|
|
40
|
+
cmd_set_value,
|
|
41
|
+
cmd_set_vibration,
|
|
42
|
+
height_cm_to_raw,
|
|
43
|
+
height_is_cm,
|
|
44
|
+
height_raw_to_cm,
|
|
45
|
+
parse_notification,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"CMD_CLEAR_LIMIT",
|
|
50
|
+
"CMD_GET_ANTI_COLLISION",
|
|
51
|
+
"CMD_GET_BRIGHTNESS",
|
|
52
|
+
"CMD_GET_CURRENT_LIMITATION",
|
|
53
|
+
"CMD_GET_LED_COLOR",
|
|
54
|
+
"CMD_GET_LIGHTING",
|
|
55
|
+
"CMD_GET_LOCK",
|
|
56
|
+
"CMD_GET_STATUS",
|
|
57
|
+
"CMD_GET_VIBRATION",
|
|
58
|
+
"CMD_HANDSHAKE",
|
|
59
|
+
"CMD_MOVE_DOWN",
|
|
60
|
+
"CMD_MOVE_UP",
|
|
61
|
+
"CMD_RECALL_MEMORY_1",
|
|
62
|
+
"CMD_RECALL_MEMORY_2",
|
|
63
|
+
"CMD_RECALL_MEMORY_3",
|
|
64
|
+
"CMD_RECALL_MEMORY_4",
|
|
65
|
+
"CMD_SAVE_MEMORY_1",
|
|
66
|
+
"CMD_SAVE_MEMORY_2",
|
|
67
|
+
"CMD_SAVE_MEMORY_3",
|
|
68
|
+
"CMD_SAVE_MEMORY_4",
|
|
69
|
+
"CMD_STOP",
|
|
70
|
+
"ControllerVariant",
|
|
71
|
+
"DeskState",
|
|
72
|
+
"DeskyBleClient",
|
|
73
|
+
"Opcode",
|
|
74
|
+
"build_frame",
|
|
75
|
+
"cmd_move_to_height",
|
|
76
|
+
"cmd_set_anti_collision",
|
|
77
|
+
"cmd_set_brightness",
|
|
78
|
+
"cmd_set_led_color",
|
|
79
|
+
"cmd_set_lighting",
|
|
80
|
+
"cmd_set_lock",
|
|
81
|
+
"cmd_set_reminder",
|
|
82
|
+
"cmd_set_touch_mode",
|
|
83
|
+
"cmd_set_unit",
|
|
84
|
+
"cmd_set_value",
|
|
85
|
+
"cmd_set_vibration",
|
|
86
|
+
"height_cm_to_raw",
|
|
87
|
+
"height_is_cm",
|
|
88
|
+
"height_raw_to_cm",
|
|
89
|
+
"parse_notification",
|
|
90
|
+
]
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""BLE client for Desky standing desks.
|
|
2
|
+
|
|
3
|
+
Standalone version without Home Assistant dependencies.
|
|
4
|
+
BLE UUIDs and constants are defined locally.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from enum import Enum, auto
|
|
13
|
+
|
|
14
|
+
from bleak import BleakClient
|
|
15
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
|
16
|
+
from bleak.backends.device import BLEDevice
|
|
17
|
+
from bleak_retry_connector import establish_connection
|
|
18
|
+
|
|
19
|
+
from .protocol import (
|
|
20
|
+
CMD_CLEAR_LIMIT,
|
|
21
|
+
CMD_GET_ANTI_COLLISION,
|
|
22
|
+
CMD_GET_BRIGHTNESS,
|
|
23
|
+
CMD_GET_LED_COLOR,
|
|
24
|
+
CMD_GET_LIGHTING,
|
|
25
|
+
CMD_GET_LOCK,
|
|
26
|
+
CMD_GET_STATUS,
|
|
27
|
+
CMD_GET_VIBRATION,
|
|
28
|
+
CMD_HANDSHAKE,
|
|
29
|
+
CMD_MOVE_DOWN,
|
|
30
|
+
CMD_MOVE_UP,
|
|
31
|
+
CMD_RECALL_MEMORY_1,
|
|
32
|
+
CMD_RECALL_MEMORY_2,
|
|
33
|
+
CMD_RECALL_MEMORY_3,
|
|
34
|
+
CMD_RECALL_MEMORY_4,
|
|
35
|
+
CMD_SAVE_MEMORY_1,
|
|
36
|
+
CMD_SAVE_MEMORY_2,
|
|
37
|
+
CMD_SAVE_MEMORY_3,
|
|
38
|
+
CMD_SAVE_MEMORY_4,
|
|
39
|
+
CMD_STOP,
|
|
40
|
+
DeskState,
|
|
41
|
+
cmd_move_to_height,
|
|
42
|
+
cmd_set_anti_collision,
|
|
43
|
+
cmd_set_brightness,
|
|
44
|
+
cmd_set_led_color,
|
|
45
|
+
cmd_set_lighting,
|
|
46
|
+
cmd_set_lock,
|
|
47
|
+
cmd_set_reminder,
|
|
48
|
+
cmd_set_touch_mode,
|
|
49
|
+
cmd_set_unit,
|
|
50
|
+
cmd_set_vibration,
|
|
51
|
+
parse_notification,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_LOGGER = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
UUID_SERVICE_LIERDA1 = "0000ff12-0000-1000-8000-00805f9b34fb"
|
|
57
|
+
UUID_CHAR_WRITE_LIERDA1 = "0000ff01-0000-1000-8000-00805f9b34fb"
|
|
58
|
+
UUID_CHAR_READ_LIERDA1 = "0000ff02-0000-1000-8000-00805f9b34fb"
|
|
59
|
+
|
|
60
|
+
UUID_SERVICE_LIERDA2 = "0000fe60-0000-1000-8000-00805f9b34fb"
|
|
61
|
+
UUID_CHAR_WRITE_LIERDA2 = "0000fe61-0000-1000-8000-00805f9b34fb"
|
|
62
|
+
UUID_CHAR_READ_LIERDA2 = "0000fe62-0000-1000-8000-00805f9b34fb"
|
|
63
|
+
|
|
64
|
+
UUID_SERVICE_PEILIN = "88121427-11e2-52a2-4615-ff00dec16800"
|
|
65
|
+
UUID_CHAR_RW_PEILIN = "88121427-11e2-52a2-4615-ff00dec16801"
|
|
66
|
+
|
|
67
|
+
UUID_SERVICE_LOGIN = "9e5d1e47-5c13-43a0-8635-82adffc0386f"
|
|
68
|
+
UUID_CHAR_LOGIN_WRITE = "9e5d1e47-5c13-43a0-8635-82adffc1386f"
|
|
69
|
+
|
|
70
|
+
LOGIN_PAYLOAD = bytes([0x01, 0x16, 0x02, 0x38, 0x38, 0x38, 0x38])
|
|
71
|
+
|
|
72
|
+
ALL_SERVICE_UUIDS: list[str] = [
|
|
73
|
+
UUID_SERVICE_LIERDA1,
|
|
74
|
+
UUID_SERVICE_LIERDA2,
|
|
75
|
+
UUID_SERVICE_PEILIN,
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
_RECALL_MEMORY_CMDS: dict[int, bytes] = {
|
|
79
|
+
1: CMD_RECALL_MEMORY_1,
|
|
80
|
+
2: CMD_RECALL_MEMORY_2,
|
|
81
|
+
3: CMD_RECALL_MEMORY_3,
|
|
82
|
+
4: CMD_RECALL_MEMORY_4,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_SAVE_MEMORY_CMDS: dict[int, bytes] = {
|
|
86
|
+
1: CMD_SAVE_MEMORY_1,
|
|
87
|
+
2: CMD_SAVE_MEMORY_2,
|
|
88
|
+
3: CMD_SAVE_MEMORY_3,
|
|
89
|
+
4: CMD_SAVE_MEMORY_4,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ControllerVariant(Enum):
|
|
94
|
+
"""BLE controller type detected from GATT services."""
|
|
95
|
+
|
|
96
|
+
LIERDA1 = auto()
|
|
97
|
+
LIERDA2 = auto()
|
|
98
|
+
PEILIN = auto()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class DeskyBleClient:
|
|
102
|
+
"""Async BLE client for Desky standing desks."""
|
|
103
|
+
|
|
104
|
+
__slots__ = (
|
|
105
|
+
"_ble_device",
|
|
106
|
+
"_client",
|
|
107
|
+
"_variant",
|
|
108
|
+
"_write_char",
|
|
109
|
+
"_read_char",
|
|
110
|
+
"_state",
|
|
111
|
+
"_state_callback",
|
|
112
|
+
"_lock",
|
|
113
|
+
"_desired_settings",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
ble_device: BLEDevice,
|
|
119
|
+
state_callback: Callable[[DeskState], None] | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
self._ble_device = ble_device
|
|
122
|
+
self._client: BleakClient | None = None
|
|
123
|
+
self._variant: ControllerVariant | None = None
|
|
124
|
+
self._write_char: str | None = None
|
|
125
|
+
self._read_char: str | None = None
|
|
126
|
+
self._state = DeskState()
|
|
127
|
+
self._state_callback = state_callback
|
|
128
|
+
self._lock = asyncio.Lock()
|
|
129
|
+
self._desired_settings: dict[str, tuple[int, bytes]] = {}
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def state(self) -> DeskState:
|
|
133
|
+
return self._state
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def variant(self) -> ControllerVariant | None:
|
|
137
|
+
return self._variant
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def is_connected(self) -> bool:
|
|
141
|
+
return self._client is not None and self._client.is_connected
|
|
142
|
+
|
|
143
|
+
async def connect(self) -> None:
|
|
144
|
+
"""Establish BLE connection with retry logic."""
|
|
145
|
+
self._client = await establish_connection(
|
|
146
|
+
BleakClient,
|
|
147
|
+
self._ble_device,
|
|
148
|
+
self._ble_device.address,
|
|
149
|
+
)
|
|
150
|
+
await self._detect_variant()
|
|
151
|
+
await self._setup_notifications()
|
|
152
|
+
await self._send(CMD_HANDSHAKE)
|
|
153
|
+
_LOGGER.debug(
|
|
154
|
+
"Connected to Desky %s (variant=%s)",
|
|
155
|
+
self._ble_device.address,
|
|
156
|
+
self._variant,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
async def disconnect(self) -> None:
|
|
160
|
+
if self._client and self._client.is_connected:
|
|
161
|
+
await self._client.disconnect()
|
|
162
|
+
self._client = None
|
|
163
|
+
|
|
164
|
+
async def _detect_variant(self) -> None:
|
|
165
|
+
"""Identify the controller variant from advertised GATT services."""
|
|
166
|
+
if self._client is None:
|
|
167
|
+
msg = "Not connected"
|
|
168
|
+
raise RuntimeError(msg)
|
|
169
|
+
|
|
170
|
+
service_uuids = {str(s.uuid).lower() for s in self._client.services}
|
|
171
|
+
|
|
172
|
+
if UUID_SERVICE_LIERDA2.lower() in service_uuids:
|
|
173
|
+
self._variant = ControllerVariant.LIERDA2
|
|
174
|
+
self._write_char = UUID_CHAR_WRITE_LIERDA2
|
|
175
|
+
self._read_char = UUID_CHAR_READ_LIERDA2
|
|
176
|
+
elif UUID_SERVICE_LIERDA1.lower() in service_uuids:
|
|
177
|
+
self._variant = ControllerVariant.LIERDA1
|
|
178
|
+
self._write_char = UUID_CHAR_WRITE_LIERDA1
|
|
179
|
+
self._read_char = UUID_CHAR_READ_LIERDA1
|
|
180
|
+
elif UUID_SERVICE_PEILIN.lower() in service_uuids:
|
|
181
|
+
self._variant = ControllerVariant.PEILIN
|
|
182
|
+
self._write_char = UUID_CHAR_RW_PEILIN
|
|
183
|
+
self._read_char = UUID_CHAR_RW_PEILIN
|
|
184
|
+
else:
|
|
185
|
+
msg = (
|
|
186
|
+
f"No known Desky service found on {self._ble_device.address}. "
|
|
187
|
+
f"Services: {service_uuids}"
|
|
188
|
+
)
|
|
189
|
+
raise RuntimeError(msg)
|
|
190
|
+
|
|
191
|
+
if self._variant == ControllerVariant.PEILIN:
|
|
192
|
+
await self._peilin_login(service_uuids)
|
|
193
|
+
|
|
194
|
+
async def _peilin_login(self, service_uuids: set[str]) -> None:
|
|
195
|
+
"""Send the login payload required by Peilin controllers."""
|
|
196
|
+
if UUID_SERVICE_LOGIN.lower() not in service_uuids:
|
|
197
|
+
_LOGGER.debug("No login service found; skipping Peilin login")
|
|
198
|
+
return
|
|
199
|
+
if self._client is None:
|
|
200
|
+
return
|
|
201
|
+
try:
|
|
202
|
+
await self._client.write_gatt_char(
|
|
203
|
+
UUID_CHAR_LOGIN_WRITE, LOGIN_PAYLOAD, response=True
|
|
204
|
+
)
|
|
205
|
+
_LOGGER.debug("Peilin login handshake sent")
|
|
206
|
+
except Exception:
|
|
207
|
+
_LOGGER.warning("Failed to send Peilin login handshake", exc_info=True)
|
|
208
|
+
|
|
209
|
+
async def _setup_notifications(self) -> None:
|
|
210
|
+
if self._client is None or self._read_char is None:
|
|
211
|
+
return
|
|
212
|
+
await self._client.start_notify(self._read_char, self._on_notification)
|
|
213
|
+
|
|
214
|
+
def _on_notification(
|
|
215
|
+
self,
|
|
216
|
+
_characteristic: BleakGATTCharacteristic,
|
|
217
|
+
data: bytearray,
|
|
218
|
+
) -> None:
|
|
219
|
+
if parse_notification(data, self._state) and self._state_callback:
|
|
220
|
+
self._state_callback(self._state)
|
|
221
|
+
|
|
222
|
+
async def _send(self, frame: bytes) -> None:
|
|
223
|
+
async with self._lock:
|
|
224
|
+
if self._client is None or self._write_char is None:
|
|
225
|
+
msg = "Not connected"
|
|
226
|
+
raise RuntimeError(msg)
|
|
227
|
+
await self._client.write_gatt_char(self._write_char, frame, response=False)
|
|
228
|
+
_LOGGER.debug("TX → %s", frame.hex())
|
|
229
|
+
|
|
230
|
+
async def _send_setting(self, frame: bytes) -> None:
|
|
231
|
+
"""Send a settings command using the APK's reliability pattern."""
|
|
232
|
+
async with self._lock:
|
|
233
|
+
if self._client is None or self._write_char is None:
|
|
234
|
+
msg = "Not connected"
|
|
235
|
+
raise RuntimeError(msg)
|
|
236
|
+
await self._client.write_gatt_char(
|
|
237
|
+
self._write_char, CMD_HANDSHAKE, response=False
|
|
238
|
+
)
|
|
239
|
+
_LOGGER.debug("TX (handshake) → %s", CMD_HANDSHAKE.hex())
|
|
240
|
+
await asyncio.sleep(0.15)
|
|
241
|
+
await self._client.write_gatt_char(self._write_char, frame, response=True)
|
|
242
|
+
_LOGGER.debug("TX (setting #1) → %s", frame.hex())
|
|
243
|
+
await asyncio.sleep(0.20)
|
|
244
|
+
await self._client.write_gatt_char(self._write_char, frame, response=True)
|
|
245
|
+
_LOGGER.debug("TX (setting #2) → %s", frame.hex())
|
|
246
|
+
|
|
247
|
+
async def _send_preset(self, frame: bytes) -> None:
|
|
248
|
+
"""Send a memory-preset command using the APK's reliability pattern."""
|
|
249
|
+
await self._send_setting(frame)
|
|
250
|
+
|
|
251
|
+
async def move_up(self) -> None:
|
|
252
|
+
await self._send(CMD_MOVE_UP)
|
|
253
|
+
|
|
254
|
+
async def move_down(self) -> None:
|
|
255
|
+
await self._send(CMD_MOVE_DOWN)
|
|
256
|
+
|
|
257
|
+
async def stop(self) -> None:
|
|
258
|
+
await self._send(CMD_STOP)
|
|
259
|
+
|
|
260
|
+
async def move_to_height(self, raw_height: int) -> None:
|
|
261
|
+
"""Move to a specific height (*raw_height* = cm × 10)."""
|
|
262
|
+
await self._send(CMD_STOP)
|
|
263
|
+
await asyncio.sleep(0.2)
|
|
264
|
+
frame = cmd_move_to_height(raw_height)
|
|
265
|
+
await self._send(frame)
|
|
266
|
+
|
|
267
|
+
async def recall_memory(self, slot: int) -> None:
|
|
268
|
+
"""Recall a memory preset (slot 1–4)."""
|
|
269
|
+
frame = _RECALL_MEMORY_CMDS.get(slot)
|
|
270
|
+
if frame is None:
|
|
271
|
+
msg = f"Invalid memory slot {slot}"
|
|
272
|
+
raise ValueError(msg)
|
|
273
|
+
await self._send_preset(frame)
|
|
274
|
+
|
|
275
|
+
async def save_memory(self, slot: int) -> None:
|
|
276
|
+
"""Save current height to a memory slot (1–4)."""
|
|
277
|
+
frame = _SAVE_MEMORY_CMDS.get(slot)
|
|
278
|
+
if frame is None:
|
|
279
|
+
msg = f"Invalid memory slot {slot}"
|
|
280
|
+
raise ValueError(msg)
|
|
281
|
+
await self._send_preset(frame)
|
|
282
|
+
|
|
283
|
+
async def request_status(self) -> None:
|
|
284
|
+
await self._send(CMD_GET_STATUS)
|
|
285
|
+
|
|
286
|
+
async def request_all_settings(self) -> None:
|
|
287
|
+
"""Query every known setting from the desk."""
|
|
288
|
+
for frame in (
|
|
289
|
+
CMD_GET_ANTI_COLLISION,
|
|
290
|
+
CMD_GET_LOCK,
|
|
291
|
+
CMD_GET_VIBRATION,
|
|
292
|
+
CMD_GET_LED_COLOR,
|
|
293
|
+
CMD_GET_LIGHTING,
|
|
294
|
+
CMD_GET_BRIGHTNESS,
|
|
295
|
+
):
|
|
296
|
+
await self._send(frame)
|
|
297
|
+
await asyncio.sleep(0.1)
|
|
298
|
+
|
|
299
|
+
def _remember(self, key: str, value: int, frame: bytes) -> None:
|
|
300
|
+
"""Record the user's intended value for a setting."""
|
|
301
|
+
self._desired_settings[key] = (value, frame)
|
|
302
|
+
|
|
303
|
+
async def set_brightness(self, value: int) -> None:
|
|
304
|
+
frame = cmd_set_brightness(value)
|
|
305
|
+
self._remember("brightness", value, frame)
|
|
306
|
+
await self._send_setting(frame)
|
|
307
|
+
|
|
308
|
+
async def set_led_color(self, value: int) -> None:
|
|
309
|
+
frame = cmd_set_led_color(value)
|
|
310
|
+
self._remember("led_color", value, frame)
|
|
311
|
+
await self._send_setting(frame)
|
|
312
|
+
|
|
313
|
+
async def set_vibration(self, value: int) -> None:
|
|
314
|
+
frame = cmd_set_vibration(value)
|
|
315
|
+
self._remember("vibration", value, frame)
|
|
316
|
+
await self._send_setting(frame)
|
|
317
|
+
|
|
318
|
+
async def set_lock(self, value: int) -> None:
|
|
319
|
+
frame = cmd_set_lock(value)
|
|
320
|
+
self._remember("lock_status", value, frame)
|
|
321
|
+
await self._send_setting(frame)
|
|
322
|
+
|
|
323
|
+
async def set_lighting(self, value: int) -> None:
|
|
324
|
+
frame = cmd_set_lighting(value)
|
|
325
|
+
self._remember("lighting", value, frame)
|
|
326
|
+
await self._send_setting(frame)
|
|
327
|
+
|
|
328
|
+
async def set_anti_collision(self, value: int) -> None:
|
|
329
|
+
frame = cmd_set_anti_collision(value)
|
|
330
|
+
self._remember("anti_collision", value, frame)
|
|
331
|
+
await self._send_setting(frame)
|
|
332
|
+
|
|
333
|
+
async def set_touch_mode(self, value: int) -> None:
|
|
334
|
+
frame = cmd_set_touch_mode(value)
|
|
335
|
+
self._remember("touch_mode", value, frame)
|
|
336
|
+
await self._send_setting(frame)
|
|
337
|
+
|
|
338
|
+
async def set_unit(self, value: int) -> None:
|
|
339
|
+
await self._send_setting(cmd_set_unit(value))
|
|
340
|
+
|
|
341
|
+
async def set_reminder(self, minutes: int) -> None:
|
|
342
|
+
await self._send_setting(cmd_set_reminder(minutes))
|
|
343
|
+
|
|
344
|
+
async def restore_settings(self) -> None:
|
|
345
|
+
"""Re-apply any user-set settings that the desk reported differently
|
|
346
|
+
after a reconnect."""
|
|
347
|
+
if not self._desired_settings:
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
for key, (desired, frame) in self._desired_settings.items():
|
|
351
|
+
current = getattr(self._state, key, None)
|
|
352
|
+
if current != desired:
|
|
353
|
+
_LOGGER.debug(
|
|
354
|
+
"Restoring setting '%s': desk=%s → desired=%s",
|
|
355
|
+
key,
|
|
356
|
+
current,
|
|
357
|
+
desired,
|
|
358
|
+
)
|
|
359
|
+
await self._send_setting(frame)
|
|
360
|
+
await asyncio.sleep(0.1)
|
|
361
|
+
|
|
362
|
+
async def clear_limits(self) -> None:
|
|
363
|
+
await self._send(CMD_CLEAR_LIMIT)
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""BLE protocol encoder/decoder for Desky standing desks.
|
|
2
|
+
|
|
3
|
+
This module is a standalone copy of the protocol layer, usable without
|
|
4
|
+
Home Assistant. It is re-exported by the ``desky_ble`` PyPI package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import enum
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
HEADER_TX = bytes([0xF1, 0xF1])
|
|
16
|
+
HEADER_RX = bytes([0xF2, 0xF2])
|
|
17
|
+
FOOTER_TX = 0x7E
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Opcode(enum.IntEnum):
|
|
21
|
+
"""Command opcodes extracted from the Desky APK."""
|
|
22
|
+
|
|
23
|
+
MOVE_UP = 0x01
|
|
24
|
+
MOVE_DOWN = 0x02
|
|
25
|
+
SAVE_MEMORY_1 = 0x03
|
|
26
|
+
SAVE_MEMORY_2 = 0x04
|
|
27
|
+
RECALL_MEMORY_1 = 0x05
|
|
28
|
+
RECALL_MEMORY_2 = 0x06
|
|
29
|
+
GET_STATUS = 0x07
|
|
30
|
+
GET_LIMIT = 0x0C
|
|
31
|
+
SET_UNIT = 0x0E
|
|
32
|
+
SET_TOUCH_MODE = 0x19
|
|
33
|
+
MOVE_TO_HEIGHT = 0x1B
|
|
34
|
+
SET_ANTI_COLLISION = 0x1D
|
|
35
|
+
SET_HIGHEST_LIMIT = 0x21
|
|
36
|
+
SET_LOWEST_LIMIT = 0x22
|
|
37
|
+
CLEAR_LIMIT = 0x23
|
|
38
|
+
SAVE_MEMORY_3 = 0x25
|
|
39
|
+
SAVE_MEMORY_4 = 0x26
|
|
40
|
+
RECALL_MEMORY_3 = 0x27
|
|
41
|
+
RECALL_MEMORY_4 = 0x28
|
|
42
|
+
STOP = 0x2B
|
|
43
|
+
SET_REMINDER = 0xB1
|
|
44
|
+
SET_LOCK = 0xB2
|
|
45
|
+
SET_VIBRATION = 0xB3
|
|
46
|
+
SET_LED_COLOR = 0xB4
|
|
47
|
+
SET_LIGHTING = 0xB5
|
|
48
|
+
SET_BRIGHTNESS = 0xB6
|
|
49
|
+
HANDSHAKE = 0xFE
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_DUAL_OPCODES = {
|
|
53
|
+
Opcode.SET_ANTI_COLLISION,
|
|
54
|
+
Opcode.SET_LOCK,
|
|
55
|
+
Opcode.SET_VIBRATION,
|
|
56
|
+
Opcode.SET_LED_COLOR,
|
|
57
|
+
Opcode.SET_LIGHTING,
|
|
58
|
+
Opcode.SET_BRIGHTNESS,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _checksum(cmd: int, length: int, data: bytes) -> int:
|
|
63
|
+
"""Compute the additive checksum over cmd + length + data."""
|
|
64
|
+
return (cmd + length + sum(data)) & 0xFF
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def height_cm_to_raw(height_cm: float) -> int:
|
|
68
|
+
"""Convert a height in *cm* to the raw protocol value (× 10)."""
|
|
69
|
+
return int(round(height_cm * 10))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def height_raw_to_cm(raw: int) -> float:
|
|
73
|
+
"""Convert raw protocol value back to cm."""
|
|
74
|
+
return raw / 10.0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def height_is_cm(raw: int) -> bool:
|
|
78
|
+
"""Return True if the raw value represents centimetres (≥ 550)."""
|
|
79
|
+
return raw >= 550
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_frame(opcode: int, data: bytes = b"") -> bytes:
|
|
83
|
+
"""Build a complete TX frame ready to write to the BLE characteristic.
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
bytes
|
|
88
|
+
``[0xF1, 0xF1, CMD, LEN, *DATA, CHECKSUM, 0x7E]``
|
|
89
|
+
"""
|
|
90
|
+
length = len(data)
|
|
91
|
+
cs = _checksum(opcode, length, data)
|
|
92
|
+
return HEADER_TX + bytes([opcode, length]) + data + bytes([cs, FOOTER_TX])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
CMD_MOVE_UP = build_frame(Opcode.MOVE_UP)
|
|
96
|
+
CMD_MOVE_DOWN = build_frame(Opcode.MOVE_DOWN)
|
|
97
|
+
CMD_STOP = build_frame(Opcode.STOP)
|
|
98
|
+
CMD_RECALL_MEMORY_1 = build_frame(Opcode.RECALL_MEMORY_1)
|
|
99
|
+
CMD_RECALL_MEMORY_2 = build_frame(Opcode.RECALL_MEMORY_2)
|
|
100
|
+
CMD_RECALL_MEMORY_3 = build_frame(Opcode.RECALL_MEMORY_3)
|
|
101
|
+
CMD_RECALL_MEMORY_4 = build_frame(Opcode.RECALL_MEMORY_4)
|
|
102
|
+
CMD_SAVE_MEMORY_1 = build_frame(Opcode.SAVE_MEMORY_1)
|
|
103
|
+
CMD_SAVE_MEMORY_2 = build_frame(Opcode.SAVE_MEMORY_2)
|
|
104
|
+
CMD_SAVE_MEMORY_3 = build_frame(Opcode.SAVE_MEMORY_3)
|
|
105
|
+
CMD_SAVE_MEMORY_4 = build_frame(Opcode.SAVE_MEMORY_4)
|
|
106
|
+
CMD_GET_STATUS = build_frame(Opcode.GET_STATUS)
|
|
107
|
+
CMD_GET_LIMIT = build_frame(Opcode.GET_LIMIT)
|
|
108
|
+
CMD_CLEAR_LIMIT = build_frame(Opcode.CLEAR_LIMIT)
|
|
109
|
+
CMD_HANDSHAKE = build_frame(Opcode.HANDSHAKE)
|
|
110
|
+
|
|
111
|
+
CMD_GET_ANTI_COLLISION = build_frame(Opcode.SET_ANTI_COLLISION)
|
|
112
|
+
CMD_GET_LOCK = build_frame(Opcode.SET_LOCK)
|
|
113
|
+
CMD_GET_VIBRATION = build_frame(Opcode.SET_VIBRATION)
|
|
114
|
+
CMD_GET_LED_COLOR = build_frame(Opcode.SET_LED_COLOR)
|
|
115
|
+
CMD_GET_LIGHTING = build_frame(Opcode.SET_LIGHTING)
|
|
116
|
+
CMD_GET_BRIGHTNESS = build_frame(Opcode.SET_BRIGHTNESS)
|
|
117
|
+
CMD_GET_CURRENT_LIMITATION = build_frame(0x20)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def cmd_move_to_height(raw_height: int) -> bytes:
|
|
121
|
+
"""Build a move-to-height command (raw = cm × 10)."""
|
|
122
|
+
data = bytes([(raw_height >> 8) & 0xFF, raw_height & 0xFF])
|
|
123
|
+
return build_frame(Opcode.MOVE_TO_HEIGHT, data)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cmd_set_highest_limit(raw_height: int) -> bytes:
|
|
127
|
+
data = bytes([(raw_height >> 8) & 0xFF, raw_height & 0xFF])
|
|
128
|
+
return build_frame(Opcode.SET_HIGHEST_LIMIT, data)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_set_lowest_limit(raw_height: int) -> bytes:
|
|
132
|
+
data = bytes([(raw_height >> 8) & 0xFF, raw_height & 0xFF])
|
|
133
|
+
return build_frame(Opcode.SET_LOWEST_LIMIT, data)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_set_value(opcode: int, value: int) -> bytes:
|
|
137
|
+
"""Build a single-byte-value set command."""
|
|
138
|
+
return build_frame(opcode, bytes([value & 0xFF]))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cmd_set_touch_mode(value: int) -> bytes:
|
|
142
|
+
return cmd_set_value(Opcode.SET_TOUCH_MODE, value)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cmd_set_unit(value: int) -> bytes:
|
|
146
|
+
return cmd_set_value(Opcode.SET_UNIT, value)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cmd_set_brightness(value: int) -> bytes:
|
|
150
|
+
return cmd_set_value(Opcode.SET_BRIGHTNESS, value)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cmd_set_led_color(value: int) -> bytes:
|
|
154
|
+
return cmd_set_value(Opcode.SET_LED_COLOR, value)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_set_vibration(value: int) -> bytes:
|
|
158
|
+
return cmd_set_value(Opcode.SET_VIBRATION, value)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cmd_set_lock(value: int) -> bytes:
|
|
162
|
+
return cmd_set_value(Opcode.SET_LOCK, value)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cmd_set_lighting(value: int) -> bytes:
|
|
166
|
+
return cmd_set_value(Opcode.SET_LIGHTING, value)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def cmd_set_anti_collision(value: int) -> bytes:
|
|
170
|
+
return cmd_set_value(Opcode.SET_ANTI_COLLISION, value)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cmd_set_reminder(minutes: int) -> bytes:
|
|
174
|
+
return cmd_set_value(Opcode.SET_REMINDER, minutes)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclasses.dataclass(slots=True)
|
|
178
|
+
class DeskState:
|
|
179
|
+
"""Mutable snapshot of all known desk state."""
|
|
180
|
+
|
|
181
|
+
height_raw: int | None = None
|
|
182
|
+
is_moving: bool = False
|
|
183
|
+
lock_status: int | None = None
|
|
184
|
+
brightness: int | None = None
|
|
185
|
+
led_color: int | None = None
|
|
186
|
+
vibration: int | None = None
|
|
187
|
+
lighting: int | None = None
|
|
188
|
+
anti_collision: int | None = None
|
|
189
|
+
touch_mode: int | None = None
|
|
190
|
+
upper_limit_raw: int | None = None
|
|
191
|
+
lower_limit_raw: int | None = None
|
|
192
|
+
has_limits: bool = False
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def height_cm(self) -> float | None:
|
|
196
|
+
if self.height_raw is None:
|
|
197
|
+
return None
|
|
198
|
+
return height_raw_to_cm(self.height_raw)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def parse_notification(data: bytes | bytearray, state: DeskState) -> bool:
|
|
202
|
+
"""Parse a BLE notification payload and update *state* in-place.
|
|
203
|
+
|
|
204
|
+
Returns ``True`` if the notification was recognised and handled.
|
|
205
|
+
|
|
206
|
+
Uses a zero-copy ``memoryview`` to avoid intermediate allocations
|
|
207
|
+
when slicing the incoming BLE payload.
|
|
208
|
+
"""
|
|
209
|
+
mv = memoryview(data)
|
|
210
|
+
|
|
211
|
+
if len(mv) < 6:
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
if mv[0] != 0xF2 or mv[1] != 0xF2:
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
cmd = mv[2]
|
|
218
|
+
data_len = mv[3]
|
|
219
|
+
|
|
220
|
+
if cmd == 0x01 and data_len == 0x03 and len(mv) >= 6:
|
|
221
|
+
raw = (mv[4] << 8) | mv[5]
|
|
222
|
+
state.height_raw = raw
|
|
223
|
+
state.is_moving = True
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
if cmd == 0x21 and data_len == 0x02 and len(mv) >= 6:
|
|
227
|
+
state.upper_limit_raw = (mv[4] << 8) | mv[5]
|
|
228
|
+
state.has_limits = True
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
if cmd == 0x22 and data_len == 0x02 and len(mv) >= 6:
|
|
232
|
+
state.lower_limit_raw = (mv[4] << 8) | mv[5]
|
|
233
|
+
state.has_limits = True
|
|
234
|
+
return True
|
|
235
|
+
|
|
236
|
+
if cmd == 0x20 and data_len == 0x01 and len(mv) >= 5:
|
|
237
|
+
state.has_limits = mv[4] != 0x00
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
if cmd == 0x1D and data_len == 0x01 and len(mv) >= 5:
|
|
241
|
+
state.anti_collision = mv[4]
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
if cmd == 0xB2 and data_len == 0x01 and len(mv) >= 5:
|
|
245
|
+
state.lock_status = mv[4]
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
if cmd == 0xB6 and data_len == 0x01 and len(mv) >= 5:
|
|
249
|
+
state.brightness = mv[4]
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
if cmd == 0xB4 and data_len == 0x01 and len(mv) >= 5:
|
|
253
|
+
state.led_color = mv[4]
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
if cmd == 0xB3 and data_len == 0x01 and len(mv) >= 5:
|
|
257
|
+
state.vibration = mv[4]
|
|
258
|
+
return True
|
|
259
|
+
|
|
260
|
+
if cmd == 0xB5 and data_len == 0x01 and len(mv) >= 5:
|
|
261
|
+
state.lighting = mv[4]
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
if cmd == 0x19 and data_len == 0x01 and len(mv) >= 5:
|
|
265
|
+
state.touch_mode = mv[4]
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
_LOGGER.debug("Unhandled notification: %s", bytes(mv).hex())
|
|
269
|
+
return False
|