Repository: lightinglibs/flux_led

Issue:
Only on-off is working
Bulb RGBCW (0x35)
by Zengge
Firmware: 11.94
Hardware: AK001-ZJ21411 

Home Assistant 2023.9.2 
Supervisor 2023.09.2
Operating System 10.5 
Frontend 20230911.0 - latest 

Bulbs are initially paired with SurpLife android app. In app everything is working fine. 
In HomeAssistant, after adding Magic Home integration bulbs are detected OK, but only on-off is working. Nothing else. Can't set color temperature, single color, effect, brightness...
When same is tried in SurpLife app - it works, but in HomeAssistant changes are not reflected in card.

Discussion:
@mirraj2: same here@eutervogel: Same here.
Here's a debug log from an attempt to change the color:

DEBUG: flux_led.aioprotocol: ('192.168.178.73', 5577) => 0x81 0x8A 0x8B 0x96 (4)
DEBUG: flux_led.aioprotocol: ('192.168.178.73', 5577) <= 0x81 0x35 0x23 0x61 0x30 0x50 0x00 0xFF 0x00 0x00 0x0A 0x00 0xF0 0xB3 (14)
DEBUG: flux_led.base_device: 192.168.178.73: set_available: Received message 81352361305000ff00000a00f0b3
DEBUG: flux_led.base_device: 192.168.178.73: State: LEDENETRawState(head=129, model_num=53, power_state=35, preset_pattern=97, mode=48, speed=80, red=0, green=255, blue=0, warm_white=0, version_number=10, cool_white=0, color_mode=240, check_sum=179)
DEBUG: flux_led.base_device: 192.168.178.73: unmapped raw state: 129,53,35,97,48,80,0,255,0,0,10,0,240,179,
DEBUG: flux_led.base_device: 192.168.178.73: Mapped State: LEDENETRawState(head=129, model_num=53, power_state=35, preset_pattern=97, mode=48, speed=80, red=0, green=255, blue=0, warm_white=0, version_number=10, cool_white=0, color_mode=240, check_sum=179)
DEBUG: flux_led.aiodevice: 192.168.178.73: device_config: wiring=None operating_mode=None
DEBUG: flux_led.base_device: 192.168.178.73: Transition time is 2.07, set _transition_complete_time to 338692.66676329
DEBUG: flux_led.base_device: 192.168.178.73: _replace_raw_state: {'red': 255, 'green': 0, 'blue': 0, 'warm_white': 0, 'cool_white': 0}
DEBUG: flux_led.aioprotocol: ('192.168.178.73', 5577) => 0x31 0xFF 0x00 0x00 0x00 0x00 0xF0 0x0F 0x2F (9)
Setting color RGB: (255, 0, 0) [red]
DEBUG: flux_led.base_device: 192.168.178.73: set_unavailable: Connection closed
DEBUG: flux_led.aioprotocol: ('192.168.178.73', 5577): Connection lost: None
DEBUG: flux_led.base_device: 192.168.178.73: set_unavailable: Connection lost

maybe it helps@3of9: From comments on issues raised in the Magic Home integration of Home Assistant, it looks like the protocol was changed.

Unfortunately, this has rendered a large number of lights useless for me. I hope this project isn't abandoned.@AlmostInteractive: I have the same hardware/firmware.  It looks like this project has been abandoned, only the on-off works anymore. If anyone knows anything about reverse engineering from packets, here's a .pcap I captured toggling the bulb red, green, red, green.  Hopefully it helps:  https://file.io/SUx14YiaUMPt@kirillsimin: I've sniffed packets from Surplife firmware `AK001-ZJ21411`. I will try to update the package, but meanwhile here are some useful responses from the bulb:

### Status
The status call is the same as with previous firmware versions.

UDP, PORT `48899`, query `b"HF-A11ASSISTHREAD"`

This returns: {ip_addr},{mac_addr},{firmware},{state}

### Control

The protocol and port are the same as with previous versions:
TCP, PORT `5577`

But the call and the response signatures have changed dramatically. I am seeing some patterns, but more investigation is needed before we can fully implement this.


```
Sent: b0b1b2b3000102 1a 0003712495 10                           | Response: b0b1b2b3000202020014ea810100350a2461015 0f 0735264 0000 00000000 7a # OFF
Sent: b0b1b2b3000102 19 0003712394 0d                           | Response: b0b1b2b3000202030014ea810100350a2361015 0f 0735264 0000 00000000 7a # ON
Sent: b0b1b2b3000102 24 000ee00100 b1 000000 6432 0000140000 38 | Response: b0b1b2b3000202020014ea810100350a2361015 00 f000000 6432 00000000 05 # cool white 50
Sent: b0b1b2b3000102 25 000ee00100 b1 000000 6464 0000140000 54 | Response: b0b1b2b3000202030014ea810100350a2361015 00 f000000 6464 00000000 38 # cool white 100
Sent: b0b1b2b3000102 4a 000ee00100 b1 000000 0032 0000140000 f9 | Response: b0b1b2b3000202020014ea810100350a2361015 00 f000000 0032 00000000 a1 # warm white 50
Sent: b0b1b2b3000102 34 000ee00100 b1 000000 0064 0000140000 24 | Response: b0b1b2b3000202020014ea810100350a2361015 00 f000000 0064 00000000 d3 # warm white 100
Sent: b0b1b2b3000102 78 000ee00100 a1 006464 0000 0000140000 ad | Response: b0b1b2b30002020d0014ea810100350a2361005 0f 0006464 0000 00000000 22 # red
Sent: b0b1b2b3000102 7a 000ee00100 a1 3c6464 0000 0000140000 eb | Response: b0b1b2b30002020e0014ea810100350a2361005 0f 03c6464 0000 00000000 5f # green
Sent: b0b1b2b3000102 79 000ee00100 a1 786464 0000 0000140000 26 | Response: b0b1b2b30002020f0014ea810100350a2361005 0f 0786464 0000 00000000 9c # blue
```@bdraco: b0b1b2b30... is usually a wrapper around another message. Some of the other devices uses it so there is already support for extracting the inner message in this library. Unfortunately, it's been a long time since I've looked at this so I don't remember exactly where it is.@kirillsimin: **RGB values have been replaced with HSV** in the new firmware, except Hue is divided by 2.

