diff --git a/homeassistant/components/avea/light.py b/homeassistant/components/avea/light.py index fd943476360c..214584aa1496 100644 --- a/homeassistant/components/avea/light.py +++ b/homeassistant/components/avea/light.py @@ -1,5 +1,6 @@ """Light platform for Avea.""" +from collections.abc import Callable from contextlib import suppress import logging from typing import Any @@ -19,6 +20,7 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -27,7 +29,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util from . import AveaConfigEntry -from .const import DOMAIN, INTEGRATION_TITLE, UNKNOWN_NAME +from .const import DOMAIN, INTEGRATION_TITLE, MODEL, UNKNOWN_NAME _LOGGER = logging.getLogger(__name__) UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError) @@ -42,6 +44,13 @@ def _normalize_name(name: str | None) -> str | None: return name +def _read_device_info_value(read: Callable[[], str | None]) -> str | None: + """Read a device information value from an Avea bulb.""" + with suppress(*UPDATE_EXCEPTIONS): + return _normalize_name(read()) + return None + + def _ha_brightness_to_avea(brightness: int) -> int: """Convert Home Assistant brightness to Avea brightness.""" return round((brightness / 255) * AVEA_MAX_BRIGHTNESS) @@ -96,7 +105,8 @@ async def async_setup_entry( ) -> None: """Set up the Avea light platform.""" async_add_entities( - [AveaLight(entry.runtime_data, entry.title)], update_before_add=True + [AveaLight(entry.runtime_data, entry.data[CONF_ADDRESS])], + update_before_add=True, ) @@ -180,14 +190,42 @@ class AveaLight(LightEntity): """Representation of an Avea.""" _attr_color_mode = ColorMode.HS + _attr_has_entity_name = True + _attr_name = None _attr_supported_color_modes = {ColorMode.HS} - def __init__(self, light: avea.Bulb, entry_title: str) -> None: + def __init__(self, light: avea.Bulb, address: str) -> None: """Initialize an AveaLight.""" self._light = light - self._attr_name = entry_title + self._attr_unique_id = address self._attr_brightness = light.brightness self._last_brightness = 255 + self._device_info_updated = False + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, address)}, + model=MODEL, + ) + + def _update_device_info(self) -> None: + """Fetch device information from the Avea bulb.""" + device_info = self._attr_device_info + assert device_info is not None + + manufacturer = _read_device_info_value(self._light.get_manufacturer_name) + hardware_revision = _read_device_info_value(self._light.get_hardware_revision) + firmware_version = _read_device_info_value(self._light.get_fw_version) + serial_number = _read_device_info_value(self._light.get_serial_number) + + if manufacturer: + device_info["manufacturer"] = manufacturer + if hardware_revision: + device_info["hw_version"] = hardware_revision + if firmware_version: + device_info["sw_version"] = firmware_version + if serial_number: + device_info["serial_number"] = serial_number + + self._device_info_updated = True def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" @@ -214,6 +252,8 @@ class AveaLight(LightEntity): connected = self._light.connect() try: + if not self._device_info_updated: + self._update_device_info() brightness = self._light.get_brightness() rgb_color = self._light.get_rgb() finally: diff --git a/tests/components/avea/__init__.py b/tests/components/avea/__init__.py index 8c29f31f6e69..3177aab8bb10 100644 --- a/tests/components/avea/__init__.py +++ b/tests/components/avea/__init__.py @@ -5,6 +5,9 @@ from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from tests.components.bluetooth import generate_advertisement_data, generate_ble_device +AVEA_FIRMWARE_VERSION = "2.4.6 (135)" +AVEA_SERIAL_NUMBER = "FFEEDDCCBBAA" + AVEA_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="Avea Bulb", address="AA:BB:CC:DD:EE:FF", diff --git a/tests/components/avea/test_light.py b/tests/components/avea/test_light.py index ed8d2f2878de..fb8b6d64fbc9 100644 --- a/tests/components/avea/test_light.py +++ b/tests/components/avea/test_light.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, call, patch from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.avea.const import UNKNOWN_NAME from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, @@ -15,8 +16,9 @@ from homeassistant.components.light import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr -from . import AVEA_DISCOVERY_INFO +from . import AVEA_DISCOVERY_INFO, AVEA_FIRMWARE_VERSION, AVEA_SERIAL_NUMBER from tests.common import MockConfigEntry, async_fire_time_changed @@ -26,10 +28,18 @@ def mock_bulb() -> MagicMock: """Return a mocked Avea bulb.""" bulb = MagicMock() bulb.name = "Unknown" + bulb.fw_version = "Unknown" + bulb.hardware_revision = "Unknown" + bulb.manufacturer_name = "Unknown" + bulb.serial_number = "Unknown" bulb.brightness = 0 bulb.connect.return_value = True bulb.get_brightness.return_value = 0 + bulb.get_fw_version.return_value = AVEA_FIRMWARE_VERSION + bulb.get_hardware_revision.return_value = "Elgato Avea" + bulb.get_manufacturer_name.return_value = "Elgato Systems GmbH" bulb.get_rgb.return_value = (0, 0, 0) + bulb.get_serial_number.return_value = AVEA_SERIAL_NUMBER return bulb @@ -65,6 +75,125 @@ async def test_init_state( assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] +async def test_device_info( + device_registry: dr.DeviceRegistry, + setup_integration: MagicMock, +) -> None: + """Test the device info.""" + bulb = setup_integration + device = device_registry.async_get_device( + connections={(dr.CONNECTION_BLUETOOTH, AVEA_DISCOVERY_INFO.address)}, + ) + + assert device is not None + assert device.name == "Bedroom" + assert device.manufacturer == "Elgato Systems GmbH" + assert device.model == "Avea" + assert device.hw_version == "Elgato Avea" + assert device.sw_version == AVEA_FIRMWARE_VERSION + assert device.serial_number == AVEA_SERIAL_NUMBER + bulb.get_manufacturer_name.assert_called_once() + bulb.get_hardware_revision.assert_called_once() + bulb.get_fw_version.assert_called_once() + bulb.get_serial_number.assert_called_once() + + +async def test_device_info_populates_when_connect_fails( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_bulb: MagicMock, +) -> None: + """Test device info is populated when the shared connection fails.""" + mock_bulb.connect.return_value = False + + with ( + patch( + "homeassistant.components.avea.async_ble_device_from_address", + return_value=AVEA_DISCOVERY_INFO.device, + ), + patch("homeassistant.components.avea.avea.Bulb", return_value=mock_bulb), + ): + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_bulb.connect.assert_called_once() + mock_bulb.get_manufacturer_name.assert_called_once() + mock_bulb.get_hardware_revision.assert_called_once() + mock_bulb.get_fw_version.assert_called_once() + mock_bulb.get_serial_number.assert_called_once() + mock_bulb.disconnect.assert_not_called() + + device = device_registry.async_get_device( + connections={(dr.CONNECTION_BLUETOOTH, AVEA_DISCOVERY_INFO.address)}, + ) + + assert device is not None + assert device.manufacturer == "Elgato Systems GmbH" + assert device.model == "Avea" + assert device.hw_version == "Elgato Avea" + assert device.sw_version == AVEA_FIRMWARE_VERSION + assert device.serial_number == AVEA_SERIAL_NUMBER + + +async def test_device_info_ignores_unknown_values( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_bulb: MagicMock, +) -> None: + """Test unknown device info is not populated.""" + mock_bulb.get_manufacturer_name.return_value = UNKNOWN_NAME + mock_bulb.get_hardware_revision.return_value = "" + mock_bulb.get_fw_version.return_value = UNKNOWN_NAME + mock_bulb.get_serial_number.return_value = "" + + with ( + patch( + "homeassistant.components.avea.async_ble_device_from_address", + return_value=AVEA_DISCOVERY_INFO.device, + ), + patch("homeassistant.components.avea.avea.Bulb", return_value=mock_bulb), + ): + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + connections={(dr.CONNECTION_BLUETOOTH, AVEA_DISCOVERY_INFO.address)}, + ) + + assert device is not None + assert device.manufacturer is None + assert device.model == "Avea" + assert device.hw_version is None + assert device.sw_version is None + assert device.serial_number is None + + +async def test_device_info_is_read_once( + hass: HomeAssistant, + setup_integration: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device info is read once.""" + bulb = setup_integration + bulb.get_manufacturer_name.reset_mock() + bulb.get_hardware_revision.reset_mock() + bulb.get_fw_version.reset_mock() + bulb.get_serial_number.reset_mock() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + bulb.get_manufacturer_name.assert_not_called() + bulb.get_hardware_revision.assert_not_called() + bulb.get_fw_version.assert_not_called() + bulb.get_serial_number.assert_not_called() + + async def test_turn_on_and_off( hass: HomeAssistant, setup_integration: MagicMock,