Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 9d3ae2a

Browse files
authoredAug 29, 2022
fix: cleanups (#12)
1 parent 366da5f commit 9d3ae2a

File tree

5 files changed

+584
-595
lines changed

5 files changed

+584
-595
lines changed
 

‎examples/run.py

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
_LOGGER = logging.getLogger(__name__)
1111

1212
ADDRESS = "D0291B39-3A1B-7FF2-787B-4E743FED5B25"
13+
ADDRESS = "D0291B39-3A1B-7FF2-787B-4E743FED5B25"
1314

1415

1516
async def run() -> None:

‎src/led_ble/__init__.py

+3-595
Large diffs are not rendered by default.

‎src/led_ble/led_ble.py

+542
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,542 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import colorsys
5+
import logging
6+
from collections.abc import Callable
7+
from dataclasses import replace
8+
from typing import Any, TypeVar, cast
9+
10+
from bleak.backends.device import BLEDevice
11+
from bleak.backends.service import BleakGATTCharacteristic, BleakGATTServiceCollection
12+
from bleak.exc import BleakDBusError
13+
from bleak_retry_connector import (
14+
BleakClientWithServiceCache,
15+
BleakError,
16+
BleakNotFoundError,
17+
ble_device_has_changed,
18+
establish_connection,
19+
)
20+
21+
from .const import (
22+
POSSIBLE_READ_CHARACTERISTIC_UUIDS,
23+
POSSIBLE_WRITE_CHARACTERISTIC_UUIDS,
24+
POWER_OFF_COMAMND,
25+
POWER_ON_COMMAND,
26+
STATE_COMMAND,
27+
)
28+
from .exceptions import CharacteristicMissingError
29+
from .models import LEDBLEState
30+
from .util import rgbw_brightness
31+
32+
__version__ = "0.5.0"
33+
34+
35+
WrapFuncType = TypeVar("WrapFuncType", bound=Callable[..., Any])
36+
37+
DISCONNECT_DELAY = 120
38+
39+
RETRY_BACKOFF_EXCEPTIONS = (BleakDBusError,)
40+
BLEAK_EXCEPTIONS = (AttributeError, BleakError, asyncio.exceptions.TimeoutError)
41+
42+
RETRY_EXCEPTIONS = (
43+
asyncio.TimeoutError,
44+
BleakError,
45+
EOFError,
46+
)
47+
_LOGGER = logging.getLogger(__name__)
48+
49+
DEFAULT_ATTEMPTS = 3
50+
51+
52+
def retry_bluetooth_connection_error(func: WrapFuncType) -> WrapFuncType:
53+
"""Define a wrapper to retry on bleak error.
54+
55+
The accessory is allowed to disconnect us any time so
56+
we need to retry the operation.
57+
"""
58+
59+
async def _async_wrap_retry_bluetooth_connection_error(
60+
self: "LEDBLE", *args: Any, **kwargs: Any
61+
) -> Any:
62+
_LOGGER.debug("%s: Starting retry loop", self.name)
63+
attempts = DEFAULT_ATTEMPTS
64+
max_attempts = attempts - 1
65+
66+
for attempt in range(attempts):
67+
try:
68+
return await func(self, *args, **kwargs)
69+
except BleakNotFoundError:
70+
# The lock cannot be found so there is no
71+
# point in retrying.
72+
raise
73+
except RETRY_BACKOFF_EXCEPTIONS as err:
74+
if attempt >= max_attempts:
75+
_LOGGER.debug(
76+
"%s: %s error calling %s, reach max attempts (%s/%s)",
77+
self.name,
78+
type(err),
79+
func,
80+
attempt,
81+
max_attempts,
82+
exc_info=True,
83+
)
84+
raise
85+
_LOGGER.debug(
86+
"%s: %s error calling %s, backing off %ss, retrying (%s/%s)...",
87+
self.name,
88+
type(err),
89+
func,
90+
0.25,
91+
attempt,
92+
max_attempts,
93+
exc_info=True,
94+
)
95+
await asyncio.sleep(0.25)
96+
except RETRY_EXCEPTIONS as err:
97+
if attempt >= max_attempts:
98+
_LOGGER.debug(
99+
"%s: %s error calling %s, reach max attempts (%s/%s)",
100+
self.name,
101+
type(err),
102+
func,
103+
attempt,
104+
max_attempts,
105+
exc_info=True,
106+
)
107+
raise
108+
_LOGGER.debug(
109+
"%s: %s error calling %s, retrying (%s/%s)...",
110+
self.name,
111+
type(err),
112+
func,
113+
attempt,
114+
max_attempts,
115+
exc_info=True,
116+
)
117+
118+
return cast(WrapFuncType, _async_wrap_retry_bluetooth_connection_error)
119+
120+
121+
class LEDBLE:
122+
def __init__(
123+
self, ble_device: BLEDevice, retry_count: int = DEFAULT_ATTEMPTS
124+
) -> None:
125+
"""Init the LEDBLE."""
126+
self._ble_device = ble_device
127+
self._operation_lock = asyncio.Lock()
128+
self._state = LEDBLEState()
129+
self._connect_lock: asyncio.Lock = asyncio.Lock()
130+
self._cached_services: BleakGATTServiceCollection | None = None
131+
self._read_char: BleakGATTCharacteristic | None = None
132+
self._write_char: BleakGATTCharacteristic | None = None
133+
self._disconnect_timer: asyncio.TimerHandle | None = None
134+
self._retry_count = retry_count
135+
self._client: BleakClientWithServiceCache | None = None
136+
self._expected_disconnect = False
137+
self.loop = asyncio.get_running_loop()
138+
self._callbacks: list[Callable[[LEDBLEState], None]] = []
139+
140+
def set_ble_device(self, ble_device: BLEDevice) -> None:
141+
"""Set the ble device."""
142+
if self._ble_device and ble_device_has_changed(self._ble_device, ble_device):
143+
_LOGGER.debug(
144+
"%s: New ble device details, clearing cached services", self.name
145+
)
146+
self._cached_services = None
147+
self._ble_device = ble_device
148+
self._address = ble_device.address
149+
150+
@property
151+
def name(self) -> str:
152+
"""Get the name of the device."""
153+
return self._ble_device.name or self._ble_device.address
154+
155+
@property
156+
def rssi(self) -> str:
157+
"""Get the name of the device."""
158+
return self._ble_device.rssi
159+
160+
@property
161+
def state(self) -> LEDBLEState:
162+
"""Return the state."""
163+
return self._state
164+
165+
@property
166+
def rgb(self) -> tuple[int, int, int]:
167+
return self._state.rgb
168+
169+
@property
170+
def w(self) -> int:
171+
return self._state.w
172+
173+
@property
174+
def rgb_unscaled(self) -> tuple[int, int, int]:
175+
"""Return the unscaled RGB."""
176+
r, g, b = self.rgb
177+
hsv = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
178+
r_p, g_p, b_p = colorsys.hsv_to_rgb(hsv[0], hsv[1], 1)
179+
return round(r_p * 255), round(g_p * 255), round(b_p * 255)
180+
181+
@property
182+
def on(self) -> bool:
183+
return self._state.power
184+
185+
@property
186+
def brightness(self) -> int:
187+
"""Return current brightness 0-255."""
188+
if self.w:
189+
return self.w
190+
r, g, b = self.rgb
191+
_, _, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
192+
return int(v * 255)
193+
194+
@retry_bluetooth_connection_error
195+
async def update(self) -> None:
196+
"""Update the LEDBLE."""
197+
_LOGGER.debug("%s: Updating", self.name)
198+
await self._send_command(STATE_COMMAND)
199+
200+
@retry_bluetooth_connection_error
201+
async def turn_on(self) -> None:
202+
"""Turn on."""
203+
_LOGGER.debug("%s: Turn on", self.name)
204+
await self._send_command(POWER_ON_COMMAND)
205+
self._state = replace(self._state, power=True)
206+
self._fire_callbacks()
207+
208+
@retry_bluetooth_connection_error
209+
async def turn_off(self) -> None:
210+
"""Turn off."""
211+
_LOGGER.debug("%s: Turn off", self.name)
212+
await self._send_command(POWER_OFF_COMAMND)
213+
self._state = replace(self._state, power=False)
214+
self._fire_callbacks()
215+
216+
async def set_brightness(self, brightness: int) -> None:
217+
"""Set the brightness."""
218+
_LOGGER.debug("%s: Set brightness: %s", self.name, brightness)
219+
await self.set_rgb(self.rgb_unscaled, brightness)
220+
221+
@retry_bluetooth_connection_error
222+
async def set_rgb(
223+
self, rgb: tuple[int, int, int], brightness: int | None = None
224+
) -> None:
225+
"""Set rgb."""
226+
_LOGGER.debug("%s: Set rgb: %s brightness: %s", self.name, rgb, brightness)
227+
for value in rgb:
228+
if not 0 <= value <= 255:
229+
raise ValueError("Value {} is outside the valid range of 0-255")
230+
if brightness is not None:
231+
rgb = self._calculate_brightness(rgb, brightness)
232+
_LOGGER.debug("%s: Set rgb after brightness: %s", self.name, rgb)
233+
234+
await self._send_command(b"\x56" + bytes(rgb) + b"\x00\xF0\xAA")
235+
self._state = replace(self._state, rgb=rgb, brightness=brightness)
236+
self._fire_callbacks()
237+
238+
@retry_bluetooth_connection_error
239+
async def set_rgbw(
240+
self, rgbw: tuple[int, int, int, int], brightness: int | None = None
241+
) -> None:
242+
"""Set rgbw."""
243+
_LOGGER.debug("%s: Set rgbw: %s brightness: %s", self.name, rgbw, brightness)
244+
for value in rgbw:
245+
if not 0 <= value <= 255:
246+
raise ValueError("Value {} is outside the valid range of 0-255")
247+
rgbw = rgbw_brightness(rgbw, brightness)
248+
_LOGGER.debug("%s: Set rgbw after brightness: %s", self.name, rgbw)
249+
250+
await self._send_command(b"\x56" + bytes(rgbw) + b"\x00\xAA")
251+
self._state = replace(
252+
self._state,
253+
rgb=(rgbw[0], rgbw[1], rgbw[2]),
254+
w=rgbw[3],
255+
brightness=brightness,
256+
)
257+
self._fire_callbacks()
258+
259+
@retry_bluetooth_connection_error
260+
async def set_white(self, brightness: int) -> None:
261+
"""Set rgb."""
262+
_LOGGER.debug("%s: Set white: %s", self.name, brightness)
263+
if not 0 <= brightness <= 255:
264+
raise ValueError("Value {} is outside the valid range of 0-255")
265+
await self._send_command(
266+
b"\x56\x00\x00\x00" + bytes([brightness]) + b"\x0F\xAA"
267+
)
268+
self._state = replace(
269+
self._state, rgb=(0, 0, 0), w=brightness, brightness=brightness
270+
)
271+
self._fire_callbacks()
272+
273+
async def stop(self) -> None:
274+
"""Stop the LEDBLE."""
275+
_LOGGER.debug("%s: Stop", self.name)
276+
await self._execute_disconnect()
277+
278+
def _calculate_brightness(
279+
self, rgb: tuple[int, int, int], level: int
280+
) -> tuple[int, int, int]:
281+
hsv = colorsys.rgb_to_hsv(*rgb)
282+
r, g, b = colorsys.hsv_to_rgb(hsv[0], hsv[1], level)
283+
return int(r), int(g), int(b)
284+
285+
def _fire_callbacks(self) -> None:
286+
"""Fire the callbacks."""
287+
for callback in self._callbacks:
288+
callback(self._state)
289+
290+
def register_callback(
291+
self, callback: Callable[[LEDBLEState], None]
292+
) -> Callable[[], None]:
293+
"""Register a callback to be called when the state changes."""
294+
295+
def unregister_callback() -> None:
296+
self._callbacks.remove(callback)
297+
298+
self._callbacks.append(callback)
299+
return unregister_callback
300+
301+
async def _ensure_connected(self) -> None:
302+
"""Ensure connection to device is established."""
303+
if self._connect_lock.locked():
304+
_LOGGER.debug(
305+
"%s: Connection already in progress, waiting for it to complete; RSSI: %s",
306+
self.name,
307+
self.rssi,
308+
)
309+
if self._client and self._client.is_connected:
310+
self._reset_disconnect_timer()
311+
return
312+
async with self._connect_lock:
313+
# Check again while holding the lock
314+
if self._client and self._client.is_connected:
315+
self._reset_disconnect_timer()
316+
return
317+
_LOGGER.debug("%s: Connecting; RSSI: %s", self.name, self.rssi)
318+
client = await establish_connection(
319+
BleakClientWithServiceCache,
320+
self._ble_device,
321+
self.name,
322+
self._disconnected,
323+
cached_services=self._cached_services,
324+
ble_device_callback=lambda: self._ble_device,
325+
)
326+
_LOGGER.debug("%s: Connected; RSSI: %s", self.name, self.rssi)
327+
resolved = self._resolve_characteristics(client.services)
328+
if not resolved:
329+
# Try to handle services failing to load
330+
resolved = self._resolve_characteristics(await client.get_services())
331+
self._cached_services = client.services if resolved else None
332+
self._client = client
333+
self._reset_disconnect_timer()
334+
335+
_LOGGER.debug(
336+
"%s: Subscribe to notifications; RSSI: %s", self.name, self.rssi
337+
)
338+
await client.start_notify(self._read_char, self._notification_handler)
339+
340+
@property
341+
def model_num(self) -> int:
342+
"""Return the model num."""
343+
return self._state.model_num
344+
345+
@property
346+
def version_num(self) -> int:
347+
"""Return the version num."""
348+
return self._state.version_num
349+
350+
@property
351+
def preset_pattern(self) -> int:
352+
"""Return the preset_pattern."""
353+
return self._state.preset_pattern
354+
355+
@property
356+
def mode(self) -> int:
357+
"""Return the mode."""
358+
return self._state.mode
359+
360+
@property
361+
def speed(self) -> int:
362+
"""Return the speed."""
363+
return self._state.speed
364+
365+
def _notification_handler(self, _sender: int, data: bytearray) -> None:
366+
"""Handle notification responses."""
367+
model_num = data[1]
368+
on = data[2] == 0x23
369+
preset_pattern = data[3]
370+
mode = data[4]
371+
speed = data[5]
372+
r = data[6]
373+
g = data[7]
374+
b = data[8]
375+
w = data[9]
376+
version = data[10]
377+
self._state = LEDBLEState(
378+
on, (r, g, b), w, model_num, preset_pattern, mode, speed, version
379+
)
380+
381+
_LOGGER.debug(
382+
"%s: Notification received; RSSI: %s: %s %s",
383+
self.name,
384+
self.rssi,
385+
data.hex(),
386+
self._state,
387+
)
388+
self._fire_callbacks()
389+
390+
def _reset_disconnect_timer(self) -> None:
391+
"""Reset disconnect timer."""
392+
if self._disconnect_timer:
393+
self._disconnect_timer.cancel()
394+
self._expected_disconnect = False
395+
self._disconnect_timer = self.loop.call_later(
396+
DISCONNECT_DELAY, self._disconnect
397+
)
398+
399+
def _disconnected(self, client: BleakClientWithServiceCache) -> None:
400+
"""Disconnected callback."""
401+
if self._expected_disconnect:
402+
_LOGGER.debug(
403+
"%s: Disconnected from device; RSSI: %s", self.name, self.rssi
404+
)
405+
return
406+
_LOGGER.warning(
407+
"%s: Device unexpectedly disconnected; RSSI: %s",
408+
self.name,
409+
self.rssi,
410+
)
411+
412+
def _disconnect(self) -> None:
413+
"""Disconnect from device."""
414+
self._disconnect_timer = None
415+
asyncio.create_task(self._execute_timed_disconnect())
416+
417+
async def _execute_timed_disconnect(self) -> None:
418+
"""Execute timed disconnection."""
419+
_LOGGER.debug(
420+
"%s: Disconnecting after timeout of %s",
421+
self.name,
422+
DISCONNECT_DELAY,
423+
)
424+
await self._execute_disconnect()
425+
426+
async def _execute_disconnect(self) -> None:
427+
"""Execute disconnection."""
428+
async with self._connect_lock:
429+
read_char = self._read_char
430+
client = self._client
431+
self._expected_disconnect = True
432+
self._client = None
433+
self._read_char = None
434+
self._write_char = None
435+
if client and client.is_connected:
436+
await client.stop_notify(read_char)
437+
await client.disconnect()
438+
439+
async def _send_command_locked(self, command: bytes) -> None:
440+
"""Send command to device and read response."""
441+
try:
442+
await self._execute_command_locked(command)
443+
except BleakDBusError as ex:
444+
# Disconnect so we can reset state and try again
445+
await asyncio.sleep(0.25)
446+
_LOGGER.debug(
447+
"%s: RSSI: %s; Backing off %ss; Disconnecting due to error: %s",
448+
self.name,
449+
self.rssi,
450+
0.25,
451+
ex,
452+
)
453+
await self._execute_disconnect()
454+
raise
455+
except BleakError as ex:
456+
# Disconnect so we can reset state and try again
457+
_LOGGER.debug(
458+
"%s: RSSI: %s; Disconnecting due to error: %s", self.name, self.rssi, ex
459+
)
460+
await self._execute_disconnect()
461+
raise
462+
463+
async def _send_command(self, command: bytes, retry: int | None = None) -> None:
464+
"""Send command to device and read response."""
465+
await self._ensure_connected()
466+
if retry is None:
467+
retry = self._retry_count
468+
_LOGGER.debug("%s: Sending command %s", self.name, command)
469+
max_attempts = retry + 1
470+
if self._operation_lock.locked():
471+
_LOGGER.debug(
472+
"%s: Operation already in progress, waiting for it to complete; RSSI: %s",
473+
self.name,
474+
self.rssi,
475+
)
476+
async with self._operation_lock:
477+
for attempt in range(max_attempts):
478+
try:
479+
await self._send_command_locked(command)
480+
return
481+
except BleakNotFoundError:
482+
_LOGGER.error(
483+
"%s: device not found, no longer in range, or poor RSSI: %s",
484+
self.name,
485+
self.rssi,
486+
exc_info=True,
487+
)
488+
return None
489+
except CharacteristicMissingError as ex:
490+
if attempt == retry:
491+
_LOGGER.error(
492+
"%s: characteristic missing: %s; Stopping trying; RSSI: %s",
493+
self.name,
494+
ex,
495+
self.rssi,
496+
exc_info=True,
497+
)
498+
return None
499+
500+
_LOGGER.debug(
501+
"%s: characteristic missing: %s; RSSI: %s",
502+
self.name,
503+
ex,
504+
self.rssi,
505+
exc_info=True,
506+
)
507+
except BLEAK_EXCEPTIONS:
508+
if attempt == retry:
509+
_LOGGER.error(
510+
"%s: communication failed; Stopping trying; RSSI: %s",
511+
self.name,
512+
self.rssi,
513+
exc_info=True,
514+
)
515+
return None
516+
517+
_LOGGER.debug(
518+
"%s: communication failed with:", self.name, exc_info=True
519+
)
520+
521+
raise RuntimeError("Unreachable")
522+
523+
async def _execute_command_locked(self, command: bytes) -> None:
524+
"""Execute command and read response."""
525+
assert self._client is not None # nosec
526+
if not self._read_char:
527+
raise CharacteristicMissingError("Read characteristic missing")
528+
if not self._write_char:
529+
raise CharacteristicMissingError("Write characteristic missing")
530+
await self._client.write_gatt_char(self._write_char, command, False)
531+
532+
def _resolve_characteristics(self, services: BleakGATTServiceCollection) -> bool:
533+
"""Resolve characteristics."""
534+
for characteristic in POSSIBLE_READ_CHARACTERISTIC_UUIDS:
535+
if char := services.get_characteristic(characteristic):
536+
self._read_char = char
537+
break
538+
for characteristic in POSSIBLE_WRITE_CHARACTERISTIC_UUIDS:
539+
if char := services.get_characteristic(characteristic):
540+
self._write_char = char
541+
break
542+
return bool(self._read_char and self._write_char)

‎src/led_ble/models.py

+1
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ class LEDBLEState:
1313
preset_pattern: int = 0
1414
mode: int = 0
1515
speed: int = 0
16+
version_num: int = 0

‎src/led_ble/util.py

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from __future__ import annotations
2+
3+
import colorsys
4+
5+
6+
def rgbw_brightness(
7+
rgbw_data: tuple[int, int, int, int],
8+
brightness: int | None = None,
9+
) -> tuple[int, int, int, int]:
10+
"""Convert rgbw to brightness."""
11+
original_r, original_g, original_b = rgbw_data[0:3]
12+
h, s, v = colorsys.rgb_to_hsv(original_r / 255, original_g / 255, original_b / 255)
13+
color_brightness = round(v * 255)
14+
ww_brightness = rgbw_data[3]
15+
current_brightness = round((color_brightness + ww_brightness) / 2)
16+
17+
if not brightness or brightness == current_brightness:
18+
return rgbw_data
19+
20+
if brightness < current_brightness:
21+
change_brightness_pct = (current_brightness - brightness) / current_brightness
22+
ww_brightness = round(ww_brightness * (1 - change_brightness_pct))
23+
color_brightness = round(color_brightness * (1 - change_brightness_pct))
24+
25+
else:
26+
change_brightness_pct = (brightness - current_brightness) / (
27+
255 - current_brightness
28+
)
29+
ww_brightness = round(
30+
(255 - ww_brightness) * change_brightness_pct + ww_brightness
31+
)
32+
color_brightness = round(
33+
(255 - color_brightness) * change_brightness_pct + color_brightness
34+
)
35+
36+
r, g, b = colorsys.hsv_to_rgb(h, s, color_brightness / 255)
37+
return (round(r * 255), round(g * 255), round(b * 255), ww_brightness)

0 commit comments

Comments
 (0)
Please sign in to comment.