Skip to content

Commit c66f0fb

Browse files
author
jr4
committed
feat: basic support for hello fairy
1 parent 51facab commit c66f0fb

File tree

9 files changed

+330
-173
lines changed

9 files changed

+330
-173
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
- uses: actions/checkout@v3
1818
- uses: actions/setup-python@v3
1919
with:
20-
python-version: "3.9"
20+
python-version: "3.10"
2121
- uses: pre-commit/action@v2.0.3
2222

2323
# Make sure commit messages follow the conventional commits convention:
@@ -36,7 +36,6 @@ jobs:
3636
fail-fast: false
3737
matrix:
3838
python-version:
39-
- "3.9"
4039
- "3.10"
4140
- "3.11"
4241
- "3.11"

.github/workflows/labels.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
- name: Set up Python
1616
uses: actions/setup-python@v3
1717
with:
18-
python-version: 3.8
18+
python-version: 3.10
1919
- name: Install labels
2020
run: pip install labels
2121
- name: Sync config with Github

examples/run.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@
99

1010
_LOGGER = logging.getLogger(__name__)
1111

12-
ADDRESS = "D0291B39-3A1B-7FF2-787B-4E743FED5B25"
13-
ADDRESS = "D0291B39-3A1B-7FF2-787B-4E743FED5B25"
12+
ADDRESS = "BE:27:E1:00:10:63" # Hello Fairy-1063PPPP
1413

1514

1615
async def run() -> None:
@@ -34,20 +33,36 @@ def on_state_changed(state: LEDBLEState) -> None:
3433
device = await future
3534
led = LEDBLE(device)
3635
cancel_callback = led.register_callback(on_state_changed)
36+
_LOGGER.info("update...")
3737
await led.update()
38+
_LOGGER.info("turn_on...")
3839
await led.turn_on()
40+
_LOGGER.info("set_rgb(red)...")
3941
await led.set_rgb((255, 0, 0), 255)
4042
await asyncio.sleep(1)
43+
_LOGGER.info("set_rgb(green)...")
4144
await led.set_rgb((0, 255, 0), 128)
4245
await asyncio.sleep(1)
46+
_LOGGER.info("set_rgb(blue)...")
4347
await led.set_rgb((0, 0, 255), 255)
4448
await asyncio.sleep(1)
49+
_LOGGER.info("set_rgbw(white)...")
4550
await led.set_rgbw((255, 255, 255, 128), 255)
4651
await asyncio.sleep(1)
52+
_LOGGER.info("set_preset_pattern(1)...")
53+
await led.async_set_preset_pattern(1, 100, 100)
54+
await asyncio.sleep(2)
55+
_LOGGER.info("set_preset_pattern(59)...")
56+
await led.async_set_preset_pattern(59, 100, 100)
57+
await asyncio.sleep(2)
58+
_LOGGER.info("turn_off...")
4759
await led.turn_off()
60+
_LOGGER.info("update...")
4861
await led.update()
62+
_LOGGER.info("finish...")
4963
cancel_callback()
5064
await scanner.stop()
65+
_LOGGER.info("done")
5166

5267

5368
logging.basicConfig(level=logging.INFO)

poetry.lock

Lines changed: 99 additions & 142 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ packages = [
2323
"Changelog" = "https://github.com/bluetooth-devices/led-ble/blob/main/CHANGELOG.md"
2424

2525
[tool.poetry.dependencies]
26-
python = "^3.9"
26+
python = "^3.10"
2727

2828
# Documentation Dependencies
2929
Sphinx = {version = "^5.0", optional = true}

src/led_ble/const.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,16 @@ class CharacteristicMissingError(Exception):
1111
"""Raised when a characteristic is missing."""
1212

1313

14+
HELLO_FAIRY_WRITE_CHARACTERISTIC = "49535343-8841-43f4-a8d4-ecbe34729bb3"
15+
HELLO_FAIRY_READ_CHARACTERISTIC = "49535343-1e4d-4bd9-ba61-23c647249616"
16+
1417
POSSIBLE_WRITE_CHARACTERISTIC_UUIDS = [
1518
BASE_UUID_FORMAT.format(part) for part in ["ff01", "ffd5", "ffd9", "ffe5", "ffe9"]
16-
]
19+
] + [HELLO_FAIRY_WRITE_CHARACTERISTIC]
20+
1721
POSSIBLE_READ_CHARACTERISTIC_UUIDS = [
1822
BASE_UUID_FORMAT.format(part) for part in ["ff02", "ffd0", "ffd4", "ffe0", "ffe4"]
19-
]
23+
] + [HELLO_FAIRY_READ_CHARACTERISTIC]
24+
2025

