Add device specific discovery for Heatit Z-TRM3 thermostat (#49804)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>pull/49912/head
parent
37dad92bf7
commit
fdc29d6a80
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue