Skip to content

Commit c8576da

Browse files
committed
feat: Add support for IOTBT segment-based devices with new commands and effects
Helps: Zengge IOTBT65C "unavailable" Fixes #69
1 parent 5ab4fa8 commit c8576da

File tree

3 files changed

+242
-8
lines changed

3 files changed

+242
-8
lines changed

custom_components/lednetwf_ble/const.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ class EffectType(IntEnum):
9292
SYMPHONY = 2 # 0x38 command WITH checksum (5 bytes)
9393
ADDRESSABLE_0x53 = 3 # 0x38 command NO checksum (4 bytes), brightness in byte 3
9494
IOTBT = 4 # 0xE0 0x02 command, effects 1-12 (Telink BLE Mesh based)
95+
IOTBT_SEGMENT = 5 # 0xE1 0x01 command, segment-based with palette (newer IOTBT)
9596

9697

9798
class ValueScale(IntEnum):
@@ -397,6 +398,14 @@ class ValueScale(IntEnum):
397398
0xD00: "Music 13", # 13 << 8
398399
}
399400

401+
# IOTBT Segment-based effects (0xE1 0x01 command)
402+
# Source: User protocol capture (Dec 2025) - IOTBT devices with addressable segments
403+
# These devices use 0xE1 0x03 for color, 0xE1 0x01 for effects, 0x3B for power
404+
# Effects are numbered 1-99 (similar to addressable strip effects)
405+
IOTBT_SEGMENT_EFFECTS: Final = {
406+
i: f"Effect {i}" for i in range(1, 100)
407+
}
408+
400409
# Product IDs with special speed encoding (inverted 0x01-0x1F scale)
401410
# Source: model_0x54.py, protocol_docs/07a_effect_commands_by_device.md
402411
# These devices use inverted speed where 0x01=fastest, 0x1F=slowest
@@ -644,6 +653,9 @@ def get_effect_list(
644653
# Plus 8 music reactive effects via 0xE1 0x05 command
645654
effects = list(IOTBT_EFFECTS.values())
646655
effects.extend(list(IOTBT_MUSIC_EFFECTS.values()))
656+
elif effect_type == EffectType.IOTBT_SEGMENT:
657+
# IOTBT Segment-based devices have 99 effects via 0xE1 0x01 command
658+
effects = list(IOTBT_SEGMENT_EFFECTS.values())
647659

648660
# Add sound reactive option for devices with built-in microphone (non-IOTBT)
649661
# IOTBT devices have specific music effects listed above instead
@@ -744,6 +756,11 @@ def get_effect_id(
744756
for eid, name in IOTBT_MUSIC_EFFECTS.items():
745757
if name == effect_name:
746758
return eid # Already encoded (e.g., 0x100 for Music 1)
759+
elif effect_type == EffectType.IOTBT_SEGMENT:
760+
# Segment-based effects (1-99) via 0xE1 0x01 command
761+
for eid, name in IOTBT_SEGMENT_EFFECTS.items():
762+
if name == effect_name:
763+
return eid
747764
return None
748765

749766

custom_components/lednetwf_ble/device.py

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ def __init__(
109109
self._firmware_ver: int | None = None # Combined firmware version (hi << 8 | lo)
110110
self._firmware_flag: int | None = None # Feature flags from service data (bits 0-4)
111111

112+
# IOTBT segment variant detection
113+
# Segment-based IOTBT devices use 0x5A00 service UUID and require different commands:
114+
# - Power: 0x3B (standard LEDnetWF, not 0x71 Telink)
115+
# - Color: 0xE1 0x03 (segment-based HSB, not 0xE2 hue)
116+
# - Effects: 0xE1 0x01 (palette-based, not 0xE0 0x02)
117+
self._is_iotbt_segment: bool = False
118+
112119
# Callbacks for state updates
113120
self._callbacks: list[Callable[[], None]] = []
114121

@@ -221,6 +228,10 @@ def effect_speed(self) -> int:
221228
@property
222229
def effect_type(self) -> EffectType:
223230
"""Return the effect type as proper enum (handles int conversion)."""
231+
# IOTBT segment-based variant uses different effect commands
232+
if self._is_iotbt_segment:
233+
return EffectType.IOTBT_SEGMENT
234+
224235
val = self._capabilities.get("effect_type", EffectType.NONE)
225236
return EffectType(val) if isinstance(val, int) else val
226237

@@ -443,6 +454,21 @@ def is_iotbt(self) -> bool:
443454
# Also check product_id directly for backwards compatibility
444455
return self._capabilities.get("is_iotbt", False) or self._product_id == 0x00
445456

457+
@property
458+
def is_iotbt_segment(self) -> bool:
459+
"""Return True if device is an IOTBT segment-based variant.
460+
461+
Segment-based IOTBT devices (detected by 0x5A00 service UUID) use
462+
different commands than standard Telink-based IOTBT:
463+
- Power: 0x3B command (standard LEDnetWF, NOT 0x71 Telink)
464+
- Color: 0xE1 0x03 command with segment-based HSB (NOT 0xE2)
465+
- Effect: 0xE1 0x01 command with palette (NOT 0xE0 0x02)
466+
- State query: Still uses 0xEA 0x81 format
467+
468+
Source: User protocol capture (Dec 2025) - IOTBT65C device
469+
"""
470+
return self._is_iotbt_segment
471+
446472
@property
447473
def color_order(self) -> int | None:
448474
"""Return current color order (1=RGB, 2=GRB, 3=BRG)."""
@@ -1190,6 +1216,10 @@ def _effect_id_to_name(self, effect_id: int) -> str | None:
11901216
elif effect_id in IOTBT_MUSIC_EFFECTS:
11911217
return IOTBT_MUSIC_EFFECTS[effect_id]
11921218
return None
1219+
elif eff_type == EffectType.IOTBT_SEGMENT:
1220+
# IOTBT segment-based variant: 99 effects (1-99)
1221+
from .const import IOTBT_SEGMENT_EFFECTS
1222+
return IOTBT_SEGMENT_EFFECTS.get(effect_id)
11931223
return None
11941224

11951225
async def _send_command(self, packet: bytearray, with_response: bool = False) -> bool:
@@ -1226,8 +1256,11 @@ async def _send_command(self, packet: bytearray, with_response: bool = False) ->
12261256

12271257
async def turn_on(self) -> bool:
12281258
"""Turn on the device."""
1229-
if self.is_iotbt:
1230-
# IOTBT devices use different power command format
1259+
if self.is_iotbt_segment:
1260+
# IOTBT segment-based variant uses standard 0x3B power command
1261+
packet = protocol.build_power_command_0x3B(turn_on=True)
1262+
elif self.is_iotbt:
1263+
# Standard IOTBT devices use 0x71 power command format
12311264
packet = protocol.build_iotbt_power_command(turn_on=True)
12321265
else:
12331266
packet = protocol.build_power_command_0x3B(turn_on=True)
@@ -1239,8 +1272,11 @@ async def turn_on(self) -> bool:
12391272

12401273
async def turn_off(self) -> bool:
12411274
"""Turn off the device."""
1242-
if self.is_iotbt:
1243-
# IOTBT devices use different power command format
1275+
if self.is_iotbt_segment:
1276+
# IOTBT segment-based variant uses standard 0x3B power command
1277+
packet = protocol.build_power_command_0x3B(turn_on=False)
1278+
elif self.is_iotbt:
1279+
# Standard IOTBT devices use 0x71 power command format
12441280
packet = protocol.build_iotbt_power_command(turn_on=False)
12451281
else:
12461282
packet = protocol.build_power_command_0x3B(turn_on=False)
@@ -1322,8 +1358,19 @@ async def set_rgb_color(self, rgb: tuple[int, int, int], brightness: int = 255)
13221358

13231359
# Standard color command (exits effect mode)
13241360
eff_type = self.effect_type
1325-
if self.is_iotbt:
1326-
# IOTBT devices use 0xE2 command with hue-based color (not RGB)
1361+
if self.is_iotbt_segment:
1362+
# IOTBT segment-based variant uses 0xE1 0x03 command with segment HSB data
1363+
# Source: User protocol capture (Dec 2025) - IOTBT65C device
1364+
brightness_pct = max(1, round(brightness * 100 / 255)) if brightness > 0 else 0
1365+
packet = protocol.build_iotbt_segment_color_command(
1366+
rgb[0], rgb[1], rgb[2], brightness_pct
1367+
)
1368+
_LOGGER.debug(
1369+
"IOTBT segment device: RGB=(%d,%d,%d), brightness=%d%% -> segment HSB",
1370+
rgb[0], rgb[1], rgb[2], brightness_pct
1371+
)
1372+
elif self.is_iotbt:
1373+
# Standard IOTBT devices use 0xE2 command with hue-based color (not RGB)
13271374
# Source: protocol_docs/17_device_configuration.md - Color Command (0xE2)
13281375
brightness_pct = max(1, round(brightness * 100 / 255)) if brightness > 0 else 0
13291376
packet = protocol.build_iotbt_color_command(
@@ -1977,6 +2024,19 @@ def update_from_advertisement(
19772024
"[%s] Service data UUIDs available: %s",
19782025
self._name, list(service_data.keys())
19792026
)
2027+
2028+
# Detect IOTBT segment-based variant
2029+
# Standard IOTBT: status byte 0x80 → Telink protocol (default)
2030+
# Segment variant: status byte 0x56 → segment-based protocol
2031+
if self.is_iotbt and protocol.is_iotbt_segment_variant(service_data):
2032+
if not self._is_iotbt_segment:
2033+
self._is_iotbt_segment = True
2034+
_LOGGER.info(
2035+
"[%s] IOTBT segment-based variant detected (status=0x56). "
2036+
"Using 0x3B power, 0xE1 0x03 color, 0xE1 0x01 effects.",
2037+
self._name
2038+
)
2039+
19802040
sd_bytes = protocol.get_service_data_from_advertisement(service_data)
19812041
if sd_bytes:
19822042
_LOGGER.debug(

custom_components/lednetwf_ble/protocol.py

Lines changed: 159 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,122 @@ def build_iotbt_state_query() -> bytearray:
385385
return wrap_command(raw_cmd, cmd_family=0x0a)
386386

387387

388+
# =============================================================================
389+
# IOTBT SEGMENT-BASED COMMANDS (IOTBT devices with addressable segments)
390+
# These devices use 0xE1 0x03 for color (not 0xE2) and 0x3B for power (not 0x71)
391+
# =============================================================================
392+
393+
def build_iotbt_segment_color_command(
394+
r: int, g: int, b: int, brightness: int = 100, segment_count: int = 20
395+
) -> bytearray:
396+
"""
397+
Build IOTBT segment-based color command (0xE1 0x03 format).
398+
399+
Source: User protocol capture (Dec 2025) - IOTBT devices with addressable segments.
400+
401+
Format: [0xE1, 0x03, 0x00, segment_count, 0x00, 0x00, segment_count, ...segment_data...]
402+
403+
Each segment (4 bytes): [0xA1, hue, saturation, brightness]
404+
- 0xA1: Segment marker
405+
- hue: 0-255 (0=red, 85=green, 170=blue, wrapping)
406+
- saturation: 0-100 (0=white, 100=full color)
407+
- brightness: 0-100 (0=off, 100=full)
408+
409+
This sets all segments to the same color. For individual segment control,
410+
the segment_data would contain different values per segment.
411+
412+
Args:
413+
r, g, b: RGB color values (0-255)
414+
brightness: Overall brightness percentage (0-100)
415+
segment_count: Number of segments (default 20)
416+
417+
Returns:
418+
Wrapped command packet
419+
"""
420+
# Convert RGB to HSV
421+
h, s, v = rgb_to_hsv(r, g, b)
422+
423+
# Convert hue from 0-360 to 0-255 scale (device uses 256-step hue)
424+
# 0=red, ~85=green, ~170=blue
425+
hue_255 = int(h * 255 / 360) & 0xFF
426+
427+
# Saturation stays 0-100
428+
sat = max(0, min(100, s))
429+
430+
# Combine brightness from both sources
431+
# RGB value gives us "color intensity", brightness param is overall
432+
combined_bright = max(1, min(100, int(brightness * v / 100)))
433+
434+
# Build header: E1 03 00 {segment_count} 00 00 {segment_count}
435+
raw_cmd = bytearray([
436+
0xE1, 0x03,
437+
0x00, # Unknown/reserved
438+
segment_count & 0xFF, # Segment count (first occurrence)
439+
0x00, 0x00, # Reserved
440+
segment_count & 0xFF, # Segment count (repeated)
441+
])
442+
443+
# Add segment data - all segments get same color
444+
for _ in range(segment_count):
445+
raw_cmd.extend([
446+
0xA1, # Segment marker
447+
hue_255 & 0xFF, # Hue (0-255)
448+
sat & 0xFF, # Saturation (0-100)
449+
combined_bright & 0xFF # Brightness (0-100)
450+
])
451+
452+
# No checksum for this command format
453+
return wrap_command(raw_cmd, cmd_family=0x0a)
454+
455+
456+
def build_iotbt_segment_effect_command(
457+
effect_id: int, speed: int = 50, brightness: int = 100, segment_count: int = 20
458+
) -> bytearray:
459+
"""
460+
Build IOTBT segment-based effect command (0xE1 0x01 format).
461+
462+
Source: User protocol capture (Dec 2025) - IOTBT segment-based effects.
463+
464+
Format: [0xE1, 0x01, effect_id, speed, brightness, 0x00, ...palette_data...]
465+
466+
The palette data contains color entries for the effect. For simplicity,
467+
we use a rainbow palette (7 colors) which works for most effects.
468+
469+
Args:
470+
effect_id: Effect number (1-99)
471+
speed: Effect speed (0-100)
472+
brightness: Effect brightness (0-100)
473+
segment_count: Number of segments
474+
475+
Returns:
476+
Wrapped command packet
477+
"""
478+
effect_id = max(1, min(99, effect_id))
479+
speed = max(0, min(100, speed))
480+
brightness = max(1, min(100, brightness))
481+
482+
# Build header
483+
raw_cmd = bytearray([
484+
0xE1, 0x01,
485+
effect_id & 0xFF,
486+
speed & 0xFF,
487+
brightness & 0xFF,
488+
0x00, # Reserved
489+
])
490+
491+
# Add rainbow palette (7 colors, similar to user's capture)
492+
# Format: [0xA1, hue, saturation, brightness] per color
493+
# Rainbow hues at roughly: 0, 43, 85, 128, 170, 213, 255 (wrapping)
494+
rainbow_hues = [0, 43, 85, 128, 170, 213, 240]
495+
for hue in rainbow_hues:
496+
raw_cmd.extend([0xA1, hue, 100, brightness])
497+
498+
# Pad remaining palette slots if needed (usually 7 is enough)
499+
500+
# No checksum for this command format
501+
return wrap_command(raw_cmd, cmd_family=0x0a)
502+
503+
388504
# =============================================================================
389505
# COLOR COMMANDS
390506
# =============================================================================
@@ -864,8 +980,12 @@ def build_effect_command(
864980
- Music effects (encoded as effect_num << 8): 0xE1 0x05 command format
865981
Speed parameter is used as mic sensitivity for music mode
866982
"""
867-
if effect_type == EffectType.IOTBT:
868-
# IOTBT devices use different commands for regular effects vs music effects
983+
if effect_type == EffectType.IOTBT_SEGMENT:
984+
# IOTBT segment-based variant uses 0xE1 0x01 command with palette
985+
# Source: User protocol capture (Dec 2025) - IOTBT65C device
986+
return build_iotbt_segment_effect_command(effect_id, speed, brightness)
987+
elif effect_type == EffectType.IOTBT:
988+
# Standard IOTBT devices use different commands for regular effects vs music effects
869989
if effect_id >= 0x100:
870990
# Music reactive effect (encoded as effect_num << 8)
871991
# Decode the effect ID and use music command
@@ -2130,3 +2250,40 @@ def get_service_data_from_advertisement(
21302250
return service_data_dict[key]
21312251

21322252
return None
2253+
2254+
2255+
def is_iotbt_segment_variant(service_data_dict: dict[str, bytes]) -> bool:
2256+
"""
2257+
Check if device is an IOTBT segment-based variant.
2258+
2259+
Segment-based IOTBT devices are identified by:
2260+
- Service UUID 0x5A00 (ZengGe manufacturer-specific)
2261+
- Status byte (byte 0) is 0x56
2262+
2263+
Standard IOTBT devices (status byte 0x80) use Telink mesh protocol.
2264+
Unknown status bytes default to standard Telink protocol for safety.
2265+
2266+
Segment-based variants (status 0x56) use different commands:
2267+
- Power: 0x3B (standard LEDnetWF, not 0x71 Telink)
2268+
- Color: 0xE1 0x03 (segment-based HSB, not 0xE2 hue)
2269+
- Effects: 0xE1 0x01 (palette-based, not 0xE0 0x02)
2270+
2271+
Args:
2272+
service_data_dict: Dict from BluetoothServiceInfoBleak.service_data
2273+
2274+
Returns:
2275+
True if segment-based IOTBT variant (status 0x56), False otherwise
2276+
"""
2277+
uuid_5a00 = "00005a00-0000-1000-8000-00805f9b34fb"
2278+
if uuid_5a00 not in service_data_dict:
2279+
return False
2280+
2281+
data = service_data_dict[uuid_5a00]
2282+
if len(data) < 1:
2283+
return False
2284+
2285+
# Status byte 0x80 = standard IOTBT (Telink mesh protocol) - DEFAULT
2286+
# Status byte 0x56 = segment-based variant
2287+
# Unknown values default to standard Telink for safety
2288+
status_byte = data[0] & 0xFF
2289+
return status_byte == 0x56

0 commit comments

Comments
 (0)