mirror of
https://github.com/home-assistant/core.git
synced 2025-07-25 06:07:17 +00:00
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:
parent
cac0e0f6e8
commit
ba8d40f7d3
@ -19,6 +19,7 @@ PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.FAN,
|
||||
Platform.LIGHT,
|
||||
Platform.LOCK,
|
||||
Platform.NUMBER,
|
||||
|
@ -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"
|
||||
|
134
homeassistant/components/homee/fan.py
Normal file
134
homeassistant/components/homee/fan.py
Normal 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)
|
@ -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"
|
||||
|
@ -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": {
|
||||
|
73
tests/components/homee/fixtures/fan.json
Normal file
73
tests/components/homee/fixtures/fan.json
Normal 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": ""
|
||||
}
|
||||
]
|
||||
}
|
63
tests/components/homee/snapshots/test_fan.ambr
Normal file
63
tests/components/homee/snapshots/test_fan.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
192
tests/components/homee/test_fan.py
Normal file
192
tests/components/homee/test_fan.py
Normal 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)
|
Loading…
x
Reference in New Issue
Block a user