zigpy / zha-device-handlers

ZHA device handlers bridge the functionality gap created when manufacturers deviate from the ZCL specification, handling deviations and exceptions by parsing custom messages to and from Zigbee devices.
Apache License 2.0
741 stars 674 forks source link

Develco Air Quality Sensor #995

Closed EverythingSmartHome closed 2 years ago

EverythingSmartHome commented 3 years ago

Hello!

Firstly, this is not a bug but wasn't sure where else to put this.

I have managed to come up with a custom quirk for a Develco Air Quality sensor that seems to be working so far, I now have the air quality values showing up correctly under "Manage Clusters", and I can retrieve the correct values etc which is great and better than it was before, but I can't figure out how to get this into a Home Assistant sensor. What have I missed?

Any suggestions would be greatly appreciated, once I can get this working I will be happy to put in a PR!

"""Develco Air Quality Sensor"""
from zigpy.profiles import zha
from zigpy.quirks import CustomCluster, CustomDevice
import zigpy.types as t
from zhaquirks import Bus, LocalDataCluster
from zigpy.zcl.clusters.general import (
    Basic,
    BinaryInput,
    Identify,
    OnOff,
    Ota,
    PollControl,
    PowerConfiguration,
    Scenes,
    Time,
)
from zigpy.zcl.clusters.measurement import TemperatureMeasurement
from zigpy.zcl.clusters.measurement import RelativeHumidity
from zigpy.zcl.clusters.security import IasZone

from zhaquirks.const import (
    DEVICE_TYPE,
    ENDPOINTS,
    INPUT_CLUSTERS,
    MODELS_INFO,
    OUTPUT_CLUSTERS,
    PROFILE_ID,
)
from zhaquirks.develco import DEVELCO, DevelcoPowerConfiguration

MANUFACTURER = 0x1015
VOC_MEASURED_VALUE = 0x0000
VOC_MIN_MEASURED_VALUE = 0x0001
VOC_MAX_MEASURED_VALUE = 0x0002
VOC_RESOLUTION = 0x0003

class DevelcoIASZone(CustomCluster, IasZone):
    """IAS Zone."""

    manufacturer_client_commands = {
        0x0000: (
            "status_change_notification",
            (
                IasZone.ZoneStatus,
                t.bitmap8,
                t.Optional(t.uint8_t),
                t.Optional(t.uint16_t),
            ),
            False,
        )
    }

class VOCMeasurement(CustomCluster):
    cluster_id = 0xfc03
    name = "VOC Measurement"
    ep_attribute = "voc_measurement"
    manufacturer_attributes = {
        VOC_MEASURED_VALUE: ("MeasuredValue", t.uint16_t),
        VOC_MIN_MEASURED_VALUE: ("MinMeasuredValue", t.uint16_t),
        VOC_MAX_MEASURED_VALUE: ("MaxMeasuredValue", t.uint16_t),
        VOC_RESOLUTION: ("Resolution", t.uint16_t)
    }
    server_commands = {}
    client_commands = {}

    def _update_attribute(self, attrid, value):
        super()._update_attribute(attrid, value)

class AQSZB110(CustomDevice):
    """Custom device air quality sensor"""
    manufacturer_id_override = MANUFACTURER

    signature = {
        # <SimpleDescriptor endpoint=1 profile=49353 device_type=1 device_version=1
        # input_clusters=[3, 5, 6] output_clusters=[]>
        # <SimpleDescriptor endpoint=38 profile=260 device_type=770 device_version=0
        # input_clusters=[0, 1, 3, 32, 1026, 1029, 64515] output_clusters=[3, 10, 25]>
        MODELS_INFO: [(DEVELCO, "AQSZB-110")],
        ENDPOINTS: {
            1: {
                PROFILE_ID: 49353,
                DEVICE_TYPE: 1,
                INPUT_CLUSTERS: [
                    Identify.cluster_id,
                    Scenes.cluster_id,
                    OnOff.cluster_id,
                ],
                OUTPUT_CLUSTERS: [],
            },
            38: {
                PROFILE_ID: 260,
                DEVICE_TYPE: 770,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    PowerConfiguration.cluster_id,
                    Identify.cluster_id,
                    PollControl.cluster_id,
                    TemperatureMeasurement.cluster_id,
                    RelativeHumidity.cluster_id,
                    0xfc03,
                ],
                OUTPUT_CLUSTERS: [3, 10, 25],
            },
        },
    }

    replacement = {
        ENDPOINTS: {
            1: {
                PROFILE_ID: 49353,
                DEVICE_TYPE: 1,
                INPUT_CLUSTERS: [
                    Identify.cluster_id,
                    Scenes.cluster_id,
                    OnOff.cluster_id,
                ],
                OUTPUT_CLUSTERS: [],
            },
            38: {
                PROFILE_ID: 260,
                DEVICE_TYPE: 770,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    PowerConfiguration.cluster_id,
                    Identify.cluster_id,
                    PollControl.cluster_id,
                    TemperatureMeasurement.cluster_id,
                    RelativeHumidity.cluster_id,
                    VOCMeasurement
                ],
                OUTPUT_CLUSTERS: [3, 10, 25],
            },
        },
    }
