mirror of
https://github.com/home-assistant/core.git
synced 2025-07-28 07:37:34 +00:00
Fan support in WiZ (#146440)
This commit is contained in:
parent
4f938d032d
commit
db45f46c8a
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@ -1758,8 +1758,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/wirelesstag/ @sergeymaysak
|
||||
/homeassistant/components/withings/ @joostlek
|
||||
/tests/components/withings/ @joostlek
|
||||
/homeassistant/components/wiz/ @sbidy
|
||||
/tests/components/wiz/ @sbidy
|
||||
/homeassistant/components/wiz/ @sbidy @arturpragacz
|
||||
/tests/components/wiz/ @sbidy @arturpragacz
|
||||
/homeassistant/components/wled/ @frenck
|
||||
/tests/components/wled/ @frenck
|
||||
/homeassistant/components/wmspro/ @mback2k
|
||||
|
@ -37,6 +37,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.FAN,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
|
139
homeassistant/components/wiz/fan.py
Normal file
139
homeassistant/components/wiz/fan.py
Normal file
@ -0,0 +1,139 @@
|
||||
"""WiZ integration fan platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pywizlight.bulblibrary import BulbType, Features
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
DIRECTION_FORWARD,
|
||||
DIRECTION_REVERSE,
|
||||
FanEntity,
|
||||
FanEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.percentage import (
|
||||
percentage_to_ranged_value,
|
||||
ranged_value_to_percentage,
|
||||
)
|
||||
|
||||
from . import WizConfigEntry
|
||||
from .entity import WizEntity
|
||||
from .models import WizData
|
||||
|
||||
PRESET_MODE_BREEZE = "breeze"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: WizConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the WiZ Platform from config_flow."""
|
||||
if entry.runtime_data.bulb.bulbtype.features.fan:
|
||||
async_add_entities([WizFanEntity(entry.runtime_data, entry.title)])
|
||||
|
||||
|
||||
class WizFanEntity(WizEntity, FanEntity):
|
||||
"""Representation of WiZ Light bulb."""
|
||||
|
||||
_attr_name = None
|
||||
|
||||
# We want the implementation of is_on to be the same as in ToggleEntity,
|
||||
# but it is being overridden in FanEntity, so we need to restore it here.
|
||||
is_on: ClassVar = ToggleEntity.is_on
|
||||
|
||||
def __init__(self, wiz_data: WizData, name: str) -> None:
|
||||
"""Initialize a WiZ fan."""
|
||||
super().__init__(wiz_data, name)
|
||||
bulb_type: BulbType = self._device.bulbtype
|
||||
features: Features = bulb_type.features
|
||||
|
||||
supported_features = (
|
||||
FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
| FanEntityFeature.SET_SPEED
|
||||
)
|
||||
if features.fan_reverse:
|
||||
supported_features |= FanEntityFeature.DIRECTION
|
||||
if features.fan_breeze_mode:
|
||||
supported_features |= FanEntityFeature.PRESET_MODE
|
||||
self._attr_preset_modes = [PRESET_MODE_BREEZE]
|
||||
|
||||
self._attr_supported_features = supported_features
|
||||
self._attr_speed_count = bulb_type.fan_speed_range
|
||||
|
||||
self._async_update_attrs()
|
||||
|
||||
@callback
|
||||
def _async_update_attrs(self) -> None:
|
||||
"""Handle updating _attr values."""
|
||||
state = self._device.state
|
||||
|
||||
self._attr_is_on = state.get_fan_state() > 0
|
||||
self._attr_percentage = ranged_value_to_percentage(
|
||||
(1, self.speed_count), state.get_fan_speed()
|
||||
)
|
||||
if FanEntityFeature.PRESET_MODE in self.supported_features:
|
||||
fan_mode = state.get_fan_mode()
|
||||
self._attr_preset_mode = PRESET_MODE_BREEZE if fan_mode == 2 else None
|
||||
if FanEntityFeature.DIRECTION in self.supported_features:
|
||||
fan_reverse = state.get_fan_reverse()
|
||||
self._attr_current_direction = None
|
||||
if fan_reverse == 0:
|
||||
self._attr_current_direction = DIRECTION_FORWARD
|
||||
elif fan_reverse == 1:
|
||||
self._attr_current_direction = DIRECTION_REVERSE
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set the preset mode of the fan."""
|
||||
# preset_mode == PRESET_MODE_BREEZE
|
||||
await self._device.fan_set_state(mode=2)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed percentage of the fan."""
|
||||
if percentage == 0:
|
||||
await self.async_turn_off()
|
||||
return
|
||||
|
||||
speed = math.ceil(percentage_to_ranged_value((1, self.speed_count), percentage))
|
||||
await self._device.fan_set_state(mode=1, speed=speed)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Turn on the fan."""
|
||||
mode: int | None = None
|
||||
speed: int | None = None
|
||||
if preset_mode is not None:
|
||||
self._valid_preset_mode_or_raise(preset_mode)
|
||||
if preset_mode == PRESET_MODE_BREEZE:
|
||||
mode = 2
|
||||
if percentage is not None:
|
||||
speed = math.ceil(
|
||||
percentage_to_ranged_value((1, self.speed_count), percentage)
|
||||
)
|
||||
if mode is None:
|
||||
mode = 1
|
||||
await self._device.fan_turn_on(mode=mode, speed=speed)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the fan."""
|
||||
await self._device.fan_turn_off(**kwargs)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_direction(self, direction: str) -> None:
|
||||
"""Set the direction of the fan."""
|
||||
reverse = 1 if direction == DIRECTION_REVERSE else 0
|
||||
await self._device.fan_set_state(reverse=reverse)
|
||||
await self.coordinator.async_request_refresh()
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "wiz",
|
||||
"name": "WiZ",
|
||||
"codeowners": ["@sbidy"],
|
||||
"codeowners": ["@sbidy", "@arturpragacz"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["network"],
|
||||
"dhcp": [
|
||||
|
@ -33,6 +33,10 @@ FAKE_STATE = PilotParser(
|
||||
"c": 0,
|
||||
"w": 0,
|
||||
"dimming": 100,
|
||||
"fanState": 0,
|
||||
"fanMode": 1,
|
||||
"fanSpeed": 1,
|
||||
"fanRevrs": 0,
|
||||
}
|
||||
)
|
||||
FAKE_IP = "1.1.1.1"
|
||||
@ -173,6 +177,25 @@ FAKE_OLD_FIRMWARE_DIMMABLE_BULB = BulbType(
|
||||
white_channels=1,
|
||||
white_to_color_ratio=80,
|
||||
)
|
||||
FAKE_DIMMABLE_FAN = BulbType(
|
||||
bulb_type=BulbClass.FANDIM,
|
||||
name="ESP03_FANDIMS_31",
|
||||
features=Features(
|
||||
color=False,
|
||||
color_tmp=False,
|
||||
effect=True,
|
||||
brightness=True,
|
||||
dual_head=False,
|
||||
fan=True,
|
||||
fan_breeze_mode=True,
|
||||
fan_reverse=True,
|
||||
),
|
||||
kelvin_range=KelvinRange(max=2700, min=2700),
|
||||
fw_version="1.31.32",
|
||||
white_channels=1,
|
||||
white_to_color_ratio=20,
|
||||
fan_speed_range=6,
|
||||
)
|
||||
|
||||
|
||||
async def setup_integration(hass: HomeAssistant) -> MockConfigEntry:
|
||||
@ -220,6 +243,9 @@ def _mocked_wizlight(
|
||||
bulb.async_close = AsyncMock()
|
||||
bulb.set_speed = AsyncMock()
|
||||
bulb.set_ratio = AsyncMock()
|
||||
bulb.fan_set_state = AsyncMock()
|
||||
bulb.fan_turn_on = AsyncMock()
|
||||
bulb.fan_turn_off = AsyncMock()
|
||||
bulb.diagnostics = {
|
||||
"mocked": "mocked",
|
||||
"roomId": 123,
|
||||
|
61
tests/components/wiz/snapshots/test_fan.ambr
Normal file
61
tests/components/wiz/snapshots/test_fan.ambr
Normal file
@ -0,0 +1,61 @@
|
||||
# serializer version: 1
|
||||
# name: test_entity[fan.mock_title-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'preset_modes': list([
|
||||
'breeze',
|
||||
]),
|
||||
}),
|
||||
'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.mock_title',
|
||||
'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': 'wiz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <FanEntityFeature: 61>,
|
||||
'translation_key': None,
|
||||
'unique_id': 'abcabcabcabc',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity[fan.mock_title-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'direction': 'forward',
|
||||
'friendly_name': 'Mock Title',
|
||||
'percentage': 16,
|
||||
'percentage_step': 16.666666666666668,
|
||||
'preset_mode': None,
|
||||
'preset_modes': list([
|
||||
'breeze',
|
||||
]),
|
||||
'supported_features': <FanEntityFeature: 61>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'fan.mock_title',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
232
tests/components/wiz/test_fan.py
Normal file
232
tests/components/wiz/test_fan.py
Normal file
@ -0,0 +1,232 @@
|
||||
"""Tests for fan platform."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
ATTR_DIRECTION,
|
||||
ATTR_PERCENTAGE,
|
||||
ATTR_PRESET_MODE,
|
||||
DIRECTION_FORWARD,
|
||||
DIRECTION_REVERSE,
|
||||
DOMAIN as FAN_DOMAIN,
|
||||
SERVICE_SET_DIRECTION,
|
||||
SERVICE_SET_PERCENTAGE,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
)
|
||||
from homeassistant.components.wiz.fan import PRESET_MODE_BREEZE
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import FAKE_DIMMABLE_FAN, FAKE_MAC, async_push_update, async_setup_integration
|
||||
|
||||
from tests.common import snapshot_platform
|
||||
|
||||
ENTITY_ID = "fan.mock_title"
|
||||
|
||||
INITIAL_PARAMS = {
|
||||
"mac": FAKE_MAC,
|
||||
"fanState": 0,
|
||||
"fanMode": 1,
|
||||
"fanSpeed": 1,
|
||||
"fanRevrs": 0,
|
||||
}
|
||||
|
||||
|
||||
@patch("homeassistant.components.wiz.PLATFORMS", [Platform.FAN])
|
||||
async def test_entity(
|
||||
hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry
|
||||
) -> None:
|
||||
"""Test the fan entity."""
|
||||
entry = (await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN))[1]
|
||||
await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
|
||||
|
||||
|
||||
def _update_params(
|
||||
params: dict[str, Any],
|
||||
state: int | None = None,
|
||||
mode: int | None = None,
|
||||
speed: int | None = None,
|
||||
reverse: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Get the parameters for the update."""
|
||||
if state is not None:
|
||||
params["fanState"] = state
|
||||
if mode is not None:
|
||||
params["fanMode"] = mode
|
||||
if speed is not None:
|
||||
params["fanSpeed"] = speed
|
||||
if reverse is not None:
|
||||
params["fanRevrs"] = reverse
|
||||
return params
|
||||
|
||||
|
||||
async def test_turn_on_off(hass: HomeAssistant) -> None:
|
||||
"""Test turning the fan on and off."""
|
||||
device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN)
|
||||
|
||||
params = INITIAL_PARAMS.copy()
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True
|
||||
)
|
||||
calls = device.fan_turn_on.mock_calls
|
||||
assert len(calls) == 1
|
||||
args = calls[0][2]
|
||||
assert args == {"mode": None, "speed": None}
|
||||
await async_push_update(hass, device, _update_params(params, state=1, **args))
|
||||
device.fan_turn_on.reset_mock()
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.state == STATE_ON
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_MODE_BREEZE},
|
||||
blocking=True,
|
||||
)
|
||||
calls = device.fan_turn_on.mock_calls
|
||||
assert len(calls) == 1
|
||||
args = calls[0][2]
|
||||
assert args == {"mode": 2, "speed": None}
|
||||
await async_push_update(hass, device, _update_params(params, state=1, **args))
|
||||
device.fan_turn_on.reset_mock()
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[ATTR_PRESET_MODE] == PRESET_MODE_BREEZE
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 50},
|
||||
blocking=True,
|
||||
)
|
||||
calls = device.fan_turn_on.mock_calls
|
||||
assert len(calls) == 1
|
||||
args = calls[0][2]
|
||||
assert args == {"mode": 1, "speed": 3}
|
||||
await async_push_update(hass, device, _update_params(params, state=1, **args))
|
||||
device.fan_turn_on.reset_mock()
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[ATTR_PERCENTAGE] == 50
|
||||
assert state.attributes[ATTR_PRESET_MODE] is None
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True
|
||||
)
|
||||
calls = device.fan_turn_off.mock_calls
|
||||
assert len(calls) == 1
|
||||
await async_push_update(hass, device, _update_params(params, state=0))
|
||||
device.fan_turn_off.reset_mock()
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
|
||||
async def test_fan_set_preset_mode(hass: HomeAssistant) -> None:
|
||||
"""Test setting the fan preset mode."""
|
||||
device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN)
|
||||
|
||||
params = INITIAL_PARAMS.copy()
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_MODE_BREEZE},
|
||||
blocking=True,
|
||||
)
|
||||
calls = device.fan_set_state.mock_calls
|
||||
assert len(calls) == 1
|
||||
args = calls[0][2]
|
||||
assert args == {"mode": 2}
|
||||
await async_push_update(hass, device, _update_params(params, state=1, **args))
|
||||
device.fan_set_state.reset_mock()
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[ATTR_PRESET_MODE] == PRESET_MODE_BREEZE
|
||||
|
||||
|
||||
async def test_fan_set_percentage(hass: HomeAssistant) -> None:
|
||||
"""Test setting the fan percentage."""
|
||||
device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN)
|
||||
|
||||
params = INITIAL_PARAMS.copy()
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_SET_PERCENTAGE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 50},
|
||||
blocking=True,
|
||||
)
|
||||
calls = device.fan_set_state.mock_calls
|
||||
assert len(calls) == 1
|
||||
args = calls[0][2]
|
||||
assert args == {"mode": 1, "speed": 3}
|
||||
await async_push_update(hass, device, _update_params(params, state=1, **args))
|
||||
device.fan_set_state.reset_mock()
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[ATTR_PERCENTAGE] == 50
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_SET_PERCENTAGE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 0},
|
||||
blocking=True,
|
||||
)
|
||||
calls = device.fan_turn_off.mock_calls
|
||||
assert len(calls) == 1
|
||||
await async_push_update(hass, device, _update_params(params, state=0))
|
||||
device.fan_set_state.reset_mock()
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.state == STATE_OFF
|
||||
assert state.attributes[ATTR_PERCENTAGE] == 50
|
||||
|
||||
|
||||
async def test_fan_set_direction(hass: HomeAssistant) -> None:
|
||||
"""Test setting the fan direction."""
|
||||
device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN)
|
||||
|
||||
params = INITIAL_PARAMS.copy()
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_SET_DIRECTION,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_DIRECTION: DIRECTION_REVERSE},
|
||||
blocking=True,
|
||||
)
|
||||
calls = device.fan_set_state.mock_calls
|
||||
assert len(calls) == 1
|
||||
args = calls[0][2]
|
||||
assert args == {"reverse": 1}
|
||||
await async_push_update(hass, device, _update_params(params, **args))
|
||||
device.fan_set_state.reset_mock()
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.state == STATE_OFF
|
||||
assert state.attributes[ATTR_DIRECTION] == DIRECTION_REVERSE
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_SET_DIRECTION,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_DIRECTION: DIRECTION_FORWARD},
|
||||
blocking=True,
|
||||
)
|
||||
calls = device.fan_set_state.mock_calls
|
||||
assert len(calls) == 1
|
||||
args = calls[0][2]
|
||||
assert args == {"reverse": 0}
|
||||
await async_push_update(hass, device, _update_params(params, **args))
|
||||
device.fan_set_state.reset_mock()
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.state == STATE_OFF
|
||||
assert state.attributes[ATTR_DIRECTION] == DIRECTION_FORWARD
|
Loading…
x
Reference in New Issue
Block a user