Add zwave_js fan preset modes and enable them for Inovelli LZW36 (#60947)

* Rework fan data templates to support preset modes, and define data for the Inovelli LZW36

* Add tests. Add dispatching to async_set_preset_mode in async_turn_on.

* Add a test case for invalid preset modes

* Remove unintended merge artifact

* Fix indentation

* Fix merge error

* rm blank line

* Add tests for invalid fan config data, and fix an issue where this prevented the node from being added.

* Fix tests and improve error handling
This commit is contained in:
Michael Kowalchuk 2022-03-18 19:56:05 -07:00 committed by GitHub
parent f9dcf5afa2
commit e0b577f8bd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 350 additions and 116 deletions

View file

@ -49,10 +49,11 @@ from homeassistant.helpers.device_registry import DeviceEntry
from .const import LOGGER
from .discovery_data_template import (
BaseDiscoverySchemaDataTemplate,
ConfigurableFanSpeedDataTemplate,
ConfigurableFanValueMappingDataTemplate,
CoverTiltDataTemplate,
DynamicCurrentTempClimateDataTemplate,
FixedFanSpeedDataTemplate,
FanValueMapping,
FixedFanValueMappingDataTemplate,
NumericSensorDataTemplate,
ZwaveValueID,
)
@ -239,25 +240,25 @@ DISCOVERY_SCHEMAS = [
# GE/Jasco - In-Wall Smart Fan Control - 12730 / ZW4002
ZWaveDiscoverySchema(
platform="fan",
hint="configured_fan_speed",
hint="has_fan_value_mapping",
manufacturer_id={0x0063},
product_id={0x3034},
product_type={0x4944},
primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
data_template=FixedFanSpeedDataTemplate(
speeds=[33, 67, 99],
data_template=FixedFanValueMappingDataTemplate(
FanValueMapping(speeds=[(1, 33), (34, 67), (68, 99)]),
),
),
# GE/Jasco - In-Wall Smart Fan Control - 14287 / ZW4002
ZWaveDiscoverySchema(
platform="fan",
hint="configured_fan_speed",
hint="has_fan_value_mapping",
manufacturer_id={0x0063},
product_id={0x3131},
product_type={0x4944},
primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
data_template=FixedFanSpeedDataTemplate(
speeds=[32, 66, 99],
data_template=FixedFanValueMappingDataTemplate(
FanValueMapping(speeds=[(1, 32), (33, 66), (67, 99)]),
),
),
# GE/Jasco - In-Wall Smart Fan Control - 14314 / ZW4002
@ -280,6 +281,7 @@ DISCOVERY_SCHEMAS = [
# The fan is endpoint 2, the light is endpoint 1.
ZWaveDiscoverySchema(
platform="fan",
hint="has_fan_value_mapping",
manufacturer_id={0x031E},
product_id={0x0001},
product_type={0x000E},
@ -289,20 +291,28 @@ DISCOVERY_SCHEMAS = [
property={CURRENT_VALUE_PROPERTY},
type={"number"},
),
data_template=FixedFanValueMappingDataTemplate(
FanValueMapping(
presets={1: "breeze"}, speeds=[(2, 33), (34, 66), (67, 99)]
),
),
),
# HomeSeer HS-FC200+
ZWaveDiscoverySchema(
platform="fan",
hint="configured_fan_speed",
hint="has_fan_value_mapping",
manufacturer_id={0x000C},
product_id={0x0001},
product_type={0x0203},
primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
data_template=ConfigurableFanSpeedDataTemplate(
data_template=ConfigurableFanValueMappingDataTemplate(
configuration_option=ZwaveValueID(
5, CommandClass.CONFIGURATION, endpoint=0
),
configuration_value_to_speeds={0: [33, 66, 99], 1: [24, 49, 74, 99]},
configuration_value_to_fan_value_mapping={
0: FanValueMapping(speeds=[(1, 33), (34, 66), (67, 99)]),
1: FanValueMapping(speeds=[(1, 24), (25, 49), (50, 74), (75, 99)]),
},
),
),
# Fibaro Shutter Fibaro FGR222

View file

@ -432,27 +432,11 @@ class CoverTiltDataTemplate(BaseDiscoverySchemaDataTemplate, TiltValueMix):
@dataclass
class FanSpeedDataTemplate:
"""Mixin to define get_speed_config."""
class FanValueMapping:
"""Data class to represent how a fan's values map to features."""
def get_speed_config(self, resolved_data: dict[str, Any]) -> list[int] | None:
"""
Get the fan speed configuration for this device.
Values should indicate the highest allowed device setting for each
actual speed, and should be sorted in ascending order.
Empty lists are not permissible.
"""
raise NotImplementedError
@dataclass
class ConfigurableFanSpeedValueMix:
"""Mixin data class for defining configurable fan speeds."""
configuration_option: ZwaveValueID
configuration_value_to_speeds: dict[int, list[int]]
presets: dict[int, str] = field(default_factory=dict)
speeds: list[tuple[int, int]] = field(default_factory=list)
def __post_init__(self) -> None:
"""
@ -461,14 +445,36 @@ class ConfigurableFanSpeedValueMix:
These inputs are hardcoded in `discovery.py`, so these checks should
only fail due to developer error.
"""
for speeds in self.configuration_value_to_speeds.values():
assert len(speeds) > 0
assert sorted(speeds) == speeds
assert len(self.speeds) > 0, "At least one speed must be specified"
for speed_range in self.speeds:
(low, high) = speed_range
assert high >= low, "Speed range values must be ordered"
@dataclass
class ConfigurableFanSpeedDataTemplate(
BaseDiscoverySchemaDataTemplate, FanSpeedDataTemplate, ConfigurableFanSpeedValueMix
class FanValueMappingDataTemplate:
"""Mixin to define `get_fan_value_mapping`."""
def get_fan_value_mapping(
self, resolved_data: dict[str, Any]
) -> FanValueMapping | None:
"""Get the value mappings for this device."""
raise NotImplementedError
@dataclass
class ConfigurableFanValueMappingValueMix:
"""Mixin data class for defining fan properties that change based on a device configuration option."""
configuration_option: ZwaveValueID
configuration_value_to_fan_value_mapping: dict[int, FanValueMapping]
@dataclass
class ConfigurableFanValueMappingDataTemplate(
BaseDiscoverySchemaDataTemplate,
FanValueMappingDataTemplate,
ConfigurableFanValueMappingValueMix,
):
"""
Gets fan speeds based on a configuration value.
@ -476,22 +482,23 @@ class ConfigurableFanSpeedDataTemplate(
Example:
ZWaveDiscoverySchema(
platform="fan",
hint="configured_fan_speed",
hint="has_fan_value_mapping",
...
data_template=ConfigurableFanSpeedDataTemplate(
data_template=ConfigurableFanValueMappingDataTemplate(
configuration_option=ZwaveValueID(
5, CommandClass.CONFIGURATION, endpoint=0
),
configuration_value_to_speeds={0: [32, 65, 99], 1: [24, 49, 74, 99]},
configuration_value_to_fan_value_mapping={
0: FanValueMapping(speeds=[(1,33), (34,66), (67,99)]),
1: FanValueMapping(speeds=[(1,24), (25,49), (50,74), (75,99)]),
},
),
),
`configuration_option` is a reference to the setting that determines how
many speeds are supported.
`configuration_option` is a reference to the setting that determines which
value mapping to use (e.g., 3 speeds or 4 speeds).
`configuration_value_to_speeds` maps the values from `configuration_option`
to a list of speeds. The specified speeds indicate the maximum setting on
the underlying switch for each actual speed.
`configuration_value_to_fan_value_mapping` maps the values from
`configuration_option` to the value mapping object.
"""
def resolve_data(self, value: ZwaveValue) -> dict[str, ZwaveConfigurationValue]:
@ -507,64 +514,61 @@ class ConfigurableFanSpeedDataTemplate(
resolved_data["configuration_value"],
]
def get_speed_config(
def get_fan_value_mapping(
self, resolved_data: dict[str, ZwaveConfigurationValue]
) -> list[int] | None:
"""Get current speed configuration from resolved data."""
) -> FanValueMapping | None:
"""Get current fan properties from resolved data."""
zwave_value: ZwaveValue = resolved_data["configuration_value"]
if zwave_value is None:
_LOGGER.warning("Unable to read device configuration value")
return None
if zwave_value.value is None:
_LOGGER.warning("Unable to read fan speed configuration value")
_LOGGER.warning("Fan configuration value is missing")
return None
speed_config = self.configuration_value_to_speeds.get(zwave_value.value)
if speed_config is None:
_LOGGER.warning("Unrecognized speed configuration value")
fan_value_mapping = self.configuration_value_to_fan_value_mapping.get(
zwave_value.value
)
if fan_value_mapping is None:
_LOGGER.warning("Unrecognized fan configuration value")
return None
return speed_config
return fan_value_mapping
@dataclass
class FixedFanSpeedValueMix:
class FixedFanValueMappingValueMix:
"""Mixin data class for defining supported fan speeds."""
speeds: list[int]
def __post_init__(self) -> None:
"""
Validate inputs.
These inputs are hardcoded in `discovery.py`, so these checks should
only fail due to developer error.
"""
assert len(self.speeds) > 0
assert sorted(self.speeds) == self.speeds
fan_value_mapping: FanValueMapping
@dataclass
class FixedFanSpeedDataTemplate(
BaseDiscoverySchemaDataTemplate, FanSpeedDataTemplate, FixedFanSpeedValueMix
class FixedFanValueMappingDataTemplate(
BaseDiscoverySchemaDataTemplate,
FanValueMappingDataTemplate,
FixedFanValueMappingValueMix,
):
"""
Specifies a fixed set of fan speeds.
Specifies a fixed set of properties for a fan.
Example:
ZWaveDiscoverySchema(
platform="fan",
hint="configured_fan_speed",
hint="has_fan_value_mapping",
...
data_template=FixedFanSpeedDataTemplate(
speeds=[32,65,99]
data_template=FixedFanValueMappingDataTemplate(
config=FanValueMapping(
speeds=[(1, 32), (33, 65), (66, 99)]
)
),
),
`speeds` indicates the maximum setting on the underlying fan controller
for each actual speed.
"""
def get_speed_config(
def get_fan_value_mapping(
self, resolved_data: dict[str, ZwaveConfigurationValue]
) -> list[int]:
"""Get the fan speed configuration for this device."""
return self.speeds
) -> FanValueMapping:
"""Get the fan properties for this device."""
return self.fan_value_mapping

View file

@ -17,6 +17,7 @@ from homeassistant.components.fan import (
SUPPORT_PRESET_MODE,
SUPPORT_SET_SPEED,
FanEntity,
NotValidPresetModeError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
@ -31,12 +32,10 @@ from homeassistant.util.percentage import (
from .const import DATA_CLIENT, DOMAIN
from .discovery import ZwaveDiscoveryInfo
from .discovery_data_template import FanSpeedDataTemplate
from .discovery_data_template import FanValueMapping, FanValueMappingDataTemplate
from .entity import ZWaveBaseEntity
from .helpers import get_value_of_zwave_value
SUPPORTED_FEATURES = SUPPORT_SET_SPEED
DEFAULT_SPEED_RANGE = (1, 99) # off is not included
ATTR_FAN_STATE = "fan_state"
@ -54,8 +53,8 @@ async def async_setup_entry(
def async_add_fan(info: ZwaveDiscoveryInfo) -> None:
"""Add Z-Wave fan."""
entities: list[ZWaveBaseEntity] = []
if info.platform_hint == "configured_fan_speed":
entities.append(ConfiguredSpeedRangeZwaveFan(config_entry, client, info))
if info.platform_hint == "has_fan_value_mapping":
entities.append(ValueMappingZwaveFan(config_entry, client, info))
elif info.platform_hint == "thermostat_fan":
entities.append(ZwaveThermostatFan(config_entry, client, info))
else:
@ -100,11 +99,13 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity):
**kwargs: Any,
) -> None:
"""Turn the device on."""
if percentage is None:
if percentage is not None:
await self.async_set_percentage(percentage)
elif preset_mode is not None:
await self.async_set_preset_mode(preset_mode)
else:
# Value 255 tells device to return to previous value
await self.info.node.async_set_value(self._target_value, 255)
else:
await self.async_set_percentage(percentage)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
@ -141,11 +142,11 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity):
@property
def supported_features(self) -> int:
"""Flag supported features."""
return SUPPORTED_FEATURES
return SUPPORT_SET_SPEED
class ConfiguredSpeedRangeZwaveFan(ZwaveFan):
"""A Zwave fan with a configured speed range (e.g., 1-24 is low)."""
class ValueMappingZwaveFan(ZwaveFan):
"""A Zwave fan with a value mapping data (e.g., 1-24 is low)."""
def __init__(
self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo
@ -153,7 +154,7 @@ class ConfiguredSpeedRangeZwaveFan(ZwaveFan):
"""Initialize the fan."""
super().__init__(config_entry, client, info)
self.data_template = cast(
FanSpeedDataTemplate, self.info.platform_data_template
FanValueMappingDataTemplate, self.info.platform_data_template
)
async def async_set_percentage(self, percentage: int) -> None:
@ -161,10 +162,21 @@ class ConfiguredSpeedRangeZwaveFan(ZwaveFan):
zwave_speed = self.percentage_to_zwave_speed(percentage)
await self.info.node.async_set_value(self._target_value, zwave_speed)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
for zwave_value, mapped_preset_mode in self.fan_value_mapping.presets.items():
if preset_mode == mapped_preset_mode:
await self.info.node.async_set_value(self._target_value, zwave_value)
return
raise NotValidPresetModeError(
f"The preset_mode {preset_mode} is not a valid preset_mode: {self.preset_modes}"
)
@property
def available(self) -> bool:
"""Return whether the entity is available."""
return super().available and self.has_speed_configuration
return super().available and self.has_fan_value_mapping
@property
def percentage(self) -> int | None:
@ -173,6 +185,9 @@ class ConfiguredSpeedRangeZwaveFan(ZwaveFan):
# guard missing value
return None
if self.preset_mode is not None:
return None
return self.zwave_speed_to_percentage(self.info.primary_value.value)
@property
@ -184,26 +199,51 @@ class ConfiguredSpeedRangeZwaveFan(ZwaveFan):
return 100 / self.speed_count
@property
def has_speed_configuration(self) -> bool:
"""Check if the speed configuration is valid."""
return self.data_template.get_speed_config(self.info.platform_data) is not None
def preset_modes(self) -> list[str]:
"""Return the available preset modes."""
if not self.has_fan_value_mapping:
return []
return list(self.fan_value_mapping.presets.values())
@property
def speed_configuration(self) -> list[int]:
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
return self.fan_value_mapping.presets.get(self.info.primary_value.value)
@property
def has_fan_value_mapping(self) -> bool:
"""Check if the speed configuration is valid."""
return (
self.data_template.get_fan_value_mapping(self.info.platform_data)
is not None
)
@property
def fan_value_mapping(self) -> FanValueMapping:
"""Return the speed configuration for this fan."""
speed_configuration = self.data_template.get_speed_config(
fan_value_mapping = self.data_template.get_fan_value_mapping(
self.info.platform_data
)
# Entity should be unavailable if this isn't set
assert speed_configuration is not None
assert fan_value_mapping is not None
return speed_configuration
return fan_value_mapping
@property
def speed_count(self) -> int:
"""Return the number of speeds the fan supports."""
return len(self.speed_configuration)
return len(self.fan_value_mapping.speeds)
@property
def supported_features(self) -> int:
"""Flag supported features."""
flags = SUPPORT_SET_SPEED
if self.has_fan_value_mapping and self.fan_value_mapping.presets:
flags |= SUPPORT_PRESET_MODE
return flags
def percentage_to_zwave_speed(self, percentage: int) -> int:
"""Map a percentage to a ZWave speed."""
@ -212,30 +252,46 @@ class ConfiguredSpeedRangeZwaveFan(ZwaveFan):
# Since the percentage steps are computed with rounding, we have to
# search to find the appropriate speed.
for speed_limit in self.speed_configuration:
step_percentage = self.zwave_speed_to_percentage(speed_limit)
for speed_range in self.fan_value_mapping.speeds:
(_, max_speed) = speed_range
step_percentage = self.zwave_speed_to_percentage(max_speed)
# zwave_speed_to_percentage will only return None if
# `self.fan_value_mapping.speeds` doesn't contain the
# specified speed. This can't happen here, because
# the input is coming from the same data structure.
assert step_percentage
if percentage <= step_percentage:
return speed_limit
return max_speed
# This shouldn't actually happen; the last entry in
# `self.speed_configuration` should map to 100%.
return self.speed_configuration[-1]
# `self.fan_value_mapping.speeds` should map to 100%.
(_, last_max_speed) = self.fan_value_mapping.speeds[-1]
return last_max_speed
def zwave_speed_to_percentage(self, zwave_speed: int) -> int:
"""Convert a Zwave speed to a percentage."""
def zwave_speed_to_percentage(self, zwave_speed: int) -> int | None:
"""
Convert a Zwave speed to a percentage.
This method may return None if the device's value mapping doesn't cover
the specified Z-Wave speed.
"""
if zwave_speed == 0:
return 0
percentage = 0.0
for speed_limit in self.speed_configuration:
for speed_range in self.fan_value_mapping.speeds:
(min_speed, max_speed) = speed_range
percentage += self.percentage_step
if zwave_speed <= speed_limit:
break
if min_speed <= zwave_speed <= max_speed:
# This choice of rounding function is to provide consistency with how
# the UI handles steps e.g., for a 3-speed fan, you get steps at 33,
# 67, and 100.
return round(percentage)
# This choice of rounding function is to provide consistency with how
# the UI handles steps e.g., for a 3-speed fan, you get steps at 33,
# 67, and 100.
return round(percentage)
# The specified Z-Wave device value doesn't map to a defined speed.
return None
class ZwaveThermostatFan(ZWaveBaseEntity, FanEntity):

View file

@ -1,10 +1,12 @@
"""Test the Z-Wave JS fan platform."""
import copy
import math
import pytest
from voluptuous.error import MultipleInvalid
from zwave_js_server.const import CommandClass
from zwave_js_server.event import Event
from zwave_js_server.model.node import Node
from homeassistant.components.fan import (
ATTR_PERCENTAGE,
@ -14,6 +16,7 @@ from homeassistant.components.fan import (
DOMAIN as FAN_DOMAIN,
SERVICE_SET_PRESET_MODE,
SUPPORT_PRESET_MODE,
NotValidPresetModeError,
)
from homeassistant.components.zwave_js.fan import ATTR_FAN_STATE
from homeassistant.const import (
@ -23,6 +26,7 @@ from homeassistant.const import (
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.exceptions import HomeAssistantError
@ -63,7 +67,6 @@ async def test_generic_fan(hass, client, fan_generic, integration):
"type": "number",
"readable": True,
"writeable": True,
"label": "Target value",
},
}
assert args["value"] == 66
@ -106,7 +109,6 @@ async def test_generic_fan(hass, client, fan_generic, integration):
"type": "number",
"readable": True,
"writeable": True,
"label": "Target value",
},
}
assert args["value"] == 255
@ -138,7 +140,6 @@ async def test_generic_fan(hass, client, fan_generic, integration):
"type": "number",
"readable": True,
"writeable": True,
"label": "Target value",
},
}
assert args["value"] == 0
@ -259,6 +260,65 @@ async def test_configurable_speeds_fan(hass, client, hs_fc200, integration):
state = hass.states.get(entity_id)
assert math.isclose(state.attributes[ATTR_PERCENTAGE_STEP], 33.3333, rel_tol=1e-3)
assert state.attributes[ATTR_PRESET_MODES] == []
async def test_configurable_speeds_fan_with_missing_config_value(
hass, client, hs_fc200_state, integration
):
"""Test a fan entity with configurable speeds."""
entity_id = "fan.scene_capable_fan_control_switch"
# Attach a modified version of the node with a bad config
bad_node_data = copy.deepcopy(hs_fc200_state)
fan_type_value = next(
(
v
for v in bad_node_data["values"]
if v["endpoint"] == 0 and v["commandClass"] == 112 and v["property"] == 5
),
None,
)
assert fan_type_value is not None
bad_node_data["values"].remove(fan_type_value)
node = Node(client, bad_node_data)
event = {"node": node}
client.driver.controller.emit("node added", event)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.state == STATE_UNAVAILABLE
async def test_configurable_speeds_fan_with_bad_config_value(
hass, client, hs_fc200_state, integration
):
"""Test a fan entity with configurable speeds."""
entity_id = "fan.scene_capable_fan_control_switch"
# Attach a modified version of the node with a bad config
bad_node_data = copy.deepcopy(hs_fc200_state)
fan_type_value = next(
(
v
for v in bad_node_data["values"]
if v["endpoint"] == 0 and v["commandClass"] == 112 and v["property"] == 5
),
None,
)
assert fan_type_value is not None
# 42 is not a valid configuration option with this device
fan_type_value["value"] = 42
node = Node(client, bad_node_data)
event = {"node": node}
client.driver.controller.emit("node added", event)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.state == STATE_UNAVAILABLE
async def test_fixed_speeds_fan(hass, client, ge_12730, integration):
@ -325,6 +385,110 @@ async def test_fixed_speeds_fan(hass, client, ge_12730, integration):
state = hass.states.get(entity_id)
assert math.isclose(state.attributes[ATTR_PERCENTAGE_STEP], 33.3333, rel_tol=1e-3)
assert state.attributes[ATTR_PRESET_MODES] == []
async def test_inovelli_lzw36(hass, client, inovelli_lzw36, integration):
"""Test an LZW36."""
node = inovelli_lzw36
node_id = 19
entity_id = "fan.family_room_combo_2"
async def get_zwave_speed_from_percentage(percentage):
"""Set the fan to a particular percentage and get the resulting Zwave speed."""
client.async_send_command.reset_mock()
await hass.services.async_call(
"fan",
"turn_on",
{"entity_id": entity_id, "percentage": percentage},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 1
args = client.async_send_command.call_args[0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node_id
return args["value"]
async def set_zwave_speed(zwave_speed):
"""Set the underlying device speed."""
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": node_id,
"args": {
"commandClassName": "Multilevel Switch",
"commandClass": 38,
"endpoint": 2,
"property": "currentValue",
"newValue": zwave_speed,
"prevValue": 0,
"propertyName": "currentValue",
},
},
)
node.receive_event(event)
async def get_percentage_from_zwave_speed(zwave_speed):
"""Set the underlying device speed and get the resulting percentage."""
await set_zwave_speed(zwave_speed)
state = hass.states.get(entity_id)
return state.attributes[ATTR_PERCENTAGE]
# This device has the speeds:
# low = 2-33, med = 34-66, high = 67-99
percentages_to_zwave_speeds = [
[[0], [0]],
[range(1, 34), range(2, 34)],
[range(34, 68), range(34, 67)],
[range(68, 101), range(67, 100)],
]
for percentages, zwave_speeds in percentages_to_zwave_speeds:
for percentage in percentages:
actual_zwave_speed = await get_zwave_speed_from_percentage(percentage)
assert actual_zwave_speed in zwave_speeds
for zwave_speed in zwave_speeds:
actual_percentage = await get_percentage_from_zwave_speed(zwave_speed)
assert actual_percentage in percentages
# Check static entity properties
state = hass.states.get(entity_id)
assert math.isclose(state.attributes[ATTR_PERCENTAGE_STEP], 33.3333, rel_tol=1e-3)
assert state.attributes[ATTR_PRESET_MODES] == ["breeze"]
# This device has one preset, where a device level of "1" is the
# "breeze" mode
await set_zwave_speed(1)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == "breeze"
assert state.attributes[ATTR_PERCENTAGE] is None
client.async_send_command.reset_mock()
await hass.services.async_call(
"fan",
"turn_on",
{"entity_id": entity_id, "preset_mode": "breeze"},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 1
args = client.async_send_command.call_args[0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node_id
assert args["value"] == 1
client.async_send_command.reset_mock()
with pytest.raises(NotValidPresetModeError):
await hass.services.async_call(
"fan",
"turn_on",
{"entity_id": entity_id, "preset_mode": "wheeze"},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 0
async def test_thermostat_fan(hass, client, climate_adc_t3000, integration):