Skip to content

Commit 39e66ab

Browse files
author
Tom Lasswell
committed
feat: Show active scene as effect on light entity
Add HA standard effect support so the active scene name displays on the light card and users can activate scenes from the effect picker. - Add active_scene_name field to GoveeDeviceState, set on scene activation, cleared on power off / color / color temp / mutual exclusion (music mode, DreamView, DIY scene) - Implement effect_list and effect properties on GoveeLightEntity - Handle ATTR_EFFECT in async_turn_on to send SceneCommand - Load scenes in async_added_to_hass with duplicate name deduplication - Respect CONF_ENABLE_SCENES option (disabled = no effect support) - Preserve active_scene_name across API polls in coordinator - Add 25 new tests (10 state model + 15 light entity) - Bump version to 2026.2.9
1 parent f718fea commit 39e66ab

6 files changed

Lines changed: 514 additions & 15 deletions

File tree

custom_components/govee/coordinator.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,9 @@ async def _fetch_device_state(
471471
self._preserve_optimistic_field(
472472
existing_state, state, device_id, "active_scene", "scene"
473473
)
474+
self._preserve_optimistic_field(
475+
existing_state, state, device_id, "active_scene_name", "scene name"
476+
)
474477
self._preserve_optimistic_field(
475478
existing_state, state, device_id, "dreamview_enabled", "DreamView"
476479
)
@@ -944,6 +947,7 @@ def clear_scene(self, device_id: str) -> None:
944947
state = self._states.get(device_id)
945948
if state:
946949
state.active_scene = None
950+
state.active_scene_name = None
947951
state.source = "optimistic"
948952

949953
def clear_diy_scene(self, device_id: str) -> None:

custom_components/govee/light.py

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from homeassistant.components.light import ( # type: ignore[attr-defined]
1616
ATTR_BRIGHTNESS,
1717
ATTR_COLOR_TEMP_KELVIN,
18+
ATTR_EFFECT,
1819
ATTR_RGB_COLOR,
1920
ColorMode,
2021
LightEntity,
@@ -26,6 +27,8 @@
2627
from homeassistant.helpers.restore_state import RestoreEntity
2728

2829
from .const import (
30+
CONF_ENABLE_SCENES,
31+
DEFAULT_ENABLE_SCENES,
2932
SEGMENT_MODE_GROUPED,
3033
SEGMENT_MODE_INDIVIDUAL,
3134
)
@@ -38,6 +41,7 @@
3841
GoveeDevice,
3942
PowerCommand,
4043
RGBColor,
44+
SceneCommand,
4145
)
4246
from .platforms.grouped_segment import GoveeGroupedSegmentEntity
4347
from .platforms.segment import GoveeSegmentEntity
@@ -61,10 +65,13 @@ async def async_setup_entry(
6165
# Get per-device segment modes
6266
device_modes = entry.options.get("segment_mode_by_device", {})
6367

68+
# Check if scenes are enabled in options
69+
enable_scenes = entry.options.get(CONF_ENABLE_SCENES, DEFAULT_ENABLE_SCENES)
70+
6471
for device in coordinator.devices.values():
6572
# Only create light entities for devices with power control (not fans)
6673
if device.supports_power and not device.is_fan:
67-
entities.append(GoveeLightEntity(coordinator, device))
74+
entities.append(GoveeLightEntity(coordinator, device, enable_scenes))
6875

6976
# Create segment entities for RGBIC devices based on per-device mode
7077
if device.supports_segments and device.segment_count > 0:
@@ -124,6 +131,7 @@ def __init__(
124131
self,
125132
coordinator: GoveeCoordinator,
126133
device: GoveeDevice,
134+
enable_scenes: bool = True,
127135
) -> None:
128136
"""Initialize the light entity."""
129137
super().__init__(coordinator, device)
@@ -138,10 +146,16 @@ def __init__(
138146
# Get device brightness range
139147
self._brightness_min, self._brightness_max = device.brightness_range
140148

141-
# Add effect support if device has scenes
142-
if device.supports_scenes:
149+
# Effect support: only if device has scenes AND scenes are enabled
150+
self._enable_scenes = device.supports_scenes and enable_scenes
151+
if self._enable_scenes:
143152
self._attr_supported_features = LightEntityFeature.EFFECT
144153

154+
# Scene-to-effect mappings (populated in async_added_to_hass)
155+
self._effect_to_scene: dict[str, tuple[int, str]] = {}
156+
self._scene_id_to_effect: dict[str, str] = {}
157+
self._effect_names: list[str] = []
158+
145159
def _determine_color_modes(self) -> set[ColorMode]:
146160
"""Determine supported color modes from device capabilities."""
147161
modes: set[ColorMode] = set()
@@ -220,6 +234,24 @@ def max_color_temp_kelvin(self) -> int:
220234
temp_range = self._device.color_temp_range
221235
return temp_range.max_kelvin if temp_range else 9000
222236

237+
@property
238+
def effect_list(self) -> list[str] | None:
239+
"""Return list of available effects (scene names)."""
240+
return self._effect_names if self._effect_names else None
241+
242+
@property
243+
def effect(self) -> str | None:
244+
"""Return currently active effect (scene name)."""
245+
state = self.device_state
246+
if not state or not state.active_scene:
247+
return None
248+
# Look up display name from scene ID mapping
249+
effect_name = self._scene_id_to_effect.get(state.active_scene)
250+
if effect_name:
251+
return effect_name
252+
# Fall back to stored scene name if ID not in mapping
253+
return state.active_scene_name
254+
223255
def _ha_to_device_brightness(self, ha_brightness: int) -> int:
224256
"""Convert HA brightness (0-255) to device range, respecting min."""
225257
ratio = ha_brightness / HA_BRIGHTNESS_MAX
@@ -233,11 +265,29 @@ def _device_to_ha_brightness(self, device_brightness: int) -> int:
233265
if device_range <= 0:
234266
return 0
235267
return int(
236-
(device_brightness - self._brightness_min) / device_range * HA_BRIGHTNESS_MAX
268+
(device_brightness - self._brightness_min)
269+
/ device_range
270+
* HA_BRIGHTNESS_MAX
237271
)
238272

239273
async def async_turn_on(self, **kwargs: Any) -> None:
240274
"""Turn the light on with optional parameters."""
275+
# Handle effect (scene activation)
276+
if ATTR_EFFECT in kwargs:
277+
effect_name = kwargs[ATTR_EFFECT]
278+
scene_info = self._effect_to_scene.get(effect_name)
279+
if scene_info:
280+
scene_id, scene_name = scene_info
281+
await self.coordinator.async_control_device(
282+
self._device_id,
283+
SceneCommand(scene_id=scene_id, scene_name=scene_name),
284+
)
285+
else:
286+
_LOGGER.warning(
287+
"Unknown effect '%s' for %s", effect_name, self._device.name
288+
)
289+
return
290+
241291
# Handle brightness
242292
if ATTR_BRIGHTNESS in kwargs:
243293
ha_brightness = kwargs[ATTR_BRIGHTNESS]
@@ -289,8 +339,35 @@ async def async_turn_off(self, **kwargs: Any) -> None:
289339
PowerCommand(power_on=False),
290340
)
291341

342+
def _build_effect_mapping(self, scenes: list[dict[str, Any]]) -> None:
343+
"""Build effect name mappings from scene data.
344+
345+
Handles duplicate scene names by appending a counter,
346+
mirroring the logic in GoveeSceneSelectEntity.
347+
"""
348+
self._effect_to_scene = {}
349+
self._scene_id_to_effect = {}
350+
names: list[str] = []
351+
352+
for scene_data in scenes:
353+
scene_id = scene_data.get("value", {}).get("id", 0)
354+
scene_name = scene_data.get("name", f"Scene {scene_id}")
355+
356+
# Handle duplicate names by appending counter
357+
unique_name = scene_name
358+
counter = 1
359+
while unique_name in self._effect_to_scene:
360+
unique_name = f"{scene_name} ({counter})"
361+
counter += 1
362+
363+
self._effect_to_scene[unique_name] = (scene_id, scene_name)
364+
self._scene_id_to_effect[str(scene_id)] = unique_name
365+
names.append(unique_name)
366+
367+
self._effect_names = names
368+
292369
async def async_added_to_hass(self) -> None:
293-
"""Restore state for group devices."""
370+
"""Restore state for group devices and load scenes for effects."""
294371
await super().async_added_to_hass()
295372

296373
if self._device.is_group:
@@ -304,3 +381,9 @@ async def async_added_to_hass(self) -> None:
304381
last_state.attributes["brightness"]
305382
)
306383
self.coordinator.restore_group_state(self._device_id, power, brightness)
384+
385+
# Load scenes for effect support (skip group devices - no scene API support)
386+
if self._enable_scenes and not self._device.is_group:
387+
scenes = await self.coordinator.async_get_scenes(self._device_id)
388+
if scenes:
389+
self._build_effect_mapping(scenes)

custom_components/govee/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,6 @@
1616
"cryptography>=41.0.0"
1717
],
1818
"ssdp": [],
19-
"version": "2026.2.8",
19+
"version": "2026.2.9",
2020
"zeroconf": []
2121
}

