Add device specific discovery for Heatit Z-TRM3 thermostat (#49804)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/49912/head
Raman Gupta 2021-04-30 10:10:25 -04:00 committed by GitHub
parent 37dad92bf7
commit fdc29d6a80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 277 additions and 8 deletions

View File

@ -44,6 +44,9 @@ from homeassistant.components.climate.const import (
SUPPORT_TARGET_TEMPERATURE,
SUPPORT_TARGET_TEMPERATURE_RANGE,
)
from homeassistant.components.zwave_js.discovery_data_template import (
DynamicCurrentTempClimateDataTemplate,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
@ -59,6 +62,7 @@ from homeassistant.helpers.temperature import convert_temperature
from .const import DATA_CLIENT, DATA_UNSUBSCRIBE, DOMAIN
from .discovery import ZwaveDiscoveryInfo
from .entity import ZWaveBaseEntity
from .helpers import get_value_of_zwave_value
# Map Z-Wave HVAC Mode to Home Assistant value
# Note: We treat "auto" as "heat_cool" as most Z-Wave devices
@ -110,7 +114,10 @@ async def async_setup_entry(
def async_add_climate(info: ZwaveDiscoveryInfo) -> None:
"""Add Z-Wave Climate."""
entities: list[ZWaveBaseEntity] = []
entities.append(ZWaveClimate(config_entry, client, info))
if info.platform_hint == "dynamic_current_temp":
entities.append(DynamicCurrentTempClimate(config_entry, client, info))
else:
entities.append(ZWaveClimate(config_entry, client, info))
async_add_entities(entities)
@ -129,7 +136,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
def __init__(
self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo
) -> None:
"""Initialize lock."""
"""Initialize thermostat."""
super().__init__(config_entry, client, info)
self._hvac_modes: dict[str, int | None] = {}
self._hvac_presets: dict[str, int | None] = {}
@ -285,12 +292,12 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
@property
def current_humidity(self) -> int | None:
"""Return the current humidity level."""
return self._current_humidity.value if self._current_humidity else None
return get_value_of_zwave_value(self._current_humidity)
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._current_temp.value if self._current_temp else None
return get_value_of_zwave_value(self._current_temp)
@property
def target_temperature(self) -> float | None:
@ -302,7 +309,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
temp = self._setpoint_value(self._current_mode_setpoint_enums[0])
except (IndexError, ValueError):
return None
return temp.value if temp else None
return get_value_of_zwave_value(temp)
@property
def target_temperature_high(self) -> float | None:
@ -314,7 +321,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
temp = self._setpoint_value(self._current_mode_setpoint_enums[1])
except (IndexError, ValueError):
return None
return temp.value if temp else None
return get_value_of_zwave_value(temp)
@property
def target_temperature_low(self) -> float | None:
@ -482,3 +489,25 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
if preset_mode_value is None:
raise ValueError(f"Received an invalid preset mode: {preset_mode}")
await self.info.node.async_set_value(self._current_mode, preset_mode_value)
class DynamicCurrentTempClimate(ZWaveClimate):
"""Representation of a thermostat that can dynamically use a different Zwave Value for current temp."""
def __init__(
self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo
) -> None:
"""Initialize thermostat."""
super().__init__(config_entry, client, info)
self.data_template = cast(
DynamicCurrentTempClimateDataTemplate, self.info.platform_data_template
)
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
assert self.info.platform_data
val = get_value_of_zwave_value(
self.data_template.current_temperature_value(self.info.platform_data)
)
return val if val is not None else super().current_temperature

View File

@ -5,13 +5,19 @@ from collections.abc import Generator
from dataclasses import dataclass
from typing import Any
from zwave_js_server.const import 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.node import Node as ZwaveNode
from zwave_js_server.model.value import Value as ZwaveValue
from homeassistant.core import callback
from .discovery_data_template import (
BaseDiscoverySchemaDataTemplate,
DynamicCurrentTempClimateDataTemplate,
ZwaveValueID,
)
@dataclass
class ZwaveDiscoveryInfo:
@ -27,6 +33,12 @@ class ZwaveDiscoveryInfo:
platform: str
# hint for the platform about this discovered entity
platform_hint: str | None = ""
# data template to use in platform logic
platform_data_template: BaseDiscoverySchemaDataTemplate | None = None
# helper data to use in platform setup
platform_data: dict[str, Any] | None = None
# additional values that need to be watched by entity
additional_value_ids_to_watch: set[str] | None = None
@dataclass
@ -69,6 +81,8 @@ class ZWaveDiscoverySchema:
primary_value: ZWaveValueDiscoverySchema
# [optional] hint for platform
hint: str | None = None
# [optional] template to generate platform specific data to use in setup
data_template: BaseDiscoverySchemaDataTemplate | None = None
# [optional] the node's manufacturer_id must match ANY of these values
manufacturer_id: set[int] | None = None
# [optional] the node's product_id must match ANY of these values
@ -214,6 +228,52 @@ DISCOVERY_SCHEMAS = [
primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA,
assumed_state=True,
),
# Heatit Z-TRM3
ZWaveDiscoverySchema(
platform="climate",
hint="dynamic_current_temp",
manufacturer_id={0x019B},
product_id={0x0203},
product_type={0x0003},
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.THERMOSTAT_MODE},
property={"mode"},
type={"number"},
),
data_template=DynamicCurrentTempClimateDataTemplate(
{
# Internal Sensor
"A": ZwaveValueID(
THERMOSTAT_CURRENT_TEMP_PROPERTY,
CommandClass.SENSOR_MULTILEVEL,
endpoint=2,
),
"AF": ZwaveValueID(
THERMOSTAT_CURRENT_TEMP_PROPERTY,
CommandClass.SENSOR_MULTILEVEL,
endpoint=2,
),
# External Sensor
"A2": ZwaveValueID(
THERMOSTAT_CURRENT_TEMP_PROPERTY,
CommandClass.SENSOR_MULTILEVEL,
endpoint=3,
),
"A2F": ZwaveValueID(
THERMOSTAT_CURRENT_TEMP_PROPERTY,
CommandClass.SENSOR_MULTILEVEL,
endpoint=3,
),
# Floor sensor
"F": ZwaveValueID(
THERMOSTAT_CURRENT_TEMP_PROPERTY,
CommandClass.SENSOR_MULTILEVEL,
endpoint=4,
),
},
ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0),
),
),
# ====== START OF CONFIG PARAMETER SPECIFIC MAPPING SCHEMAS =======
# Door lock mode config parameter. Functionality equivalent to Notification CC
# list sensors.
@ -524,6 +584,15 @@ def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None
):
continue
# resolve helper data from template
resolved_data = None
additional_value_ids_to_watch = None
if schema.data_template:
resolved_data = schema.data_template.resolve_data(value)
additional_value_ids_to_watch = schema.data_template.value_ids_to_watch(
resolved_data
)
# all checks passed, this value belongs to an entity
yield ZwaveDiscoveryInfo(
node=value.node,
@ -531,6 +600,9 @@ def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None
assumed_state=schema.assumed_state,
platform=schema.platform,
platform_hint=schema.hint,
platform_data_template=schema.data_template,
platform_data=resolved_data,
additional_value_ids_to_watch=additional_value_ids_to_watch,
)
if not schema.allow_multi:

