Add homee fan platform (#143524)

* Initial fan

* add more tests

* add last fan tests and small fixes

* fix tests after latest change

* another small correction

* use common strings

* add snapshot test

* fix review comments

* fix typing

* remove uneeded None

* remove unwanted file

* fix turn_on function

* typo

* Use constants for preset modes.

* fix  review notes.
This commit is contained in:
Markus Adrario 2025-05-09 18:51:57 +02:00 committed by GitHub
parent cac0e0f6e8
commit ba8d40f7d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 495 additions and 1 deletions

View File

@ -19,6 +19,7 @@ PLATFORMS = [
Platform.BUTTON,
Platform.CLIMATE,
Platform.COVER,
Platform.FAN,
Platform.LIGHT,
Platform.LOCK,
Platform.NUMBER,

View File

@ -96,5 +96,7 @@ LIGHT_PROFILES = [
NodeProfile.WIFI_ON_OFF_DIMMABLE_METERING_SWITCH,
]
# Climate Presets
# Preset modes
PRESET_AUTO = "auto"
PRESET_MANUAL = "manual"
PRESET_SUMMER = "summer"

View File

@ -0,0 +1,134 @@
"""The Homee fan platform."""
import math
from typing import Any, cast
from pyHomee.const import AttributeType, NodeProfile
from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
)
from homeassistant.util.scaling import int_states_in_range
from . import HomeeConfigEntry
from .const import DOMAIN, PRESET_AUTO, PRESET_MANUAL, PRESET_SUMMER
from .entity import HomeeNodeEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Homee fan platform."""
async_add_devices(
HomeeFan(node, config_entry)
for node in config_entry.runtime_data.nodes
if node.profile == NodeProfile.VENTILATION_CONTROL
)
class HomeeFan(HomeeNodeEntity, FanEntity):
"""Representation of a Homee fan entity."""
_attr_translation_key = DOMAIN
_attr_name = None
_attr_preset_modes = [PRESET_MANUAL, PRESET_AUTO, PRESET_SUMMER]
speed_range = (1, 8)
_attr_speed_count = int_states_in_range(speed_range)
def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None:
"""Initialize a Homee fan entity."""
super().__init__(node, entry)
self._speed_attribute: HomeeAttribute = cast(
HomeeAttribute, node.get_attribute_by_type(AttributeType.VENTILATION_LEVEL)
)
self._mode_attribute: HomeeAttribute = cast(
HomeeAttribute, node.get_attribute_by_type(AttributeType.VENTILATION_MODE)
)
@property
def supported_features(self) -> FanEntityFeature:
"""Return the supported features based on preset_mode."""
features = FanEntityFeature.PRESET_MODE
if self.preset_mode == PRESET_MANUAL:
features |= (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
return features
@property
def is_on(self) -> bool:
"""Return true if the entity is on."""
return self.percentage > 0
@property
def percentage(self) -> int:
"""Return the current speed percentage."""
return ranged_value_to_percentage(
self.speed_range, self._speed_attribute.current_value
)
@property
def preset_mode(self) -> str:
"""Return the mode from the float state."""
return self._attr_preset_modes[int(self._mode_attribute.current_value)]
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
await self.async_set_homee_value(
self._speed_attribute,
math.ceil(percentage_to_ranged_value(self.speed_range, percentage)),
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
await self.async_set_homee_value(
self._mode_attribute, self._attr_preset_modes.index(preset_mode)
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
await self.async_set_homee_value(self._speed_attribute, 0)
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn the fan on."""
if preset_mode is not None:
if preset_mode != "manual":
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_preset_mode",
translation_placeholders={"preset_mode": preset_mode},
)
await self.async_set_preset_mode(preset_mode)
# If no percentage is given, use the last known value.
if percentage is None:
percentage = ranged_value_to_percentage(
self.speed_range,
self._speed_attribute.last_value,
)
# If the last known value is 0, set 100%.
if percentage == 0:
percentage = 100
await self.async_set_percentage(percentage)

View File