rampage128 commented 3 years ago

Hey!

I am trying to figure out the same thing. But AFAIK ZHA only exposes predefined cluster ids/attributes as sensors and does not allow custom ones. Also if I remember correctly there is a limitation in ZHA that you can not have multiple descriptors for the same endpoint (Can not find the link anymore).

Also note: You have to add ("frient A/S", "AQSZB-110") to the MODELS_INFO to make it fully compatible. At least frient A/S is the manufacturer my device returns.

That said, I found a workaround utilizing a sql-sensor:

sensor:
  - platform: sql
    db_url: sqlite:////config/zigbee.db
    scan_interval: 10
    queries:
      - name: VOC
        query: "SELECT value FROM attributes where ieee = '<device ieee address>' and cluster = 64515 and attrid = 0"
        column: "value" 

That will create a new sensor and read the value from the DB.

EverythingSmartHome commented 3 years ago

Hello!

I did see that same page and I am tracking it to see when it gets resolved although I wasn't sure if it was quite the same issue or if there was a work around.

Mine is different in terms of the manufacturer and appears just like in my code, so it would appear there is slight differences there! Is yours rebranded or resold by anyone?

I saw that workaround but was reluctant to use it, although may give it a shot for now, thanks!

rampage128 commented 3 years ago

You are right. It seems these devices are sold from different companies. Mine is this one: https://frient.com/products/air-quality-sensor/ I now also checked the develco product page and the device looks exactly the same.

Not sure about the rest:

With the manufacturer addition to the quirk and a change of PowerConfiguration.cluster_id to DevelcoPowerConfiguration (in the replacement) my device now works much better.

I guess we have to wait for ZHA/HASS to allow configuring custom sensors from arbitrary cluster attributes. Not sure why this is not possible in the first place 🤔.

EDIT: I just checked the device in HASS again and now it is reporting a proper battery value ... after 1 week of operation and reporting "None" when fetching the attribute from the cluster.

EDIT2: I just found https://github.com/home-assistant/core/blob/dev/homeassistant/components/zha/sensor.py#L326 and it seems there is a VOC sensor definition. Maybe it is possible to rewrite the attribute or id in the quirk to make it match this definition.

EverythingSmartHome commented 3 years ago

Mine is (almost) direct from Develco, Develco appear to be a white label supplier of these devices that others resell. So good thing is, if we can get it working with the original Develco hardware, in theory it should be simple to get it working with any rebrands.

Hopefully some of the Devs can chime in on getting the attributes into HA when they have a moment, would be great to solve this one.

Will take a look at that link, thanks!

EverythingSmartHome commented 3 years ago

If I read the above link properly, I think it expects the VOC cluster to be on 0x042e where as the Develco VOC is reported on the Manufacturer Specific Cluster of 0xfc03, so perhaps that's why its not being picked up?

Is it possible to have it report on a different cluster ID somehow using the replace? I'm very new to writing quirks so may not be possible but hopefully you know what I mean!

EverythingSmartHome commented 3 years ago

@rampage128 Got it working, thanks to your link above!

I went in and manually modified the ZHA file on Home Assistant here: https://github.com/home-assistant/core/blob/7f309b4e6e4e8917f7a64521b1c86c5174bd3b29/homeassistant/components/zha/core/registries.py#L35

To read 0xfc03, repaired the device with a slightly modified quirk to the one above, and it now shows the VOC level perfectly. So that cluster ID is key.

Not sure how to make that work for everyone else though?

@Adminiuga sorry to ping you directly, would you have any input on how best to proceed with making this work for everyone else based on the above PR you made here.

rampage128 commented 3 years ago

@EverythingSmartHome nice glad you got it working!

Would you share what you changed? I wrote a new quirk and managed to remap the cluster to the new ID. But I can not get it to show up as a sensor. Maybe we can combine our efforts and make the quirk work without having to change the core files.

EverythingSmartHome commented 3 years ago

@rampage128 Sure I would be happy too, how are you doing the remapping? That's the last piece of the puzzle I think, if you can let me know how to do that then I can post back the full completed quirk

rampage128 commented 3 years ago

I basically use the Bus functionality from the example in the README.md.

This is my quirk so far (based off of your initially posted version):

"""Develco Air Quality Sensor https://github.com/zigpy/zha-device-handlers/issues/995"""
from zigpy.profiles import zha
from zigpy.quirks import CustomCluster, CustomDevice
import zigpy.types as t
from zhaquirks import Bus, LocalDataCluster
from zigpy.zcl.clusters.general import (
    Basic,
    Identify,
    OnOff,
    Ota,
    PollControl,
    PowerConfiguration,
    Scenes,
    Time,
)
from zigpy.zcl.clusters.measurement import TemperatureMeasurement
from zigpy.zcl.clusters.measurement import RelativeHumidity

