Skip to content

Commit 4815e56

Browse files
author
Tom Lasswell
committed
feat: Add DIY style control and music mode via BLE passthrough
Extends BLE-to-MQTT integration with two new features: - DIY Style select entity for animation styles (Fade, Jumping, Flicker, Marquee, Music) - Music Mode switch entity to enable/disable audio-reactive microphone mode Both features require MQTT connection for BLE passthrough (ptReal command). Changes: - Add build_diy_style_packet() and build_music_mode_packet() to ble_packet.py - Add DIYStyle enum and style name mappings - Add supports_music_mode property to device model - Add diy_style and music_mode_enabled state fields - Add async_send_diy_style() and async_send_music_mode() coordinator methods - Add GoveeDIYStyleSelectEntity and GoveeMusicModeSwitchEntity - Add translations and orphan cleanup support - Add comprehensive unit tests (73 BLE packet tests)
1 parent 635f2c9 commit 4815e56

15 files changed

Lines changed: 1449 additions & 10 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,7 @@ logs/
152152

153153
# Local/temporary documentation (keep govee-protocol-reference.md)
154154
docs/epic-*.md
155-
docs/legacy-*.md
155+
docs/legacy-*.md
156+
157+
# GitHub profile README (not part of this project)
158+
profile-README.md

custom_components/govee/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
# Order determines entity display order in device view
4242
PLATFORMS: list[Platform] = [
4343
Platform.SELECT, # Scene dropdowns - show first
44+
Platform.NUMBER, # DIY speed controls
4445
Platform.LIGHT, # Main light + segments
4546
Platform.SWITCH,
4647
Platform.SENSOR,
@@ -241,8 +242,10 @@ async def _async_cleanup_orphaned_entities(
241242
entity_suffixes = (
242243
"_scene_select",
243244
"_diy_scene_select",
245+
"_diy_style_select",
244246
"_refresh_scenes",
245247
"_night_light",
248+
"_music_mode",
246249
)
247250

248251
# Get all entity entries for this config entry
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
"""BLE packet construction for Govee devices.
2+
3+
Builds 20-byte BLE packets that can be sent via the AWS IoT MQTT
4+
ptReal (passthrough real) command to control device features not
5+
exposed via the REST API.
6+
7+
Packet format:
8+
- Bytes 0-18: Command data (padded with 0x00)
9+
- Byte 19: XOR checksum of bytes 0-18
10+
11+
DIY Speed packet:
12+
- Byte 0: 0xA1 (DIY packet identifier)
13+
- Byte 1: 0x02 (DIY command type)
14+
- Byte 2: 0x01 (number of segments/modes)
15+
- Byte 3: 0x00 (style - default)
16+
- Byte 4: 0x00 (mode - default)
17+
- Byte 5: speed (0-100, where 0 is static and 100 is fastest)
18+
19+
DIY Style packet:
20+
- Byte 0: 0xA1 (DIY packet identifier)
21+
- Byte 1: 0x02 (DIY command type)
22+
- Byte 2: 0x01 (number of segments/modes)
23+
- Byte 3: style (0x00=Fade, 0x01=Jumping, 0x02=Flicker, 0x03=Marquee, 0x04=Music)
24+
- Byte 4: 0x00 (mode - default)
25+
- Byte 5: speed (0-100, where 0 is static and 100 is fastest)
26+
27+
Music Mode packet:
28+
- Byte 0: 0x33 (standard command prefix)
29+
- Byte 1: 0x05 (color/mode command)
30+
- Byte 2: 0x01 (music mode indicator)
31+
- Byte 3: enabled (0x01=on, 0x00=off)
32+
- Byte 4: sensitivity (0-100)
33+
"""
34+
35+
from __future__ import annotations
36+
37+
import base64
38+
from enum import IntEnum
39+
40+
# DIY packet constants
41+
DIY_PACKET_ID = 0xA1
42+
DIY_COMMAND = 0x02
43+
44+
# Music mode packet constants
45+
MUSIC_PACKET_PREFIX = 0x33
46+
MUSIC_MODE_COMMAND = 0x05
47+
MUSIC_MODE_INDICATOR = 0x01
48+
49+
50+
class DIYStyle(IntEnum):
51+
"""DIY animation style options."""
52+
53+
FADE = 0x00
54+
JUMPING = 0x01
55+
FLICKER = 0x02
56+
MARQUEE = 0x03
57+
MUSIC = 0x04
58+
59+
60+
# Style name to enum mapping for select entity
61+
DIY_STYLE_NAMES = {
62+
"Fade": DIYStyle.FADE,
63+
"Jumping": DIYStyle.JUMPING,
64+
"Flicker": DIYStyle.FLICKER,
65+
"Marquee": DIYStyle.MARQUEE,
66+
"Music": DIYStyle.MUSIC,
67+
}
68+
69+
70+
def calculate_checksum(data: list[int]) -> int:
71+
"""Calculate XOR checksum of all bytes.
72+
73+
Args:
74+
data: List of byte values to checksum.
75+
76+
Returns:
77+
XOR of all bytes, masked to 8 bits.
78+
"""
79+
checksum = 0
80+
for byte in data:
81+
checksum ^= byte
82+
return checksum & 0xFF
83+
84+
85+
def build_packet(data: list[int]) -> bytes:
86+
"""Build a 20-byte BLE packet with checksum.
87+
88+
Pads the data to 19 bytes and appends XOR checksum.
89+
90+
Args:
91+
data: Command bytes (will be padded to 19 bytes).
92+
93+
Returns:
94+
20-byte packet as bytes.
95+
"""
96+
packet = list(data)
97+
98+
# Pad to 19 bytes
99+
while len(packet) < 19:
100+
packet.append(0x00)
101+
102+
# Truncate if too long
103+
packet = packet[:19]
104+
105+
# Append checksum
106+
packet.append(calculate_checksum(packet))
107+
108+
return bytes(packet)
109+
110+
111+
def build_diy_speed_packet(speed: int) -> bytes:
112+
"""Build DIY scene speed control packet.
113+
114+
Args:
115+
speed: Playback speed 0-100, where 0 is static (no animation)
116+
and 100 is the fastest playback speed.
117+
118+
Returns:
119+
20-byte BLE packet for DIY speed command.
120+
"""
121+
# Clamp speed to valid range
122+
speed = max(0, min(100, speed))
123+
124+
# Build command data
125+
# Packet: A1 02 [NUM] [STYLE] [MODE] [SPEED] ...
126+
data = [
127+
DIY_PACKET_ID, # 0xA1 - DIY packet identifier
128+
DIY_COMMAND, # 0x02 - DIY command type
129+
0x01, # Number of segments/modes
130+
0x00, # Style (default)
131+
0x00, # Mode (default)
132+
speed, # Speed value 0-100
133+
]
134+
135+
return build_packet(data)
136+
137+
138+
def build_diy_style_packet(style: int | DIYStyle, speed: int = 50) -> bytes:
139+
"""Build DIY scene style control packet.
140+
141+
Args:
142+
style: Animation style (0=Fade, 1=Jumping, 2=Flicker, 3=Marquee, 4=Music).
143+
speed: Playback speed 0-100, where 0 is static and 100 is fastest.
144+
145+
Returns:
146+
20-byte BLE packet for DIY style command.
147+
"""
148+
# Clamp values to valid ranges
149+
style_val = max(0, min(4, int(style)))
150+
speed = max(0, min(100, speed))
151+
152+
# Build command data
153+
# Packet: A1 02 [NUM] [STYLE] [MODE] [SPEED] ...
154+
data = [
155+
DIY_PACKET_ID, # 0xA1 - DIY packet identifier
156+
DIY_COMMAND, # 0x02 - DIY command type
157+
0x01, # Number of segments/modes
158+
style_val, # Style value
159+
0x00, # Mode (default)
160+
speed, # Speed value 0-100
161+
]
162+
163+
return build_packet(data)
164+
165+
166+
def build_music_mode_packet(enabled: bool, sensitivity: int = 50) -> bytes:
167+
"""Build music mode control packet.
168+
169+
Args:
170+
enabled: True to enable music mode, False to disable.
171+
sensitivity: Microphone sensitivity 0-100.
172+
173+
Returns:
174+
20-byte BLE packet for music mode command.
175+
"""
176+
# Clamp sensitivity to valid range
177+
sensitivity = max(0, min(100, sensitivity))
178+
179+
# Build command data
180+
# Packet: 33 05 01 [ENABLED] [SENSITIVITY] ...
181+
data = [
182+
MUSIC_PACKET_PREFIX, # 0x33 - Standard command prefix
183+
MUSIC_MODE_COMMAND, # 0x05 - Color/mode command
184+
MUSIC_MODE_INDICATOR, # 0x01 - Music mode indicator
185+
0x01 if enabled else 0x00, # Enabled state
186+
sensitivity, # Sensitivity 0-100
187+
]
188+
189+
return build_packet(data)
190+
191+
192+
def encode_packet_base64(packet: bytes) -> str:
193+
"""Base64 encode a packet for ptReal command.
194+
195+
Args:
196+
packet: Raw BLE packet bytes.
197+
198+
Returns:
199+
Base64-encoded ASCII string.
200+
"""
201+
return base64.b64encode(packet).decode("ascii")

custom_components/govee/api/mqtt.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import logging
2020
import ssl
2121
import tempfile
22+
import time
2223
from pathlib import Path
2324
from typing import TYPE_CHECKING, Any, Callable
2425

@@ -106,6 +107,7 @@ def __init__(
106107
self._task: asyncio.Task[None] | None = None
107108
self._temp_dir: tempfile.TemporaryDirectory[str] | None = None
108109
self._max_backoff_count = 0
110+
self._client: Any | None = None # aiomqtt.Client when connected
109111

110112
@property
111113
def connected(self) -> bool:
@@ -164,6 +166,7 @@ async def async_stop(self) -> None:
164166
except Exception:
165167
pass
166168

169+
self._client = None
167170
self._connected = False
168171
_LOGGER.info("AWS IoT MQTT client stopped")
169172

@@ -257,6 +260,7 @@ async def _connection_loop(self) -> None:
257260
keepalive=AWS_IOT_KEEPALIVE,
258261
timeout=CONNECTION_TIMEOUT,
259262
) as client:
263+
self._client = client
260264
self._connected = True
261265
self._max_backoff_count = 0
262266
reconnect_interval = RECONNECT_BASE
@@ -276,11 +280,14 @@ async def _connection_loop(self) -> None:
276280
break # type: ignore[unreachable]
277281
await self._handle_message(message)
278282

283+
self._client = None
284+
279285
except asyncio.CancelledError:
280286
_LOGGER.debug("AWS IoT connection loop cancelled")
281287
raise
282288

283289
except Exception as err:
290+
self._client = None
284291
self._connected = False
285292

286293
if self._running:
@@ -346,3 +353,55 @@ async def _handle_message(self, message: Any) -> None:
346353
_LOGGER.warning("Failed to parse AWS IoT message: %s", err)
347354
except Exception as err:
348355
_LOGGER.error("Error handling AWS IoT message: %s", err)
356+
357+
async def async_publish_ptreal(
358+
self,
359+
device_id: str,
360+
sku: str,
361+
ble_packet_base64: str,
362+
) -> bool:
363+
"""Publish BLE passthrough command via MQTT.
364+
365+
Sends a ptReal command to the device to execute a BLE packet.
366+
This allows controlling device features not exposed via REST API.
367+
368+
Args:
369+
device_id: Target device identifier.
370+
sku: Device SKU/model.
371+
ble_packet_base64: Base64-encoded BLE packet.
372+
373+
Returns:
374+
True if publish succeeded, False otherwise.
375+
"""
376+
if not self._connected or self._client is None:
377+
_LOGGER.warning("Cannot publish ptReal: MQTT not connected")
378+
return False
379+
380+
# Build ptReal payload
381+
payload = {
382+
"msg": {
383+
"cmd": "ptReal",
384+
"data": {
385+
"command": [ble_packet_base64],
386+
},
387+
"cmdVersion": 0,
388+
"transaction": f"v_{int(time.time() * 1000)}",
389+
"type": 1,
390+
}
391+
}
392+
393+
# Publish to account topic (same as subscription topic)
394+
topic = self._credentials.account_topic
395+
396+
try:
397+
await self._client.publish(topic, json.dumps(payload))
398+
_LOGGER.debug(
399+
"Published ptReal to %s for device %s (sku=%s)",
400+
topic[:30] + "...",
401+
device_id,
402+
sku,
403+
)
404+
return True
405+
except Exception as err:
406+
_LOGGER.error("Failed to publish ptReal: %s", err)
407+
return False

0 commit comments

Comments
 (0)