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.
@@ -0,0 +1,10 @@
1
+ decompiled
2
+ extracted
3
+ *.xapk
4
+ .venv
5
+ .ruff_cache
6
+ .pytest_cache
7
+ *pycache*
8
+ examples
9
+ tests
10
+ .github
@@ -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