2126
QUERY_STATE_BYTES = bytearray([0xEF, 0x01, 0x77])

src/led_ble/led_ble.py

Lines changed: 76 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@
2525
from flux_led.utils import rgbw_brightness
2626

2727
from led_ble.model_db import LEDBLEModel
28+
from led_ble.protocol import ProtocolFairy
2829

2930
from .const import (
31+
HELLO_FAIRY_READ_CHARACTERISTIC,
3032
POSSIBLE_READ_CHARACTERISTIC_UUIDS,
3133
POSSIBLE_WRITE_CHARACTERISTIC_UUIDS,
3234
STATE_COMMAND,
@@ -279,10 +281,14 @@ def _generate_preset_pattern(
279281
brightness = int(brightness * 255 / 100)
280282
speed = int(speed * 255 / 100)
281283
return bytearray([0x9E, 0x00, pattern, speed, brightness, 0x00, 0xE9])
282-
PresetPattern.valid_or_raise(pattern)
284+
if not self._is_hello_fairy():
285+
PresetPattern.valid_or_raise(pattern)
283286
if not (1 <= brightness <= 100):
284287
raise ValueError("Brightness must be between 1 and 100")
285288
assert self._protocol is not None # nosec
289+
if self._is_hello_fairy() and pattern > 58:
290+
rgb = [[255, 0, 0], [0, 255, 0], [0, 0, 255]] * 8 + [[255, 0, 0]]
291+
return self._protocol.construct_custom_effect(rgb, speed, "")
286292
return self._protocol.construct_preset_pattern(pattern, speed, brightness)
287293

288294
async def async_set_preset_pattern(
@@ -431,29 +437,64 @@ def _named_effect(self) -> str | None:
431437
"""Returns the named effect."""
432438
return EFFECT_ID_NAME.get(self.preset_pattern_num)
433439

440+
# ideally replace with classes to encapsulate the differences between device makes
441+
def _is_hello_fairy(self) -> bool:
442+
if self._read_char is None:
443+
return False
444+
d = self._read_char.descriptors
445+
c = d[0].characteristic_uuid if (len(d) > 0) else None
446+
return c == HELLO_FAIRY_READ_CHARACTERISTIC
447+
434448
def _notification_handler(self, _sender: int, data: bytearray) -> None:
435449
"""Handle notification responses."""
436450
_LOGGER.debug("%s: Notification received: %s", self.name, data.hex())
437451

438-
if len(data) == 4 and data[0] == 0xCC:
439-
on = data[1] == 0x23
440-
self._state = replace(self._state, power=on)
441-
return
442-
if len(data) < 11:
443-
return
444-
model_num = data[1]
445-
on = data[2] == 0x23
446-
preset_pattern = data[3]
447-
mode = data[4]
448-
speed = data[5]
449-
r = data[6]
450-
g = data[7]
451-
b = data[8]
452-
w = data[9]
453-
version = data[10]
454-
self._state = LEDBLEState(
455-
on, (r, g, b), w, model_num, preset_pattern, mode, speed, version
456-
)
452+
model_num = 0
453+
if self._is_hello_fairy():
454+
if data[0] == 0xAA:
455+
if data[1] == 0x00: # hw info
456+
if len(data) > 7:
457+
version_string = data[3:8].decode("ascii")
458+
_LOGGER.debug("version %s", version_string)
459+
self._state = replace(
460+
self._state,
461+
version_num=(data[3] - 48) * 100
462+
+ (data[5] - 48) * 10
463+
+ (data[7] - 48),
464+
)
465+
if len(data) > 12:
466+
model = data[8:13].decode("ascii")
467+
_LOGGER.debug("model %s", model)
468+
if len(data) > 24:
469+
lights = data[24] # guessing
470+
_LOGGER.debug("lights %d", lights)
471+
if len(data) > 33:
472+
effects = data[33] # guessing
473+
_LOGGER.debug("effects %d", effects)
474+
475+
if data[1] == 0x01: # state info
476+
if len(data) > 6:
477+
self._state = replace(self._state, power=data[6] > 0)
478+
else:
479+
if len(data) == 4 and data[0] == 0xCC:
480+
on = data[1] == 0x23
481+
self._state = replace(self._state, power=on)
482+
return
483+
if len(data) < 11:
484+
return
485+
model_num = data[1]
486+
on = data[2] == 0x23
487+
preset_pattern = data[3]
488+
mode = data[4]
489+
speed = data[5]
490+
r = data[6]
491+
g = data[7]
492+
b = data[8]
493+
w = data[9]
494+
version = data[10]
495+
self._state = LEDBLEState(
496+
on, (r, g, b), w, model_num, preset_pattern, mode, speed, version
497+
)
457498

458499
_LOGGER.debug(
459500
"%s: Notification received; RSSI: %s: %s %s",
@@ -466,8 +507,10 @@ def _notification_handler(self, _sender: int, data: bytearray) -> None:
466507
if not self._resolve_protocol_event.is_set():
467508
self._resolve_protocol_event.set()
468509
self._model_data = get_model(model_num)
469-
self._set_protocol(self._model_data.protocol_for_version_num(version))
470-
510+
if self._is_hello_fairy():
511+
self._protocol = ProtocolFairy()
512+
else:
513+
self._set_protocol(self._model_data.protocol_for_version_num(version))
471514
self._fire_callbacks()
472515

473516
def _reset_disconnect_timer(self) -> None:
@@ -622,13 +665,23 @@ def _resolve_characteristics(self, services: BleakGATTServiceCollection) -> bool
622665
if char := services.get_characteristic(characteristic):
623666
self._write_char = char
624667
break
668+
_LOGGER.debug(
669+
"using characteristic %s for read, characteristic %s for write",
670+
self._read_char,
671+
self._write_char,
672+
)
625673
return bool(self._read_char and self._write_char)
626674

627675
async def _resolve_protocol(self) -> None:
628676
"""Resolve protocol."""
629677
if self._resolve_protocol_event.is_set():
630678
return
631-
await self._send_command_while_connected([STATE_COMMAND])
679+
if self._is_hello_fairy():
680+
await self._send_command_while_connected(
681+
[b"\xaa\x00\x00\xaa"]
682+
) # get version and capabilities
683+
else:
684+
await self._send_command_while_connected([STATE_COMMAND])
632685
async with asyncio_timeout(10):
633686
await self._resolve_protocol_event.wait()
634687

src/led_ble/model_db.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ def protocol_for_version_num(self, version_num: int) -> str:
3131

3232

3333
MODELS = [
34+
LEDBLEModel(
35+
model_num=0x00,
36+
models=["Hello Fairy:BMSL6"],
37+
description="Controller RGB",
38+
protocols=[
39+
MinVersionProtocol(0, "Fairy"),
40+
],
41+
color_modes=COLOR_MODES_RGB_W, # Formerly rgbwcapable
42+
),
3443
LEDBLEModel(
3544
model_num=0x04,
3645
models=["Triones:C10511000166"],

src/led_ble/protocol.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import colorsys
2+
from math import floor
3+
from flux_led.protocol import ProtocolBase
4+
from led_ble.led_ble import LevelWriteMode
5+
6+
7+
class ProtocolFairy(ProtocolBase):
8+
"""Protocol for Hello Fairy devices."""
9+
10+
@property
11+
def name(self) -> str:
12+
"""The name of the protocol."""
13+
return "Fairy"
14+
15+
def construct_state_query(self) -> bytearray:
16+
"""The bytes to send for a query request."""
17+
return self.construct_message(bytearray([0xAA, 0x01, 0x00]))
18+
19+
def construct_state_change(self, turn_on: int) -> bytearray:
20+
"""The bytes to send for a state change request."""
21+
return self.construct_message(
22+
bytearray([0xAA, 0x02, 0x01, 1 if turn_on else 0])
23+
)
24+
25+
def construct_message(self, raw_bytes: bytearray) -> bytearray:
26+
"""Calculate checksum of byte array and add to end."""
27+
csum = sum(raw_bytes) & 0xFF
28+
raw_bytes.append(csum)
29+
return raw_bytes
30+
31+
def construct_levels_change(
32+
self,
33+
persist: int,
34+
red: int | None,
35+
green: int | None,
36+
blue: int | None,
37+
warm_white: int | None,
38+
cool_white: int | None,
39+
write_mode: LevelWriteMode,
40+
) -> list[bytearray]:
41+
"""The bytes to send for a level change request."""
42+
h, s, v = colorsys.rgb_to_hsv(
43+
(red or 0) / 255, (green or 0) / 255, (blue or 0) / 255
44+
)
45+
h_scaled = min(359, floor(h * 360))
46+
s_scaled = round(s * 1000)
47+
v_scaled = round(v * 1000)
48+
return [
49+
self.construct_message(
50+
bytearray(
51+
[
52+
0xAA,
53+
0x03,
54+
0x07,
55+
0x01,
56+
h_scaled >> 8,
57+
h_scaled & 0xFF,
58+
s_scaled >> 8,
59+
s_scaled & 0xFF,
60+
v_scaled >> 8,
61+
v_scaled & 0xFF,
62+
]
63+
)
64+
)
65+
]
66+
67+
def construct_preset_pattern(
68+
self, pattern: int, speed: int, brightness: int
69+
) -> list[bytearray]:
70+
"""The bytes to send for a preset pattern."""
71+
return [
72+
self.construct_message(
73+
bytearray(
74+
[
75+
0xAA,
76+
0x03,
77+
0x04,
78+
0x02,
79+
pattern & 0xFF,
80+
(brightness >> 8) & 0xFF,
81+
brightness & 0xFF,
82+
]
83+
)
84+
),
85+
self.construct_message(bytearray([0xAA, 0x0C, 0x01, min(speed, 100)])),
86+
]
87+
88+
def construct_custom_effect(
89+
self, rgb_list: list[tuple[int, int, int]], speed: int, transition_type: str
90+
) -> list[bytearray]:
91+
"""The bytes to send for a custom effect."""
92+
data_bytes = len(rgb_list) * 3 + 1
93+
hue_message = bytearray(data_bytes + 3)
94+
hue_message[0:4] = [0xAA, 0xDA, data_bytes, 0x01]
95+
for [i, [r, g, b]] in enumerate(rgb_list):
96+
h, s, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
97+
if v < 0.25:
98+
h = 0xFE # black
99+
elif s < 0.25:
100+
h = 0xFF # white
101+
else:
102+
h = floor(h * 0xAF)
103+
# necessary to satisfy both flake and ruff:
104+
a = i * 3 + 4
105+
b = a + 3
106+
hue_message[a:b] = [i >> 8, i & 0xFF, h]
107+
return [
108+
*self.construct_motion(speed, 0),
109+
self.construct_message(hue_message),
110+
*self.construct_motion(speed, 2),
111+
]
112+
113+
def construct_motion(self, speed: int, transition: int) -> list[bytearray]:
114+
"""The bytes to send for motion speed and transition."""
115+
return [
116+
self.construct_message(
117+
bytearray([0xAA, 0xD0, 0x04, transition, 0x64, speed, 0x01])
118+
)
119+
]

0 commit comments

Comments
 (0)