jacekk015 / zha_quirks

All quirks in one place
MIT License
144 stars 20 forks source link

Support for _TZE204_o9d1hdma TS0601 #66

Open RistoNiinemets opened 5 days ago

RistoNiinemets commented 5 days ago

It turned on-off as needed with avatto2 quirk, signatures are a match

I tried to debug some commands/events:

heating icon off (0x0465) Received command 0x01 (TSN 39): get_data(param=Command(status=0, tsn=214, command_id=1125, function=0, data=[1, 1]))

heating icon on (0x0465) 2024-11-18 16:09:11.324 DEBUG (MainThread) [zigpy.zcl] [0x7EB1:1:0xef00] Received command 0x01 (TSN 45): get_data(param=Command(status=0, tsn=221, command_id=1125, function=0, data=[1, 0]))

set target temp to 20 (0x0210) 2024-11-18 16:09:11.128 DEBUG (MainThread) [zigpy.zcl] [0x7EB1:1:0xef00] Received command 0x01 (TSN 44): get_data(param=Command(status=0, tsn=220, command_id=528, function=0, data=[4, 0, 0, 0, 20]))

turn off (0x0101) 2024-11-18 16:12:44.994 DEBUG (MainThread) [zigpy.zcl] [0x7EB1:1:0xef00] Received command 0x01 (TSN 54): get_data(param=Command(status=0, tsn=233, command_id=1125, function=0, data=[1, 1]))

turn on (0x0101) 2024-11-18 16:13:13.951 DEBUG (MainThread) [zigpy.zcl] [0x7EB1:1:0xef00] Received command 0x01 (TSN 56): get_data(param=Command(status=0, tsn=235, command_id=1125, function=0, data=[1, 0]))

current room temperature (19) (0x0218) 2024-11-18 16:13:42.701 DEBUG (MainThread) [zigpy.zcl] [0x7EB1:1:0xef00] Received command 0x01 (TSN 58): get_data(param=Command(status=0, tsn=237, command_id=536, function=0, data=[4, 0, 0, 0, 19]))

current room temperature (19.5) (0x0218) 2024-11-18 16:13:42.701 DEBUG (MainThread) [zigpy.zcl] [0x7EB1:1:0xef00] Received command 0x01 (TSN 58): get_data(param=Command(status=0, tsn=237, command_id=536, function=0, data=[4, 0, 0, 0, 19]))

child lock on (0x0128) 2024-11-18 16:47:15.702 DEBUG (MainThread) [zigpy.zcl] [0x7EB1:1:0xef00] Received command 0x01 (TSN 114): get_data(param=Command(status=0, tsn=57, command_id=296, function=0, data=[1, 1]))

child lock off (0x0128) 2024-11-18 16:47:54.640 DEBUG (MainThread) [zigpy.zcl] [0x7EB1:1:0xef00] Received command 0x01 (TSN 115): get_data(param=Command(status=0, tsn=58, command_id=296, function=0, data=[1, 0]))

The device also shows Floor temperature, for which I did not find communication. Also I did notice that the current room temperature was rounded - might be quirk thing?

There are also: 1) timer settings 2) room temperature calibration (if temp is 20 then can set to 22) 3) floor temperature calibration (if temp is 20 then can set to 22) 4) modes (away, child, economy) but this sets only target temperature 5) sensor selection (00 internal, 01 external, 02 both) 6) upper temperature limit (max) 7) lower temperature limit (min) 8) switch deviation setting (output deviation value in degrees 0-10) 9) output delay time (seconds 0-10) 10) warning value of floor temperature (0-80 degrees)

Product: https://www.aliexpress.com/item/1005006947010748.html?spm=a2g0o.order_list.order_list_main.17.614c1802ViAbvY

RistoNiinemets commented 3 days ago

Here's my current take that I've composed of zha_quirks/ts0601_electric_heating and your avatto2 quirk.

Basic functionality almost works. It shows 1) heating icon/status - idle/heating 2) current temperature 3) room temperature

Known issues:

Also the product supports increment only in 1 degree, not 0.5 degrees.

Please note that I'm not a Python developer. :) Would appreciate if you can take a look.


"""Map from manufacturer to standard clusters for electric heating thermostats."""

import logging
from zigpy.profiles import zha
import zigpy.types as t
from typing import Optional, Union
from zigpy.zcl import foundation
from zigpy.zcl.clusters.general import Basic, Groups, Ota, Scenes, Time, GreenPowerProxy, OnOff
from zhaquirks import Bus, LocalDataCluster

_LOGGER = logging.getLogger(__name__)

