diff --git a/homeassistant/components/airq/__init__.py b/homeassistant/components/airq/__init__.py index ab64915c8ae..f87365797e7 100644 --- a/homeassistant/components/airq/__init__.py +++ b/homeassistant/components/airq/__init__.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE from .coordinator import AirQCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR] AirQConfigEntry = ConfigEntry[AirQCoordinator] diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index 3ab41978b05..7c62a023a11 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -75,6 +75,7 @@ class AirQCoordinator(DataUpdateCoordinator): return_average=self.return_average, clip_negative_values=self.clip_negative, ) + data["brightness"] = await self.airq.get_current_brightness() if warming_up_sensors := identify_warming_up_sensors(data): _LOGGER.debug( "Following sensors are still warming up: %s", warming_up_sensors diff --git a/homeassistant/components/airq/number.py b/homeassistant/components/airq/number.py new file mode 100644 index 00000000000..e980760ed52 --- /dev/null +++ b/homeassistant/components/airq/number.py @@ -0,0 +1,85 @@ +"""Definition of air-Q number platform used to control the LED strips.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import logging + +from aioairq.core import AirQ + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import AirQConfigEntry, AirQCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class AirQBrightnessDescription(NumberEntityDescription): + """Describes AirQ number entity responsible for brightness control.""" + + value: Callable[[dict], float] + set_value: Callable[[AirQ, float], Awaitable[None]] + + +AIRQ_LED_BRIGHTNESS = AirQBrightnessDescription( + key="airq_led_brightness", + translation_key="airq_led_brightness", + native_min_value=0.0, + native_max_value=100.0, + native_step=1.0, + native_unit_of_measurement=PERCENTAGE, + value=lambda data: data["brightness"], + set_value=lambda device, value: device.set_current_brightness(value), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AirQConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up number entities: a single entity for the LEDs.""" + + coordinator = entry.runtime_data + entities = [AirQLEDBrightness(coordinator, AIRQ_LED_BRIGHTNESS)] + + async_add_entities(entities) + + +class AirQLEDBrightness(CoordinatorEntity[AirQCoordinator], NumberEntity): + """Representation of the LEDs from a single AirQ.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirQCoordinator, + description: AirQBrightnessDescription, + ) -> None: + """Initialize a single sensor.""" + super().__init__(coordinator) + self.entity_description: AirQBrightnessDescription = description + + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.device_id}_{description.key}" + + @property + def native_value(self) -> float: + """Return the brightness of the LEDs in %.""" + return self.entity_description.value(self.coordinator.data) + + async def async_set_native_value(self, value: float) -> None: + """Set the brightness of the LEDs to the value in %.""" + _LOGGER.debug( + "Changing LED brighntess from %.0f%% to %.0f%%", + self.coordinator.data["brightness"], + value, + ) + await self.entity_description.set_value(self.coordinator.airq, value) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json index de8c7d86b09..2972ba5c15b 100644 --- a/homeassistant/components/airq/strings.json +++ b/homeassistant/components/airq/strings.json @@ -35,6 +35,11 @@ } }, "entity": { + "number": { + "airq_led_brightness": { + "name": "LED brightness" + } + }, "sensor": { "acetaldehyde": { "name": "Acetaldehyde" diff --git a/tests/components/airq/__init__.py b/tests/components/airq/__init__.py index 612761c0653..41bc1e467dc 100644 --- a/tests/components/airq/__init__.py +++ b/tests/components/airq/__init__.py @@ -1 +1,32 @@ """Tests for the air-Q integration.""" + +from unittest.mock import patch + +from homeassistant.components.airq.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .common import TEST_DEVICE_INFO, TEST_USER_DATA + +from tests.common import MockConfigEntry + + +async def setup_platform(hass: HomeAssistant, platform: Platform) -> None: + """Load AirQ integration. + + This function does not patch AirQ itself, rather it depends on being + run in presence of `mock_coordinator_airq` fixture, which patches calls + by `AirQCoordinator.airq`, which are done under `async_setup`. + + Patching airq.PLATFORMS allows to set up a single platform in isolation. + """ + config_entry = MockConfigEntry( + domain=DOMAIN, data=TEST_USER_DATA, unique_id=TEST_DEVICE_INFO["id"] + ) + config_entry.add_to_hass(hass) + + # The patching is now handled by the mock_airq fixture. + # We just need to load the component. + with patch("homeassistant.components.airq.PLATFORMS", [platform]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/airq/common.py b/tests/components/airq/common.py index 275da60e3a2..2e568c3c3cb 100644 --- a/tests/components/airq/common.py +++ b/tests/components/airq/common.py @@ -16,3 +16,4 @@ TEST_DEVICE_INFO = DeviceInfo( hw_version="hw", ) TEST_DEVICE_DATA = {"co2": 500.0, "Status": "OK"} +TEST_BRIGHTNESS = 42 diff --git a/tests/components/airq/conftest.py b/tests/components/airq/conftest.py index 52d7fc77eb4..21118c3ef27 100644 --- a/tests/components/airq/conftest.py +++ b/tests/components/airq/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest -from .common import TEST_DEVICE_DATA, TEST_DEVICE_INFO +from .common import TEST_BRIGHTNESS, TEST_DEVICE_DATA, TEST_DEVICE_INFO @pytest.fixture @@ -38,4 +38,5 @@ def mock_airq(): # Pre-configure default mock values for setup airq.fetch_device_info = AsyncMock(return_value=TEST_DEVICE_INFO) airq.get_latest_data = AsyncMock(return_value=TEST_DEVICE_DATA) + airq.get_current_brightness = AsyncMock(return_value=TEST_BRIGHTNESS) yield airq diff --git a/tests/components/airq/test_number.py b/tests/components/airq/test_number.py new file mode 100644 index 00000000000..b5fa4d65ef1 --- /dev/null +++ b/tests/components/airq/test_number.py @@ -0,0 +1,70 @@ +"""Test the NUMBER platform from air-Q integration.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from . import setup_platform +from .common import TEST_BRIGHTNESS, TEST_DEVICE_INFO + +ENTITY_ID = f"number.{TEST_DEVICE_INFO['name']}_led_brightness" + + +@pytest.fixture(autouse=True) +async def number_platform(hass: HomeAssistant, mock_airq: AsyncMock) -> None: + """Configure AirQ integration and validate the setup for NUMBER platform.""" + await setup_platform(hass, Platform.NUMBER) + + # Validate the setup + state = hass.states.get(ENTITY_ID) + assert state is not None, ( + f"{ENTITY_ID} not found among {hass.states.async_entity_ids()}" + ) + assert float(state.state) == TEST_BRIGHTNESS + + +@pytest.mark.parametrize("new_brightness", [0, 100, (TEST_BRIGHTNESS + 10) % 100]) +async def test_number_set_value( + hass: HomeAssistant, mock_airq: AsyncMock, new_brightness +) -> None: + """Test that setting value works.""" + # Simulate the device confirming the new brightness on the next poll + mock_airq.get_current_brightness.return_value = new_brightness + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": ENTITY_ID, "value": new_brightness}, + blocking=True, + ) + await hass.async_block_till_done() + + # Verify the API methods were called correctly + mock_airq.set_current_brightness.assert_called_once_with(new_brightness) + + # Validate that the update propagated to the state + state = hass.states.get(ENTITY_ID) + assert state is not None, ( + f"{ENTITY_ID} not found among {hass.states.async_entity_ids()}" + ) + assert float(state.state) == new_brightness + + +@pytest.mark.parametrize("new_brightness", [-1, 110]) +async def test_number_set_invalid_value_caught_by_hass( + hass: HomeAssistant, mock_airq: AsyncMock, new_brightness +) -> None: + """Test that setting incorrect values errors.""" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + "number", + "set_value", + {"entity_id": ENTITY_ID, "value": new_brightness}, + blocking=True, + ) + + mock_airq.set_current_brightness.assert_not_called()