diff --git a/homeassistant/components/elgato/__init__.py b/homeassistant/components/elgato/__init__.py index 4345203d4bd..770e6f13f9a 100644 --- a/homeassistant/components/elgato/__init__.py +++ b/homeassistant/components/elgato/__init__.py @@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import ElgatoDataUpdateCoordinator -PLATFORMS = [Platform.BUTTON, Platform.LIGHT] +PLATFORMS = [Platform.BUTTON, Platform.LIGHT, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/elgato/coordinator.py b/homeassistant/components/elgato/coordinator.py index 143eba69f20..0dda6bad292 100644 --- a/homeassistant/components/elgato/coordinator.py +++ b/homeassistant/components/elgato/coordinator.py @@ -1,7 +1,7 @@ """DataUpdateCoordinator for Elgato.""" from dataclasses import dataclass -from elgato import Elgato, ElgatoConnectionError, Info, Settings, State +from elgato import BatteryInfo, Elgato, ElgatoConnectionError, Info, Settings, State from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT @@ -16,6 +16,7 @@ from .const import DOMAIN, LOGGER, SCAN_INTERVAL class ElgatoData: """Elgato data stored in the DataUpdateCoordinator.""" + battery: BatteryInfo | None info: Info settings: Settings state: State @@ -25,6 +26,7 @@ class ElgatoDataUpdateCoordinator(DataUpdateCoordinator[ElgatoData]): """Class to manage fetching Elgato data.""" config_entry: ConfigEntry + has_battery: bool | None = None def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the coordinator.""" @@ -44,7 +46,11 @@ class ElgatoDataUpdateCoordinator(DataUpdateCoordinator[ElgatoData]): async def _async_update_data(self) -> ElgatoData: """Fetch data from the Elgato device.""" try: + if self.has_battery is None: + self.has_battery = await self.client.has_battery() + return ElgatoData( + battery=await self.client.battery() if self.has_battery else None, info=await self.client.info(), settings=await self.client.settings(), state=await self.client.state(), diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py new file mode 100644 index 00000000000..6096a0b003d --- /dev/null +++ b/homeassistant/components/elgato/sensor.py @@ -0,0 +1,94 @@ +"""Support for Elgato sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator +from .entity import ElgatoEntity + + +@dataclass +class ElgatoEntityDescriptionMixin: + """Mixin values for Elgato entities.""" + + value_fn: Callable[[ElgatoData], float | int | None] + + +@dataclass +class ElgatoSensorEntityDescription( + SensorEntityDescription, ElgatoEntityDescriptionMixin +): + """Class describing Elgato sensor entities.""" + + has_fn: Callable[[ElgatoData], bool] = lambda _: True + + +SENSORS = [ + ElgatoSensorEntityDescription( + key="battery", + name="Battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + has_fn=lambda x: x.battery is not None, + value_fn=lambda x: x.battery.level if x.battery else None, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Elgato sensor based on a config entry.""" + coordinator: ElgatoDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + ElgatoSensorEntity( + coordinator=coordinator, + description=description, + ) + for description in SENSORS + if description.has_fn(coordinator.data) + ) + + +class ElgatoSensorEntity(ElgatoEntity, SensorEntity): + """Representation of a Elgato sensor.""" + + entity_description: ElgatoSensorEntityDescription + + def __init__( + self, + coordinator: ElgatoDataUpdateCoordinator, + description: ElgatoSensorEntityDescription, + ) -> None: + """Initiate Elgato sensor.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.data.info.serial_number}_{description.key}" + ) + + @property + def native_value(self) -> float | int | None: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/tests/components/elgato/conftest.py b/tests/components/elgato/conftest.py index fad3c04c10e..a32b3a70368 100644 --- a/tests/components/elgato/conftest.py +++ b/tests/components/elgato/conftest.py @@ -2,14 +2,14 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from elgato import Info, Settings, State +from elgato import BatteryInfo, ElgatoNoBatteryError, Info, Settings, State import pytest from homeassistant.components.elgato.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, get_fixture_path, load_fixture from tests.components.light.conftest import mock_light_profiles # noqa: F401 @@ -90,6 +90,17 @@ def mock_elgato( elgato.settings.return_value = Settings.parse_raw( load_fixture(f"{device_fixtures}/settings.json", DOMAIN) ) + + # This may, or may not, be a battery-powered device + if get_fixture_path(f"{device_fixtures}/battery.json", DOMAIN).exists(): + elgato.has_battery.return_value = True + elgato.battery.return_value = BatteryInfo.parse_raw( + load_fixture(f"{device_fixtures}/battery.json", DOMAIN) + ) + else: + elgato.has_battery.return_value = False + elgato.battery.side_effect = ElgatoNoBatteryError + yield elgato diff --git a/tests/components/elgato/fixtures/key-light-mini/battery.json b/tests/components/elgato/fixtures/key-light-mini/battery.json new file mode 100644 index 00000000000..65b10f3ca58 --- /dev/null +++ b/tests/components/elgato/fixtures/key-light-mini/battery.json @@ -0,0 +1,8 @@ +{ + "powerSource": 1, + "level": 78.57, + "status": 2, + "currentBatteryVoltage": 3860, + "inputChargeVoltage": 4208, + "inputChargeCurrent": 3008 +} diff --git a/tests/components/elgato/fixtures/key-light-mini/info.json b/tests/components/elgato/fixtures/key-light-mini/info.json new file mode 100644 index 00000000000..3cd5e8a2134 --- /dev/null +++ b/tests/components/elgato/fixtures/key-light-mini/info.json @@ -0,0 +1,16 @@ +{ + "productName": "Elgato Key Light Mini", + "hardwareBoardType": 202, + "hardwareRevision": 0.1, + "macAddress": "11:22:33:44:55:66", + "firmwareBuildNumber": 229, + "firmwareVersion": "1.0.4", + "serialNumber": "GW24L1A02987", + "displayName": "Frenck", + "features": ["lights"], + "wifi-info": { + "ssid": "Frenck-IoT", + "frequencyMHz": 2400, + "rssi": -41 + } +} diff --git a/tests/components/elgato/fixtures/key-light-mini/settings.json b/tests/components/elgato/fixtures/key-light-mini/settings.json new file mode 100644 index 00000000000..38052f62cd9 --- /dev/null +++ b/tests/components/elgato/fixtures/key-light-mini/settings.json @@ -0,0 +1,20 @@ +{ + "powerOnBehavior": 1, + "powerOnBrightness": 20, + "powerOnTemperature": 230, + "switchOnDurationMs": 100, + "switchOffDurationMs": 300, + "colorChangeDurationMs": 100, + "battery": { + "energySaving": { + "enable": 0, + "minimumBatteryLevel": 15.0, + "disableWifi": 0, + "adjustBrightness": { + "enable": 0, + "brightness": 10.0 + } + }, + "bypass": 0 + } +} diff --git a/tests/components/elgato/fixtures/key-light-mini/state.json b/tests/components/elgato/fixtures/key-light-mini/state.json new file mode 100644 index 00000000000..5b3d7690d85 --- /dev/null +++ b/tests/components/elgato/fixtures/key-light-mini/state.json @@ -0,0 +1,5 @@ +{ + "on": 1, + "brightness": 21, + "temperature": 297 +} diff --git a/tests/components/elgato/test_sensor.py b/tests/components/elgato/test_sensor.py new file mode 100644 index 00000000000..b4bd1ab0818 --- /dev/null +++ b/tests/components/elgato/test_sensor.py @@ -0,0 +1,63 @@ +"""Tests for the Elgato sensor platform.""" + +import pytest + +from homeassistant.components.elgato.const import DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory + + +@pytest.mark.parametrize("device_fixtures", ["key-light-mini"]) +@pytest.mark.usefixtures( + "device_fixtures", + "entity_registry_enabled_by_default", + "init_integration", + "mock_elgato", +) +async def test_battery_sensor(hass: HomeAssistant) -> None: + """Test the Elgato battery sensor.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.frenck_battery") + assert state + assert state.state == "78.57" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.BATTERY + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck Battery" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + assert not state.attributes.get(ATTR_ICON) + + entry = entity_registry.async_get("sensor.frenck_battery") + assert entry + assert entry.unique_id == "GW24L1A02987_battery" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert entry.options == {"sensor": {"suggested_display_precision": 0}} + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.configuration_url is None + assert device_entry.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") + } + assert device_entry.entry_type is None + assert device_entry.identifiers == {(DOMAIN, "GW24L1A02987")} + assert device_entry.manufacturer == "Elgato" + assert device_entry.model == "Elgato Key Light Mini" + assert device_entry.name == "Frenck" + assert device_entry.sw_version == "1.0.4 (229)" + assert device_entry.hw_version == "202"