Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions custom_components/govee/api/ble_packet.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
# DreamView (Movie Mode) packet constants
DREAMVIEW_COMMAND = 0x05 # Same as music mode command byte
DREAMVIEW_INDICATOR = 0x04 # Scene mode indicator (vs 0x01 for music)
DIY_MODE_INDICATOR = 0x0A # DIY mode indicator

# DIY style name to value mapping for select entity
DIY_STYLE_NAMES: dict[str, int] = {
Expand Down Expand Up @@ -128,6 +129,35 @@ def build_dreamview_packet(enabled: bool) -> bytes:
return build_packet(data)


def build_diy_scene_packet(scene_id: int) -> bytes:
"""Build DIY scene activation packet.

Activates a saved DIY scene by ID. Uses the DIY mode indicator (0x0A)
with the scene ID encoded as 4-byte little-endian.

Args:
scene_id: DIY scene ID from the API (e.g., 21104832).

Returns:
20-byte BLE packet for DIY scene activation.
"""
# Encode scene_id as 4-byte little-endian
id_bytes = scene_id.to_bytes(4, byteorder="little")

# Packet: 33 05 0A [id_byte0] [id_byte1] [id_byte2] [id_byte3] 00...00 [XOR]
data = [
MUSIC_PACKET_PREFIX, # 0x33 - Standard command prefix
MUSIC_MODE_COMMAND, # 0x05 - Color/mode command
DIY_MODE_INDICATOR, # 0x0A - DIY mode indicator
id_bytes[0],
id_bytes[1],
id_bytes[2],
id_bytes[3],
]

return build_packet(data)


def encode_packet_base64(packet: bytes) -> str:
"""Base64 encode a packet for ptReal command.

Expand Down
21 changes: 21 additions & 0 deletions custom_components/govee/ble_passthrough.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import Any

from .api.ble_packet import (
build_diy_scene_packet,
build_dreamview_packet,
build_music_mode_packet,
encode_packet_base64,
Expand Down Expand Up @@ -121,3 +122,23 @@ async def async_send_dreamview(
packet = build_dreamview_packet(enabled)
encoded = encode_packet_base64(packet)
return await self.async_send_ble_packet(device_id, sku, encoded)

async def async_send_diy_scene(
self,
device_id: str,
sku: str,
scene_id: int,
) -> bool:
"""Send DIY scene activation via BLE passthrough.

Args:
device_id: Device identifier.
sku: Device SKU.
scene_id: DIY scene ID from the API.

Returns:
True if command was sent successfully.
"""
packet = build_diy_scene_packet(scene_id)
encoded = encode_packet_base64(packet)
return await self.async_send_ble_packet(device_id, sku, encoded)
65 changes: 65 additions & 0 deletions custom_components/govee/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,71 @@ async def async_send_dreamview(self, device_id: str, enabled: bool) -> bool:

return success

async def async_send_diy_scene(
self,
device_id: str,
scene_id: int,
scene_name: str = "",
) -> bool:
"""Send DIY scene command via REST API, with BLE fallback.

Args:
device_id: Device identifier.
scene_id: DIY scene ID from the API.
scene_name: DIY scene name for logging/state.

Returns:
True if command was sent successfully.
"""
device = self._devices.get(device_id)
if not device:
_LOGGER.error("Unknown device for DIY scene: %s", device_id)
return False

# Try REST API first
try:
command = DIYSceneCommand(scene_id=scene_id, scene_name=scene_name)
success = await self.async_control_device(device_id, command)
if success:
_LOGGER.debug(
"Activated DIY scene '%s' on %s via REST API",
scene_name,
device.name,
)
return True
_LOGGER.debug(
"REST DIY scene returned failure for %s, trying BLE passthrough",
device.name,
)
except ConfigEntryAuthFailed:
raise
except Exception as err:
_LOGGER.debug("REST DIY scene failed for %s: %s", device.name, err)

# Fall back to BLE passthrough
if not self._ble_manager.available:
_LOGGER.warning(
"Cannot send DIY scene for %s: MQTT not connected",
device_id,
)
return False

success = await self._ble_manager.async_send_diy_scene(
device_id, device.sku, scene_id
)

if success:
state = self._states.get(device_id)
if state:
state.apply_optimistic_diy_scene(str(scene_id))
_LOGGER.debug(
"Activated DIY scene '%s' on %s via BLE passthrough",
scene_name,
device.name,
)

return success

async def async_send_diy_style(
self, device_id: str, style: str, speed: int = 50
) -> bool:
Expand Down
10 changes: 2 additions & 8 deletions custom_components/govee/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
from .coordinator import GoveeCoordinator
from .entity import GoveeEntity
from .models import (
DIYSceneCommand,
GoveeDevice,
ModeCommand,
MusicModeCommand,
Expand Down Expand Up @@ -408,18 +407,13 @@ async def async_select_option(self, option: str) -> None:

scene_id, scene_name = scene_info

command = DIYSceneCommand(
success = await self.coordinator.async_send_diy_scene(
self._device_id,
scene_id=scene_id,
scene_name=scene_name,
)

success = await self.coordinator.async_control_device(
self._device_id,
command,
)

if success:
# State update with mutual exclusion is handled in coordinator
self.async_write_ha_state()
_LOGGER.debug(
"Activated DIY scene '%s' on %s",
Expand Down