From 05bfb3a52e4e7065a4f4870e12e6104ad0363381 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Apr 2026 09:39:15 +0200 Subject: [PATCH] Add number platform to Fumis integration (#169100) --- homeassistant/components/fumis/__init__.py | 1 + homeassistant/components/fumis/icons.json | 8 ++ homeassistant/components/fumis/number.py | 97 ++++++++++++++ homeassistant/components/fumis/strings.json | 8 ++ .../fumis/snapshots/test_number.ambr | 119 +++++++++++++++++ tests/components/fumis/test_number.py | 126 ++++++++++++++++++ 6 files changed, 359 insertions(+) create mode 100644 homeassistant/components/fumis/number.py create mode 100644 tests/components/fumis/snapshots/test_number.ambr create mode 100644 tests/components/fumis/test_number.py diff --git a/homeassistant/components/fumis/__init__.py b/homeassistant/components/fumis/__init__.py index 5cb25a70a82..e04b1b1527d 100644 --- a/homeassistant/components/fumis/__init__.py +++ b/homeassistant/components/fumis/__init__.py @@ -11,6 +11,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.NUMBER, Platform.SENSOR, ] diff --git a/homeassistant/components/fumis/icons.json b/homeassistant/components/fumis/icons.json index 6cfb39c3911..86184da85c8 100644 --- a/homeassistant/components/fumis/icons.json +++ b/homeassistant/components/fumis/icons.json @@ -5,6 +5,14 @@ "default": "mdi:clock-sync" } }, + "number": { + "fan_speed": { + "default": "mdi:fan" + }, + "power_level": { + "default": "mdi:fire" + } + }, "sensor": { "combustion_chamber_temperature": { "default": "mdi:thermometer-high" diff --git a/homeassistant/components/fumis/number.py b/homeassistant/components/fumis/number.py new file mode 100644 index 00000000000..c966ba5d248 --- /dev/null +++ b/homeassistant/components/fumis/number.py @@ -0,0 +1,97 @@ +"""Support for Fumis number entities.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from fumis import Fumis, FumisInfo + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator +from .entity import FumisEntity +from .helpers import fumis_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class FumisNumberEntityDescription(NumberEntityDescription): + """Describes a Fumis number entity.""" + + has_fn: Callable[[FumisInfo], bool] = lambda _: True + value_fn: Callable[[FumisInfo], float | None] + set_fn: Callable[[Fumis, float], Awaitable[Any]] + + +NUMBERS: tuple[FumisNumberEntityDescription, ...] = ( + FumisNumberEntityDescription( + key="fan_speed", + translation_key="fan_speed", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_min_value=0, + native_max_value=5, + native_step=1, + has_fn=lambda data: len(data.controller.fans) > 0, + value_fn=lambda data: ( + data.controller.fans[0].speed if data.controller.fans else None + ), + set_fn=lambda client, value: client.set_fan_speed(int(value)), + ), + FumisNumberEntityDescription( + key="power_level", + translation_key="power_level", + native_min_value=1, + native_max_value=5, + native_step=1, + value_fn=lambda data: data.controller.power.set_power, + set_fn=lambda client, value: client.set_power(int(value)), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FumisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Fumis number entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + FumisNumberEntity(coordinator=coordinator, description=description) + for description in NUMBERS + if description.has_fn(coordinator.data) + ) + + +class FumisNumberEntity(FumisEntity, NumberEntity): + """Defines a Fumis number entity.""" + + entity_description: FumisNumberEntityDescription + + def __init__( + self, + coordinator: FumisDataUpdateCoordinator, + description: FumisNumberEntityDescription, + ) -> None: + """Initialize the Fumis number entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @property + def native_value(self) -> float | None: + """Return the current value.""" + return self.entity_description.value_fn(self.coordinator.data) + + @fumis_exception_handler + async def async_set_native_value(self, value: float) -> None: + """Set a new value.""" + await self.entity_description.set_fn(self.coordinator.client, value) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/fumis/strings.json b/homeassistant/components/fumis/strings.json index 85b11a82be3..37944e1dc32 100644 --- a/homeassistant/components/fumis/strings.json +++ b/homeassistant/components/fumis/strings.json @@ -58,6 +58,14 @@ "name": "Sync clock" } }, + "number": { + "fan_speed": { + "name": "Fan speed" + }, + "power_level": { + "name": "Power level" + } + }, "sensor": { "combustion_chamber_temperature": { "name": "Combustion chamber" diff --git a/tests/components/fumis/snapshots/test_number.ambr b/tests/components/fumis/snapshots/test_number.ambr new file mode 100644 index 00000000000..ba63b5145b9 --- /dev/null +++ b/tests/components/fumis/snapshots/test_number.ambr @@ -0,0 +1,119 @@ +# serializer version: 1 +# name: test_numbers[number][number.clou_duo_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + '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.clou_duo_fan_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fan speed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan speed', + 'platform': 'fumis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fan_speed', + 'unique_id': 'aa:bb:cc:dd:ee:ff_fan_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[number][number.clou_duo_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Clou Duo Fan speed', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.clou_duo_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_numbers[number][number.clou_duo_power_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.clou_duo_power_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power level', + 'platform': 'fumis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_level', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[number][number.clou_duo_power_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Clou Duo Power level', + 'max': 5, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.clou_duo_power_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- diff --git a/tests/components/fumis/test_number.py b/tests/components/fumis/test_number.py new file mode 100644 index 00000000000..8e642a70f6e --- /dev/null +++ b/tests/components/fumis/test_number.py @@ -0,0 +1,126 @@ +"""Tests for the Fumis number entities.""" + +from unittest.mock import MagicMock + +from fumis import FumisConnectionError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fumis.const 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.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .const import UNIQUE_ID + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = pytest.mark.parametrize( + "init_integration", [Platform.NUMBER], indirect=True +) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_numbers( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Fumis number entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_set_power_level( + hass: HomeAssistant, + mock_fumis: MagicMock, +) -> None: + """Test setting the power level.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.clou_duo_power_level", ATTR_VALUE: 3}, + blocking=True, + ) + + mock_fumis.set_power.assert_called_once_with(3) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_set_fan_speed( + hass: HomeAssistant, + mock_fumis: MagicMock, +) -> None: + """Test setting the fan speed.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.clou_duo_fan_speed", ATTR_VALUE: 2}, + blocking=True, + ) + + mock_fumis.set_fan_speed.assert_called_once_with(2) + + +@pytest.mark.usefixtures("init_integration") +async def test_number_error_handling( + hass: HomeAssistant, + mock_fumis: MagicMock, +) -> None: + """Test error handling for number actions.""" + mock_fumis.set_power.side_effect = FumisConnectionError + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.clou_duo_power_level", ATTR_VALUE: 3}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "communication_error" + + +@pytest.mark.parametrize( + "unique_id", + [ + f"{UNIQUE_ID}_fan_speed", + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_numbers_disabled_by_default( + entity_registry: er.EntityRegistry, + unique_id: str, +) -> None: + """Test number entities that are disabled by default.""" + entry = entity_registry.async_get_entity_id("number", "fumis", unique_id) + assert entry is not None, f"Entity with unique_id {unique_id} not found" + assert (entity_entry := entity_registry.async_get(entry)) + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.parametrize("device_fixture", ["info_minimal"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_numbers_conditional_creation( + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test fan_speed number is not created when data is missing.""" + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + unique_ids = {entry.unique_id for entry in entity_entries} + + # Fan speed should NOT exist with the minimal fixture + assert f"{UNIQUE_ID}_fan_speed" not in unique_ids + + # Power level should still exist + assert f"{UNIQUE_ID}_power_level" in unique_ids