zha-device-handlers
zha-device-handlers copied to clipboard
Develco Air Quality Sensor
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],
},
},
}
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.
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!
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:
- Frient is a royal pain to pair initally with ZHA and raspbee2
- It generates sensors for temp and humidity, ~a battery level sensor that always says "unknown %"~ (See EDIT) and a non functioning on/off switch
- After replacing batteries it will show max-values (255/25535) for temperature/humidity/VOC for a while before the values settle to a more realistic value (they then still need an hour to get somewhat close to reality)
- Before using the quirk I actually got tons of communication errors in the zha logs and was barely able to fetch any cluster values in the cluster management (Not sure if this being fixed is really related to the quirk or repairing it 100 times tho).
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.
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!
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!
@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.
@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.
@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
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],
},
},
}
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],
},
},
}```
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!
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.
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.
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.
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?
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.
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.
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
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.
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
:
- Changed the naming to include the Manufacturer name, because this is standard for all quirks I checked so far to include it on source clusters or custom manufacturer clusters.
- Added setting app_cluster in the device to self (More in the
EmulatedVOCMeasurement
) - Added a debug logging statement in the
_update_attribute
method
DevelcoRelativeHumidity
- Added as replacement for humidity readings to do value validation. Range of sensor is
0% RH
-100% RH
. Other quirks do this everywhere to limit the value reporting to the range of the device specification or at least to drop non-sane values. - Naming is again containing the Manufacturer name to make the naming pattern consistent.
- Added a debug logging statement in the
_update_attribute
method
DevelcoTemperatureMeasurement
- Added as replacement for temperature readings to do value validation. Range of sensor is
0°C
-50°C
. - Added a debug logging statement in the
_update_attribute
method
EmulatedVOCMeasurement
- Changed name to indicate that this is an Emulated default cluster. See Waxman leaksmart quirk, which remaps cluster data in a very similar way to this quirk.
- Added
bind
method, to allow binding of this cluster. I guess this is what does the magic. When binding happens, the method calls bind on theapp_cluster
set in the device, which is theDevelcoVOCMeasurement
. This also comes from the Waxman leaksmart quirk. - Added
write-attributes
method to drop write attribute commands. Thought it could not do harm for an "emulated cluster" to do this. - Added validation to the value range. Range of sensor is
0 ppb
-60000 ppb
. - Added conversion from
ppb
tomg/m³
. I have done some test calculations with the reference VOC table in the Develco spec and found that they use a factor of approximately 4.5 to convert fromppb
toug/m³
. Now the VOC sensor already multiplies by1e6
and my guess is it does that because it expects all devices to return the official standard unit for VOC, which ismg/m³
. So I have done that by using a factor of 0.0000045 instead. Now the ppb converts almost bang on to a ug/m³ value from their table.
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 😅
@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!
Should I create a PR or do you want to?
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.
@rampage128 I have went ahead and create the PR here
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.
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
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.