Add number platform for LED brightness to air-Q (#150492)

This commit is contained in:
Renat Sibgatulin
2025-08-12 15:39:28 +02:00
committed by GitHub
parent a3904ce60c
commit 711afa306c
8 changed files with 196 additions and 2 deletions

View File

@@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant
from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE
from .coordinator import AirQCoordinator from .coordinator import AirQCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR] PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR]
AirQConfigEntry = ConfigEntry[AirQCoordinator] AirQConfigEntry = ConfigEntry[AirQCoordinator]

View File

@@ -75,6 +75,7 @@ class AirQCoordinator(DataUpdateCoordinator):
return_average=self.return_average, return_average=self.return_average,
clip_negative_values=self.clip_negative, clip_negative_values=self.clip_negative,
) )
data["brightness"] = await self.airq.get_current_brightness()
if warming_up_sensors := identify_warming_up_sensors(data): if warming_up_sensors := identify_warming_up_sensors(data):
_LOGGER.debug( _LOGGER.debug(
"Following sensors are still warming up: %s", warming_up_sensors "Following sensors are still warming up: %s", warming_up_sensors

View File

@@ -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()

View File

@@ -35,6 +35,11 @@
} }
}, },
"entity": { "entity": {
"number": {
"airq_led_brightness": {
"name": "LED brightness"
}
},
"sensor": { "sensor": {
"acetaldehyde": { "acetaldehyde": {
"name": "Acetaldehyde" "name": "Acetaldehyde"

View File

@@ -1 +1,32 @@
"""Tests for the air-Q integration.""" """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()

View File

@@ -16,3 +16,4 @@ TEST_DEVICE_INFO = DeviceInfo(
hw_version="hw", hw_version="hw",
) )
TEST_DEVICE_DATA = {"co2": 500.0, "Status": "OK"} TEST_DEVICE_DATA = {"co2": 500.0, "Status": "OK"}
TEST_BRIGHTNESS = 42

View File

@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from .common import TEST_DEVICE_DATA, TEST_DEVICE_INFO from .common import TEST_BRIGHTNESS, TEST_DEVICE_DATA, TEST_DEVICE_INFO
@pytest.fixture @pytest.fixture
@@ -38,4 +38,5 @@ def mock_airq():
# Pre-configure default mock values for setup # Pre-configure default mock values for setup
airq.fetch_device_info = AsyncMock(return_value=TEST_DEVICE_INFO) airq.fetch_device_info = AsyncMock(return_value=TEST_DEVICE_INFO)
airq.get_latest_data = AsyncMock(return_value=TEST_DEVICE_DATA) airq.get_latest_data = AsyncMock(return_value=TEST_DEVICE_DATA)
airq.get_current_brightness = AsyncMock(return_value=TEST_BRIGHTNESS)
yield airq yield airq

View File

@@ -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()