Add support for effects in Govee lights (#137846)

This commit is contained in:
Galorhallen 2025-02-25 17:04:53 +01:00 committed by GitHub
parent 9ec9110e1e
commit f3021b40ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 558 additions and 242 deletions

View File

@ -89,6 +89,10 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]):
"""Set light color in kelvin."""
await device.set_temperature(temperature)
async def set_scene(self, device: GoveeController, scene: str) -> None:
"""Set light scene."""
await device.set_scene(scene)
@property
def devices(self) -> list[GoveeDevice]:
"""Return a list of discovered Govee devices."""

View File

@ -10,9 +10,11 @@ from govee_local_api import GoveeDevice, GoveeLightFeatures
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_RGB_COLOR,
ColorMode,
LightEntity,
LightEntityFeature,
filter_supported_color_modes,
)
from homeassistant.core import HomeAssistant, callback
@ -25,6 +27,8 @@ from .coordinator import GoveeLocalApiCoordinator, GoveeLocalConfigEntry
_LOGGER = logging.getLogger(__name__)
_NONE_SCENE = "none"
async def async_setup_entry(
hass: HomeAssistant,
@ -50,10 +54,22 @@ async def async_setup_entry(
class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
"""Govee Light."""
_attr_translation_key = "govee_light"
_attr_has_entity_name = True
_attr_name = None
_attr_supported_color_modes: set[ColorMode]
_fixed_color_mode: ColorMode | None = None
_attr_effect_list: list[str] | None = None
_attr_effect: str | None = None
_attr_supported_features: LightEntityFeature = LightEntityFeature(0)
_last_color_state: (
tuple[
ColorMode | str | None,
int | None,
tuple[int, int, int] | tuple[int | None] | None,
]
| None
) = None
def __init__(
self,
@ -80,6 +96,13 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
if GoveeLightFeatures.BRIGHTNESS & capabilities.features:
color_modes.add(ColorMode.BRIGHTNESS)
if (
GoveeLightFeatures.SCENES & capabilities.features
and capabilities.scenes
):
self._attr_supported_features = LightEntityFeature.EFFECT
self._attr_effect_list = [_NONE_SCENE, *capabilities.scenes.keys()]
self._attr_supported_color_modes = filter_supported_color_modes(color_modes)
if len(self._attr_supported_color_modes) == 1:
# If the light supports only a single color mode, set it now
@ -143,12 +166,27 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
if ATTR_RGB_COLOR in kwargs:
self._attr_color_mode = ColorMode.RGB
self._attr_effect = None
self._last_color_state = None
red, green, blue = kwargs[ATTR_RGB_COLOR]
await self.coordinator.set_rgb_color(self._device, red, green, blue)
elif ATTR_COLOR_TEMP_KELVIN in kwargs:
self._attr_color_mode = ColorMode.COLOR_TEMP
self._attr_effect = None
self._last_color_state = None
temperature: float = kwargs[ATTR_COLOR_TEMP_KELVIN]
await self.coordinator.set_temperature(self._device, int(temperature))
elif ATTR_EFFECT in kwargs:
effect = kwargs[ATTR_EFFECT]
if effect and self._attr_effect_list and effect in self._attr_effect_list:
if effect == _NONE_SCENE:
self._attr_effect = None
await self._restore_last_color_state()
else:
self._attr_effect = effect
self._save_last_color_state()
await self.coordinator.set_scene(self._device, effect)
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
@ -159,3 +197,27 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
@callback
def _update_callback(self, device: GoveeDevice) -> None:
self.async_write_ha_state()
def _save_last_color_state(self) -> None:
color_mode = self.color_mode
self._last_color_state = (
color_mode,
self.brightness,
(self.color_temp_kelvin,)
if color_mode == ColorMode.COLOR_TEMP
else self.rgb_color,
)
async def _restore_last_color_state(self) -> None:
if self._last_color_state:
color_mode, brightness, color = self._last_color_state
if color:
if color_mode == ColorMode.RGB:
await self.coordinator.set_rgb_color(self._device, *color)
elif color_mode == ColorMode.COLOR_TEMP:
await self.coordinator.set_temperature(self._device, *color)
if brightness:
await self.coordinator.set_brightness(
self._device, int((float(brightness) / 255.0) * 100.0)
)
self._last_color_state = None

View File

@ -9,5 +9,29 @@
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
}
},
"entity": {
"light": {
"govee_light": {
"state_attributes": {
"effect": {
"state": {
"none": "None",
"sunrise": "Sunrise",
"sunset": "Sunset",
"movie": "Movie",
"dating": "Dating",
"romantic": "Romantic",
"twinkle": "Twinkle",
"candlelight": "Candlelight",
"snowflake": "Snowflake",
"energetic": "Energetic",
"breathe": "Breathe",
"crossing": "Crossing"
}
}
}
}
}
}
}

View File

