From 15bf4dae9bb76cec1d7636ef96a3bf8b94c5557b Mon Sep 17 00:00:00 2001 From: Michael Kowalchuk Date: Sun, 28 Nov 2021 23:27:32 -0800 Subject: [PATCH] Add zwave_js speed configurations for GE/Jasco 12730 and 14287 fans (#60517) --- .../components/zwave_js/discovery.py | 29 ++++- .../zwave_js/discovery_data_template.py | 45 ++++++++ tests/components/zwave_js/conftest.py | 12 +- ...trol_state.json => fan_generic_state.json} | 18 +-- tests/components/zwave_js/test_fan.py | 109 ++++++++++++++---- 5 files changed, 176 insertions(+), 37 deletions(-) rename tests/components/zwave_js/fixtures/{in_wall_smart_fan_control_state.json => fan_generic_state.json} (96%) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 5620afb9714..6f2d83f99c3 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -47,6 +47,7 @@ from .discovery_data_template import ( ConfigurableFanSpeedDataTemplate, CoverTiltDataTemplate, DynamicCurrentTempClimateDataTemplate, + FixedFanSpeedDataTemplate, NumericSensorDataTemplate, ZwaveValueID, ) @@ -230,11 +231,35 @@ DISCOVERY_SCHEMAS = [ product_type={0x4944}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, ), - # GE/Jasco fan controllers using switch multilevel CC + # GE/Jasco - In-Wall Smart Fan Control - 12730 / ZW4002 + ZWaveDiscoverySchema( + platform="fan", + hint="configured_fan_speed", + manufacturer_id={0x0063}, + product_id={0x3034}, + product_type={0x4944}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + data_template=FixedFanSpeedDataTemplate( + speeds=[33, 67, 99], + ), + ), + # GE/Jasco - In-Wall Smart Fan Control - 14287 / ZW4002 + ZWaveDiscoverySchema( + platform="fan", + hint="configured_fan_speed", + manufacturer_id={0x0063}, + product_id={0x3131}, + product_type={0x4944}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + data_template=FixedFanSpeedDataTemplate( + speeds=[32, 66, 99], + ), + ), + # GE/Jasco - In-Wall Smart Fan Control - 14314 / ZW4002 ZWaveDiscoverySchema( platform="fan", manufacturer_id={0x0063}, - product_id={0x3034, 0x3131, 0x3138}, + product_id={0x3138}, product_type={0x4944}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, ), diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 3c5ef220ae9..3e7db7cdcd9 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -523,3 +523,48 @@ class ConfigurableFanSpeedDataTemplate( return None return speed_config + + +@dataclass +class FixedFanSpeedValueMix: + """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 + + +@dataclass +class FixedFanSpeedDataTemplate( + BaseDiscoverySchemaDataTemplate, FanSpeedDataTemplate, FixedFanSpeedValueMix +): + """ + Specifies a fixed set of fan speeds. + + Example: + ZWaveDiscoverySchema( + platform="fan", + hint="configured_fan_speed", + ... + data_template=FixedFanSpeedDataTemplate( + speeds=[32,65,99] + ), + ), + + `speeds` indicates the maximum setting on the underlying fan controller + for each actual speed. + """ + + def get_speed_config( + self, resolved_data: dict[str, ZwaveConfigurationValue] + ) -> list[int]: + """Get the fan speed configuration for this device.""" + return self.speeds diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 6d75f899e4c..e2db9fe7a6b 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -326,10 +326,10 @@ def window_cover_state_fixture(): return json.loads(load_fixture("zwave_js/chain_actuator_zws12_state.json")) -@pytest.fixture(name="in_wall_smart_fan_control_state", scope="session") -def in_wall_smart_fan_control_state_fixture(): +@pytest.fixture(name="fan_generic_state", scope="session") +def fan_generic_state_fixture(): """Load the fan node state fixture data.""" - return json.loads(load_fixture("zwave_js/in_wall_smart_fan_control_state.json")) + return json.loads(load_fixture("zwave_js/fan_generic_state.json")) @pytest.fixture(name="hs_fc200_state", scope="session") @@ -695,10 +695,10 @@ def window_cover_fixture(client, chain_actuator_zws12_state): return node -@pytest.fixture(name="in_wall_smart_fan_control") -def in_wall_smart_fan_control_fixture(client, in_wall_smart_fan_control_state): +@pytest.fixture(name="fan_generic") +def fan_generic_fixture(client, fan_generic_state): """Mock a fan node.""" - node = Node(client, copy.deepcopy(in_wall_smart_fan_control_state)) + node = Node(client, copy.deepcopy(fan_generic_state)) client.driver.controller.nodes[node.node_id] = node return node diff --git a/tests/components/zwave_js/fixtures/in_wall_smart_fan_control_state.json b/tests/components/zwave_js/fixtures/fan_generic_state.json similarity index 96% rename from tests/components/zwave_js/fixtures/in_wall_smart_fan_control_state.json rename to tests/components/zwave_js/fixtures/fan_generic_state.json index 74467664955..1f4f55dd220 100644 --- a/tests/components/zwave_js/fixtures/in_wall_smart_fan_control_state.json +++ b/tests/components/zwave_js/fixtures/fan_generic_state.json @@ -19,22 +19,22 @@ "isSecure": false, "version": 4, "isBeaming": true, - "manufacturerId": 99, - "productId": 12593, - "productType": 18756, + "manufacturerId": 4919, + "productId": 4919, + "productType": 4919, "firmwareVersion": "5.22", "zwavePlusVersion": 1, "nodeType": 0, "roleType": 5, "deviceConfig": { - "manufacturerId": 99, - "manufacturer": "GE/Jasco", + "manufacturerId": 4919, + "manufacturer": "Unknown", "label": "ZW4002", - "description": "In-Wall Smart Fan Control", + "description": "Generic Fan Controller", "devices": [ { - "productType": "0x4944", - "productId": "0x3131" + "productType": "0x1337", + "productId": "0x1337" } ], "firmwareVersion": { @@ -349,4 +349,4 @@ } } ] - } \ No newline at end of file + } diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index f5d8a0b89e3..74ad642127f 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -11,14 +11,12 @@ from homeassistant.components.fan import ( SPEED_MEDIUM, ) -STANDARD_FAN_ENTITY = "fan.in_wall_smart_fan_control" -HS_FAN_ENTITY = "fan.scene_capable_fan_control_switch" - -async def test_standard_fan(hass, client, in_wall_smart_fan_control, integration): - """Test the fan entity.""" - node = in_wall_smart_fan_control - state = hass.states.get(STANDARD_FAN_ENTITY) +async def test_generic_fan(hass, client, fan_generic, integration): + """Test the fan entity for a generic fan that lacks specific speed configuration.""" + node = fan_generic + entity_id = "fan.generic_fan_controller" + state = hass.states.get(entity_id) assert state assert state.state == "off" @@ -27,7 +25,7 @@ async def test_standard_fan(hass, client, in_wall_smart_fan_control, integration await hass.services.async_call( "fan", "turn_on", - {"entity_id": STANDARD_FAN_ENTITY, "speed": SPEED_MEDIUM}, + {"entity_id": entity_id, "speed": SPEED_MEDIUM}, blocking=True, ) @@ -60,7 +58,7 @@ async def test_standard_fan(hass, client, in_wall_smart_fan_control, integration await hass.services.async_call( "fan", "set_speed", - {"entity_id": STANDARD_FAN_ENTITY, "speed": 99}, + {"entity_id": entity_id, "speed": 99}, blocking=True, ) @@ -70,7 +68,7 @@ async def test_standard_fan(hass, client, in_wall_smart_fan_control, integration await hass.services.async_call( "fan", "turn_on", - {"entity_id": STANDARD_FAN_ENTITY}, + {"entity_id": entity_id}, blocking=True, ) @@ -102,7 +100,7 @@ async def test_standard_fan(hass, client, in_wall_smart_fan_control, integration await hass.services.async_call( "fan", "turn_off", - {"entity_id": STANDARD_FAN_ENTITY}, + {"entity_id": entity_id}, blocking=True, ) @@ -150,7 +148,7 @@ async def test_standard_fan(hass, client, in_wall_smart_fan_control, integration ) node.receive_event(event) - state = hass.states.get(STANDARD_FAN_ENTITY) + state = hass.states.get(entity_id) assert state.state == "on" assert state.attributes[ATTR_SPEED] == "high" @@ -175,13 +173,16 @@ async def test_standard_fan(hass, client, in_wall_smart_fan_control, integration ) node.receive_event(event) - state = hass.states.get(STANDARD_FAN_ENTITY) + state = hass.states.get(entity_id) assert state.state == "off" assert state.attributes[ATTR_SPEED] == "off" -async def test_hs_fan(hass, client, hs_fc200, integration): +async def test_configurable_speeds_fan(hass, client, hs_fc200, integration): """Test a fan entity with configurable speeds.""" + node = hs_fc200 + node_id = 39 + entity_id = "fan.scene_capable_fan_control_switch" async def get_zwave_speed_from_percentage(percentage): """Set the fan to a particular percentage and get the resulting Zwave speed.""" @@ -189,14 +190,14 @@ async def test_hs_fan(hass, client, hs_fc200, integration): await hass.services.async_call( "fan", "turn_on", - {"entity_id": HS_FAN_ENTITY, "percentage": percentage}, + {"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"] == 39 + assert args["nodeId"] == node_id return args["value"] async def get_percentage_from_zwave_speed(zwave_speed): @@ -206,7 +207,7 @@ async def test_hs_fan(hass, client, hs_fc200, integration): data={ "source": "node", "event": "value updated", - "nodeId": 39, + "nodeId": node_id, "args": { "commandClassName": "Multilevel Switch", "commandClass": 38, @@ -218,10 +219,12 @@ async def test_hs_fan(hass, client, hs_fc200, integration): }, }, ) - hs_fc200.receive_event(event) - state = hass.states.get(HS_FAN_ENTITY) + node.receive_event(event) + state = hass.states.get(entity_id) return state.attributes[ATTR_PERCENTAGE] + # In 3-speed mode, the speeds are: + # low = 1-33, med=34-66, high=67-99 percentages_to_zwave_speeds = [ [[0], [0]], [range(1, 34), range(1, 34)], @@ -237,5 +240,71 @@ async def test_hs_fan(hass, client, hs_fc200, integration): actual_percentage = await get_percentage_from_zwave_speed(zwave_speed) assert actual_percentage in percentages - state = hass.states.get(HS_FAN_ENTITY) + state = hass.states.get(entity_id) + assert math.isclose(state.attributes[ATTR_PERCENTAGE_STEP], 33.3333, rel_tol=1e-3) + + +async def test_fixed_speeds_fan(hass, client, ge_12730, integration): + """Test a fan entity with fixed speeds.""" + node = ge_12730 + node_id = 24 + entity_id = "fan.in_wall_smart_fan_control" + + 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 get_percentage_from_zwave_speed(zwave_speed): + """Set the underlying device speed and get the resulting percentage.""" + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node_id, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": zwave_speed, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + state = hass.states.get(entity_id) + return state.attributes[ATTR_PERCENTAGE] + + # This device has the speeds: + # low = 1-33, med = 34-67, high = 68-99 + percentages_to_zwave_speeds = [ + [[0], [0]], + [range(1, 34), range(1, 34)], + [range(34, 68), range(34, 68)], + [range(68, 101), range(68, 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 + + state = hass.states.get(entity_id) assert math.isclose(state.attributes[ATTR_PERCENTAGE_STEP], 33.3333, rel_tol=1e-3)