Skip to content

Commit bae9323

Browse files
author
Tom Lasswell
committed
feat: Implement multi-packet protocol for regular scene speed control
Previously the scene speed slider for regular (non-DIY) scenes didn't work because it was sending a simple 0x33 0x05 0x02 packet for manual color mode instead of the proper scene speed protocol. This implements the correct multi-packet 0xA3 BLE protocol: - Get the active scene's scenceParam (base64 animation data) from library - Decode and modify the speed byte at the speedIndex position - Build multi-packet sequence (first/middle/last packets) - Send activation packet 0x33 0x05 0x04 with scene code Changes: - api/ble_packet.py: Add modify_scene_speed(), build_multi_packet_sequence(), and build_scene_activation_packet() functions - api/auth.py: Enhance fetch_light_effect_library() to extract scenceParam, sceneCode, speedIndex, and speed range from scene data - api/mqtt.py: Update async_publish_ptreal() to accept list of packets - coordinator.py: Rewrite async_send_scene_speed() with multi-packet protocol, add get_scene_speed_data() helper - number.py: Make speed range dynamic based on active scene, only show available when a speed-capable scene is active - tests/test_ble_packet.py: Add 28 tests for new functions
1 parent 630d6a3 commit bae9323

7 files changed

Lines changed: 732 additions & 43 deletions

File tree

