diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index e580833da9d..7d9c235c00a 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -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 diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 3e7db7cdcd9..bfcc1ed87a5 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -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 diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 585a72fc6de..21c45bbbf42 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -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): diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 2535377e9d3..140d9fb3d83 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -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):