Skip to content

Commit b7e8b84

Browse files
authored
Parse extended state messages from 0x35 v10 (#428)
1 parent e484521 commit b7e8b84

File tree

5 files changed

+631
-34
lines changed

5 files changed

+631
-34
lines changed

flux_led/aiodevice.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,9 @@ def _async_process_message(self, msg: bytes) -> None:
729729
self._last_message["state"] = msg
730730
self._async_process_state_response(msg)
731731
self._process_state_futures()
732+
elif self._protocol.is_valid_extended_state_response(msg):
733+
self._last_message["extended_state"] = msg
734+
self.process_extended_state_response(msg)
732735
elif self._protocol.is_valid_power_state_response(msg):
733736
self._last_message["power_state"] = msg
734737
self.process_power_state_response(msg)

flux_led/base_device.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -778,16 +778,19 @@ def process_device_config_response(self, msg: bytes) -> None:
778778
_LOGGER.debug("%s: device_config: %s", self.ipaddr, self._device_config)
779779

780780
def process_state_response(self, rx: bytes) -> bool:
781+
"""Process a state change message."""
781782
assert self._protocol is not None
782-
783783
if not self._protocol.is_valid_state_response(rx):
784784
_LOGGER.warning(
785-
"%s: Recieved invalid response: %s",
785+
"%s: Invalid response: %s",
786786
self.ipaddr,
787787
utils.raw_state_to_dec(rx),
788788
)
789789
return False
790+
return self._process_valid_state_response(rx)
790791

792+
def _process_valid_state_response(self, rx: bytes) -> bool:
793+
assert self._protocol is not None
791794
raw_state: LEDENETOriginalRawState | LEDENETRawState = (
792795
self._protocol.named_raw_state(rx)
793796
)
@@ -853,6 +856,12 @@ def process_power_state_response(self, msg: bytes) -> bool:
853856
self._set_power_state(msg[2])
854857
return True
855858

859+
def process_extended_state_response(self, msg: bytes) -> bool:
860+
"""Process and extended state response."""
861+
assert self._protocol is not None
862+
self._process_valid_state_response(self._protocol.extended_state_to_state(msg))
863+
return True
864+
856865
def _set_raw_state(
857866
self,
858867
raw_state: LEDENETOriginalRawState | LEDENETRawState,

flux_led/protocol.py

Lines changed: 112 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@
2828
MultiColorEffects,
2929
)
3030
from .timer import LedTimer
31-
from .utils import utils, white_levels_to_scaled_color_temp
31+
from .utils import (
32+
scaled_color_temp_to_white_levels,
33+
utils,
34+
white_levels_to_scaled_color_temp,
35+
)
3236

3337

3438
class RemoteConfig(Enum):
@@ -502,6 +506,13 @@ def construct_state_query(self) -> bytearray:
502506
def is_valid_state_response(self, raw_state: bytes) -> bool:
503507
"""Check if a state response is valid."""
504508

509+
def is_valid_extended_state_response(self, raw_state: bytes) -> bool:
510+
return False
511+
512+
@abstractmethod
513+
def extended_state_to_state(self, raw_state: bytes) -> bytes:
514+
"""Convert an extended state response to a state response."""
515+
505516
def is_checksum_correct(self, msg: bytes) -> bool:
506517
"""Check a checksum of a message."""
507518
expected_sum = sum(msg[0:-1]) & 0xFF
@@ -1423,14 +1434,97 @@ def name(self) -> str:
14231434
"""The name of the protocol."""
14241435
return PROTOCOL_LEDENET_25BYTE
14251436

1437+
def is_valid_extended_state_response(self, raw_state: bytes) -> bool:
1438+
"""Check if a state response is valid."""
1439+
return raw_state[0] == 0xEA and raw_state[1] == 0x81 and len(raw_state) >= 20
1440+
1441+
def extended_state_to_state(self, raw_state: bytes) -> bytes:
1442+
"""Convert an extended state response to a state response."""
1443+
# pos 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
1444+
# EA 81 01 10 35 0A 23 61 01 50 0F 3C 64 64 00 64 00 00 00 00
1445+
# | | | | | | | | | | | | | | | | | | | |
1446+
# | | | | | | | | | | | | | | | | | | | ??
1447+
# | | | | | | | | | | | | | | | | | | ??
1448+
# | | | | | | | | | | | | | | | | | ??
1449+
# | | | | | | | | | | | | | | | | ??
1450+
# | | | | | | | | | | | | | | | White brightness
1451+
# | | | | | | | | | | | | | | White temperature
1452+
# | | | | | | | | | | | | | Value
1453+
# | | | | | | | | | | | | Saturation
1454+
# | | | | | | | | | | | Hue / 2 (0-180)
1455+
# | | | | | | | | | | 0f white / f0 rgb
1456+
# | | | | | | | | | Speed?
1457+
# | | | | | | | | w 01 / rgb 00
1458+
# | | | | | | | ??
1459+
# | | | | | | Power state (0x23 = ON, 0x24 = OFF)
1460+
# | | | | | ??
1461+
# | | | | Version number
1462+
# | | | Model number
1463+
# | | Unknown / reserved
1464+
# | Unknown / reserved
1465+
# Extended message header (ea 81)
1466+
1467+
if len(raw_state) < 20:
1468+
return b""
1469+
1470+
model_num = raw_state[4]
1471+
version_number = raw_state[5]
1472+
power_state = raw_state[6]
1473+
preset_pattern = raw_state[7]
1474+
speed = raw_state[9]
1475+
1476+
hue = raw_state[11]
1477+
saturation = raw_state[12]
1478+
value = raw_state[13]
1479+
1480+
white_temp = raw_state[14]
1481+
white_brightness = raw_state[15]
1482+
levels = scaled_color_temp_to_white_levels(white_temp, white_brightness)
1483+
1484+
cool_white = levels.cool_white
1485+
warm_white = levels.warm_white
1486+
1487+
# Convert HSV to RGB
1488+
h = (hue * 2) / 360
1489+
s = saturation / 100
1490+
v = value / 100
1491+
r_f, g_f, b_f = colorsys.hsv_to_rgb(h, s, v)
1492+
red = min(int(max(0, r_f) * 255), 255)
1493+
green = min(int(max(0, g_f) * 255), 255)
1494+
blue = min(int(max(0, b_f) * 255), 255)
1495+
1496+
# Fill standard state structure
1497+
mode = 0
1498+
color_mode = 0
1499+
check_sum = 0 # Set to 0; not critical
1500+
1501+
return bytes(
1502+
(
1503+
raw_state[1], # Head (second byte of EA 81)
1504+
model_num,
1505+
power_state,
1506+
preset_pattern,
1507+
mode,
1508+
speed,
1509+
red,
1510+
green,
1511+
blue,
1512+
warm_white,
1513+
version_number,
1514+
cool_white,
1515+
color_mode,
1516+
check_sum,
1517+
)
1518+
)
1519+
14261520
def construct_levels_change(
14271521
self,
14281522
persist: int,
1429-
red: int | None,
1430-
green: int | None,
1431-
blue: int | None,
1432-
warm_white: int | None,
1433-
cool_white: int | None,
1523+
red: int | None, # 0-255
1524+
green: int | None, # 0-255
1525+
blue: int | None, # 0-255
1526+
warm_white: int | None, # 0-255
1527+
cool_white: int | None, # 0-255
14341528
write_mode: LevelWriteMode | int,
14351529
) -> list[bytearray]:
14361530
"""The bytes to send for a level change request."""
@@ -1457,16 +1551,19 @@ def construct_levels_change(
14571551
else:
14581552
h = s = v = 0x00
14591553

1460-
if warm_white is not None and cool_white is not None:
1461-
# warm white comes through as 0, even when temp is set to 6500
1462-
white_brightness = round(((warm_white + cool_white) / (2 * 255)) * 64)
1463-
white_temp = round((cool_white / (warm_white + cool_white)) * 64)
1464-
elif cool_white is not None and warm_white is None:
1465-
white_temp = 0x64
1466-
white_brightness = int((cool_white / 255) * 100)
1554+
if (
1555+
cool_white is None
1556+
or warm_white is None
1557+
or (cool_white == 0 and warm_white == 0)
1558+
):
1559+
white_temp = white_brightness = 0
14671560
else:
1468-
white_temp = 0x00
1469-
white_brightness = 0x00
1561+
total = warm_white + cool_white
1562+
# temperature: ratio of cool to total, scaled to 0-100
1563+
white_temp = round((cool_white / float(total)) * 100)
1564+
# brightness: clamp sum at 255, then scale to 0-100
1565+
clamped_sum = min(total, 255)
1566+
white_brightness = round((clamped_sum / 255.0) * 100)
14701567

14711568
return [
14721569
self.construct_wrapped_message(

flux_led/utils.py

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@
44
import colorsys
55
import contextlib
66
import datetime
7-
from collections import namedtuple
87
from collections.abc import Iterable
9-
from typing import cast
8+
from typing import NamedTuple, cast
109

1110
import webcolors # type: ignore[import-untyped]
1211

@@ -15,22 +14,18 @@
1514
MAX_MIN_TEMP_DIFF = MAX_TEMP - MIN_TEMP
1615

1716

18-
WhiteLevels = namedtuple(
19-
"WhiteLevels",
20-
[
21-
"warm_white",
22-
"cool_white",
23-
],
24-
)
17+
class WhiteLevels(NamedTuple):
18+
"""White level for a color."""
2519

20+
warm_white: int
21+
cool_white: int
2622

27-
TemperatureBrightness = namedtuple(
28-
"TemperatureBrightness",
29-
[
30-
"temperature",
31-
"brightness",
32-
],
33-
)
23+
24+
class TemperatureBrightness(NamedTuple):
25+
"""Temperature and brightness for a color."""
26+
27+
temperature: int
28+
brightness: int
3429

3530

3631
class utils:

0 commit comments

Comments
 (0)