From 9769dec44b304844c73a2024b00b77d435caef7d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 23 Jun 2024 17:45:43 +0200 Subject: [PATCH] Add number platform to AirGradient (#120247) * Add number entity * Add airgradient number entities * Fix --- .../components/airgradient/__init__.py | 2 +- .../components/airgradient/number.py | 130 ++++++++++++++++++ .../components/airgradient/strings.json | 8 ++ .../airgradient/snapshots/test_number.ambr | 113 +++++++++++++++ tests/components/airgradient/test_number.py | 101 ++++++++++++++ 5 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/airgradient/number.py create mode 100644 tests/components/airgradient/snapshots/test_number.ambr create mode 100644 tests/components/airgradient/test_number.py diff --git a/homeassistant/components/airgradient/__init__.py b/homeassistant/components/airgradient/__init__.py index 91ee0a440a6..76e11c05527 100644 --- a/homeassistant/components/airgradient/__init__.py +++ b/homeassistant/components/airgradient/__init__.py @@ -15,7 +15,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN from .coordinator import AirGradientConfigCoordinator, AirGradientMeasurementCoordinator -PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SELECT, Platform.SENSOR] @dataclass diff --git a/homeassistant/components/airgradient/number.py b/homeassistant/components/airgradient/number.py new file mode 100644 index 00000000000..e065b76ed51 --- /dev/null +++ b/homeassistant/components/airgradient/number.py @@ -0,0 +1,130 @@ +"""Support for AirGradient number entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from airgradient import AirGradientClient, Config +from airgradient.models import ConfigurationControl + +from homeassistant.components.number import ( + DOMAIN as NUMBER_DOMAIN, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AirGradientConfigEntry +from .const import DOMAIN +from .coordinator import AirGradientConfigCoordinator +from .entity import AirGradientEntity + + +@dataclass(frozen=True, kw_only=True) +class AirGradientNumberEntityDescription(NumberEntityDescription): + """Describes AirGradient number entity.""" + + value_fn: Callable[[Config], int] + set_value_fn: Callable[[AirGradientClient, int], Awaitable[None]] + + +DISPLAY_BRIGHTNESS = AirGradientNumberEntityDescription( + key="display_brightness", + translation_key="display_brightness", + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=100, + native_step=1, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda config: config.display_brightness, + set_value_fn=lambda client, value: client.set_display_brightness(value), +) + +LED_BAR_BRIGHTNESS = AirGradientNumberEntityDescription( + key="led_bar_brightness", + translation_key="led_bar_brightness", + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=100, + native_step=1, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda config: config.led_bar_brightness, + set_value_fn=lambda client, value: client.set_led_bar_brightness(value), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AirGradientConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up AirGradient number entities based on a config entry.""" + + model = entry.runtime_data.measurement.data.model + coordinator = entry.runtime_data.config + + added_entities = False + + @callback + def _async_check_entities() -> None: + nonlocal added_entities + + if ( + coordinator.data.configuration_control is ConfigurationControl.LOCAL + and not added_entities + ): + entities = [] + if "I" in model: + entities.append(AirGradientNumber(coordinator, DISPLAY_BRIGHTNESS)) + if "L" in model: + entities.append(AirGradientNumber(coordinator, LED_BAR_BRIGHTNESS)) + + async_add_entities(entities) + added_entities = True + elif ( + coordinator.data.configuration_control is not ConfigurationControl.LOCAL + and added_entities + ): + entity_registry = er.async_get(hass) + unique_ids = [ + f"{coordinator.serial_number}-{entity_description.key}" + for entity_description in (DISPLAY_BRIGHTNESS, LED_BAR_BRIGHTNESS) + ] + for unique_id in unique_ids: + if entity_id := entity_registry.async_get_entity_id( + NUMBER_DOMAIN, DOMAIN, unique_id + ): + entity_registry.async_remove(entity_id) + added_entities = False + + coordinator.async_add_listener(_async_check_entities) + _async_check_entities() + + +class AirGradientNumber(AirGradientEntity, NumberEntity): + """Defines an AirGradient number entity.""" + + entity_description: AirGradientNumberEntityDescription + coordinator: AirGradientConfigCoordinator + + def __init__( + self, + coordinator: AirGradientConfigCoordinator, + description: AirGradientNumberEntityDescription, + ) -> None: + """Initialize AirGradient number.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" + + @property + def native_value(self) -> int | None: + """Return the state of the number.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_set_native_value(self, value: float) -> None: + """Set the selected value.""" + await self.entity_description.set_value_fn(self.coordinator.client, int(value)) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index f4b558cf31a..0ab80286570 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -24,6 +24,14 @@ } }, "entity": { + "number": { + "led_bar_brightness": { + "name": "LED bar brightness" + }, + "display_brightness": { + "name": "Display brightness" + } + }, "select": { "configuration_control": { "name": "Configuration source", diff --git a/tests/components/airgradient/snapshots/test_number.ambr b/tests/components/airgradient/snapshots/test_number.ambr new file mode 100644 index 00000000000..87df8757eeb --- /dev/null +++ b/tests/components/airgradient/snapshots/test_number.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_all_entities[number.airgradient_display_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.airgradient_display_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Display brightness', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display_brightness', + 'unique_id': '84fce612f5b8-display_brightness', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[number.airgradient_display_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient Display brightness', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.airgradient_display_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[number.airgradient_led_bar_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.airgradient_led_bar_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED bar brightness', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'led_bar_brightness', + 'unique_id': '84fce612f5b8-led_bar_brightness', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[number.airgradient_led_bar_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient LED bar brightness', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.airgradient_led_bar_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/airgradient/test_number.py b/tests/components/airgradient/test_number.py new file mode 100644 index 00000000000..ba659829c50 --- /dev/null +++ b/tests/components/airgradient/test_number.py @@ -0,0 +1,101 @@ +"""Tests for the AirGradient button platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from airgradient import Config +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.components.airgradient import DOMAIN +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_fixture, + snapshot_platform, +) + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.airgradient.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_setting_value( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 50}, + target={ATTR_ENTITY_ID: "number.airgradient_display_brightness"}, + blocking=True, + ) + mock_airgradient_client.set_display_brightness.assert_called_once() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 50}, + target={ATTR_ENTITY_ID: "number.airgradient_led_bar_brightness"}, + blocking=True, + ) + mock_airgradient_client.set_led_bar_brightness.assert_called_once() + + +async def test_cloud_creates_no_number( + hass: HomeAssistant, + mock_cloud_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test cloud configuration control.""" + with patch("homeassistant.components.airgradient.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + assert len(hass.states.async_all()) == 0 + + mock_cloud_airgradient_client.get_config.return_value = Config.from_json( + load_fixture("get_config_local.json", DOMAIN) + ) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 2 + + mock_cloud_airgradient_client.get_config.return_value = Config.from_json( + load_fixture("get_config_cloud.json", DOMAIN) + ) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0