custom_components/govee/models/state.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ class GoveeDeviceState:
8383
color: RGBColor | None = None
8484
color_temp_kelvin: int | None = None
8585
active_scene: str | None = None
86+
active_scene_name: str | None = None # Display name of active scene
8687
active_diy_scene: str | None = None # DIY scene ID (separate from regular scenes)
8788
segments: list[SegmentState] = field(default_factory=list)
8889
diy_style: str | None = None # DIY animation style (Fade, Jumping, etc.)
@@ -110,7 +111,9 @@ class GoveeDeviceState:
110111
fan_speed: int | None = None # Fan speed mode value (1=Low, 2=Medium, 3=High)
111112

112113
# Purifier state
113-
purifier_mode: int | None = None # Purifier mode value (1=Sleep, 2=Low, 3=High, etc.)
114+
purifier_mode: int | None = (
115+
None # Purifier mode value (1=Sleep, 2=Low, 3=High, etc.)
116+
)
114117

115118
# Last activated scene (for restoring after music mode off)
116119
last_scene_id: str | None = None
@@ -205,6 +208,7 @@ def apply_optimistic_power(self, power_on: bool) -> None:
205208
# Clear scene when turning off (scene is no longer active)
206209
if not power_on:
207210
self.active_scene = None
211+
self.active_scene_name = None
208212

