Skip to content

Commit 1845f79

Browse files
committed
feat: add extended timer support for 0xB6 devices
The 0xB6 device uses a distinct timer wire format (e0 05/06 wrapped commands, variable-length 6-slot list, per-slot set): - const.py: TIMER_ACTION_* and TIMER_EFFECT_* constants - protocol.py: MSG_TIMERS_EXTENDED and the timer methods on ProtocolLEDENETExtendedCustom - timer.py: LedTimerExtended dataclass (to_bytes/from_bytes, __str__) - aiodevice.py: timer types widened, async_set_timers sends 0xB6 timers one at a time, new async_set_timer Stacked on the effect-APIs PR.
1 parent 134fd15 commit 1845f79

5 files changed

Lines changed: 852 additions & 8 deletions

File tree

flux_led/aiodevice.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,12 @@
4949
ProtocolLEDENET8Byte,
5050
ProtocolLEDENETAddressableA3,
5151
ProtocolLEDENETAddressableChristmas,
52+
ProtocolLEDENETExtendedCustom,
5253
ProtocolLEDENETOriginal,
5354
RemoteConfig,
5455
)
5556
from .scanner import FluxLEDDiscovery
56-
from .timer import LedTimer
57+
from .timer import LedTimer, LedTimerExtended
5758
from .utils import color_temp_to_white_levels, rgbw_brightness, rgbww_brightness
5859

5960
_LOGGER = logging.getLogger(__name__)
@@ -87,7 +88,7 @@ def __init__(
8788
self._get_time_future: asyncio.Future[bool] | None = None
8889
self._get_timers_lock: asyncio.Lock = asyncio.Lock()
8990
self._get_timers_future: asyncio.Future[bool] | None = None
90-
self._timers: list[LedTimer] | None = None
91+
self._timers: list[LedTimer] | list[LedTimerExtended] | None = None
9192
self._power_restore_future: asyncio.Future[bool] = loop.create_future()
9293
self._device_config_lock: asyncio.Lock = asyncio.Lock()
9394
self._device_config_future: asyncio.Future[bool] = loop.create_future()
@@ -711,7 +712,7 @@ async def async_get_time(self) -> datetime | None:
711712
return None
712713
return self._last_time
713714

714-
async def async_get_timers(self) -> list[LedTimer] | None:
715+
async def async_get_timers(self) -> list[LedTimer] | list[LedTimerExtended] | None:
715716
"""Get the timers."""
716717
assert self._protocol is not None
717718
if isinstance(self._protocol, ProtocolLEDENETOriginal):
@@ -728,10 +729,24 @@ async def async_get_timers(self) -> list[LedTimer] | None:
728729
return None
729730
return self._timers
730731

731-
async def async_set_timers(self, timer_list: list[LedTimer]) -> None:
732+
async def async_set_timers(
733+
self, timer_list: list[LedTimer] | list[LedTimerExtended]
734+
) -> None:
732735
"""Set the timers."""
733736
assert self._protocol is not None
734-
await self._async_send_msg(self._protocol.construct_set_timers(timer_list))
737+
if isinstance(self._protocol, ProtocolLEDENETExtendedCustom):
738+
# 0xB6 devices set timers one at a time
739+
commands = self._protocol.construct_set_timers(timer_list) # type: ignore[arg-type]
740+
for cmd in commands:
741+
await self._async_send_msg(cmd)
742+
else:
743+
await self._async_send_msg(self._protocol.construct_set_timers(timer_list)) # type: ignore[arg-type]
744+
745+
async def async_set_timer(self, timer: LedTimerExtended) -> None:
746+
"""Set a single timer (for 0xB6 devices)."""
747+
assert self._protocol is not None
748+
if isinstance(self._protocol, ProtocolLEDENETExtendedCustom):
749+
await self._async_send_msg(self._protocol.construct_set_timer(timer))
735750

736751
async def async_set_time(self, time: datetime | None = None) -> None:
737752
"""Set the current time."""

flux_led/const.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,15 @@ class ScribbleLED(NamedTuple):
256256
PUSH_UPDATE_INTERVAL = 90 # seconds
257257

258258
NEVER_TIME = -PUSH_UPDATE_INTERVAL
259+
260+
261+
# Extended Timer Action Types (0xB6 devices)
262+
TIMER_ACTION_ON: Final = 0x23
263+
TIMER_ACTION_OFF: Final = 0x24
264+
TIMER_ACTION_COLOR: Final = 0xA1
265+
TIMER_ACTION_SCENE_GRADIENT: Final = 0x29
266+
TIMER_ACTION_SCENE_SEGMENTS: Final = 0x6B
267+
268+
# Extended Timer Effect Types
269+
TIMER_EFFECT_GRADIENT: Final = 0x21 # e1 21 - gradient overlay
270+
TIMER_EFFECT_SEGMENTS: Final = 0x22 # e1 22 - static segments/colorful

flux_led/protocol.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
LevelWriteModeData,
2929
MultiColorEffects,
3030
)
31-
from .timer import LedTimer
31+
from .timer import LedTimer, LedTimerExtended
3232
from .utils import (
3333
scaled_color_temp_to_white_levels,
3434
utils,
@@ -130,6 +130,7 @@ class PowerRestoreStates:
130130
MSG_STATE = "state"
131131
MSG_TIME = "time"
132132
MSG_TIMERS = "timers"
133+
MSG_TIMERS_EXTENDED = "timers_extended"
133134
MSG_MUSIC_MODE_STATE = "music_mode_state"
134135
MSG_ADDRESSABLE_STATE = "addressable_state"
135136
MSG_DEVICE_CONFIG = "device_config"
@@ -165,6 +166,7 @@ class PowerRestoreStates:
165166
(0x63,): MSG_A1_DEVICE_CONFIG,
166167
(0x72,): MSG_MUSIC_MODE_STATE,
167168
(0x2B,): MSG_REMOTE_CONFIG,
169+
(0xE0, 0x06): MSG_TIMERS_EXTENDED, # 0xB6 device timer response
168170
}
169171