from zhaquirks.const import (
    DEVICE_TYPE,
    ENDPOINTS,
    INPUT_CLUSTERS,
    MODELS_INFO,
    OUTPUT_CLUSTERS,
    PROFILE_ID,
)
from zhaquirks.tuya import (
    TuyaManufCluster,
    TuyaManufClusterAttributes,
    TuyaThermostat,
    TuyaThermostatCluster,
    TuyaUserInterfaceCluster,
    TuyaTimePayload,
    TuyaPowerConfigurationCluster,
)

MOESBHT_TARGET_TEMP_ATTR = 0x0210  # 528
MOESBHT_TEMPERATURE_ATTR = 0x0218  # 536
MOESBHT_ENABLED_ATTR = 0x0101  # 257
MOESBHT_HEAT_STATE_ATTR = 0x0465  # 1125
MOESBHT_CHILD_LOCK_ATTR = 0x0128  # 296

Avatto2ManufClusterSelf = {}

class CustomTuyaOnOff(LocalDataCluster, OnOff):
    """Custom Tuya OnOff cluster."""

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)
        self.endpoint.device.thermostat_onoff_bus.add_listener(self)

    # pylint: disable=R0201
    def map_attribute(self, attribute, value):
        """Map standardized attribute value to dict of manufacturer values."""
        return {}

    async def write_attributes(self, attributes, manufacturer=None):
        """Implement writeable attributes."""

        records = self._write_attr_records(attributes)

        if not records:
            return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]]

        manufacturer_attrs = {}
        for record in records:
            attr_name = self.attributes[record.attrid].name
            new_attrs = self.map_attribute(attr_name, record.value.value)

            _LOGGER.debug(
                "[0x%04x:%s:0x%04x] Mapping standard %s (0x%04x) "
                "with value %s to custom %s",
                self.endpoint.device.nwk,
                self.endpoint.endpoint_id,
                self.cluster_id,
                attr_name,
                record.attrid,
                repr(record.value.value),
                repr(new_attrs),
            )

            manufacturer_attrs.update(new_attrs)

        if not manufacturer_attrs:
            return [
                [
                    foundation.WriteAttributesStatusRecord(
                        foundation.Status.FAILURE, r.attrid
                    )
                    for r in records
                ]
            ]

        await Avatto2ManufClusterSelf[
            self.endpoint.device.ieee
        ].endpoint.tuya_manufacturer.write_attributes(
            manufacturer_attrs, manufacturer=manufacturer
        )

        return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]]

    async def command(
        self,
        command_id: Union[foundation.GeneralCommand, int, t.uint8_t],
        *args,
        manufacturer: Optional[Union[int, t.uint16_t]] = None,
        expect_reply: bool = True,
        tsn: Optional[Union[int, t.uint8_t]] = None,
    ):
        """Override the default Cluster command."""

        if command_id in (0x0000, 0x0001, 0x0002):

            if command_id == 0x0000:
                value = True
            elif command_id == 0x0001:
                value = False
            else:
                attrid = self.attributes_by_name["on_off"].id
                success, _ = await self.read_attributes(
                    (attrid,), manufacturer=manufacturer
                )
                try:
                    value = success[attrid]
                except KeyError:
                    return foundation.Status.FAILURE
                value = not value

            (res,) = await self.write_attributes(
                {"on_off": value},
                manufacturer=manufacturer,
            )
            return [command_id, res[0].status]

        return [command_id, foundation.Status.UNSUP_CLUSTER_COMMAND]