from zhaquirks.const import (
    DEVICE_TYPE,
    ENDPOINTS,
    INPUT_CLUSTERS,
    MODELS_INFO,
    OUTPUT_CLUSTERS,
    PROFILE_ID,
)
from zhaquirks.develco import DEVELCO, DevelcoPowerConfiguration

MANUFACTURER = 0x1015
VOC_MEASURED_VALUE = 0x0000
VOC_MIN_MEASURED_VALUE = 0x0001
VOC_MAX_MEASURED_VALUE = 0x0002
VOC_RESOLUTION = 0x0003

VOC_REPORTED = "voc_reported"
MIN_VOC_REPORTED = "min_voc_reported"
MAX_VOC_REPORTED = "max_voc_reported"
VOC_RESOLUTION_REPORTED = "voc_resolution_reported"

class DevelcoVOCInputCluster(CustomCluster):
    """Input Cluster to route manufacturer specific VOC cluster to actual VOC cluster."""

    cluster_id = 0xfc03
    manufacturer_attributes = {
        VOC_MEASURED_VALUE: ("measured_value", t.uint16_t),
        VOC_MIN_MEASURED_VALUE: ("min_measured_value", t.uint16_t),
        VOC_MAX_MEASURED_VALUE: ("max_measured_value", t.uint16_t),
        VOC_RESOLUTION: ("resolution", t.uint16_t)
    }

    def __init__(self, *args, **kwargs):
        """Init."""
        self._current_state = {}
        super().__init__(*args, **kwargs)

    def _update_attribute(self, attrid, value):
        super()._update_attribute(attrid, value)
        if attrid == VOC_MEASURED_VALUE and value is not None:
            self.endpoint.device.voc_bus.listener_event(VOC_REPORTED, value)
        if attrid == VOC_MIN_MEASURED_VALUE and value is not None:
            self.endpoint.device.voc_bus.listener_event(MIN_VOC_REPORTED, value)
        if attrid == VOC_MAX_MEASURED_VALUE and value is not None:
            self.endpoint.device.voc_bus.listener_event(MAX_VOC_REPORTED, value)
        if attrid == VOC_RESOLUTION and value is not None:
            self.endpoint.device.voc_bus.listener_event(VOC_RESOLUTION_REPORTED, value)

class VOCMeasurementCluster(LocalDataCluster):
    """VOC measurement cluster to receive reports from the Develco VOC cluster."""

    cluster_id = 0x042E
    name = "VOC Level"
    ep_attribute = "voc_level"
    manufacturer_attributes = {
        VOC_MEASURED_VALUE: ("measured_value", t.uint16_t),
        VOC_MIN_MEASURED_VALUE: ("min_measured_value", t.uint16_t),
        VOC_MAX_MEASURED_VALUE: ("max_measured_value", t.uint16_t),
        VOC_RESOLUTION: ("resolution", t.uint16_t)
    }
    MEASURED_VALUE_ID = 0x0000
    MIN_MEASURED_VALUE_ID = 0x0001
    MAX_MEASURED_VALUE_ID = 0x0002
    RESOLUTION_ID = 0x0003

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

    def voc_reported(self, value):
        """VOC reported."""
        self._update_attribute(self.MEASURED_VALUE_ID, value)

    def min_voc_reported(self, value):
        """Minimum Measured VOC reported."""
        self._update_attribute(self.MIN_MEASURED_VALUE_ID, value)

    def max_voc_reported(self, value):
        """Maximum Measured VOC reported."""
        self._update_attribute(self.MAX_MEASURED_VALUE_ID, value)

    def voc_resolution_reported(self, value):
        """VOC Resolution reported."""
        self._update_attribute(self.RESOLUTION_ID, value)

class AQSZB110(CustomDevice):
    """Custom device air quality sensor"""
    manufacturer_id_override = MANUFACTURER

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

    signature = {
        # <SimpleDescriptor endpoint=1 profile=49353 device_type=1 device_version=1
        # input_clusters=[3, 5, 6] output_clusters=[]>
        # <SimpleDescriptor endpoint=38 profile=260 device_type=770 device_version=0
        # input_clusters=[0, 1, 3, 32, 1026, 1029, 64515, 1070] output_clusters=[3, 10, 25]>
        MODELS_INFO: [
            (DEVELCO, "AQSZB-110"),
            ("frient A/S", "AQSZB-110"),
        ],
        ENDPOINTS: {
            1: {
                PROFILE_ID: 49353,
                DEVICE_TYPE: 1,
                INPUT_CLUSTERS: [
                    Identify.cluster_id,
                    Scenes.cluster_id,
                    OnOff.cluster_id,
                ],
                OUTPUT_CLUSTERS: [],
            },
            38: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.TEMPERATURE_SENSOR,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    PowerConfiguration.cluster_id,
                    Identify.cluster_id,
                    PollControl.cluster_id,
                    TemperatureMeasurement.cluster_id,
                    RelativeHumidity.cluster_id,
                    0xfc03,
                ],
                OUTPUT_CLUSTERS: [Identify.cluster_id, Time.cluster_id, Ota.cluster_id],
            },
        },
    }

    replacement = {
        ENDPOINTS: {
            1: {
                PROFILE_ID: 49353,
                DEVICE_TYPE: 1,
                INPUT_CLUSTERS: [
                    Identify.cluster_id,
                    Scenes.cluster_id,
                    OnOff.cluster_id,
                ],
                OUTPUT_CLUSTERS: [],
            },
            38: {
                PROFILE_ID: 260,
                DEVICE_TYPE: 770,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    DevelcoPowerConfiguration,
                    Identify.cluster_id,
                    PollControl.cluster_id,
                    TemperatureMeasurement.cluster_id,
                    RelativeHumidity.cluster_id,
                    DevelcoVOCInputCluster,
                    VOCMeasurementCluster,
                ],
                OUTPUT_CLUSTERS: [Identify.cluster_id, Time.cluster_id, Ota.cluster_id],
            },
        },
    }