170172
MSG_LENGTHS = {
@@ -2033,6 +2035,115 @@ def construct_scribble_paint(
20332035
msg, inner_pre_constructed=True, version=0x02
20342036
)
20352037

2038+
# --- Timer Support for 0xB6 devices ---
2039+
# This protocol uses a different timer format than other protocols:
2040+
# - Query: e0 06 (wrapped)
2041+
# - Response: e0 06 + variable length slot data
2042+
# - Set: e0 05 SS f0 HH MM 00 RR ... (wrapped)
2043+
2044+
def construct_get_timers(self) -> bytearray:
2045+
"""Construct a get timers request.
2046+
2047+
0xB6 devices use the wrapped e0 06 command for timer queries.
2048+
"""
2049+
inner = bytearray([0xE0, 0x06])
2050+
return self.construct_wrapped_message(
2051+
inner, inner_pre_constructed=True, version=0x01
2052+
)
2053+
2054+
def construct_set_timer(self, timer: LedTimerExtended) -> bytearray:
2055+
"""Construct a set timer command for a single timer.
2056+
2057+
0xB6 devices set timers one at a time using e0 05 SS ...
2058+
2059+
Args:
2060+
timer: The LedTimerExtended object to set
2061+
2062+
Returns:
2063+
Wrapped command bytearray
2064+
"""
2065+
inner = bytearray([0xE0, 0x05, timer.slot])
2066+
inner.extend(timer.to_bytes())
2067+
return self.construct_wrapped_message(
2068+
inner, inner_pre_constructed=True, version=0x01
2069+
)
2070+
2071+
def construct_set_timers( # type: ignore[override]
2072+
self, timer_list: list[LedTimerExtended]
2073+
) -> list[bytearray]:
2074+
"""Construct set timer commands for multiple timers.
2075+
2076+
0xB6 devices set timers one at a time, so this returns a list
2077+
of commands (one per timer).
2078+
2079+
Args:
2080+
timer_list: List of LedTimerExtended objects to set
2081+
2082+
Returns:
2083+
List of wrapped command bytearrays
2084+
"""
2085+
return [self.construct_set_timer(timer) for timer in timer_list]
2086+
2087+
def is_valid_timers_response(self, msg: bytes) -> bool:
2088+
"""Check if a message is a valid timers response.
2089+
2090+
0xB6 timer responses start with e0 06.
2091+
"""
2092+
if len(msg) < 2:
2093+
return False
2094+
return msg[0] == 0xE0 and msg[1] == 0x06
2095+
2096+
def parse_get_timers(self, msg: bytes) -> list[LedTimerExtended]: # type: ignore[override]
2097+
"""Parse timer response into list of LedTimerExtended objects.
2098+
2099+
Response format:
2100+
- Empty: e0 06 (2 bytes)
2101+
- With timers: e0 06 [slot1] [slot2] ... [slot6]
2102+
2103+
Each slot is either:
2104+
- 7 bytes if empty/inactive
2105+
- 21 bytes if simple timer (ON/OFF/color)
2106+
- Variable (48+) bytes if scene timer
2107+
"""
2108+
if len(msg) <= 2:
2109+
# Empty response or just header - no timers configured
2110+
return []
2111+
2112+
timers: list[LedTimerExtended] = []
2113+
offset = 2 # Skip e0 06 header
2114+
2115+
while offset < len(msg):
2116+
# Check if we have enough bytes for minimum slot (7 bytes)
2117+
if offset + 7 > len(msg):
2118+
break
2119+
2120+
timer, consumed = LedTimerExtended.from_bytes(msg, offset)
2121+
timers.append(timer)
2122+
offset += consumed
2123+
2124+
return timers
2125+
2126+
@property
2127+
def timer_count(self) -> int:
2128+
"""Number of timer slots supported."""
2129+
return 6
2130+
2131+
def expected_timers_response_length(self, data: bytes) -> int:
2132+
"""Calculate expected timer response length.
2133+
2134+
For 0xB6 devices, the timer response has variable length
2135+
based on the inner message length encoded in the wrapped message.
2136+
"""
2137+
# Timer responses are wrapped, so we extract from the wrapper
2138+
if (
2139+
len(data) >= OUTER_MESSAGE_WRAPPER_START_LEN
2140+
and data[0] == OUTER_MESSAGE_FIRST_BYTE
2141+
):
2142+
inner_msg_len = (data[8] << 8) + data[9]
2143+
return OUTER_MESSAGE_WRAPPER_START_LEN + inner_msg_len + CHECKSUM_LEN
2144+
# Fallback
2145+
return len(data)
2146+
20362147

20372148
class ProtocolLEDENETAddressableBase(ProtocolLEDENET9Byte):
20382149
"""Base class for addressable protocols."""

0 commit comments

Comments
 (0)