Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions homeassistant/components/fumis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.NUMBER,
Platform.SENSOR,
]

Expand Down
8 changes: 8 additions & 0 deletions homeassistant/components/fumis/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
97 changes: 97 additions & 0 deletions homeassistant/components/fumis/number.py
Original file line number Diff line number Diff line change
@@ -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()
8 changes: 8 additions & 0 deletions homeassistant/components/fumis/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
119 changes: 119 additions & 0 deletions tests/components/fumis/snapshots/test_number.ambr
Original file line number Diff line number Diff line change
@@ -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 changes: 126 additions & 0 deletions tests/components/fumis/test_number.py
Original file line number Diff line number Diff line change
@@ -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