@ -11,6 +11,19 @@
}
}
},
"fan": {
"homee": {
"state_attributes": {
"preset_mode": {
"state": {
"manual": "mdi:hand-back-left",
"auto": "mdi:auto-mode",
"summer": "mdi:sun-thermometer-outline"
}
}
}
}
},
"sensor": {
"brightness": {
"default": "mdi:brightness-5"

View File

@ -142,6 +142,19 @@
}
}
},
"fan": {
"homee": {
"state_attributes": {
"preset_mode": {
"state": {
"manual": "[%key:common::state::manual%]",
"auto": "[%key:common::state::auto%]",
"summer": "Summer"
}
}
}
}
},
"light": {
"light_instance": {
"name": "Light {instance}"
@ -356,6 +369,9 @@
"exceptions": {
"connection_closed": {
"message": "Could not connect to homee while setting attribute."
},
"invalid_preset_mode": {
"message": "Invalid preset mode: {preset_mode}. Turning on is only supported with preset mode 'Manual'."
}
},
"issues": {

View File

@ -0,0 +1,73 @@
{
"id": 77,
"name": "Test Fan",
"profile": 3019,
"image": "default",
"favorite": 0,
"order": 76,
"protocol": 3,
"routing": 0,
"state": 1,
"state_changed": 1736106044,
"added": 1723550156,
"history": 1,
"cube_type": 3,
"note": "",
"services": 1,
"phonetic_name": "",
"owner": 2,
"security": 0,
"attributes": [
{
"id": 1,
"node_id": 77,
"instance": 0,
"minimum": 0,
"maximum": 8,
"current_value": 3.0,
"target_value": 3.0,
"last_value": 6.0,
"unit": "n/a",
"step_value": 1.0,
"editable": 1,
"type": 99,
"state": 5,
"last_changed": 1729920212,
"changed_by": 1,
"changed_by_id": 0,
"based_on": 1,
"data": "",
"name": "",
"options": {
"automations": ["step"],
"history": {
"day": 35,
"week": 5,
"month": 1,
"stepped": true
}
}
},
{
"id": 2,
"node_id": 77,
"instance": 0,
"minimum": 0,
"maximum": 2,
"current_value": 0.0,
"target_value": 0.0,
"last_value": 1.0,
"unit": "n/a",
"step_value": 1.0,
"editable": 1,
"type": 100,
"state": 1,
"last_changed": 1736106312,
"changed_by": 1,
"changed_by_id": 0,
"based_on": 1,
"data": "",
"name": ""
}
]
}

View File

@ -0,0 +1,63 @@
# serializer version: 1
# name: test_fan_snapshot[fan.test_fan-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'preset_modes': list([
'manual',
'auto',
'summer',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'fan',
'entity_category': None,
'entity_id': 'fan.test_fan',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'homee',
'previous_unique_id': None,
'supported_features': <FanEntityFeature: 57>,
'translation_key': 'homee',
'unique_id': '00055511EECC-77',
'unit_of_measurement': None,
})
# ---
# name: test_fan_snapshot[fan.test_fan-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Fan',
'percentage': 37,
'percentage_step': 12.5,
'preset_mode': 'manual',
'preset_modes': list([
'manual',
'auto',
'summer',
]),
'supported_features': <FanEntityFeature: 57>,
}),
'context': <ANY>,
'entity_id': 'fan.test_fan',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@ -0,0 +1,192 @@
"""Test Homee fans."""
from unittest.mock import MagicMock, call, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.fan import (
ATTR_PERCENTAGE,
ATTR_PRESET_MODE,
DOMAIN as FAN_DOMAIN,
SERVICE_DECREASE_SPEED,
SERVICE_INCREASE_SPEED,
SERVICE_SET_PERCENTAGE,
SERVICE_SET_PRESET_MODE,
SERVICE_TOGGLE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.components.homee.const import (
DOMAIN,
PRESET_AUTO,
PRESET_MANUAL,
PRESET_SUMMER,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er
from . import build_mock_node, setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.parametrize(
("speed", "expected"),
[
(0, 0),
(1, 12),
(2, 25),
(3, 37),
(4, 50),
(5, 62),
(6, 75),
(7, 87),
(8, 100),
],
)
async def test_percentage(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_homee: MagicMock,
speed: int,
expected: int,
) -> None:
"""Test percentage."""
mock_homee.nodes = [build_mock_node("fan.json")]
mock_homee.nodes[0].attributes[0].current_value = speed
await setup_integration(hass, mock_config_entry)
assert hass.states.get("fan.test_fan").attributes["percentage"] == expected
@pytest.mark.parametrize(
("mode_value", "expected"),
[
(0, "manual"),
(1, "auto"),
(2, "summer"),
],
)
async def test_preset_mode(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_homee: MagicMock,
mode_value: int,
expected: str,
) -> None:
"""Test preset mode."""
mock_homee.nodes = [build_mock_node("fan.json")]
mock_homee.nodes[0].attributes[1].current_value = mode_value
await setup_integration(hass, mock_config_entry)
assert hass.states.get("fan.test_fan").attributes["preset_mode"] == expected
@pytest.mark.parametrize(
("service", "options", "expected"),
[
(SERVICE_TURN_ON, {ATTR_PERCENTAGE: 100}, (77, 1, 8)),
(SERVICE_TURN_ON, {ATTR_PERCENTAGE: 86}, (77, 1, 7)),
(SERVICE_TURN_ON, {ATTR_PERCENTAGE: 63}, (77, 1, 6)),
(SERVICE_TURN_ON, {ATTR_PERCENTAGE: 60}, (77, 1, 5)),
(SERVICE_TURN_ON, {ATTR_PERCENTAGE: 50}, (77, 1, 4)),
(SERVICE_TURN_ON, {ATTR_PERCENTAGE: 34}, (77, 1, 3)),
(SERVICE_TURN_ON, {ATTR_PERCENTAGE: 17}, (77, 1, 2)),
(SERVICE_TURN_ON, {ATTR_PERCENTAGE: 8}, (77, 1, 1)),
(SERVICE_TURN_ON, {}, (77, 1, 6)),
(SERVICE_TURN_OFF, {}, (77, 1, 0)),
(SERVICE_INCREASE_SPEED, {}, (77, 1, 4)),
(SERVICE_DECREASE_SPEED, {}, (77, 1, 2)),
(SERVICE_SET_PERCENTAGE, {ATTR_PERCENTAGE: 42}, (77, 1, 4)),
(SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: PRESET_MANUAL}, (77, 2, 0)),
(SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: PRESET_AUTO}, (77, 2, 1)),
(SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: PRESET_SUMMER}, (77, 2, 2)),
(SERVICE_TOGGLE, {}, (77, 1, 0)),
],
)
async def test_fan_services(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_homee: MagicMock,
service: str,
options: int | None,
expected: tuple[int, int, int],
) -> None:
"""Test fan services."""
mock_homee.nodes = [build_mock_node("fan.json")]
await setup_integration(hass, mock_config_entry)
OPTIONS = {ATTR_ENTITY_ID: "fan.test_fan"}
OPTIONS.update(options)
await hass.services.async_call(
FAN_DOMAIN,
service,
OPTIONS,
blocking=True,
)
mock_homee.set_value.assert_called_once_with(*expected)
async def test_turn_on_preset_last_value_zero(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_homee: MagicMock,
) -> None:
"""Test turn on with preset last value == 0."""
mock_homee.nodes = [build_mock_node("fan.json")]
mock_homee.nodes[0].attributes[0].last_value = 0
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "fan.test_fan", ATTR_PRESET_MODE: PRESET_MANUAL},
blocking=True,
)
assert mock_homee.set_value.call_args_list == [
call(77, 2, 0),
call(77, 1, 8),
]
async def test_turn_on_invalid_preset(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_homee: MagicMock,
) -> None:
"""Test turn on with invalid preset."""
mock_homee.nodes = [build_mock_node("fan.json")]
await setup_integration(hass, mock_config_entry)
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "fan.test_fan", ATTR_PRESET_MODE: PRESET_AUTO},
blocking=True,
)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "invalid_preset_mode"
async def test_fan_snapshot(
hass: HomeAssistant,
mock_homee: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the fan snapshot."""
mock_homee.nodes = [build_mock_node("fan.json")]
mock_homee.get_node_by_id.return_value = mock_homee.nodes[0]
with patch("homeassistant.components.homee.PLATFORMS", [Platform.FAN]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)