diff --git a/CODEOWNERS b/CODEOWNERS index 1aac954b280..d0e0bddf0d6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -495,6 +495,8 @@ build.json @home-assistant/supervisor /tests/components/google_travel_time/ @eifinger /homeassistant/components/govee_ble/ @bdraco @PierreAronnax /tests/components/govee_ble/ @bdraco @PierreAronnax +/homeassistant/components/govee_light_local/ @Galorhallen +/tests/components/govee_light_local/ @Galorhallen /homeassistant/components/gpsd/ @fabaff /homeassistant/components/gree/ @cmroche /tests/components/gree/ @cmroche diff --git a/homeassistant/brands/govee.json b/homeassistant/brands/govee.json new file mode 100644 index 00000000000..92091d68f58 --- /dev/null +++ b/homeassistant/brands/govee.json @@ -0,0 +1,5 @@ +{ + "domain": "govee", + "name": "Govee", + "integrations": ["govee_ble", "govee_light_local"] +} diff --git a/homeassistant/components/govee_light_local/__init__.py b/homeassistant/components/govee_light_local/__init__.py new file mode 100644 index 00000000000..ab20f4cefcd --- /dev/null +++ b/homeassistant/components/govee_light_local/__init__.py @@ -0,0 +1,44 @@ +"""The Govee Light local integration.""" +from __future__ import annotations + +import asyncio + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import GoveeLocalApiCoordinator + +PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Govee light local from a config entry.""" + + coordinator: GoveeLocalApiCoordinator = GoveeLocalApiCoordinator(hass=hass) + entry.async_on_unload(coordinator.cleanup) + + await coordinator.start() + + await coordinator.async_config_entry_first_refresh() + + try: + async with asyncio.timeout(delay=5): + while not coordinator.devices: + await asyncio.sleep(delay=1) + except asyncio.TimeoutError as ex: + raise ConfigEntryNotReady from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/govee_light_local/config_flow.py b/homeassistant/components/govee_light_local/config_flow.py new file mode 100644 index 00000000000..8ab14966828 --- /dev/null +++ b/homeassistant/components/govee_light_local/config_flow.py @@ -0,0 +1,58 @@ +"""Config flow for Govee light local.""" + +from __future__ import annotations + +import asyncio +import logging + +from govee_local_api import GoveeController + +from homeassistant.components import network +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_flow + +from .const import ( + CONF_LISTENING_PORT_DEFAULT, + CONF_MULTICAST_ADDRESS_DEFAULT, + CONF_TARGET_PORT_DEFAULT, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +async def _async_has_devices(hass: HomeAssistant) -> bool: + """Return if there are devices that can be discovered.""" + + adapter = await network.async_get_source_ip(hass, network.PUBLIC_TARGET_IP) + + controller: GoveeController = GoveeController( + loop=hass.loop, + logger=_LOGGER, + listening_address=adapter, + broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT, + broadcast_port=CONF_TARGET_PORT_DEFAULT, + listening_port=CONF_LISTENING_PORT_DEFAULT, + discovery_enabled=True, + discovery_interval=1, + update_enabled=False, + ) + + await controller.start() + + try: + async with asyncio.timeout(delay=5): + while not controller.devices: + await asyncio.sleep(delay=1) + except asyncio.TimeoutError: + _LOGGER.debug("No devices found") + + devices_count = len(controller.devices) + controller.cleanup() + + return devices_count > 0 + + +config_entry_flow.register_discovery_flow( + DOMAIN, "Govee light local", _async_has_devices +) diff --git a/homeassistant/components/govee_light_local/const.py b/homeassistant/components/govee_light_local/const.py new file mode 100644 index 00000000000..d9410c9c05e --- /dev/null +++ b/homeassistant/components/govee_light_local/const.py @@ -0,0 +1,13 @@ +"""Constants for the Govee light local integration.""" + +from datetime import timedelta + +DOMAIN = "govee_light_local" +MANUFACTURER = "Govee" + +CONF_MULTICAST_ADDRESS_DEFAULT = "239.255.255.250" +CONF_TARGET_PORT_DEFAULT = 4001 +CONF_LISTENING_PORT_DEFAULT = 4002 +CONF_DISCOVERY_INTERVAL_DEFAULT = 60 + +SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/govee_light_local/coordinator.py b/homeassistant/components/govee_light_local/coordinator.py new file mode 100644 index 00000000000..79b572e89ae --- /dev/null +++ b/homeassistant/components/govee_light_local/coordinator.py @@ -0,0 +1,90 @@ +"""Coordinator for Govee light local.""" + +from collections.abc import Callable +import logging + +from govee_local_api import GoveeController, GoveeDevice + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + CONF_DISCOVERY_INTERVAL_DEFAULT, + CONF_LISTENING_PORT_DEFAULT, + CONF_MULTICAST_ADDRESS_DEFAULT, + CONF_TARGET_PORT_DEFAULT, + SCAN_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + + +class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]): + """Govee light local coordinator.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize my coordinator.""" + super().__init__( + hass=hass, + logger=_LOGGER, + name="GoveeLightLocalApi", + update_interval=SCAN_INTERVAL, + ) + + self._controller = GoveeController( + loop=hass.loop, + logger=_LOGGER, + broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT, + broadcast_port=CONF_TARGET_PORT_DEFAULT, + listening_port=CONF_LISTENING_PORT_DEFAULT, + discovery_enabled=True, + discovery_interval=CONF_DISCOVERY_INTERVAL_DEFAULT, + discovered_callback=None, + update_enabled=False, + ) + + async def start(self) -> None: + """Start the Govee coordinator.""" + await self._controller.start() + self._controller.send_update_message() + + async def set_discovery_callback( + self, callback: Callable[[GoveeDevice, bool], bool] + ) -> None: + """Set discovery callback for automatic Govee light discovery.""" + self._controller.set_device_discovered_callback(callback) + + def cleanup(self) -> None: + """Stop and cleanup the cooridinator.""" + self._controller.cleanup() + + async def turn_on(self, device: GoveeDevice) -> None: + """Turn on the light.""" + await device.turn_on() + + async def turn_off(self, device: GoveeDevice) -> None: + """Turn off the light.""" + await device.turn_off() + + async def set_brightness(self, device: GoveeDevice, brightness: int) -> None: + """Set light brightness.""" + await device.set_brightness(brightness) + + async def set_rgb_color( + self, device: GoveeDevice, red: int, green: int, blue: int + ) -> None: + """Set light RGB color.""" + await device.set_rgb_color(red, green, blue) + + async def set_temperature(self, device: GoveeDevice, temperature: int) -> None: + """Set light color in kelvin.""" + await device.set_temperature(temperature) + + @property + def devices(self) -> list[GoveeDevice]: + """Return a list of discovered Govee devices.""" + return self._controller.devices + + async def _async_update_data(self) -> list[GoveeDevice]: + self._controller.send_update_message() + return self._controller.devices diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py new file mode 100644 index 00000000000..fec0ff5a898 --- /dev/null +++ b/homeassistant/components/govee_light_local/light.py @@ -0,0 +1,160 @@ +"""Govee light local.""" + +from __future__ import annotations + +import logging +from typing import Any + +from govee_local_api import GoveeDevice, GoveeLightCapability + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ColorMode, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import GoveeLocalApiCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Govee light setup.""" + + coordinator: GoveeLocalApiCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + def discovery_callback(device: GoveeDevice, is_new: bool) -> bool: + if is_new: + async_add_entities([GoveeLight(coordinator, device)]) + return True + + async_add_entities( + GoveeLight(coordinator, device) for device in coordinator.devices + ) + + await coordinator.set_discovery_callback(discovery_callback) + + +class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): + """Govee Light.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: GoveeLocalApiCoordinator, + device: GoveeDevice, + ) -> None: + """Govee Light constructor.""" + + super().__init__(coordinator) + self._device = device + device.set_update_callback(self._update_callback) + + self._attr_unique_id = device.fingerprint + + capabilities = device.capabilities + color_modes = set() + if capabilities: + if GoveeLightCapability.COLOR_RGB in capabilities: + color_modes.add(ColorMode.RGB) + if GoveeLightCapability.COLOR_KELVIN_TEMPERATURE in capabilities: + color_modes.add(ColorMode.COLOR_TEMP) + self._attr_max_color_temp_kelvin = 9000 + self._attr_min_color_temp_kelvin = 2000 + if GoveeLightCapability.BRIGHTNESS in capabilities: + color_modes.add(ColorMode.BRIGHTNESS) + else: + color_modes.add(ColorMode.ONOFF) + + self._attr_supported_color_modes = color_modes + + self._attr_device_info = DeviceInfo( + identifiers={ + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, device.fingerprint) + }, + name=device.sku, + manufacturer=MANUFACTURER, + model=device.sku, + connections={(CONNECTION_NETWORK_MAC, device.fingerprint)}, + ) + + @property + def is_on(self) -> bool: + """Return true if device is on (brightness above 0).""" + return self._device.on + + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + return int((self._device.brightness / 100.0) * 255.0) + + @property + def color_temp_kelvin(self) -> int | None: + """Return the color temperature in Kelvin.""" + return self._device.temperature_color + + @property + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the rgb color.""" + return self._device.rgb_color + + @property + def color_mode(self) -> ColorMode | str | None: + """Return the color mode.""" + if ( + self._device.temperature_color is not None + and self._device.temperature_color > 0 + ): + return ColorMode.COLOR_TEMP + if self._device.rgb_color is not None and any(self._device.rgb_color): + return ColorMode.RGB + + if ( + self._attr_supported_color_modes + and ColorMode.BRIGHTNESS in self._attr_supported_color_modes + ): + return ColorMode.BRIGHTNESS + return ColorMode.ONOFF + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + if not self.is_on or not kwargs: + await self.coordinator.turn_on(self._device) + + if ATTR_BRIGHTNESS in kwargs: + brightness: int = int((float(kwargs[ATTR_BRIGHTNESS]) / 255.0) * 100.0) + await self.coordinator.set_brightness(self._device, brightness) + + if ATTR_RGB_COLOR in kwargs: + self._attr_color_mode = ColorMode.RGB + 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 + temperature: float = kwargs[ATTR_COLOR_TEMP_KELVIN] + await self.coordinator.set_temperature(self._device, int(temperature)) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.coordinator.turn_off(self._device) + self.async_write_ha_state() + + @callback + def _update_callback(self, device: GoveeDevice) -> None: + self.async_write_ha_state() diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json new file mode 100644 index 00000000000..f34fd0b899f --- /dev/null +++ b/homeassistant/components/govee_light_local/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "govee_light_local", + "name": "Govee lights local", + "codeowners": ["@Galorhallen"], + "config_flow": true, + "dependencies": ["network"], + "documentation": "https://www.home-assistant.io/integrations/govee_light_local", + "iot_class": "local_push", + "requirements": ["govee-local-api==1.4.1"] +} diff --git a/homeassistant/components/govee_light_local/strings.json b/homeassistant/components/govee_light_local/strings.json new file mode 100644 index 00000000000..ad8f0f41ae7 --- /dev/null +++ b/homeassistant/components/govee_light_local/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 22b0fcf064f..603a8e33e2c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -197,6 +197,7 @@ FLOWS = { "google_translate", "google_travel_time", "govee_ble", + "govee_light_local", "gpslogger", "gree", "growatt_server", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ff35cf3235f..383661410cc 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2266,11 +2266,22 @@ } } }, - "govee_ble": { - "name": "Govee Bluetooth", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" + "govee": { + "name": "Govee", + "integrations": { + "govee_ble": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Govee Bluetooth" + }, + "govee_light_local": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Govee lights local" + } + } }, "gpsd": { "name": "GPSD", diff --git a/requirements_all.txt b/requirements_all.txt index 8c1abd6ad32..d58a208774a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -954,6 +954,9 @@ gotailwind==0.2.2 # homeassistant.components.govee_ble govee-ble==0.27.3 +# homeassistant.components.govee_light_local +govee-local-api==1.4.1 + # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3fb7844baa9..4cc6cea4b0c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -771,6 +771,9 @@ gotailwind==0.2.2 # homeassistant.components.govee_ble govee-ble==0.27.3 +# homeassistant.components.govee_light_local +govee-local-api==1.4.1 + # homeassistant.components.gree greeclimate==1.4.1 diff --git a/tests/components/govee_light_local/__init__.py b/tests/components/govee_light_local/__init__.py new file mode 100644 index 00000000000..b4ea9560d25 --- /dev/null +++ b/tests/components/govee_light_local/__init__.py @@ -0,0 +1 @@ +"""Tests for the Govee Light local integration.""" diff --git a/tests/components/govee_light_local/conftest.py b/tests/components/govee_light_local/conftest.py new file mode 100644 index 00000000000..2b3690f7011 --- /dev/null +++ b/tests/components/govee_light_local/conftest.py @@ -0,0 +1,37 @@ +"""Tests configuration for Govee Local API.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from govee_local_api import GoveeLightCapability +import pytest + +from homeassistant.components.govee_light_local.coordinator import GoveeController + + +@pytest.fixture(name="mock_govee_api") +def fixture_mock_govee_api(): + """Set up Govee Local API fixture.""" + mock_api = AsyncMock(spec=GoveeController) + mock_api.start = AsyncMock() + mock_api.turn_on_off = AsyncMock() + mock_api.set_brightness = AsyncMock() + mock_api.set_color = AsyncMock() + mock_api._async_update_data = AsyncMock() + return mock_api + + +@pytest.fixture(name="mock_setup_entry") +def fixture_mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.govee_light_local.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +DEFAULT_CAPABILITEIS: set[GoveeLightCapability] = { + GoveeLightCapability.COLOR_RGB, + GoveeLightCapability.COLOR_KELVIN_TEMPERATURE, + GoveeLightCapability.BRIGHTNESS, +} diff --git a/tests/components/govee_light_local/test_config_flow.py b/tests/components/govee_light_local/test_config_flow.py new file mode 100644 index 00000000000..7753b40c29c --- /dev/null +++ b/tests/components/govee_light_local/test_config_flow.py @@ -0,0 +1,74 @@ +"""Test Govee light local config flow.""" +from unittest.mock import AsyncMock, patch + +from govee_local_api import GoveeDevice + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.govee_light_local.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import DEFAULT_CAPABILITEIS + + +async def test_creating_entry_has_no_devices( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_govee_api: AsyncMock +) -> None: + """Test setting up Govee with no devices.""" + + mock_govee_api.devices = [] + + 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} + ) + + # Confirmation form + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + + await hass.async_block_till_done() + + mock_govee_api.start.assert_awaited_once() + mock_setup_entry.assert_not_called() + + +async def test_creating_entry_has_with_devices( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_govee_api: AsyncMock, +) -> None: + """Test setting up Govee with devices.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd1", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + 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} + ) + + # Confirmation form + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + await hass.async_block_till_done() + + mock_govee_api.start.assert_awaited_once() + mock_setup_entry.assert_awaited_once() diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py new file mode 100644 index 00000000000..63ffd7179a2 --- /dev/null +++ b/tests/components/govee_light_local/test_light.py @@ -0,0 +1,338 @@ +"""Test Govee light local.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +from govee_local_api import GoveeDevice + +from homeassistant.components.govee_light_local.const import DOMAIN +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_CAPABILITEIS + +from tests.common import MockConfigEntry + + +async def test_light_known_device( + hass: HomeAssistant, mock_govee_api: AsyncMock +) -> None: + """Test adding a known device.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + 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 + + color_modes = light.attributes[ATTR_SUPPORTED_COLOR_MODES] + assert ColorMode.RGB in color_modes + assert ColorMode.BRIGHTNESS in color_modes + assert ColorMode.COLOR_TEMP in color_modes + + # Remove + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("light.H615A") is None + + +async def test_light_unknown_device( + hass: HomeAssistant, mock_govee_api: AsyncMock +) -> None: + """Test adding an unknown device.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.101", + fingerprint="unkown_device", + sku="XYZK", + capabilities=None, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + 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.XYZK") + assert light is not None + + assert light.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] + + +async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: + """Test adding a known device.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd1", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + 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 hass.states.get("light.H615A") is not None + + # Remove 1 + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + +async def test_light_setup_retry( + hass: HomeAssistant, mock_govee_api: AsyncMock +) -> None: + """Test adding an unknown device.""" + + 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) + + with patch( + "homeassistant.components.govee_light_local.asyncio.timeout", + side_effect=asyncio.TimeoutError, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: + """Test adding a known device.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + 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}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Turn off + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": light.entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False) + + +async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: + """Test changing brightness.""" + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + 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, "brightness_pct": 50}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) + assert light.attributes["brightness"] == 127 + + 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["brightness"] == 255 + mock_govee_api.set_brightness.assert_awaited_with( + mock_govee_api.devices[0], 100 + ) + + 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["brightness"] == 255 + 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: + """Test changing brightness.""" + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + 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, "rgb_color": [100, 255, 50]}, + 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"] == (100, 255, 50) + assert light.attributes["color_mode"] == ColorMode.RGB + + mock_govee_api.set_color.assert_awaited_with( + mock_govee_api.devices[0], rgb=(100, 255, 50), temperature=None + ) + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "kelvin": 4400}, + 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"] == 4400 + assert light.attributes["color_mode"] == ColorMode.COLOR_TEMP + + mock_govee_api.set_color.assert_awaited_with( + mock_govee_api.devices[0], rgb=None, temperature=4400 + )