EverythingSmartHome commented 3 years ago

Nice, it works! I'd actually tried using the bus on one of my many failed attempts but that was before I knew the correct cluster ID to use that has the VOC level sensor. This is now working fully for me and shows up as a sensor without any changes to the HA files:


"""Develco Air Quality Sensor"""
from zigpy.profiles import zha
from zigpy.quirks import CustomCluster, CustomDevice
import zigpy.types as t
from zhaquirks import Bus, LocalDataCluster
from zigpy.zcl.clusters.general import (
    Basic,
    BinaryInput,
    Identify,
    OnOff,
    Ota,
    PollControl,
    PowerConfiguration,
    Scenes,
    Time,
)
from zigpy.zcl.clusters.measurement import TemperatureMeasurement
from zigpy.zcl.clusters.measurement import RelativeHumidity
from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster
from zigpy.zcl.clusters.security import IasZone

from zhaquirks.const import (
    DEVICE_TYPE,
    ENDPOINTS,
    INPUT_CLUSTERS,
    MODELS_INFO,
    OUTPUT_CLUSTERS,
    PROFILE_ID,
)
from zhaquirks.develco import DEVELCO, DevelcoPowerConfiguration

MANUFACTURER = 0x1015
VOC_MEASURED_VALUE = 0x0000
VOC_MIN_MEASURED_VALUE = 0x0001
VOC_MAX_MEASURED_VALUE = 0x0002
VOC_RESOLUTION = 0x0003

VOC_REPORTED = "voc_reported"
MIN_VOC_REPORTED = "min_voc_reported"
MAX_VOC_REPORTED = "max_voc_reported"
VOC_RESOLUTION_REPORTED = "voc_resolution_reported"

class VOCMeasurement(CustomCluster, ManufacturerSpecificCluster):
    cluster_id = 0xfc03
    name = "VOC Level"
    ep_attribute = "voc_level"
    manufacturer_attributes = {
        VOC_MEASURED_VALUE: ("measured_value", t.uint16_t),
        VOC_MIN_MEASURED_VALUE: ("min_measured _value", t.uint16_t),
        VOC_MAX_MEASURED_VALUE: ("max_measured_value", t.uint16_t),
        VOC_RESOLUTION: ("tolerance", t.uint16_t)
    }
    server_commands = {}
    client_commands = {}

    def __init__(self, *args, **kwargs):
        """Init."""
        self._current_state = {}
        super().__init__(*args, **kwargs)

    def _update_attribute(self, attrid, value):
        super()._update_attribute(attrid, value)
        if attrid == VOC_MEASURED_VALUE and value is not None:
            self.endpoint.device.voc_bus.listener_event(VOC_REPORTED, value)
        if attrid == VOC_MIN_MEASURED_VALUE and value is not None:
            self.endpoint.device.voc_bus.listener_event(MIN_VOC_REPORTED, value)
        if attrid == VOC_MAX_MEASURED_VALUE and value is not None:
            self.endpoint.device.voc_bus.listener_event(MAX_VOC_REPORTED, value)
        if attrid == VOC_RESOLUTION and value is not None:
            self.endpoint.device.voc_bus.listener_event(VOC_RESOLUTION_REPORTED, value)

class VOCMeasurementCluster(LocalDataCluster):
    """VOC measurement cluster to receive reports from the Develco VOC cluster."""

    cluster_id = 0x042E
    name = "VOC Level"
    ep_attribute = "voc_level"
    manufacturer_attributes = {
        VOC_MEASURED_VALUE: ("measured_value", t.uint16_t),
        VOC_MIN_MEASURED_VALUE: ("min_measured_value", t.uint16_t),
        VOC_MAX_MEASURED_VALUE: ("max_measured_value", t.uint16_t),
        VOC_RESOLUTION: ("tolerance", t.uint16_t)
    }
    MEASURED_VALUE_ID = 0x0000
    MIN_MEASURED_VALUE_ID = 0x0001
    MAX_MEASURED_VALUE_ID = 0x0002
    RESOLUTION_ID = 0x0003

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

    def voc_reported(self, value):
        """VOC reported."""
        self._update_attribute(self.MEASURED_VALUE_ID, value)

    def min_voc_reported(self, value):
        """Minimum Measured VOC reported."""
        self._update_attribute(self.MIN_MEASURED_VALUE_ID, value)

    def max_voc_reported(self, value):
        """Maximum Measured VOC reported."""
        self._update_attribute(self.MAX_MEASURED_VALUE_ID, value)

    def voc_resolution_reported(self, value):
        """VOC Resolution reported."""
        self._update_attribute(self.RESOLUTION_ID, value)

    # def _update_attribute(self, attrid, value):
    #     super()._update_attribute(attrid, value)