So far, the structure seems to be the this. ON / OFF seem to follow its own pattern, but white / color are as follows:

**b0b1b2b3000102** -- header, always the same
**34** -- counter, keeps ticking up
**000ee00100** -- filler ??
**b1** - white / **a1** - color
**000000** - white, HEX values for Hue / 2, Saturation , Value if color (ie **006464**)
**00** -- warm / **64** cool
**64** -- brightness for white
**0000140000** -- filler?
**24** -- checksum@kirillsimin: I've got a working branch here: https://github.com/kirillsimin/flux_led/tree/add-firmware-11

Will clean up some and add tests and make a PR.@kirillsimin: My branch is functionally ready with warm/cool white and RGB, but transitions aren't working yet. It looks like it's not just a byte that triggers transitions anymore. The protocol has changed drastically, it and it looks like they're sending all of the colors in the byte body. I captured the six transitions from the app below. Spaces are arbitrary; they represent my attempt at understanding patterns.

How concerned are we with transitions for the pull request? Is it ok to put one up with rgbwc working?

```
b0b1b2b3000102 4f 002ce00400 64 030103 a1 006464 010125 00000000 0203a1 3fe464 0100c0 00000000 0303a1 76e464 0100c6 00000000 3d # Three color gradient
b0b1b2b3000102 52 002ce00400 64 030103 a1 006464 01001e 00000000 0204a1 000000 01003c 00000000 0303a1 006464 010032 00000000 a4 # Heartbeat
b0b1b2b3000102 53 002ce00400 64 030104 a1 000000 010028 00820000 0202a1 000064 02000a 00000001 0304a1 000000 010028 00c60001 92 # Lightning
b0b1b2b3000102 50 0053e00400 64 060104 a1 006464 010063 00000000 0204a1 5ce464 010063 00000000 0304a1 7ce464 010068 00000000 0404a1 a56464 010060 00000000 0504a1 926464 010064 00000000 0604a1 416464 010064 00000000 09 # Five Color Jump
b0b1b2b3000102 51 0060e00400 64 070101 a1 006464 0100ca 00000000 0201a1 0d6464 0100c7 00000000 0301a1 186464 0100c8 00000000 0401a1 446464 0100c8 00000000 0501a1 59e464 0100c8 00000000 0601a1 75e464 0100c5 00000000 0701a1 97e464 0100ca 00000000 98 # Colorful Breath
b0b1b2b3000102 54 006de00400 64 080103 a1 066464 010014 00530000 0203a1 006414 010021 00690000 0303a1 00644e 010032 00620000 0403a1 06e41b 01001e 00670000 0503a1 056447 010014 00690000 0603a1 006434 010014 009a0000 0703a1 076451 010014 00660000 0803a1 07e427 010014 00660000 62 # Flame
```@bdraco: Don't worry about fixing it all in one pull request, it can always be done a little bit at a time. From a review perspective, that's better, as it reduces the chance anyone will get frustrated with the review@kirillsimin: Hey @bdraco, when does Home Assistant pick up the new update? Is there something we need to trigger?@bdraco: @kirillsimin It has to be done manually but I didn't do it because we still need to work out the push messages that come back on change so the state updates in HA. I started that in https://github.com/lightinglibs/flux_led/pull/428 but only as an example of how to do it.  I was hoping you could use it as a basis to finish the work.@bdraco: fixed via https://github.com/lightinglibs/flux_led/pull/428

Solution patch:
diff --git a/flux_led/base_device.py b/flux_led/base_device.py
index 157d6d6..0c2e167 100644
--- a/flux_led/base_device.py
+++ b/flux_led/base_device.py
@@ -7,7 +7,7 @@ import time
 from collections.abc import Iterable
 from dataclasses import asdict, is_dataclass
 from enum import Enum