View File

@ -0,0 +1,109 @@
"""Data template classes for discovery used to generate device specific data for setup."""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
from zwave_js_server.model.node import Node as ZwaveNode
from zwave_js_server.model.value import Value as ZwaveValue, get_value_id
@dataclass
class ZwaveValueID:
"""Class to represent a value ID."""
property_: str | int
command_class: int
endpoint: int | None = None
property_key: str | int | None = None
@dataclass
class BaseDiscoverySchemaDataTemplate:
"""Base class for discovery schema data templates."""
def resolve_data(self, value: ZwaveValue) -> dict[str, Any]:
"""
Resolve helper class data for a discovered value.
Can optionally be implemented by subclasses if input data needs to be
transformed once discovered Value is available.
"""
# pylint: disable=no-self-use
return {}
def values_to_watch(self, resolved_data: dict[str, Any]) -> Iterable[ZwaveValue]:
"""
Return list of all ZwaveValues resolved by helper that should be watched.
Should be implemented by subclasses only if there are values to watch.
"""
# pylint: disable=no-self-use
return []
def value_ids_to_watch(self, resolved_data: dict[str, Any]) -> set[str]:
"""
Return list of all Value IDs resolved by helper that should be watched.
Not to be overwritten by subclasses.
"""
return {val.value_id for val in self.values_to_watch(resolved_data) if val}
@staticmethod
def _get_value_from_id(
node: ZwaveNode, value_id_obj: ZwaveValueID
) -> ZwaveValue | None:
"""Get a ZwaveValue from a node using a ZwaveValueDict."""
value_id = get_value_id(
node,
value_id_obj.command_class,
value_id_obj.property_,
endpoint=value_id_obj.endpoint,
property_key=value_id_obj.property_key,
)
return node.values.get(value_id)
@dataclass
class DynamicCurrentTempClimateDataTemplate(BaseDiscoverySchemaDataTemplate):
"""Data template class for Z-Wave JS Climate entities with dynamic current temps."""
lookup_table: dict[str | int, ZwaveValueID]
dependent_value: ZwaveValueID
def resolve_data(self, value: ZwaveValue) -> dict[str, Any]:
"""Resolve helper class data for a discovered value."""
data: dict[str, Any] = {
"lookup_table": {},
"dependent_value": self._get_value_from_id(
value.node, self.dependent_value
),
}
for key in self.lookup_table:
data["lookup_table"][key] = self._get_value_from_id(
value.node, self.lookup_table[key]
)
return data
def values_to_watch(self, resolved_data: dict[str, Any]) -> Iterable[ZwaveValue]:
"""Return list of all ZwaveValues resolved by helper that should be watched."""
return [
*resolved_data["lookup_table"].values(),
resolved_data["dependent_value"],
]
@staticmethod
def current_temperature_value(resolved_data: dict[str, Any]) -> ZwaveValue | None:
"""Get current temperature ZwaveValue from resolved data."""
lookup_table: dict[str | int, ZwaveValue | None] = resolved_data["lookup_table"]
dependent_value: ZwaveValue | None = resolved_data["dependent_value"]
if dependent_value:
lookup_key = dependent_value.metadata.states[
str(dependent_value.value)
].split("-")[0]
return lookup_table.get(lookup_key)
return None

