1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-08 17:49:37 +01:00

Add number platform to Fumis integration (#169100)

This commit is contained in:
Franck Nijhof
2026-04-29 09:39:15 +02:00
committed by GitHub
parent 2a5b95ba4d
commit 05bfb3a52e
6 changed files with 359 additions and 0 deletions
@@ -11,6 +11,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.NUMBER,
Platform.SENSOR,
]
@@ -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"
+97
View File
@@ -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()
@@ -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"
@@ -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': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.clou_duo_fan_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'context': <ANY>,
'entity_id': 'number.clou_duo_fan_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'context': <ANY>,
'entity_id': 'number.clou_duo_power_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5',
})
# ---
+126
View File
@@ -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