-from typing import Any, Union
+from typing import TYPE_CHECKING, Any, Union
 
 from .const import (  # imported for back compat, remove once Home Assistant no longer uses
     ADDRESSABLE_STATE_CHANGE_LATENCY,
@@ -49,9 +49,6 @@ from .const import (  # imported for back compat, remove once Home Assistant no
     STATE_RED,
     STATE_WARM_WHITE,
     STATIC_MODES,
-    WRITE_ALL_COLORS,
-    WRITE_ALL_WHITES,
-    LevelWriteMode,
     WhiteChannelType,
 )
 from .models_db import (
@@ -86,6 +83,7 @@ from .protocol import (
     PROTOCOL_LEDENET_9BYTE,
     PROTOCOL_LEDENET_9BYTE_AUTO_ON,
     PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS,
+    PROTOCOL_LEDENET_25BYTE,
     PROTOCOL_LEDENET_ADDRESSABLE_A1,
     PROTOCOL_LEDENET_ADDRESSABLE_A2,
     PROTOCOL_LEDENET_ADDRESSABLE_A3,
@@ -105,6 +103,7 @@ from .protocol import (
     ProtocolLEDENET9Byte,
     ProtocolLEDENET9ByteAutoOn,
     ProtocolLEDENET9ByteDimmableEffects,
+    ProtocolLEDENET25Byte,
     ProtocolLEDENETAddressableA1,
     ProtocolLEDENETAddressableA2,
     ProtocolLEDENETAddressableA3,
@@ -143,6 +142,7 @@ PROTOCOL_TYPES = Union[
     ProtocolLEDENET9Byte,
     ProtocolLEDENET9ByteAutoOn,
     ProtocolLEDENET9ByteDimmableEffects,
+    ProtocolLEDENET25Byte,
     ProtocolLEDENETAddressableA1,
     ProtocolLEDENETAddressableA2,
     ProtocolLEDENETAddressableA3,
@@ -186,6 +186,7 @@ PROTOCOL_NAME_TO_CLS = {
     PROTOCOL_LEDENET_9BYTE: ProtocolLEDENET9Byte,
     PROTOCOL_LEDENET_9BYTE_AUTO_ON: ProtocolLEDENET9ByteAutoOn,
     PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS: ProtocolLEDENET9ByteDimmableEffects,
+    PROTOCOL_LEDENET_25BYTE: ProtocolLEDENET25Byte,
     PROTOCOL_LEDENET_ADDRESSABLE_A3: ProtocolLEDENETAddressableA3,
     PROTOCOL_LEDENET_ADDRESSABLE_A2: ProtocolLEDENETAddressableA2,
     PROTOCOL_LEDENET_ADDRESSABLE_A1: ProtocolLEDENETAddressableA1,
@@ -1128,13 +1129,18 @@ class LEDENETDevice:
         else:
             w2_value = int(w2)
 
-        write_mode = LevelWriteMode.ALL
+        # color / white write mode changed in Firmware 11 (25 byte)
+        if TYPE_CHECKING:
+            assert self._protocol is not None, "Protocol should not be None"
+        level_write_mode = self._protocol.level_write_modes
+
+        write_mode = level_write_mode.ALL
         # rgbwprotocol always overwrite both color & whites
         if not self.rgbwprotocol:
             if w is None and w2 is None:
-                write_mode = LevelWriteMode.COLORS
+                write_mode = level_write_mode.COLORS
             elif r is None and g is None and b is None:
-                write_mode = LevelWriteMode.WHITES
+                write_mode = level_write_mode.WHITES
 
         assert self._protocol is not None
         msgs = self._protocol.construct_levels_change(
@@ -1142,11 +1148,11 @@ class LEDENETDevice:
         )
         updates = {}
         multi_mode = self.multi_color_mode
-        if multi_mode or write_mode in WRITE_ALL_COLORS:
+        if multi_mode or write_mode in self._protocol.get_write_all_colors():
             updates.update(
                 {"red": r_value or 0, "green": g_value or 0, "blue": b_value or 0}
             )
-        if multi_mode or write_mode in WRITE_ALL_WHITES:
+        if multi_mode or write_mode in self._protocol.get_write_all_whites():
             updates.update({"warm_white": w_value or 0, "cool_white": w2_value or 0})
         return msgs, updates
 
diff --git a/flux_led/const.py b/flux_led/const.py
index a032fd9..ca122a6 100755
--- a/flux_led/const.py
+++ b/flux_led/const.py
@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+from dataclasses import dataclass
 from enum import Enum
 from typing import Final  # pylint: disable=no-name-in-module
 
@@ -15,12 +16,20 @@ class WhiteChannelType(Enum):
     COLD = MAX_TEMP
 
 
+# Legacy LevelWriteModel left for backwards compatibility
 class LevelWriteMode(Enum):
     ALL = 0x00
     COLORS = 0xF0
     WHITES = 0x0F
 
 
+@dataclass(frozen=True)
+class LevelWriteModeData:
+    ALL: int
+    COLORS: int
+    WHITES: int
+
+
 class MultiColorEffects(Enum):
     STATIC = 0x01
     RUNNING_WATER = 0x02
@@ -62,7 +71,8 @@ STATE_CHANGE_LATENCY: Final = 2
 ADDRESSABLE_STATE_CHANGE_LATENCY: Final = 5
 PRESET_PATTERN_CHANGE_LATENCY: Final = 40  # Time to switch to music mode
 
-
+# WRITE_ALL_COLORS and WRITE_ALL_WHITES are used in external
+# libraries, leaving them for backwards compatibility
 WRITE_ALL_COLORS = (LevelWriteMode.ALL, LevelWriteMode.COLORS)
 WRITE_ALL_WHITES = (LevelWriteMode.ALL, LevelWriteMode.WHITES)
 
diff --git a/flux_led/models_db.py b/flux_led/models_db.py
index ac4d0d3..84fd218 100755
--- a/flux_led/models_db.py
+++ b/flux_led/models_db.py
@@ -40,6 +40,7 @@ from .protocol import (
     PROTOCOL_LEDENET_9BYTE,
     PROTOCOL_LEDENET_9BYTE_AUTO_ON,
     PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS,
+    PROTOCOL_LEDENET_25BYTE,
     PROTOCOL_LEDENET_ADDRESSABLE_A1,
     PROTOCOL_LEDENET_ADDRESSABLE_A2,
     PROTOCOL_LEDENET_ADDRESSABLE_A3,
@@ -1024,6 +1025,7 @@ MODELS = [
         # 'AK001-ZJ2146' is v9.62, 40w flood light, newer smart bulbs (with RF remote control support)
         # 'AK001-ZJ2147' is v9.7 (with RF remote control support)
         # 'AK001-ZJ21410' is v9.91 seen on the Bunpeon smart floor light ASIN:B09MN65324
+        # 'AK001-ZJ21411' is v11.78 with Remote and 2.4G remote settings
         models=[
             "AK001-ZJ200",
             "AK001-ZJ2101",
@@ -1032,10 +1034,12 @@ MODELS = [
             "AK001-ZJ2146",
             "AK001-ZJ2147",
             "AK001-ZJ21410",
+            "AK001-ZJ21411",
         ],
         description="Bulb RGBCW",
         always_writes_white_and_colors=False,  # Formerly rgbwprotocol
         protocols=[
+            MinVersionProtocol(10, PROTOCOL_LEDENET_25BYTE),
             MinVersionProtocol(9, PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS),
             MinVersionProtocol(7, PROTOCOL_LEDENET_9BYTE_AUTO_ON),
             MinVersionProtocol(0, PROTOCOL_LEDENET_9BYTE),
diff --git a/flux_led/protocol.py b/flux_led/protocol.py
index 1be05af..0df2bb1 100755
--- a/flux_led/protocol.py
+++ b/flux_led/protocol.py
@@ -24,6 +24,7 @@ from .const import (
     TRANSITION_JUMP,
     TRANSITION_STROBE,
     LevelWriteMode,
+    LevelWriteModeData,
     MultiColorEffects,
 )
 from .timer import LedTimer
@@ -91,6 +92,7 @@ PROTOCOL_LEDENET_ADDRESSABLE_A3 = "LEDENET_ADDRESSABLE_A3"
 PROTOCOL_LEDENET_CCT = "LEDENET_CCT"
 PROTOCOL_LEDENET_CCT_WRAPPED = "LEDENET_CCT_WRAPPED"
 PROTOCOL_LEDENET_ADDRESSABLE_CHRISTMAS = "LEDENET_CHRISTMAS"
+PROTOCOL_LEDENET_25BYTE = "LEDENET_25_BYTE"
 
 TRANSITION_BYTES = {
     TRANSITION_JUMP: 0x3B,
@@ -172,7 +174,7 @@ MSG_LENGTHS = {
 }
 
 OUTER_MESSAGE_WRAPPER_FIRST_BYTES = [OUTER_MESSAGE_FIRST_BYTE, 0xB1, 0xB2, 0xB3, 0x00]
-OUTER_MESSAGE_WRAPPER = [*OUTER_MESSAGE_WRAPPER_FIRST_BYTES, 0x01, 0x01]
+OUTER_MESSAGE_WRAPPER = [*OUTER_MESSAGE_WRAPPER_FIRST_BYTES, 0x01]
 OUTER_MESSAGE_WRAPPER_START_LEN = 10
 CHECKSUM_LEN = 1
 
@@ -408,6 +410,7 @@ class ProtocolBase:
     """The base protocol."""
 
     power_state_response_length = MSG_LENGTHS[MSG_POWER_STATE]
+    level_write_modes = LevelWriteModeData(ALL=0x00, COLORS=0xF0, WHITES=0x0F)
 
     def __init__(self) -> None:
         self._counter = -1
@@ -463,7 +466,8 @@ class ProtocolBase:
         return False
 
     def expected_response_length(self, data: bytes) -> int:
-        """Return the number of bytes expected in the response.
+        """
+        Return the number of bytes expected in the response.
 
         If the response is unknown, we assume the response is
         a complete message since we have no way of knowing otherwise.
@@ -641,7 +645,8 @@ class ProtocolBase:
     def construct_power_restore_state_change(
         self, restore_state: PowerRestoreStates
     ) -> bytearray:
-        """The bytes to send for a power restore state change.
+        """
+        The bytes to send for a power restore state change.
 
         Set power on state to keep last state
         31f0f0f0f0f0e1
@@ -684,7 +689,7 @@ class ProtocolBase:
         blue: int | None,
         warm_white: int | None,
         cool_white: int | None,
-        write_mode: LevelWriteMode,
+        write_mode: LevelWriteMode | int,
     ) -> list[bytearray]:
         """The bytes to send for a level change request."""
 
@@ -736,7 +741,10 @@ class ProtocolBase:
         """Original protocol uses no checksum."""
 
     def construct_wrapped_message(
-        self, msg: bytearray, inner_pre_constructed: bool = False
+        self,
+        msg: bytearray,
+        inner_pre_constructed: bool = False,
+        version: int = 0x01,
     ) -> bytearray:
         """Construct a wrapped message."""
         inner_msg = msg if inner_pre_constructed else self.construct_message(msg)
@@ -745,6 +753,7 @@ class ProtocolBase:
             bytearray(
                 [
                     *OUTER_MESSAGE_WRAPPER,
+                    version,
                     self._increment_counter(),
                     inner_msg_len >> 8,
                     inner_msg_len & 0xFF,
@@ -819,6 +828,12 @@ class ProtocolBase:
             )
         )
 
+    def get_write_all_colors(self) -> set[int]:
+        return {self.level_write_modes.ALL, self.level_write_modes.COLORS}
+
+    def get_write_all_whites(self) -> set[int]:
+        return {self.level_write_modes.ALL, self.level_write_modes.WHITES}
+
 
 class ProtocolLEDENETOriginal(ProtocolBase):
     """The original LEDENET protocol with no checksums."""
@@ -866,7 +881,7 @@ class ProtocolLEDENETOriginal(ProtocolBase):
         blue: int | None,
         warm_white: int | None,
         cool_white: int | None,
-        write_mode: LevelWriteMode,
+        write_mode: LevelWriteMode | int,
     ) -> list[bytearray]:
         """The bytes to send for a level change request."""
         # sample message for original LEDENET protocol (w/o checksum at end)
@@ -908,7 +923,7 @@ class ProtocolLEDENETOriginalRGBW(ProtocolLEDENETOriginal):
         blue: int | None,
         warm_white: int | None,
         cool_white: int | None,
-        write_mode: LevelWriteMode,
+        write_mode: LevelWriteMode | int,
     ) -> list[bytearray]:
         """The bytes to send for a level change request."""
         # sample message for original LEDENET RGBW protocol (w/o checksum at end)
@@ -921,7 +936,9 @@ class ProtocolLEDENETOriginalRGBW(ProtocolLEDENETOriginal):
                         green or 0x00,
                         blue or 0x00,
                         warm_white or 0x00,
-                        write_mode.value,
+                        write_mode.value
+                        if isinstance(write_mode, LevelWriteMode)
+                        else write_mode,
                         0xAA,
                     ]
                 )
@@ -943,7 +960,7 @@ class ProtocolLEDENETOriginalCCT(ProtocolLEDENETOriginal):
         blue: int | None,
         warm_white: int | None,
         cool_white: int | None,
-        write_mode: LevelWriteMode,
+        write_mode: LevelWriteMode | int,
     ) -> list[bytearray]:
         """The bytes to send for a level change request."""
         # sample message for original LEDENET protocol (w/o checksum at end)
@@ -997,7 +1014,8 @@ class ProtocolLEDENET8Byte(ProtocolBase):
         return self.is_checksum_correct(raw_state)
 
     def construct_state_change(self, turn_on: int) -> bytearray:
-        """The bytes to send for a state change request.
+        """
+        The bytes to send for a state change request.
 
         Alternate messages
 
@@ -1023,7 +1041,7 @@ class ProtocolLEDENET8Byte(ProtocolBase):
         blue: int | None,
         warm_white: int | None,
         cool_white: int | None,
-        write_mode: LevelWriteMode,
+        write_mode: LevelWriteMode | int,
     ) -> list[bytearray]:
         """The bytes to send for a level change request."""
         # sample message for 8-byte protocols (w/ checksum at end)
@@ -1059,7 +1077,9 @@ class ProtocolLEDENET8Byte(ProtocolBase):
                         green or 0x00,
                         blue or 0x00,
                         warm_white or 0x00,
-                        write_mode.value,
+                        write_mode.value
+                        if isinstance(write_mode, LevelWriteMode)
+                        else write_mode,
                         0x0F,
                     ]
                 )
@@ -1089,7 +1109,8 @@ class ProtocolLEDENET8Byte(ProtocolBase):
         foreground_color: tuple[int, int, int] | None = None,
         background_color: tuple[int, int, int] | None = None,
     ) -> list[bytearray]:
-        """The bytes to send for music mode.
+        """
+        The bytes to send for music mode.
 
         Known messages
         73 01 4d 0f d0
@@ -1135,7 +1156,8 @@ class ProtocolLEDENET8Byte(ProtocolBase):
         music_pixels_per_segment: int | None,  # music pixels per segment
         music_segments: int | None,  # number of music segments
     ) -> bytearray:
