Add zwave_js support for HeatIt Z-TRM2fx (#50317)

* Add zwave_js support for HeatIt Z-TRM2fx

* fix docstring

* use AwesomeVersion to support firmware version ranges

* add guard against empty firmware range

* switch guard approach to raise exception sooner

* make post init more generic

* Set up firmware range schema as AwesomeVersion during initialization

* Update homeassistant/components/zwave_js/discovery.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Allow min_ver and max_ver to be None

* fix docstring

* reduce import scope

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Raman Gupta 2021-05-09 15:28:35 -04:00 committed by GitHub
parent ba31d7d1b4
commit 1b81849271
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 1610 additions and 4 deletions

View File

@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Generator from collections.abc import Generator
from dataclasses import dataclass from dataclasses import asdict, dataclass, field
from typing import Any from typing import Any
from awesomeversion import AwesomeVersion
from zwave_js_server.const import THERMOSTAT_CURRENT_TEMP_PROPERTY, CommandClass from zwave_js_server.const import THERMOSTAT_CURRENT_TEMP_PROPERTY, CommandClass
from zwave_js_server.model.device_class import DeviceClassItem from zwave_js_server.model.device_class import DeviceClassItem
from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.node import Node as ZwaveNode
@ -19,6 +20,33 @@ from .discovery_data_template import (
) )
class DataclassMustHaveAtLeastOne:
"""A dataclass that must have at least one input parameter that is not None."""
def __post_init__(self) -> None:
"""Post dataclass initialization."""
if all(val is None for val in asdict(self).values()):
raise ValueError("At least one input parameter must not be None")
@dataclass
class FirmwareVersionRange(DataclassMustHaveAtLeastOne):
"""Firmware version range dictionary."""
min: str | None = None
max: str | None = None
min_ver: AwesomeVersion | None = field(default=None, init=False)
max_ver: AwesomeVersion | None = field(default=None, init=False)
def __post_init__(self) -> None:
"""Post dataclass initialization."""
super().__post_init__()
if self.min:
self.min_ver = AwesomeVersion(self.min)
if self.max:
self.max_ver = AwesomeVersion(self.max)
@dataclass @dataclass
class ZwaveDiscoveryInfo: class ZwaveDiscoveryInfo:
"""Info discovered from (primary) ZWave Value to create entity.""" """Info discovered from (primary) ZWave Value to create entity."""
@ -42,7 +70,7 @@ class ZwaveDiscoveryInfo:
@dataclass @dataclass
class ZWaveValueDiscoverySchema: class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne):
"""Z-Wave Value discovery schema. """Z-Wave Value discovery schema.
The Z-Wave Value must match these conditions. The Z-Wave Value must match these conditions.
@ -89,6 +117,8 @@ class ZWaveDiscoverySchema:
product_id: set[int] | None = None product_id: set[int] | None = None
# [optional] the node's product_type must match ANY of these values # [optional] the node's product_type must match ANY of these values
product_type: set[int] | None = None product_type: set[int] | None = None
# [optional] the node's firmware_version must be within this range
firmware_version_range: FirmwareVersionRange | None = None
# [optional] the node's firmware_version must match ANY of these values # [optional] the node's firmware_version must match ANY of these values
firmware_version: set[str] | None = None firmware_version: set[str] | None = None
# [optional] the node's basic device class must match ANY of these values # [optional] the node's basic device class must match ANY of these values
@ -274,6 +304,42 @@ DISCOVERY_SCHEMAS = [
ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0),
), ),
), ),
# Heatit Z-TRM2fx
ZWaveDiscoverySchema(
platform="climate",
hint="dynamic_current_temp",
manufacturer_id={0x019B},
product_id={0x0202},
product_type={0x0003},
firmware_version_range=FirmwareVersionRange(min="3.0"),
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.THERMOSTAT_MODE},
property={"mode"},
type={"number"},
),
data_template=DynamicCurrentTempClimateDataTemplate(
{
# External Sensor
"A2": ZwaveValueID(
THERMOSTAT_CURRENT_TEMP_PROPERTY,
CommandClass.SENSOR_MULTILEVEL,
endpoint=2,
),
"A2F": ZwaveValueID(
THERMOSTAT_CURRENT_TEMP_PROPERTY,
CommandClass.SENSOR_MULTILEVEL,
endpoint=2,
),
# Floor sensor
"F": ZwaveValueID(
THERMOSTAT_CURRENT_TEMP_PROPERTY,
CommandClass.SENSOR_MULTILEVEL,
endpoint=3,
),
},
ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0),
),
),
# ====== START OF CONFIG PARAMETER SPECIFIC MAPPING SCHEMAS ======= # ====== START OF CONFIG PARAMETER SPECIFIC MAPPING SCHEMAS =======
# Door lock mode config parameter. Functionality equivalent to Notification CC # Door lock mode config parameter. Functionality equivalent to Notification CC
# list sensors. # list sensors.
@ -541,6 +607,21 @@ def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None
): ):
continue continue
# check firmware_version_range
if schema.firmware_version_range is not None and (
(
schema.firmware_version_range.min is not None
and schema.firmware_version_range.min_ver
> AwesomeVersion(value.node.firmware_version)
)
or (
schema.firmware_version_range.max is not None
and schema.firmware_version_range.max_ver
< AwesomeVersion(value.node.firmware_version)
)
):
continue
# check firmware_version # check firmware_version
if ( if (
schema.firmware_version is not None schema.firmware_version is not None

View File

@ -240,6 +240,12 @@ def climate_heatit_z_trm3_state_fixture():
return json.loads(load_fixture("zwave_js/climate_heatit_z_trm3_state.json")) return json.loads(load_fixture("zwave_js/climate_heatit_z_trm3_state.json"))
@pytest.fixture(name="climate_heatit_z_trm2fx_state", scope="session")
def climate_heatit_z_trm2fx_state_fixture():
"""Load the climate HEATIT Z-TRM2fx thermostat node state fixture data."""
return json.loads(load_fixture("zwave_js/climate_heatit_z_trm2fx_state.json"))
@pytest.fixture(name="nortek_thermostat_state", scope="session") @pytest.fixture(name="nortek_thermostat_state", scope="session")
def nortek_thermostat_state_fixture(): def nortek_thermostat_state_fixture():
"""Load the nortek thermostat node state fixture data.""" """Load the nortek thermostat node state fixture data."""
@ -484,6 +490,14 @@ def climate_heatit_z_trm3_fixture(client, climate_heatit_z_trm3_state):
return node return node
@pytest.fixture(name="climate_heatit_z_trm2fx")
def climate_heatit_z_trm2fx_fixture(client, climate_heatit_z_trm2fx_state):
"""Mock a climate radio HEATIT Z-TRM2fx node."""
node = Node(client, copy.deepcopy(climate_heatit_z_trm2fx_state))
client.driver.controller.nodes[node.node_id] = node
return node
@pytest.fixture(name="nortek_thermostat") @pytest.fixture(name="nortek_thermostat")
def nortek_thermostat_fixture(client, nortek_thermostat_state): def nortek_thermostat_fixture(client, nortek_thermostat_state):
"""Mock a nortek thermostat node.""" """Mock a nortek thermostat node."""

View File

@ -28,6 +28,7 @@ from homeassistant.components.climate.const import (
SERVICE_SET_PRESET_MODE, SERVICE_SET_PRESET_MODE,
SERVICE_SET_TEMPERATURE, SERVICE_SET_TEMPERATURE,
SUPPORT_FAN_MODE, SUPPORT_FAN_MODE,
SUPPORT_PRESET_MODE,
SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE,
SUPPORT_TARGET_TEMPERATURE_RANGE, SUPPORT_TARGET_TEMPERATURE_RANGE,
) )
@ -436,8 +437,10 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat
client.async_send_command_no_wait.reset_mock() client.async_send_command_no_wait.reset_mock()
async def test_thermostat_heatit(hass, client, climate_heatit_z_trm3, integration): async def test_thermostat_heatit_z_trm3(
"""Test a thermostat v2 command class entity.""" hass, client, climate_heatit_z_trm3, integration
):
"""Test a heatit Z-TRM3 entity."""
node = climate_heatit_z_trm3 node = climate_heatit_z_trm3
state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY) state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY)
@ -501,6 +504,53 @@ async def test_thermostat_heatit(hass, client, climate_heatit_z_trm3, integratio
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 25.5 assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 25.5
async def test_thermostat_heatit_z_trm2fx(
hass, client, climate_heatit_z_trm2fx, integration
):
"""Test a heatit Z-TRM2fx entity."""
node = climate_heatit_z_trm2fx
state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY)
assert state
assert state.state == HVAC_MODE_HEAT
assert state.attributes[ATTR_HVAC_MODES] == [
HVAC_MODE_OFF,
HVAC_MODE_HEAT,
HVAC_MODE_COOL,
]
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 28.8
assert state.attributes[ATTR_TEMPERATURE] == 29
assert (
state.attributes[ATTR_SUPPORTED_FEATURES]
== SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
)
assert state.attributes[ATTR_MIN_TEMP] == 7
assert state.attributes[ATTR_MAX_TEMP] == 35
# Try switching to external sensor
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": 24,
"args": {
"commandClassName": "Configuration",
"commandClass": 112,
"endpoint": 0,
"property": 2,
"propertyName": "Sensor mode",
"newValue": 4,
"prevValue": 2,
},
},
)
node.receive_event(event)
state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY)
assert state
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 0
async def test_thermostat_srt321_hrt4_zw(hass, client, srt321_hrt4_zw, integration): async def test_thermostat_srt321_hrt4_zw(hass, client, srt321_hrt4_zw, integration):
"""Test a climate entity from a HRT4-ZW / SRT321 thermostat device. """Test a climate entity from a HRT4-ZW / SRT321 thermostat device.

View File

@ -1,4 +1,11 @@
"""Test discovery of entities for device-specific schemas for the Z-Wave JS integration.""" """Test discovery of entities for device-specific schemas for the Z-Wave JS integration."""
import pytest
from homeassistant.components.zwave_js.discovery import (
FirmwareVersionRange,
ZWaveDiscoverySchema,
ZWaveValueDiscoverySchema,
)
async def test_iblinds_v2(hass, client, iblinds_v2, integration): async def test_iblinds_v2(hass, client, iblinds_v2, integration):
@ -48,3 +55,13 @@ async def test_vision_security_zl7432(
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state assert state
assert state.attributes["assumed_state"] assert state.attributes["assumed_state"]
async def test_firmware_version_range_exception(hass):
"""Test FirmwareVersionRange exception."""
with pytest.raises(ValueError):
ZWaveDiscoverySchema(
"test",
ZWaveValueDiscoverySchema(command_class=1),
firmware_version_range=FirmwareVersionRange(),
)

File diff suppressed because it is too large Load Diff