Fan support in WiZ (#146440)

This commit is contained in:
Artur Pragacz 2025-07-15 11:14:47 +02:00 committed by GitHub
parent 4f938d032d
commit db45f46c8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 462 additions and 3 deletions

4
CODEOWNERS generated
View File

@ -1758,8 +1758,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/wirelesstag/ @sergeymaysak /homeassistant/components/wirelesstag/ @sergeymaysak
/homeassistant/components/withings/ @joostlek /homeassistant/components/withings/ @joostlek
/tests/components/withings/ @joostlek /tests/components/withings/ @joostlek
/homeassistant/components/wiz/ @sbidy /homeassistant/components/wiz/ @sbidy @arturpragacz
/tests/components/wiz/ @sbidy /tests/components/wiz/ @sbidy @arturpragacz
/homeassistant/components/wled/ @frenck /homeassistant/components/wled/ @frenck
/tests/components/wled/ @frenck /tests/components/wled/ @frenck
/homeassistant/components/wmspro/ @mback2k /homeassistant/components/wmspro/ @mback2k

View File

@ -37,6 +37,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [ PLATFORMS = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.FAN,
Platform.LIGHT, Platform.LIGHT,
Platform.NUMBER, Platform.NUMBER,
Platform.SENSOR, Platform.SENSOR,

View 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()

View File

@ -1,7 +1,7 @@
{ {
"domain": "wiz", "domain": "wiz",
"name": "WiZ", "name": "WiZ",
"codeowners": ["@sbidy"], "codeowners": ["@sbidy", "@arturpragacz"],
"config_flow": true, "config_flow": true,
"dependencies": ["network"], "dependencies": ["network"],
"dhcp": [ "dhcp": [

View File

@ -33,6 +33,10 @@ FAKE_STATE = PilotParser(
"c": 0, "c": 0,
"w": 0, "w": 0,
"dimming": 100, "dimming": 100,
"fanState": 0,
"fanMode": 1,
"fanSpeed": 1,
"fanRevrs": 0,
} }
) )
FAKE_IP = "1.1.1.1" FAKE_IP = "1.1.1.1"
@ -173,6 +177,25 @@ FAKE_OLD_FIRMWARE_DIMMABLE_BULB = BulbType(
white_channels=1, white_channels=1,
white_to_color_ratio=80, 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: async def setup_integration(hass: HomeAssistant) -> MockConfigEntry:
@ -220,6 +243,9 @@ def _mocked_wizlight(
bulb.async_close = AsyncMock() bulb.async_close = AsyncMock()
bulb.set_speed = AsyncMock() bulb.set_speed = AsyncMock()
bulb.set_ratio = AsyncMock() bulb.set_ratio = AsyncMock()
bulb.fan_set_state = AsyncMock()
bulb.fan_turn_on = AsyncMock()
bulb.fan_turn_off = AsyncMock()
bulb.diagnostics = { bulb.diagnostics = {
"mocked": "mocked", "mocked": "mocked",
"roomId": 123, "roomId": 123,

View 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',
})
# ---

View 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