-        """The bytes to send to change device config.
+        """
+        The bytes to send to change device config.
 
         RGBW 0x06
         62 06 02 0f 79 - RGB/W GRB W
@@ -1225,7 +1247,8 @@ class ProtocolLEDENET8ByteDimmableEffects(ProtocolLEDENET8ByteAutoOn):
         foreground_color: tuple[int, int, int] | None = None,
         background_color: tuple[int, int, int] | None = None,
     ) -> list[bytearray]:
-        """The bytes to send for music mode.
+        """
+        The bytes to send for music mode.
 
         Known messages
         73 01 4d 0f d0
@@ -1298,7 +1321,7 @@ class ProtocolLEDENET9Byte(ProtocolLEDENET8Byte):
         blue: int | None,
         warm_white: int | None,
         cool_white: int | None,
-        write_mode: LevelWriteMode,
+        write_mode: LevelWriteMode | int,
     ) -> list[bytearray]:
         """The bytes to send for a level change request."""
         # sample message for 9-byte LEDENET protocol (w/ checksum at end)
@@ -1324,7 +1347,9 @@ class ProtocolLEDENET9Byte(ProtocolLEDENET8Byte):
                         blue or 0x00,
                         warm_white or 0x00,
                         cool_white or 0x00,
-                        write_mode.value,
+                        write_mode.value
+                        if isinstance(write_mode, LevelWriteMode)
+                        else write_mode,
                         0x0F,
                     ]
                 )