custom_components/govee/api/auth.py

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,8 @@ async def fetch_light_effect_library(
286286
This API returns scene information including speed support:
287287
- support_speed: Whether the device supports scene speed control
288288
- scenes with speed_info: Per-scene speed configuration
289+
- scenceParam: Base64-encoded animation data for multi-packet protocol
290+
- sceneCode: Activation code for BLE scene activation packet
289291
290292
Reference: wez/govee2mqtt light effect library API
291293
@@ -297,17 +299,20 @@ async def fetch_light_effect_library(
297299
Dict with light effect library data:
298300
{
299301
"support_speed": 1, # 0 or 1
300-
"scenes": [
301-
{
302+
"categories": [...], # Raw category data
303+
"scenes": { # Flattened scene lookup by sceneId
304+
"123": {
302305
"sceneId": 123,
303306
"sceneName": "Aurora",
304-
"speed_info": {
305-
"moveAll": [10, 100], # [min, max] range
306-
"default": 50
307-
},
308-
...
309-
}
310-
]
307+
"sceneCode": 10191,
308+
"scenceParam": "base64-encoded-data",
309+
"speedIndex": 5, # Byte position of speed in scenceParam
310+
"speedMin": 10,
311+
"speedMax": 100,
312+
"speedDefault": 50,
313+
},
314+
...
315+
}
311316
}
312317
313318
Raises:
@@ -339,14 +344,64 @@ async def fetch_light_effect_library(
339344
code=response.status,
340345
)
341346

342-
# Response structure: { "data": { "support_speed": 1, "scenes": [...] } }
343-
result = data.get("data", {}) if isinstance(data, dict) else {}
347+
# Response structure: { "data": { "support_speed": 1, "categories": [...] } }
348+
raw_data = data.get("data", {}) if isinstance(data, dict) else {}
349+
350+
# Build flattened scene lookup for easy access
351+
scenes_lookup: dict[str, dict[str, Any]] = {}
352+
categories = raw_data.get("categories", [])
353+
354+
for category in categories:
355+
for scene in category.get("scenes", []):
356+
scene_id = scene.get("sceneId")
357+
if scene_id is None:
358+
continue
359+
360+
scene_data: dict[str, Any] = {
361+
"sceneId": scene_id,
362+
"sceneName": scene.get("sceneName", ""),
363+
"sceneCode": scene.get("sceneCode"),
364+
"sceneType": scene.get("sceneType", 1),
365+
}
366+
367+
# Extract scenceParam from lightEffects if available
368+
light_effects = scene.get("lightEffects", [])
369+
if light_effects:
370+
# Use the first light effect (usually the only one)
371+
effect = light_effects[0]
372+
scene_data["scenceParam"] = effect.get("scenceParam")
373+
scene_data["scenceParamId"] = effect.get("scenceParamId")
374+
375+
# Extract speed_info if available
376+
speed_info = scene.get("speed_info")
377+
if speed_info:
378+
scene_data["speedIndex"] = speed_info.get("speedIndex")
379+
380+
# moveAll contains [min, max] speed range
381+
move_all = speed_info.get("moveAll", [])
382+
if len(move_all) >= 2:
383+
scene_data["speedMin"] = move_all[0]
384+
scene_data["speedMax"] = move_all[1]
385+
else:
386+
# Default range
387+
scene_data["speedMin"] = 1
388+
scene_data["speedMax"] = 100
389+
390+
scene_data["speedDefault"] = speed_info.get("default", 50)
391+
392+
scenes_lookup[str(scene_id)] = scene_data
393+
394+
result = {
395+
"support_speed": raw_data.get("support_speed", 0),
396+
"categories": categories,
397+
"scenes": scenes_lookup,
398+
}
344399

345400
_LOGGER.debug(
346401
"Light effect library for %s: support_speed=%s, scenes=%d",
347402
sku,
348403
result.get("support_speed", 0),
349-
len(result.get("scenes", [])),
404+
len(scenes_lookup),
350405
)
351406
return result
352407

custom_components/govee/api/ble_packet.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,164 @@ def encode_packet_base64(packet: bytes) -> str:
244244
Base64-encoded ASCII string.
245245
"""
246246
return base64.b64encode(packet).decode("ascii")
247+
248+
249+
# ==============================================================================
250+
# Multi-Packet Scene Speed Protocol (0xA3)
251+
# ==============================================================================
252+
253+
# Multi-packet constants
254+
MULTI_PACKET_ID = 0xA3
255+
MULTI_PACKET_FIRST_INDEX = 0x00
256+
MULTI_PACKET_LAST_INDEX = 0xFF
257+
258+
# Scene activation constants
259+
SCENE_ACTIVATION_PREFIX = 0x33
260+
SCENE_ACTIVATION_COMMAND = 0x05
261+
SCENE_ACTIVATION_INDICATOR = 0x04
262+
263+
264+
def modify_scene_speed(scence_param_b64: str, speed_index: int, new_speed: int) -> bytes:
265+
"""Decode scenceParam and modify the speed byte at speedIndex.
266+
267+
The scenceParam is base64-encoded animation data from the light effect
268+
library. The speedIndex indicates which byte position contains the speed
269+
value that needs to be modified.
270+
271+
Args:
272+
scence_param_b64: Base64-encoded scenceParam from light effect library.
273+
speed_index: Byte position of the speed value in the decoded data.
274+
new_speed: New speed value (will be clamped to valid range).
275+
276+
Returns:
277+
Modified animation data as bytes.
278+
279+
Raises:
280+
ValueError: If speed_index is out of bounds or base64 is invalid.
281+
"""
282+
try:
283+
# Decode base64 to get raw animation data
284+
data = bytearray(base64.b64decode(scence_param_b64))
285+
except Exception as err:
286+
raise ValueError(f"Invalid base64 scenceParam: {err}") from err
287+
288+
# Validate speed_index is within bounds
289+
if speed_index < 0 or speed_index >= len(data):
290+
raise ValueError(
291+
f"speedIndex {speed_index} out of bounds for data length {len(data)}"
292+
)
293+
294+
# Clamp speed to valid range (typically 1-100 for regular scenes)
295+
new_speed = max(1, min(100, new_speed))
296+
297+
# Modify the speed byte
298+
data[speed_index] = new_speed
299+
300+
return bytes(data)
301+
302+
303+
def build_multi_packet_sequence(data: bytes, scene_type: int = 2) -> list[bytes]:
304+
"""Build 0xA3 multi-packet sequence for scene animation data.
305+
306+
Multi-packet format:
307+
- First packet: [0xA3, 0x00, count, scene_type, data[0:14]...]
308+
- Middle packet: [0xA3, index, data[offset:offset+17]...]
309+
- Last packet: [0xA3, 0xFF, remaining_data...]
310+
311+
Each packet is 20 bytes (19 data + 1 XOR checksum).
312+
313+
Args:
314+
data: Animation data bytes to send.
315+
scene_type: Scene type indicator (default 2 for regular scenes).
316+
317+
Returns:
318+
List of 20-byte packets ready for transmission.
319+
"""
320+
packets: list[bytes] = []
321+
322+
# Calculate how many packets we need
323+
# First packet has 14 bytes of data (after 0xA3, 0x00, count, scene_type)
324+
# Middle packets have 17 bytes of data (after 0xA3, index)
325+
# Last packet has remaining data (after 0xA3, 0xFF)
326+
327+
if len(data) == 0:
328+
# Edge case: empty data, just send a minimal packet
329+
packets.append(build_packet([MULTI_PACKET_ID, MULTI_PACKET_FIRST_INDEX, 1, scene_type]))
330+
packets.append(build_packet([MULTI_PACKET_ID, MULTI_PACKET_LAST_INDEX]))
331+
return packets
332+
333+
# First packet: 0xA3, 0x00, count, scene_type, data[0:14]
334+
first_data_size = 14
335+
first_chunk = data[:first_data_size]
336+
remaining = data[first_data_size:]
337+
338+
# Calculate total packet count (including first and last)
339+
middle_data_size = 17
340+
if len(remaining) == 0:
341+
total_packets = 2 # Just first and last
342+
else:
343+
# Middle packets + last packet for remaining data
344+
middle_packet_count = (len(remaining) - 1) // middle_data_size + 1
345+
total_packets = 1 + middle_packet_count # First + middle/last
346+
347+
# Build first packet
348+
first_packet_data = [MULTI_PACKET_ID, MULTI_PACKET_FIRST_INDEX, total_packets, scene_type]
349+
first_packet_data.extend(first_chunk)
350+
packets.append(build_packet(first_packet_data))
351+
352+
# Build middle packets (if needed)
353+
index = 1
354+
offset = 0
355+
while offset < len(remaining):
356+
chunk = remaining[offset : offset + middle_data_size]
357+
is_last = offset + middle_data_size >= len(remaining)
358+
359+
if is_last:
360+
# Last packet uses 0xFF as index
361+
packet_data = [MULTI_PACKET_ID, MULTI_PACKET_LAST_INDEX]
362+
else:
363+
# Middle packet uses sequential index
364+
packet_data = [MULTI_PACKET_ID, index]
365+
366+
packet_data.extend(chunk)
367+
packets.append(build_packet(packet_data))
368+
369+
offset += middle_data_size
370+
index += 1
371+
372+
# If we only had first packet data, add an empty last packet
373+
if len(remaining) == 0:
374+
packets.append(build_packet([MULTI_PACKET_ID, MULTI_PACKET_LAST_INDEX]))
375+
376+
return packets
377+
378+
379+
def build_scene_activation_packet(scene_code: int) -> bytes:
380+
"""Build scene activation packet (0x33 0x05 0x04).
381+
382+
This packet is sent after the multi-packet scene data to activate
383+
the scene on the device.
384+
385+
Packet format:
386+
[0x33, 0x05, 0x04, code_lo, code_hi, 0x00...] + checksum
387+
388+
Args:
389+
scene_code: Scene code from light effect library (16-bit value).
390+
391+
Returns:
392+
20-byte BLE packet for scene activation.
393+
"""
394+
# Extract low and high bytes of scene code
395+
code_lo = scene_code & 0xFF
396+
code_hi = (scene_code >> 8) & 0xFF
397+
398+
# Build activation packet
399+
data = [
400+
SCENE_ACTIVATION_PREFIX, # 0x33
401+
SCENE_ACTIVATION_COMMAND, # 0x05
402+
SCENE_ACTIVATION_INDICATOR, # 0x04
403+
code_lo,
404+
code_hi,
405+
]
406+
407+
return build_packet(data)

custom_components/govee/api/mqtt.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -370,18 +370,20 @@ async def async_publish_ptreal(
370370
self,
371371
device_id: str,
372372
sku: str,
373-
ble_packet_base64: str,
373+
ble_packet_base64: str | list[str],
374374
device_topic: str | None = None,
375375
) -> bool:
376376
"""Publish BLE passthrough command via MQTT.
377377
378-
Sends a ptReal command to the device to execute a BLE packet.
378+
Sends a ptReal command to the device to execute BLE packet(s).
379379
This allows controlling device features not exposed via REST API.
380380
381381
Args:
382382
device_id: Target device identifier.
383383
sku: Device SKU/model.
384-
ble_packet_base64: Base64-encoded BLE packet.
384+
ble_packet_base64: Base64-encoded BLE packet or list of packets.
385+
For multi-packet sequences (e.g., scene speed),
386+
pass a list of base64-encoded packets.
385387
device_topic: Device-specific MQTT topic for publishing commands.
386388
Required for AWS IoT - obtained from undocumented API.
387389
@@ -400,12 +402,18 @@ async def async_publish_ptreal(
400402
)
401403
return False
402404

405+
# Normalize to list for consistent handling
406+
if isinstance(ble_packet_base64, str):
407+
packets = [ble_packet_base64]
408+
else:
409+
packets = ble_packet_base64
410+
403411
# Build ptReal payload with device targeting
404412
payload = {
405413
"msg": {
406414
"cmd": "ptReal",
407415
"data": {
408-
"command": [ble_packet_base64],
416+
"command": packets,
409417
"device": device_id,
410418
"sku": sku,
411419
},
@@ -418,10 +426,11 @@ async def async_publish_ptreal(
418426
try:
419427
await self._client.publish(device_topic, json.dumps(payload))
420428
_LOGGER.debug(
421-
"Published ptReal to %s for device %s (sku=%s)",
429+
"Published ptReal to %s for device %s (sku=%s, packets=%d)",
422430
device_topic[:30] + "...",
423431
device_id,
424432
sku,
433+
len(packets),
425434
)
426435
return True
427436
except Exception as err:

0 commit comments

Comments
 (0)