Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions homeassistant/components/zwave_js/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
ConfigurableFanSpeedDataTemplate,
CoverTiltDataTemplate,
DynamicCurrentTempClimateDataTemplate,
FixedFanSpeedDataTemplate,
NumericSensorDataTemplate,
ZwaveValueID,
)
Expand Down Expand Up @@ -230,11 +231,35 @@ def get_config_parameter_discovery_schema(
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,
),
Expand Down
45 changes: 45 additions & 0 deletions homeassistant/components/zwave_js/discovery_data_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,3 +523,48 @@ def get_speed_config(
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
12 changes: 6 additions & 6 deletions tests/components/zwave_js/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -349,4 +349,4 @@
}
}
]
}
}
109 changes: 89 additions & 20 deletions tests/components/zwave_js/test_fan.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,12 @@
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"
Expand All @@ -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,
)

Expand Down Expand Up @@ -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,
)

Expand All @@ -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,
)

Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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"

Expand All @@ -175,28 +173,31 @@ 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."""
client.async_send_command.reset_mock()
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):
Expand All @@ -206,7 +207,7 @@ async def get_percentage_from_zwave_speed(zwave_speed):
data={
"source": "node",
"event": "value updated",
"nodeId": 39,
"nodeId": node_id,
"args": {
"commandClassName": "Multilevel Switch",
"commandClass": 38,
Expand All @@ -218,10 +219,12 @@ async def get_percentage_from_zwave_speed(zwave_speed):
},
},
)
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)],
Expand All @@ -237,5 +240,71 @@ async def get_percentage_from_zwave_speed(zwave_speed):
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)