@@ -1378,6 +1403,89 @@ class ProtocolLEDENET9ByteDimmableEffects(ProtocolLEDENET9ByteAutoOn):
         return self.construct_message(bytearray([0x38, pattern, delay, brightness]))
 
 
+class ProtocolLEDENET25Byte(ProtocolLEDENET9Byte):
+    """25 byte protocol"""
+
+    level_write_modes = LevelWriteModeData(ALL=0x00, COLORS=0xA1, WHITES=0xB1)
+
+    @property
+    def name(self) -> str:
+        """The name of the protocol."""
+        return PROTOCOL_LEDENET_25BYTE
+
+    def construct_levels_change(
+        self,
+        persist: int,
+        red: int | None,
+        green: int | None,
+        blue: int | None,
+        warm_white: int | None,
+        cool_white: int | None,
+        write_mode: LevelWriteMode | int,
+    ) -> list[bytearray]:
+        """The bytes to send for a level change request."""
+        # sample message for 25-byte LEDENET protocol (w/ checksum at end)
+        #  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
+        # b0 b1 b2 b3 00 01 02 79 00 0e e0 01 00 a1 78 64 64 00 00 00 00 14 00 00
+        #                    |  |              |  |  |  |  |  |  |              |
+        #                    |  |              |  |  |  |  |  |  |              footer
+        #                    |  |              |  |  |  |  |  |  white brightness
+        #                    |  |              |  |  |  |  |  white temperature (00 warm - 64 cool)
+        #                    |  |              |  |  |  |  value
+        #                    |  |              |  |  |  saturation
+        #                    |  |              |  |  hue / 2
+        #                    |  |              |  write mode (a1 color, b1 white)
+        #                    |  |              unknown filler
+        #                    |  increment counter
+        #                    header
+
+        if red is not None and green is not None and blue is not None:
+            h, s, v = colorsys.rgb_to_hsv(red / 255, green / 255, blue / 255)
+            h = int((h * 360) / 2)  # Hue needs to be halved
+            s = int(s * 100)
+            v = int(v * 100)
+        else:
+            h = s = v = 0x00
+
+        if warm_white is not None and cool_white is not None:
+            # warm white comes through as 0, even when temp is set to 6500
+            white_brightness = round(((warm_white + cool_white) / (2 * 255)) * 64)
+            white_temp = round((cool_white / (warm_white + cool_white)) * 64)
+        elif cool_white is not None and warm_white is None:
+            white_temp = 0x64
+            white_brightness = int((cool_white / 255) * 100)
+        else:
+            white_temp = 0x00
+            white_brightness = 0x00
+
+        return [
+            self.construct_wrapped_message(
+                bytearray(
+                    [
+                        0xE0,
+                        0x01,
+                        0x00,
+                        write_mode.value
+                        if isinstance(write_mode, LevelWriteMode)
+                        else write_mode,
+                        h,
+                        s,
+                        v,
+                        white_temp,
+                        white_brightness,
+                        0x00,
+                        0x00,
+                        0x14,
+                        0x00,
+                        0x00,
+                    ]
+                ),
+                inner_pre_constructed=True,
+                version=0x02,
+            )
+        ]
+
+
 class ProtocolLEDENETAddressableBase(ProtocolLEDENET9Byte):
     """Base class for addressable protocols."""
 