@ -4,15 +4,15 @@ from asyncio import Event
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from govee_local_api import GoveeLightCapabilities
from govee_local_api.light_capabilities import COMMON_FEATURES
from govee_local_api import GoveeLightCapabilities, GoveeLightFeatures
from govee_local_api.light_capabilities import COMMON_FEATURES, SCENE_CODES
import pytest
from homeassistant.components.govee_light_local.coordinator import GoveeController
@pytest.fixture(name="mock_govee_api")
def fixture_mock_govee_api():
def fixture_mock_govee_api() -> Generator[AsyncMock]:
"""Set up Govee Local API fixture."""
mock_api = AsyncMock(spec=GoveeController)
mock_api.start = AsyncMock()
@ -21,8 +21,20 @@ def fixture_mock_govee_api():
mock_api.turn_on_off = AsyncMock()
mock_api.set_brightness = AsyncMock()
mock_api.set_color = AsyncMock()
mock_api.set_scene = AsyncMock()
mock_api._async_update_data = AsyncMock()
return mock_api
with (
patch(
"homeassistant.components.govee_light_local.coordinator.GoveeController",
return_value=mock_api,
) as mock_controller,
patch(
"homeassistant.components.govee_light_local.config_flow.GoveeController",
return_value=mock_api,
),
):
yield mock_controller.return_value
@pytest.fixture(name="mock_setup_entry")
@ -38,3 +50,9 @@ def fixture_mock_setup_entry() -> Generator[AsyncMock]:
DEFAULT_CAPABILITIES: GoveeLightCapabilities = GoveeLightCapabilities(
features=COMMON_FEATURES, segments=[], scenes={}
)
SCENE_CAPABILITIES: GoveeLightCapabilities = GoveeLightCapabilities(
features=COMMON_FEATURES | GoveeLightFeatures.SCENES,
segments=[],
scenes=SCENE_CODES,
)

View File