View File

@ -37,6 +37,11 @@ class ZWaveBaseEntity(Entity):
# entities requiring additional values, can add extra ids to this list
self.watched_value_ids = {self.info.primary_value.value_id}
if self.info.additional_value_ids_to_watch:
self.watched_value_ids = self.watched_value_ids.union(
self.info.additional_value_ids_to_watch
)
@callback
def on_value_update(self) -> None:
"""Call when one of the watched values change.

View File

@ -1,10 +1,11 @@
"""Helper functions for Z-Wave JS integration."""
from __future__ import annotations
from typing import cast
from typing import Any, cast
from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.model.node import Node as ZwaveNode
from zwave_js_server.model.value import Value as ZwaveValue
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import __version__ as HA_VERSION
@ -15,6 +16,12 @@ from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg
from .const import CONF_DATA_COLLECTION_OPTED_IN, DATA_CLIENT, DOMAIN
@callback
def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None:
"""Return the value of a ZwaveValue."""
return value.value if value else None
async def async_enable_statistics(client: ZwaveClient) -> None:
"""Enable statistics on the driver."""
await client.driver.async_enable_statistics("Home Assistant", HA_VERSION)

View File

@ -438,6 +438,7 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat
async def test_thermostat_heatit(hass, client, climate_heatit_z_trm3, integration):
"""Test a thermostat v2 command class entity."""
node = climate_heatit_z_trm3
state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY)
assert state
@ -453,6 +454,52 @@ async def test_thermostat_heatit(hass, client, climate_heatit_z_trm3, integratio
assert state.attributes[ATTR_MIN_TEMP] == 5
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
# Try switching to floor 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": 0,
"prevValue": 4,
},
},
)
node.receive_event(event)
state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY)
assert state
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 25.5
async def test_thermostat_srt321_hrt4_zw(hass, client, srt321_hrt4_zw, integration):
"""Test a climate entity from a HRT4-ZW / SRT321 thermostat device.