@@ -1485,7 +1593,8 @@ class ProtocolLEDENETAddressableA1(ProtocolLEDENETAddressableBase):
         music_pixels_per_segment: int | None,  # music pixels per segment
         music_segments: int | None,  # number of music segments
     ) -> bytearray:
-        """The bytes to send to change device config.
+        """
+        The bytes to send to change device config.
         pos  0  1  2  3  4  5  6  7  8  9 10 11 12
             62 04 00 04 00 00 00 00 00 00 02 f0 5c <- checksum
              |  |  |  |  |  |  |  |  |  |  |  |
@@ -1575,9 +1684,10 @@ class ProtocolLEDENETAddressableA2(ProtocolLEDENETAddressableBase):
         blue: int | None,
         warm_white: int | None,
         cool_white: int | None,
-        write_mode: LevelWriteMode,
+        write_mode: LevelWriteMode | int,
     ) -> list[bytearray]:
-        """The bytes to send for a level change request.
+        """
+        The bytes to send for a level change request.
 
         white  41 01 ff ff ff 00 00 00 60 ff 00 00 9e
         """
@@ -1617,7 +1727,8 @@ class ProtocolLEDENETAddressableA2(ProtocolLEDENETAddressableBase):
         foreground_color: tuple[int, int, int] | None = None,
         background_color: tuple[int, int, int] | None = None,
     ) -> list[bytearray]:
-        """The bytes to send for music mode.
+        """
+        The bytes to send for music mode.
 
         Known messages
         73 01 27 01 00 00 00 00 ff ff 64 64 62 - lowest brightness music
@@ -1740,7 +1851,8 @@ class ProtocolLEDENETAddressableA2(ProtocolLEDENETAddressableBase):
         music_pixels_per_segment: int | None,  # music pixels per segment
         music_segments: int | None,  # number of music segments
     ) -> bytearray:
-        """The bytes to send to change device config.
+        """
+        The bytes to send to change device config.
         pos  0  1  2  3  4  5  6  7  8  9 10
             62 01 2c 00 06 01 04 32 01 0f dc
              |  |  |  |  |  |  |  |  |  |  |
@@ -1935,7 +2047,8 @@ class ProtocolLEDENETAddressableA3(ProtocolLEDENETAddressableA2):
         foreground_color: tuple[int, int, int] | None = None,
         background_color: tuple[int, int, int] | None = None,
     ) -> list[bytearray]:
-        """The bytes to send for music mode.
+        """
+        The bytes to send for music mode.
 
         Known messages
         b0 b1 b2 b3 00 01 01 1f 00 0d 73 01 27 01 ff 00 00 ff 00 00 64 64 62 b8 - Music mode
@@ -1981,9 +2094,10 @@ class ProtocolLEDENETAddressableA3(ProtocolLEDENETAddressableA2):
         blue: int | None,
         warm_white: int | None,
         cool_white: int | None,
-        write_mode: LevelWriteMode,
+        write_mode: LevelWriteMode | int,
     ) -> list[bytearray]:
-        """The bytes to send for a level change request.
+        """
+        The bytes to send for a level change request.
 
         b0 [unknown static?] b1 [unknown static?] b2 [unknown static?] b3 [unknown static?] 00 [unknown static?] 01 [unknown static?] 01 [unknown static?] 6a [incrementing sequence number] 00 [unknown static?] 0d [unknown, sometimes 0c] 41 [unknown static?] 02 [preset number] ff [foreground r] 00 [foreground g] 00 [foreground b] 00 [background red] ff [background green] 00 [background blue] 06 [speed or direction?] 00 [unknown static?] 00 [unknown static?] 00 [unknown static?] 47 [speed or direction?] cd [check sum]
 
@@ -2038,7 +2152,8 @@ class ProtocolLEDENETAddressableA3(ProtocolLEDENETAddressableA2):
         speed: int,
         effect: MultiColorEffects,
     ) -> bytearray:
-        """The bytes to send for multiple zones.
+        """
+        The bytes to send for multiple zones.
 
         Blue/Green - Static
         590063ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff00000000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff001e04640024
@@ -2179,9 +2294,10 @@ class ProtocolLEDENETCCT(ProtocolLEDENET9Byte):
         blue: int | None,
         warm_white: int | None,
         cool_white: int | None,
-        write_mode: LevelWriteMode,
+        write_mode: LevelWriteMode | int,
     ) -> list[bytearray]:
-        """The bytes to send for a level change request.
+        """
+        The bytes to send for a level change request.
 
         b0 b1 b2 b3 00 01 01 52 00 09 35 b1 00 64 00 00 00 03 4d bd - 100% warm
         b0 b1 b2 b3 00 01 01 72 00 09 35 b1 64 64 00 00 00 03 b1 a5 - 100% cool
@@ -2250,9 +2366,10 @@ class ProtocolLEDENETCCTWrapped(ProtocolLEDENETCCT):
         blue: int | None,
         warm_white: int | None,
         cool_white: int | None,
-        write_mode: LevelWriteMode,
+        write_mode: LevelWriteMode | int,
     ) -> list[bytearray]:
-        """The bytes to send for a level change request.
+        """
+        The bytes to send for a level change request.
 
         b0 b1 b2 b3 00 01 01 52 00 09 35 b1 00 64 00 00 00 03 4d bd - 100% warm
         b0 b1 b2 b3 00 01 01 72 00 09 35 b1 64 64 00 00 00 03 b1 a5 - 100% cool
@@ -2316,7 +2433,8 @@ class ProtocolLEDENETAddressableChristmas(ProtocolLEDENETAddressableBase):
     def construct_preset_pattern(
         self, pattern: int, speed: int, brightness: int
     ) -> bytearray:
-        """The bytes to send for a preset pattern.
+        """
+        The bytes to send for a preset pattern.
         0xB0 0xB1 0xB2 0xB3 0x00 0x01 0x01 0x2A 0x00 0x04 0x38 0x01 0x10 0x00 0x3F (15)
         0xB0 0xB1 0xB2 0xB3 0x00 0x01 0x01 0x2B 0x00 0x04 0x38 0x02 0x10 0x00 0x41 (15)
         0xB0 0xB1 0xB2 0xB3 0x00 0x01 0x01 0x2C 0x00 0x04 0x38 0x03 0x10 0x00 0x43 (15)
@@ -2342,9 +2460,10 @@ class ProtocolLEDENETAddressableChristmas(ProtocolLEDENETAddressableBase):
         blue: int | None,
         warm_white: int | None,
         cool_white: int | None,
-        write_mode: LevelWriteMode,
+        write_mode: LevelWriteMode | int,
     ) -> list[bytearray]:
-        """The bytes to send for a level change request.
+        """
+        The bytes to send for a level change request.
 
         Green 100%:
         b0b1b2b300010180000d3ba100646400000000000000a49d
@@ -2425,7 +2544,8 @@ class ProtocolLEDENETAddressableChristmas(ProtocolLEDENETAddressableBase):
         speed: int,
         effect: MultiColorEffects,
     ) -> bytearray:
-        """The bytes to send for multiple zones.
+        """
+        The bytes to send for multiple zones.
 
         6 Zone All red
         a000060001ff00000000ff0002ff00000000ff0003ff00000000ff0004ff00000000ff0005ff00000000ff0006ff00000000ffaf


Test patch:
diff --git a/tests/test_sync.py b/tests/test_sync.py
index 6d276dd..ed34624 100644
--- a/tests/test_sync.py
+++ b/tests/test_sync.py
@@ -29,6 +29,7 @@ from flux_led.protocol import (
     PROTOCOL_LEDENET_9BYTE,
     PROTOCOL_LEDENET_9BYTE_AUTO_ON,
     PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS,
+    PROTOCOL_LEDENET_25BYTE,
     PROTOCOL_LEDENET_ADDRESSABLE_A1,
     PROTOCOL_LEDENET_ADDRESSABLE_A2,
     PROTOCOL_LEDENET_ADDRESSABLE_A3,
@@ -824,6 +825,78 @@ class TestLight(unittest.TestCase):
         self.assertEqual(mock_read.call_count, 6)
         self.assertEqual(mock_send.call_count, 8)
 
+    @patch("flux_led.WifiLedBulb._send_msg")
+    @patch("flux_led.WifiLedBulb._read_msg")
+    @patch("flux_led.WifiLedBulb.connect")
+    def test_rgbww_controller_version_10(self, mock_connect, mock_read, mock_send):
+        calls = 0
+
+        def read_data(expected):
+            nonlocal calls
+            calls += 1
+            if calls == 1:
+                self.assertEqual(expected, 2)
+                return bytearray(b"\x81\x35")
+            if calls == 2:
+                self.assertEqual(expected, 12)
+                return bytearray(b"\x23\x61\x30\x40\xb5\x00\x9c\x00\x0a\x00\xf0\xf5")
+            if calls == 3:
+                self.assertEqual(expected, 14)
+                return bytearray(
+                    b"\x81\x35\x23\x61\x30\x40\xb5\x00\x9c\x00\x0a\x00\xf0\xf5"
+                )
+            if calls == 4:
+                self.assertEqual(expected, 14)
+                return bytearray(
+                    b"\x81\x35\x23\x61\x30\x40\xb5\x00\x9c\x00\x0a\x00\xf0\xf5"
+                )
+            if calls == 5:
+                self.assertEqual(expected, 14)
+                return bytearray(
+                    b"\x81\x35\x23\x61\x30\x40\xb5\x00\x9c\x00\x0a\x00\xf0\xf5"
+                )
+            if calls == 6:
+                self.assertEqual(expected, 94)
+                return bytearray(
+                    b"\x0f\x22\xf0\x16\x01\x04\x00\x2b\x00\x00\x61\x19\x47\xff\x00\x00\xf0"
+                    b"\xf0\x16\x01\x04\x04\x2c\x00\x00\x61\x7f\xff\x00\x00\x00\xf0\xf0\x16"
+                    b"\x01\x03\x16\x1f\x00\x00\x61\xff\x00\x00\x00\x00\xf0\xf0\x16\x01\x03"
+                    b"\x17\x13\x00\x00\x61\x81\x81\x81\x00\x00\xf0\xf0\x16\x01\x03\x17\x28"
+                    b"\x00\x00\x61\x00\xff\x00\x00\x00\xf0\xf0\x16\x01\x04\x07\x2c\x00\x00"
+                    b"\x61\x21\x00\xff\x00\x00\xf0\x00\x00"
+                )
+
+        mock_read.side_effect = read_data
+        light = flux_led.WifiLedBulb("192.168.1.164")
+
+        assert light.color_modes == {COLOR_MODE_RGB, COLOR_MODE_CCT}
+        self.assertEqual(light.protocol, PROTOCOL_LEDENET_25BYTE)
+        self.assertEqual(light.model_num, 0x35)
+        self.assertEqual(light.version_num, 10)
+
+        light.setRgb(255, 100, 50)
+        self.assertEqual(mock_send.call_count, 2)
+        self.assertEqual(light.getRgb(), (255, 100, 50))
+        self.assertEqual(light.getRgbw(), (255, 100, 50, 0))
+        self.assertEqual(light.getRgbww(), (255, 100, 50, 0, 0))
+
+        light.setColdWhite255(200)
+        self.assertEqual(mock_send.call_count, 3)
+        self.assertEqual(light.getRgb(), (255, 255, 255))
+        self.assertEqual(light.getRgbw(), (255, 255, 255, 255))
+        self.assertEqual(light.getRgbww(), (255, 255, 255, 255, 255))
+
+        light.setWhiteTemperature(2700, 100)
+        self.assertEqual(mock_send.call_count, 4)
+
+        light.setWhiteTemperature(6500, 100)
+        self.assertEqual(mock_send.call_count, 5)
+
+        light.update_state()
+        self.assertEqual(mock_read.call_count, 3)
+        self.assertEqual(mock_send.call_count, 6)
+        self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY)))
+
     @patch("flux_led.WifiLedBulb._send_msg")
     @patch("flux_led.WifiLedBulb._read_msg")
     @patch("flux_led.WifiLedBulb.connect")