@ -32,15 +32,9 @@ async def test_creating_entry_has_no_devices(
mock_govee_api.devices = []
with (
patch(
"homeassistant.components.govee_light_local.config_flow.GoveeController",
return_value=mock_govee_api,
),
patch(
with patch(
"homeassistant.components.govee_light_local.config_flow.DISCOVERY_TIMEOUT",
0,
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -67,10 +61,6 @@ async def test_creating_entry_has_with_devices(
mock_govee_api.devices = _get_devices(mock_govee_api)
with patch(
"homeassistant.components.govee_light_local.config_flow.GoveeController",
return_value=mock_govee_api,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@ -99,10 +89,6 @@ async def test_creating_entry_errno(
mock_govee_api.start.side_effect = e
mock_govee_api.devices = _get_devices(mock_govee_api)
with patch(
"homeassistant.components.govee_light_local.config_flow.GoveeController",
return_value=mock_govee_api,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)

View File

@ -10,7 +10,7 @@ from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from .conftest import DEFAULT_CAPABILITIES
from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES
from tests.common import MockConfigEntry
@ -30,10 +30,6 @@ async def test_light_known_device(
)
]
with patch(
"homeassistant.components.govee_light_local.coordinator.GoveeController",
return_value=mock_govee_api,
):
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
@ -69,10 +65,6 @@ async def test_light_unknown_device(
)
]
with patch(
"homeassistant.components.govee_light_local.coordinator.GoveeController",
return_value=mock_govee_api,
):
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
@ -88,7 +80,7 @@ async def test_light_unknown_device(
async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None:
"""Test adding a known device."""
"""Test remove device."""
mock_govee_api.devices = [
GoveeDevice(
@ -100,10 +92,6 @@ async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> N
)
]
with patch(
"homeassistant.components.govee_light_local.coordinator.GoveeController",
return_value=mock_govee_api,
):
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
@ -120,14 +108,10 @@ async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> N
async def test_light_setup_retry(
hass: HomeAssistant, mock_govee_api: AsyncMock
) -> None:
"""Test adding an unknown device."""
"""Test setup retry."""
mock_govee_api.devices = []
with patch(
"homeassistant.components.govee_light_local.coordinator.GoveeController",
return_value=mock_govee_api,
):
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
@ -142,7 +126,7 @@ async def test_light_setup_retry(
async def test_light_setup_retry_eaddrinuse(
hass: HomeAssistant, mock_govee_api: AsyncMock
) -> None:
"""Test adding an unknown device."""
"""Test retry on address already in use."""
mock_govee_api.start.side_effect = OSError()
mock_govee_api.start.side_effect.errno = EADDRINUSE
@ -156,10 +140,6 @@ async def test_light_setup_retry_eaddrinuse(
)
]
with patch(
"homeassistant.components.govee_light_local.coordinator.GoveeController",
return_value=mock_govee_api,
):
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
@ -170,7 +150,7 @@ async def test_light_setup_retry_eaddrinuse(
async def test_light_setup_error(
hass: HomeAssistant, mock_govee_api: AsyncMock
) -> None:
"""Test adding an unknown device."""
"""Test setup error."""
mock_govee_api.start.side_effect = OSError()
mock_govee_api.start.side_effect.errno = ENETDOWN
@ -184,10 +164,6 @@ async def test_light_setup_error(
)
]
with patch(
"homeassistant.components.govee_light_local.coordinator.GoveeController",
return_value=mock_govee_api,
):
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
@ -196,7 +172,7 @@ async def test_light_setup_error(
async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> None:
"""Test adding a known device."""
"""Test light on and then off."""
mock_govee_api.devices = [
GoveeDevice(
@ -208,10 +184,6 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N
)
]
with patch(
"homeassistant.components.govee_light_local.coordinator.GoveeController",
return_value=mock_govee_api,
):
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
@ -264,10 +236,6 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock)
)
]
with patch(
"homeassistant.components.govee_light_local.coordinator.GoveeController",
return_value=mock_govee_api,
):
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
@ -306,9 +274,7 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock)
assert light is not None
assert light.state == "on"
assert light.attributes["brightness"] == 255
mock_govee_api.set_brightness.assert_awaited_with(
mock_govee_api.devices[0], 100
)
mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100)
await hass.services.async_call(
"light",
@ -322,9 +288,7 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock)
assert light is not None
assert light.state == "on"
assert light.attributes["brightness"] == 255
mock_govee_api.set_brightness.assert_awaited_with(
mock_govee_api.devices[0], 100
)
mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100)
async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> None:
@ -339,10 +303,6 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No
)
]
with patch(
"homeassistant.components.govee_light_local.coordinator.GoveeController",
return_value=mock_govee_api,
):
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
@ -390,3 +350,265 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No
mock_govee_api.set_color.assert_awaited_with(
mock_govee_api.devices[0], rgb=None, temperature=4400
)
async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None:
"""Test turning on scene."""
mock_govee_api.devices = [
GoveeDevice(
controller=mock_govee_api,
ip="192.168.1.100",
fingerprint="asdawdqwdqwd",
sku="H615A",
capabilities=SCENE_CAPABILITIES,
)
]
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "off"
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "effect": "sunrise"},
blocking=True,
)
await hass.async_block_till_done()
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["effect"] == "sunrise"
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
async def test_scene_restore_rgb(
hass: HomeAssistant, mock_govee_api: MagicMock
) -> None:
"""Test restore rgb color."""
mock_govee_api.devices = [
GoveeDevice(
controller=mock_govee_api,
ip="192.168.1.100",
fingerprint="asdawdqwdqwd",
sku="H615A",
capabilities=SCENE_CAPABILITIES,
)
]
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1
initial_color = (12, 34, 56)
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "off"
# Set initial color
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "rgb_color": initial_color},
blocking=True,
)
await hass.async_block_till_done()
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "brightness": 255},
blocking=True,
)
await hass.async_block_till_done()
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["rgb_color"] == initial_color
assert light.attributes["brightness"] == 255
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
# Activate scene
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "effect": "sunrise"},
blocking=True,
)
await hass.async_block_till_done()
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["effect"] == "sunrise"
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
# Deactivate scene
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "effect": "none"},
blocking=True,
)
await hass.async_block_till_done()
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["effect"] is None
assert light.attributes["rgb_color"] == initial_color
assert light.attributes["brightness"] == 255
async def test_scene_restore_temperature(
hass: HomeAssistant, mock_govee_api: MagicMock
) -> None:
"""Test restore color temperature."""
mock_govee_api.devices = [
GoveeDevice(
controller=mock_govee_api,
ip="192.168.1.100",
fingerprint="asdawdqwdqwd",
sku="H615A",
capabilities=SCENE_CAPABILITIES,
)
]
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1
initial_color = 3456
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "off"
# Set initial color
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "color_temp_kelvin": initial_color},
blocking=True,
)
await hass.async_block_till_done()
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["color_temp_kelvin"] == initial_color
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
# Activate scene
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "effect": "sunrise"},
blocking=True,
)
await hass.async_block_till_done()
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["effect"] == "sunrise"
mock_govee_api.set_scene.assert_awaited_with(mock_govee_api.devices[0], "sunrise")
# Deactivate scene
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "effect": "none"},
blocking=True,
)
await hass.async_block_till_done()
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["effect"] is None
assert light.attributes["color_temp_kelvin"] == initial_color
async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> None:
"""Test turn on 'none' scene."""
mock_govee_api.devices = [
GoveeDevice(
controller=mock_govee_api,
ip="192.168.1.100",
fingerprint="asdawdqwdqwd",
sku="H615A",
capabilities=SCENE_CAPABILITIES,
)
]
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1
initial_color = (12, 34, 56)
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "off"
# Set initial color
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "rgb_color": initial_color},
blocking=True,
)
await hass.async_block_till_done()
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "brightness": 255},
blocking=True,
)
await hass.async_block_till_done()
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["rgb_color"] == initial_color
assert light.attributes["brightness"] == 255
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
# Activate scene
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "effect": "none"},
blocking=True,
)
await hass.async_block_till_done()
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["effect"] is None
mock_govee_api.set_scene.assert_not_called()