From cbebfdf1497e63a0268fb9960b83cfd473da0e76 Mon Sep 17 00:00:00 2001 From: prana-dev-official Date: Tue, 17 Mar 2026 20:53:50 +0200 Subject: [PATCH] Add number platform for Prana integration (#165816) --- homeassistant/components/prana/__init__.py | 2 +- homeassistant/components/prana/icons.json | 14 ++++ homeassistant/components/prana/number.py | 80 +++++++++++++++++++ homeassistant/components/prana/strings.json | 5 ++ .../prana/snapshots/test_number.ambr | 60 ++++++++++++++ tests/components/prana/test_number.py | 76 ++++++++++++++++++ 6 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/prana/number.py create mode 100644 tests/components/prana/snapshots/test_number.ambr create mode 100644 tests/components/prana/test_number.py diff --git a/homeassistant/components/prana/__init__.py b/homeassistant/components/prana/__init__.py index abe2013ca52..68c3a7f2f65 100644 --- a/homeassistant/components/prana/__init__.py +++ b/homeassistant/components/prana/__init__.py @@ -14,7 +14,7 @@ from .coordinator import PranaConfigEntry, PranaCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.FAN, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.FAN, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: PranaConfigEntry) -> bool: diff --git a/homeassistant/components/prana/icons.json b/homeassistant/components/prana/icons.json index 799291adbed..c22e36bd07c 100644 --- a/homeassistant/components/prana/icons.json +++ b/homeassistant/components/prana/icons.json @@ -8,6 +8,20 @@ "default": "mdi:arrow-expand-left" } }, + "number": { + "display_brightness": { + "default": "mdi:brightness-6", + "state": { + "0": "mdi:brightness-2", + "1": "mdi:brightness-4", + "2": "mdi:brightness-4", + "3": "mdi:brightness-5", + "4": "mdi:brightness-5", + "5": "mdi:brightness-7", + "6": "mdi:brightness-7" + } + } + }, "sensor": { "inside_temperature": { "default": "mdi:home-thermometer" diff --git a/homeassistant/components/prana/number.py b/homeassistant/components/prana/number.py new file mode 100644 index 00000000000..5e4a9ab026e --- /dev/null +++ b/homeassistant/components/prana/number.py @@ -0,0 +1,80 @@ +"""Number platform for Prana integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import PranaConfigEntry, PranaCoordinator +from .entity import PranaBaseEntity + +PARALLEL_UPDATES = 1 + + +class PranaNumberType(StrEnum): + """Enumerates Prana number types exposed by the device API.""" + + DISPLAY_BRIGHTNESS = "display_brightness" + + +@dataclass(frozen=True, kw_only=True) +class PranaNumberEntityDescription(NumberEntityDescription): + """Description of a Prana number entity.""" + + key: PranaNumberType + value_fn: Callable[[PranaCoordinator], float | None] + set_value_fn: Callable[[Any, float], Any] + + +ENTITIES: tuple[PranaNumberEntityDescription, ...] = ( + PranaNumberEntityDescription( + key=PranaNumberType.DISPLAY_BRIGHTNESS, + translation_key="display_brightness", + native_min_value=0, + native_max_value=6, + native_step=1, + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + value_fn=lambda coord: coord.data.brightness, + set_value_fn=lambda api, val: api.set_brightness( + 0 if val == 0 else 2 ** (int(val) - 1) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PranaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Prana number entities from a config entry.""" + async_add_entities( + PranaNumber(entry.runtime_data, entity_description) + for entity_description in ENTITIES + ) + + +class PranaNumber(PranaBaseEntity, NumberEntity): + """Representation of a Prana number entity.""" + + entity_description: PranaNumberEntityDescription + + @property + def native_value(self) -> float | None: + """Return the entity value.""" + return self.entity_description.value_fn(self.coordinator) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + await self.entity_description.set_value_fn(self.coordinator.api_client, value) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/prana/strings.json b/homeassistant/components/prana/strings.json index 1163fed4468..283df344b5e 100644 --- a/homeassistant/components/prana/strings.json +++ b/homeassistant/components/prana/strings.json @@ -49,6 +49,11 @@ } } }, + "number": { + "display_brightness": { + "name": "Display brightness" + } + }, "sensor": { "inside_temperature": { "name": "Inside temperature" diff --git a/tests/components/prana/snapshots/test_number.ambr b/tests/components/prana/snapshots/test_number.ambr new file mode 100644 index 00000000000..f0d376e0671 --- /dev/null +++ b/tests/components/prana/snapshots/test_number.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_numbers[number.prana_recuperator_display_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 6, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.prana_recuperator_display_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Display brightness', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Display brightness', + 'platform': 'prana', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'display_brightness', + 'unique_id': 'ECC9FFE0E574_display_brightness', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[number.prana_recuperator_display_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PRANA RECUPERATOR Display brightness', + 'max': 6, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.prana_recuperator_display_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- diff --git a/tests/components/prana/test_number.py b/tests/components/prana/test_number.py new file mode 100644 index 00000000000..6dcdb25a2c4 --- /dev/null +++ b/tests/components/prana/test_number.py @@ -0,0 +1,76 @@ +"""Integration-style tests for Prana numbers.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +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 async_init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_numbers( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_prana_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the Prana numbers snapshot.""" + with patch("homeassistant.components.prana.PLATFORMS", [Platform.NUMBER]): + await async_init_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("input_value", "expected_api_value"), + [ + (0.0, 0), # 0 -> 0 + (1.0, 1), # 2^(1-1) -> 1 + (2.0, 2), # 2^(2-1) -> 2 + (3.0, 4), # 2^(3-1) -> 4 + (4.0, 8), # 2^(4-1) -> 8 + (5.0, 16), # 2^(5-1) -> 16 + (6.0, 32), # 2^(6-1) -> 32 + ], +) +async def test_number_actions( + hass: HomeAssistant, + mock_prana_api: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + input_value: float, + expected_api_value: int, +) -> None: + """Test setting number values calls the API with correct math conversion.""" + await async_init_integration(hass, mock_config_entry) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert entries + + target = "number.prana_recuperator_display_brightness" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: target, + ATTR_VALUE: input_value, + }, + blocking=True, + ) + + mock_prana_api.set_brightness.assert_called_with(expected_api_value)