209213
def apply_optimistic_brightness(self, brightness: int) -> None:
210214
"""Apply optimistic brightness update."""
@@ -216,12 +220,18 @@ def apply_optimistic_color(self, color: RGBColor) -> None:
216220
self.color = color
217221
self.color_temp_kelvin = None # RGB mode
218222
self.source = "optimistic"
223+
# Setting a color overrides any running scene
224+
self.active_scene = None
225+
self.active_scene_name = None
219226

220227
def apply_optimistic_color_temp(self, kelvin: int) -> None:
221228
"""Apply optimistic color temperature update."""
222229
self.color_temp_kelvin = kelvin
223230
self.color = None # Color temp mode
224231
self.source = "optimistic"
232+
# Setting color temp overrides any running scene
233+
self.active_scene = None
234+
self.active_scene_name = None
225235

226236
def apply_optimistic_scene(
227237
self, scene_id: str, scene_name: str | None = None
@@ -232,6 +242,7 @@ def apply_optimistic_scene(
232242
When a Scene is activated, DreamView, music mode, and DIY scene are cleared.
233243
"""
234244
self.active_scene = scene_id
245+
self.active_scene_name = scene_name
235246
self.last_scene_id = scene_id
236247
self.last_scene_name = scene_name
237248
self.source = "optimistic"
@@ -256,6 +267,7 @@ def apply_optimistic_diy_scene(self, scene_id: str) -> None:
256267
self.music_mode_value = None
257268
self.music_mode_name = None
258269
self.active_scene = None
270+
self.active_scene_name = None
259271

260272
def apply_optimistic_diy_style(
261273
self, style: str, style_value: int | None = None
@@ -282,6 +294,7 @@ def apply_optimistic_music_mode(self, enabled: bool) -> None:
282294
if enabled:
283295
self.dreamview_enabled = False
284296
self.active_scene = None
297+
self.active_scene_name = None
285298
self.active_diy_scene = None
286299

287300
def apply_optimistic_music_mode_struct(
@@ -308,6 +321,7 @@ def apply_optimistic_music_mode_struct(
308321
# Mutual exclusion: clear other modes when enabling music mode
309322
self.dreamview_enabled = False
310323
self.active_scene = None
324+
self.active_scene_name = None
311325
self.active_diy_scene = None
312326

313327
def apply_optimistic_oscillation(self, oscillating: bool) -> None:
@@ -340,6 +354,7 @@ def apply_optimistic_dreamview(self, enabled: bool) -> None:
340354
self.music_mode_value = None
341355
self.music_mode_name = None
342356
self.active_scene = None
357+
self.active_scene_name = None
343358
self.active_diy_scene = None
344359

345360
@classmethod

0 commit comments

Comments
 (0)