class MoesBHTManufCluster(TuyaManufClusterAttributes):
    """Manufacturer Specific Cluster of some electric heating thermostats."""

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)
        global Avatto2ManufClusterSelf
        Avatto2ManufClusterSelf[self.endpoint.device.ieee] = self

    server_commands = {
        0x0000: foundation.ZCLCommandDef(
            "set_data",
            {"param": TuyaManufCluster.Command},
            False,
            is_manufacturer_specific=False,
        ),
        0x0010: foundation.ZCLCommandDef(
            "mcu_version_req",
            {"param": t.uint16_t},
            False,
            is_manufacturer_specific=True,
        ),
        0x0024: foundation.ZCLCommandDef(
            "set_time",
            {"param": TuyaTimePayload},
            False,
            is_manufacturer_specific=True,
        ),
    }

    attributes = TuyaManufClusterAttributes.attributes.copy()
    attributes.update(
        {
            MOESBHT_TARGET_TEMP_ATTR: ("target_temperature", t.uint32_t, True),
            MOESBHT_TEMPERATURE_ATTR: ("temperature", t.uint32_t, True),
            MOESBHT_ENABLED_ATTR: ("system_mode", t.uint8_t, True),
            MOESBHT_HEAT_STATE_ATTR: ("heat_state", t.uint8_t, True),
            MOESBHT_CHILD_LOCK_ATTR: ("child_lock", t.uint8_t, True),
        }
    )

    def _update_attribute(self, attrid, value):
        super()._update_attribute(attrid, value)
        if attrid == MOESBHT_TARGET_TEMP_ATTR:
            self.endpoint.device.thermostat_bus.listener_event(
                "temperature_change",
                "occupied_heating_setpoint",
                value * 100
            )
        elif attrid == MOESBHT_TEMPERATURE_ATTR:
            self.endpoint.device.thermostat_bus.listener_event(
                "temperature_change",
                "local_temperature",
                value * 100
            )
        elif attrid == MOESBHT_ENABLED_ATTR:
            self.endpoint.device.thermostat_bus.listener_event(
                "system_mode_change",
                self.SystemMode.Heat if value == 1 else self.SystemMode.Off,
            )
        elif attrid == MOESBHT_HEAT_STATE_ATTR:
            # value is inverted
            self.endpoint.device.thermostat_bus.listener_event(
                "state_change",
                1 - value
            )
        elif attrid == MOESBHT_CHILD_LOCK_ATTR:
            self.endpoint.device.ui_bus.listener_event("child_lock_change", value)
            self.endpoint.device.thermostat_onoff_bus.listener_event(
                "child_lock_change", value
            )

class MoesBHTThermostat(TuyaThermostatCluster):
    """Thermostat cluster for some electric heating controllers."""

    def __init__(self, *args, **kwargs):
        """Init."""
        super().__init__(*args, **kwargs)
        self.endpoint.device.thermostat_bus.add_listener(self)

    def map_attribute(self, attribute, value):
        """Map standardized attribute value to dict of manufacturer values."""

        if attribute == "occupied_heating_setpoint":
            # centidegree to degree
            return {MOESBHT_TARGET_TEMP_ATTR: value // 100}
        if attribute == "system_mode":
            if value == self.SystemMode.Off:
                return {MOESBHT_ENABLED_ATTR: 1}
            else:
                return {MOESBHT_ENABLED_ATTR: 0}
            self.error("Unsupported value for SystemMode")

        return super().map_attribute(attribute, value)

    def system_mode_change(self, value):
        """System mode change."""
        if value == 1:
            mode = self.SystemMode.Off
        else:
            mode = self.SystemMode.Heat
        self._update_attribute(self.attributes_by_name["system_mode"].id, mode)

class MoesBHTUserInterface(TuyaUserInterfaceCluster):
    """HVAC User interface cluster for tuya electric heating thermostats."""

    _CHILD_LOCK_ATTR = MOESBHT_CHILD_LOCK_ATTR

class MoesBHTChildLock(CustomTuyaOnOff):
    """On/Off cluster for the child lock function of the electric heating thermostats."""

    def child_lock_change(self, value):
        """Child lock change."""
        self._update_attribute(self.attributes_by_name["on_off"].id, value)

    def map_attribute(self, attribute, value):
        """Map standardized attribute value to dict of manufacturer values."""
        if attribute == "on_off":
            return {MOESBHT_CHILD_LOCK_ATTR: value}

class MoesBHT(TuyaThermostat):
    """Tuya thermostat for devices like the Moes BHT-002GCLZB valve and BHT-003GBLZB Electric floor heating."""

    def __init__(self, *args, **kwargs):
        """Init device."""
        self.thermostat_onoff_bus = Bus()
        super().__init__(*args, **kwargs)

    signature = {
        MODELS_INFO: [
            ("_TZE204_o9d1hdma", "TS0601"),
        ],
        ENDPOINTS: {
            1: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    Groups.cluster_id,
                    Scenes.cluster_id,
                    TuyaManufClusterAttributes.cluster_id,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            },
            242: {
                PROFILE_ID: 41440,
                DEVICE_TYPE: 97,
                INPUT_CLUSTERS: [],
                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
            },
        },
    }

    replacement = {
        ENDPOINTS: {
            1: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.THERMOSTAT,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    Groups.cluster_id,
                    Scenes.cluster_id,
                    MoesBHTManufCluster,
                    MoesBHTThermostat,
                    MoesBHTUserInterface,
                    TuyaPowerConfigurationCluster
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            },
            2: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
                INPUT_CLUSTERS: [MoesBHTChildLock],
            },
            242: {
                PROFILE_ID: 41440,
                DEVICE_TYPE: 97,
                INPUT_CLUSTERS: [],
                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
            },
        }
    }```