class AQSZB110(CustomDevice):
    """Custom device air quality sensor"""
    manufacturer_id_override = MANUFACTURER

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

    signature = {
        # <SimpleDescriptor endpoint=1 profile=49353 device_type=1 device_version=1
        # input_clusters=[3, 5, 6] output_clusters=[]>
        # <SimpleDescriptor endpoint=38 profile=260 device_type=770 device_version=0
        # input_clusters=[0, 1, 3, 32, 1026, 1029, 64515] output_clusters=[3, 10, 25]>
        MODELS_INFO: [(DEVELCO, "AQSZB-110"), ("frient A/S", "AQSZB-110")],
        ENDPOINTS: {
            1: {
                PROFILE_ID: 49353,
                DEVICE_TYPE: 1,
                INPUT_CLUSTERS: [
                    Identify.cluster_id,
                    Scenes.cluster_id,
                    OnOff.cluster_id,
                ],
                OUTPUT_CLUSTERS: [],
            },
            38: {
                PROFILE_ID: 260,
                DEVICE_TYPE: 770,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    PowerConfiguration.cluster_id,
                    Identify.cluster_id,
                    PollControl.cluster_id,
                    TemperatureMeasurement.cluster_id,
                    RelativeHumidity.cluster_id,
                    0xfc03,
                ],
                OUTPUT_CLUSTERS: [3, 10, 25],
            },
        },
    }

    replacement = {
        ENDPOINTS: {
            1: {
                PROFILE_ID: 49353,
                DEVICE_TYPE: 1,
                INPUT_CLUSTERS: [
                    Identify.cluster_id,
                    Scenes.cluster_id,
                    OnOff.cluster_id,
                ],
                OUTPUT_CLUSTERS: [],
            },
            38: {
                PROFILE_ID: 260,
                DEVICE_TYPE: 770,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    DevelcoPowerConfiguration,
                    Identify.cluster_id,
                    PollControl.cluster_id,
                    TemperatureMeasurement.cluster_id,
                    RelativeHumidity.cluster_id,
                    VOCMeasurement,
                    VOCMeasurementCluster,
                ],
                OUTPUT_CLUSTERS: [3, 10, 25],
            },
        },
    }```
EverythingSmartHome commented 3 years ago

One final issue - it won't automatically poll the VOCMeasurement cluster now, it will only trigger an update if I go into manage clusters and ask for it, meaning the bus value isn't being updated.

Thoughts? We are so close!

rampage128 commented 3 years ago

Yeah I noticed that too. But that has to be an issue of the develco somehow. It should report the value periodically and when it is reported, it should update the cluster and the bus. Probably we somehow have to tell it to enable reporting for that custom cluster. But I am at a loss on how to do that.

BTW I can not get HASS to show a sensor for the VOC value in my installation. I am running Core 2021.7.4 ... maybe I need to update 🤔

EDIT: I updated my HA installation and now the sensor shows up. We also have to recalculate the value. develco outputs ppb (parts per billion). HA wants µg/m³, which is a funny unit for something that consists of many different particles of various molar masses.

EverythingSmartHome commented 3 years ago

Yeah I noticed that too. But that has to be an issue of the develco somehow. It should report the value periodically and when it is reported, it should update the cluster and the bus. Probably we somehow have to tell it to enable reporting for that custom cluster. But I am at a loss on how to do that.

BTW I can not get HASS to show a sensor for the VOC value in my installation. I am running Core 2021.7.4 ... maybe I need to update 🤔

EDIT: I updated my HA installation and now the sensor shows up. We also have to recalculate the value. develco outputs ppb (parts per billion). HA wants µg/m³, which is a funny unit for something that consists of many different particles of various molar masses.

I'm not sure its that because previously when I edited the HA files to look at the 0xfc03 cluster directly, the value updated every 2 minutes as per the spec. Will keep digging!

Yes you need to update as you found because the VOC sensor was only added in 2021.8, glad you got it.

Yes, all you need to do is divide by a million in order to cancel out the original multiplication that was done in the PR.

rampage128 commented 3 years ago

Default reporting is set to Min reporting interval: 60 sec Max reporting interval: 600 sec Reportable Change: 10 ppb

If the VOC value is stable it will be sent every 10 minutes. If the VOC changes more than 10 ppb it will be reported but not faster than every 1 minute since last reporting value.

The above quote is from the technical documentation of the AQSZB-110. So technically it should work out of the box. The thing is that my device does not send the VOC on its own at all. At least I find nothing in the zha logs.

Yes, all you need to do is divide by a million in order to cancel out the original multiplication that was done in the PR.

That would not be right. To get the mg/m³ of the ppb value you need to divide the mass by the volume of the particles. With VOC this is not exactly possible because the different particles measured have a wide spectrum of masses/volumes. I am currently researching if there is some approximation table to do a conversion. Only thing I could find is a ranged conversion table by the german government, which unfortunately does not have a single conversion but converts to different ranges based on ppb ranges which would give 6 different conversion factors.

EverythingSmartHome commented 3 years ago

Yes it technically should, it worked as I say when I modified the cluster ID that HA was looking for and was reporting perfectly, so not sure why it won't when we remap the cluster ID.

I wasn't saying that dividing by 1 million is the correct calculation, I'm saying that the raw value retrieved from the VOC sensor is already in PPB, but if we take a look at the code here

class VOCLevel(Sensor): """VOC Level sensor."""

SENSOR_ATTR = "measured_value"
_decimals = 0
_multiplier = 1e6
_unit = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER

That the HA code is applying multiplication to our already correct value (the raw value is already correct, it doesn't need anything doing to it) and multiplying it by 1 million (1e6), so we can simply reverse this by dividing by 1 million.

Hopefully that makes sense and I am not missing something?

rampage128 commented 3 years ago

I guess regarding the unit we are talking about different things. I am not trying to display the ppb, but I want to convert it to ug/m³ because that is what home-assistant shows as unit of measurement for the sensor. Even without the conversion the sensor does, the value shown (ppb) would not match the shown unit of measurement (ug/m³).

But I digress. Let's focus on the refreshing issue of the value:

I am walking out on a limb here, but my guess as to why it worked when you changed the clusterID of the sensor is probably because HA does an active polling of the cluster (basically doing the "get zigbee attribute" call in the background periodically). Now that we use the proper cluster ID with the bus, the HA sensor is polling the clusterID (0x042e)... but that clusterID will not actually be requested from the develco, but instead it is a cached value from the Bus we set up.

If I am right with that assumption, we somehow would have to tell the VOCMeasurementCluster that it has to poll the VOCMeasurement whenever it is polled. Or maybe we can set up a timer in the AQSZB110 class that will poll the 0xfc03 cluster periodically. I will check if there is any other quirk doing something like that.

EverythingSmartHome commented 3 years ago

Agree!

Tried lots of things so far, no luck. I don't understand why other quirks I look at that use the bus function don't seem to have this problem.

rampage128 commented 3 years ago

I do not know. Maybe zha is not relaying the reported values without an active sensor from home-assistant. Or maybe the develco device is just not reporting any values at all.

I disabled all my other zigbee devices and let the zha debug log run for a couple of hours. And it does never mention a reporting for anything on the develco. If I am not mistaken the logs should give an entry if a device actively reports a value. At least that happens for my lightbulbs when they report their state.

On the other hand ... I can also not see any polling occuring on the temperature or humidity... It just works and the zha logs do not contain anything about it.

EDIT:

The only thing I ever get from the develco sensor in zha is:

2021-08-19 16:32:24 DEBUG (MainThread) [homeassistant.components.zha.core.channels.base] [0xC160:38:0x0020]: Received 0 tsn command 'checkin': []
2021-08-19 16:32:26 DEBUG (MainThread) [homeassistant.components.zha.core.channels.base] [0xC160:38:0x0020]: executed 'checkin_response' command with args: '(True, 8)' kwargs: '{'tsn': 0}' result: [0, <Status.SUCCESS: 0>]
2021-08-19 16:32:27 DEBUG (MainThread) [homeassistant.components.zha.core.channels.base] [0xC160:38:0x0020]: executed 'set_long_poll_interval' command with args: '(24,)' kwargs: '{}' result: [2, <Status.SUCCESS: 0>]
2021-08-19 16:32:27 DEBUG (MainThread) [homeassistant.components.zha.core.channels.base] [0xC160:38:0x0020]: executed 'fast_poll_stop' command with args: '()' kwargs: '{}' result: [1, <Status.SUCCESS: 0>]

This happens whenever I replace the batteries or periodically once every hour

rampage128 commented 3 years ago

Good morning. I have been working on the Quirk last night and finally got it to work including proper updates, unit conversion and validation!

The VOC is now auto-reported as defined in the Spec for me: Changes over threshold each minute and changes below/no changes every 10 minutes.

Screenshot 2021-08-20 083629

Would you test and confirm if that quirk below does work and update for you too (Explanation of all changes after it)?

"""Develco Air Quality Sensor https://github.com/zigpy/zha-device-handlers/issues/995"""
import logging
from zigpy.profiles import zha
from zigpy.quirks import CustomCluster, CustomDevice
import zigpy.types as t
from zhaquirks import Bus, LocalDataCluster
from zigpy.zcl.clusters.general import (
    Basic,
    Identify,
    OnOff,
    Ota,
    PollControl,
    PowerConfiguration,
    Scenes,
    Time,
)
from zigpy.zcl.clusters.measurement import TemperatureMeasurement
from zigpy.zcl.clusters.measurement import RelativeHumidity
from zigpy.zcl import Cluster

from zhaquirks.const import (
    DEVICE_TYPE,
    ENDPOINTS,
    INPUT_CLUSTERS,
    MODELS_INFO,
    OUTPUT_CLUSTERS,
    PROFILE_ID,
)
from zhaquirks.develco import DEVELCO, DevelcoPowerConfiguration

MANUFACTURER = 0x1015
VOC_MEASURED_VALUE = 0x0000
VOC_MIN_MEASURED_VALUE = 0x0001
VOC_MAX_MEASURED_VALUE = 0x0002
VOC_RESOLUTION = 0x0003

VOC_REPORTED = "voc_reported"
MIN_VOC_REPORTED = "min_voc_reported"
MAX_VOC_REPORTED = "max_voc_reported"
VOC_RESOLUTION_REPORTED = "voc_resolution_reported"

_LOGGER = logging.getLogger(__name__)

class DevelcoVOCMeasurement(CustomCluster):
    """Input Cluster to route manufacturer specific VOC cluster to actual VOC cluster."""

    cluster_id = 0xfc03
    name = "VOC Level"
    ep_attribute = "voc_level"
    manufacturer_attributes = {
        VOC_MEASURED_VALUE: ("measured_value", t.uint16_t),
        VOC_MIN_MEASURED_VALUE: ("min_measured_value", t.uint16_t),
        VOC_MAX_MEASURED_VALUE: ("max_measured_value", t.uint16_t),
        VOC_RESOLUTION: ("resolution", t.uint16_t)
    }
    server_commands = {}
    client_commands = {}

    def __init__(self, *args, **kwargs):
        """Init."""
        self._current_state = {}
        super().__init__(*args, **kwargs)
        self.endpoint.device.app_cluster = self

    def _update_attribute(self, attrid, value):
        super()._update_attribute(attrid, value)
        if attrid == VOC_MEASURED_VALUE and value is not None:
            self.endpoint.device.voc_bus.listener_event(VOC_REPORTED, value)
        if attrid == VOC_MIN_MEASURED_VALUE and value is not None:
            self.endpoint.device.voc_bus.listener_event(MIN_VOC_REPORTED, value)
        if attrid == VOC_MAX_MEASURED_VALUE and value is not None:
            self.endpoint.device.voc_bus.listener_event(MAX_VOC_REPORTED, value)
        if attrid == VOC_RESOLUTION and value is not None:
            self.endpoint.device.voc_bus.listener_event(VOC_RESOLUTION_REPORTED, value)
        _LOGGER.debug(
            "%s Develco VOC : [%s]",
            self.endpoint.device.ieee,
            self._attr_cache,
        )

class DevelcoRelativeHumidity(RelativeHumidity):
    def _update_attribute(self, attrid, value):
        # Drop values out of specified range (0-100% RH)
        if (0 <= value <= 10000):
            super()._update_attribute(attrid, value)
        _LOGGER.debug(
            "%s Develco Humidity : [%s]",
            self.endpoint.device.ieee,
            self._attr_cache,
        )

class DevelcoTemperatureMeasurement(TemperatureMeasurement):
    def _update_attribute(self, attrid, value):
        # Drop values out of specified range (0-50°C)
        if (0 <= value <= 5000):
            super()._update_attribute(attrid, value)
        _LOGGER.debug(
            "%s Develco Temperature : [%s]",
            self.endpoint.device.ieee,
            self._attr_cache,
        )

class EmulatedVOCMeasurement(LocalDataCluster):
    """VOC measurement cluster to receive reports from the Develco VOC cluster."""

    cluster_id = 0x042E
    name = "VOC Level"
    ep_attribute = "voc_level"
    manufacturer_attributes = {
        VOC_MEASURED_VALUE: ("measured_value", t.uint16_t),
        VOC_MIN_MEASURED_VALUE: ("min_measured_value", t.uint16_t),
        VOC_MAX_MEASURED_VALUE: ("max_measured_value", t.uint16_t),
        VOC_RESOLUTION: ("resolution", t.uint16_t)
    }
    MEASURED_VALUE_ID = 0x0000
    MIN_MEASURED_VALUE_ID = 0x0001
    MAX_MEASURED_VALUE_ID = 0x0002
    RESOLUTION_ID = 0x0003

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

    async def bind(self):
        """Bind cluster."""
        result = await self.endpoint.device.app_cluster.bind()
        return result

    async def write_attributes(self, attributes, manufacturer=None):
        """Ignore write_attributes."""
        return (0,)

    def _update_attribute(self, attrid, value):
        # Drop values out of specified range (0-60000 ppb)
        if (0 <= value <= 60000):
            # Convert ppb into mg/m³ approximation according to develco spec
            value = value * 0.0000045
            super()._update_attribute(attrid, value)

    def voc_reported(self, value):
        """VOC reported."""
        self._update_attribute(self.MEASURED_VALUE_ID, value)

    def min_voc_reported(self, value):
        """Minimum Measured VOC reported."""
        self._update_attribute(self.MIN_MEASURED_VALUE_ID, value)

    def max_voc_reported(self, value):
        """Maximum Measured VOC reported."""
        self._update_attribute(self.MAX_MEASURED_VALUE_ID, value)

    def voc_resolution_reported(self, value):
        """VOC Resolution reported."""
        self._update_attribute(self.RESOLUTION_ID, value)

class AQSZB110(CustomDevice):
    """Custom device air quality sensor"""
    manufacturer_id_override = MANUFACTURER

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

    signature = {
        # <SimpleDescriptor endpoint=1 profile=49353 device_type=1 device_version=1
        # input_clusters=[3, 5, 6] output_clusters=[]>
        # <SimpleDescriptor endpoint=38 profile=260 device_type=770 device_version=0
        # input_clusters=[0, 1, 3, 32, 1026, 1029, 64515] output_clusters=[3, 10, 25]>
        MODELS_INFO: [
            (DEVELCO, "AQSZB-110"),
            ("frient A/S", "AQSZB-110"),
        ],
        ENDPOINTS: {
            1: {
                PROFILE_ID: 0xC0C9,
                DEVICE_TYPE: 1,
                INPUT_CLUSTERS: [
                    Identify.cluster_id,
                    Scenes.cluster_id,
                    OnOff.cluster_id,
                ],
                OUTPUT_CLUSTERS: [],
            },
            38: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.TEMPERATURE_SENSOR,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    PowerConfiguration.cluster_id,
                    Identify.cluster_id,
                    PollControl.cluster_id,
                    TemperatureMeasurement.cluster_id,
                    RelativeHumidity.cluster_id,
                    0xFC03,
                ],
                OUTPUT_CLUSTERS: [Identify.cluster_id, Time.cluster_id, Ota.cluster_id],
            },
        },
    }

    replacement = {
        ENDPOINTS: {
            1: {
                PROFILE_ID: 0xC0C9,
                DEVICE_TYPE: 1,
                INPUT_CLUSTERS: [
                    Identify.cluster_id,
                    Scenes.cluster_id,
                    OnOff.cluster_id,
                ],
                OUTPUT_CLUSTERS: [],
            },
            38: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.TEMPERATURE_SENSOR,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    DevelcoPowerConfiguration,
                    Identify.cluster_id,
                    PollControl.cluster_id,
                    DevelcoTemperatureMeasurement,
                    DevelcoRelativeHumidity,
                    DevelcoVOCMeasurement,
                    EmulatedVOCMeasurement,
                ],
                OUTPUT_CLUSTERS: [Identify.cluster_id, Time.cluster_id, Ota.cluster_id],
            },
        },
    }

DevelcoVOCMeasurement:

DevelcoRelativeHumidity

DevelcoTemperatureMeasurement

EmulatedVOCMeasurement

More on validation

The way the validation works is that it omits the call to self._update_attribute() if the value is out of range. This means illegal values get dropped. I am not entirely happy with that because it renders the device "stuck" (not updating any values) for 15 minutes after replacing the batteries. Originally I thought about reporting 0 as a default value, so the user can at least see that the device is not producing any readings but is still updating (working). But all other quirks do drop invalid values, so I followed the "standard".


I think we are ready for a PR draft 😅

EverythingSmartHome commented 3 years ago

@rampage128 Excellent work, it works great! Well done!

I tried the binding yesterday because I also found the Waxman quirk, but it didn't work for me at the time, but guessing I did something else wrong. Anyways, fantastic work!

rampage128 commented 3 years ago

Should I create a PR or do you want to?

EverythingSmartHome commented 3 years ago

I am happy to if you want me to or feel free, I have been working on writing quirks for all the Develco devices so will get PR's for them too.

EverythingSmartHome commented 3 years ago

@rampage128 I have went ahead and create the PR here

github-actions[bot] commented 2 years ago

There hasn't been any activity on this issue recently. Due to the high number of incoming GitHub notifications, we have to clean some of the old issues, as many of them have already been resolved with the latest updates. Please make sure to update to the latest version and check if that solves the issue. Let us know if that works for you by adding a comment 👍 This issue has now been marked as stale and will be closed if no further activity occurs. Thank you for your contributions.

X52p commented 2 years ago

Currently, it is partly working, (Temperature and Humidity are displayed but Battery and VOC are not) But there is already a new issue for this:

1273

github-actions[bot] commented 2 years ago

There hasn't been any activity on this issue recently. Due to the high number of incoming GitHub notifications, we have to clean some of the old issues, as many of them have already been resolved with the latest updates. Please make sure to update to the latest version and check if that solves the issue. Let us know if that works for you by adding a comment 👍 This issue has now been marked as stale and will be closed if no further activity occurs. Thank you for your contributions.