From e020bee4fe50e5de7eca7e5a9ea3b30abd1824e9 Mon Sep 17 00:00:00 2001 From: Romancha Date: Wed, 5 Nov 2025 22:26:05 +0300 Subject: [PATCH 1/6] Add quirk for EfektaLab EFEKTA_iAQ3 air quality sensor with VOC measurement --- tests/test_efekta_iaq3.py | 278 +++++++++++++++++++++++++++ zhaquirks/efekta/__init__.py | 1 + zhaquirks/efekta/iaq3.py | 363 +++++++++++++++++++++++++++++++++++ 3 files changed, 642 insertions(+) create mode 100644 tests/test_efekta_iaq3.py create mode 100644 zhaquirks/efekta/__init__.py create mode 100644 zhaquirks/efekta/iaq3.py diff --git a/tests/test_efekta_iaq3.py b/tests/test_efekta_iaq3.py new file mode 100644 index 0000000000..bbfe1e2737 --- /dev/null +++ b/tests/test_efekta_iaq3.py @@ -0,0 +1,278 @@ +"""Tests for EfektaLab EFEKTA_iAQ3 air quality sensor quirk.""" + +from unittest import mock + +import pytest +from zigpy.zcl import foundation +from zigpy.zcl.clusters.general import AnalogInput +from zigpy.zcl.clusters.measurement import ( + CarbonDioxideConcentration, + RelativeHumidity, + TemperatureMeasurement, +) + +from tests.common import ClusterListener +import zhaquirks +from zhaquirks.efecta.iaq3 import ( + AnalogInputCluster, + CO2ConcentrationConfig, + EmulatedVOCMeasurement, + RelativeHumidityConfig, + TemperatureMeasurementConfig, +) + +zhaquirks.setup() + +# Test constants +MANUFACTURER = "EfektaLab" +MODEL = "EFEKTA_iAQ3" +ENDPOINT_IDS = [1, 2] + + +async def test_efekta_iaq3_device_creation(zigpy_device_from_v2_quirk): + """Test EFEKTA_iAQ3 device is created correctly with custom clusters.""" + device = zigpy_device_from_v2_quirk( + manufacturer=MANUFACTURER, + model=MODEL, + endpoint_ids=ENDPOINT_IDS, + ) + + assert device.manufacturer == MANUFACTURER + assert device.model == MODEL + + # Verify endpoint 1 has custom clusters + ep1 = device.endpoints[1] + assert TemperatureMeasurement.cluster_id in ep1.in_clusters + assert RelativeHumidity.cluster_id in ep1.in_clusters + assert CarbonDioxideConcentration.cluster_id in ep1.in_clusters + + # Verify clusters are actually the custom versions + temp_cluster = ep1.in_clusters[TemperatureMeasurement.cluster_id] + assert isinstance(temp_cluster, TemperatureMeasurementConfig) + + humidity_cluster = ep1.in_clusters[RelativeHumidity.cluster_id] + assert isinstance(humidity_cluster, RelativeHumidityConfig) + + co2_cluster = ep1.in_clusters[CarbonDioxideConcentration.cluster_id] + assert isinstance(co2_cluster, CO2ConcentrationConfig) + + # Verify endpoint 2 has VOC clusters + ep2 = device.endpoints[2] + assert AnalogInput.cluster_id in ep2.in_clusters + assert EmulatedVOCMeasurement.cluster_id in ep2.in_clusters + + analog_cluster = ep2.in_clusters[AnalogInput.cluster_id] + assert isinstance(analog_cluster, AnalogInputCluster) + + voc_cluster = ep2.in_clusters[EmulatedVOCMeasurement.cluster_id] + assert isinstance(voc_cluster, EmulatedVOCMeasurement) + + +async def test_voc_relay_functionality(zigpy_device_from_v2_quirk): + """Test VOC value relay from AnalogInput to EmulatedVOCMeasurement.""" + device = zigpy_device_from_v2_quirk( + manufacturer=MANUFACTURER, + model=MODEL, + endpoint_ids=ENDPOINT_IDS, + ) + + ep2 = device.endpoints[2] + analog_cluster = ep2.in_clusters[AnalogInput.cluster_id] + voc_cluster = ep2.in_clusters[EmulatedVOCMeasurement.cluster_id] + + # Set up listener for VOC cluster + voc_listener = ClusterListener(voc_cluster) + + # Simulate AnalogInput present_value update + test_voc_values = [50.0, 150.0, 250.0, 350.0] + + for test_value in test_voc_values: + # Update AnalogInput present_value + analog_cluster._update_attribute(AnalogInputCluster.PRESENT_VALUE, test_value) + + # Verify VOC cluster was updated with the same value + assert len(voc_listener.attribute_updates) > 0 + last_update = voc_listener.attribute_updates[-1] + assert last_update == (0x0000, test_value) # measured_value attribute + + # Verify total updates match test values + assert len(voc_listener.attribute_updates) == len(test_voc_values) + + +async def test_voc_relay_ignores_none_values(zigpy_device_from_v2_quirk): + """Test VOC relay ignores None values.""" + device = zigpy_device_from_v2_quirk( + manufacturer=MANUFACTURER, + model=MODEL, + endpoint_ids=ENDPOINT_IDS, + ) + + ep2 = device.endpoints[2] + analog_cluster = ep2.in_clusters[AnalogInput.cluster_id] + voc_cluster = ep2.in_clusters[EmulatedVOCMeasurement.cluster_id] + + voc_listener = ClusterListener(voc_cluster) + + # Update with None value - should not trigger VOC update + analog_cluster._update_attribute(AnalogInputCluster.PRESENT_VALUE, None) + + # No updates should have occurred + assert len(voc_listener.attribute_updates) == 0 + + # Now update with valid value - should trigger update + analog_cluster._update_attribute(AnalogInputCluster.PRESENT_VALUE, 100.0) + assert len(voc_listener.attribute_updates) == 1 + assert voc_listener.attribute_updates[0] == (0x0000, 100.0) + + +async def test_voc_relay_ignores_other_attributes(zigpy_device_from_v2_quirk): + """Test VOC relay only relays present_value, not other attributes.""" + device = zigpy_device_from_v2_quirk( + manufacturer=MANUFACTURER, + model=MODEL, + endpoint_ids=ENDPOINT_IDS, + ) + + ep2 = device.endpoints[2] + analog_cluster = ep2.in_clusters[AnalogInput.cluster_id] + voc_cluster = ep2.in_clusters[EmulatedVOCMeasurement.cluster_id] + + voc_listener = ClusterListener(voc_cluster) + + # Update a different attribute (not present_value) + analog_cluster._update_attribute(0x001C, 50.0) # description attribute + + # No VOC updates should have occurred + assert len(voc_listener.attribute_updates) == 0 + + +async def test_temperature_offset_attribute(zigpy_device_from_v2_quirk): + """Test temperature offset configuration attribute.""" + device = zigpy_device_from_v2_quirk( + manufacturer=MANUFACTURER, + model=MODEL, + endpoint_ids=ENDPOINT_IDS, + ) + + temp_cluster = device.endpoints[1].in_clusters[TemperatureMeasurement.cluster_id] + + # Verify the custom attribute exists in the cluster class + assert hasattr(type(temp_cluster).AttributeDefs, "temperature_offset") + temp_offset_attr = type(temp_cluster).AttributeDefs.temperature_offset + assert temp_offset_attr.id == 0x0210 + + +async def test_humidity_offset_attribute(zigpy_device_from_v2_quirk): + """Test humidity offset configuration attribute.""" + device = zigpy_device_from_v2_quirk( + manufacturer=MANUFACTURER, + model=MODEL, + endpoint_ids=ENDPOINT_IDS, + ) + + humidity_cluster = device.endpoints[1].in_clusters[RelativeHumidity.cluster_id] + + # Verify the custom attribute exists in the cluster class + assert hasattr(type(humidity_cluster).AttributeDefs, "humidity_offset") + humidity_offset_attr = type(humidity_cluster).AttributeDefs.humidity_offset + assert humidity_offset_attr.id == 0x0210 + + +async def test_co2_configuration_attributes(zigpy_device_from_v2_quirk): + """Test CO2 sensor configuration attributes exist.""" + device = zigpy_device_from_v2_quirk( + manufacturer=MANUFACTURER, + model=MODEL, + endpoint_ids=ENDPOINT_IDS, + ) + + co2_cluster = device.endpoints[1].in_clusters[CarbonDioxideConcentration.cluster_id] + + # Test key configuration attributes exist in the cluster class + config_attrs = [ + "forced_recalibration", + "auto_brightness", + "long_chart_period", + "set_altitude", + "factory_reset_co2", + "manual_forced_recalibration", + "enable_co2", + "high_co2", + "low_co2", + "invert_logic_co2", + "rotate", + "internal_or_external", + "night_onoff_backlight", + "automatic_scal", + "long_chart_period2", + "night_on_backlight", + "night_off_backlight", + ] + + for attr_name in config_attrs: + assert hasattr(type(co2_cluster).AttributeDefs, attr_name), ( + f"Missing attribute: {attr_name}" + ) + + +async def test_voc_bind_and_configure_reporting(zigpy_device_from_v2_quirk): + """Test VOC cluster bind configures reporting on AnalogInput cluster.""" + device = zigpy_device_from_v2_quirk( + manufacturer=MANUFACTURER, + model=MODEL, + endpoint_ids=ENDPOINT_IDS, + ) + + ep2 = device.endpoints[2] + analog_cluster = ep2.in_clusters[AnalogInput.cluster_id] + voc_cluster = ep2.in_clusters[EmulatedVOCMeasurement.cluster_id] + + # Mock the bind and configure_reporting methods + patch_analog_bind = mock.patch.object( + analog_cluster, + "bind", + mock.AsyncMock(return_value=[foundation.Status.SUCCESS]), + ) + + patch_analog_configure = mock.patch.object( + analog_cluster, + "configure_reporting", + mock.AsyncMock(return_value=[foundation.Status.SUCCESS]), + ) + + with patch_analog_bind, patch_analog_configure: + # Call bind on the VOC cluster + result = await voc_cluster.bind() + + # Verify bind was called on the AnalogInput cluster + assert analog_cluster.bind.called + + # Verify configure_reporting was called with correct parameters + assert analog_cluster.configure_reporting.called + call_args = analog_cluster.configure_reporting.call_args[0] + assert call_args[0] == AnalogInputCluster.PRESENT_VALUE # attribute + assert call_args[1] == 30 # min_interval + assert call_args[2] == 600 # max_interval + assert call_args[3] == 1.0 # reportable_change + + # Result should be from the AnalogInput bind + assert result == [foundation.Status.SUCCESS] + + +@pytest.mark.parametrize( + "attr_name,attr_id", + [ + ("temperature_offset", 0x0210), + ("humidity_offset", 0x0210), + ], +) +def test_offset_attribute_definitions(attr_name, attr_id): + """Test offset attribute definitions have correct IDs and types.""" + if attr_name == "temperature_offset": + attr_def = TemperatureMeasurementConfig.AttributeDefs.temperature_offset + else: + attr_def = RelativeHumidityConfig.AttributeDefs.humidity_offset + + assert attr_def.id == attr_id + # Verify type is defined (int16s) + assert attr_def.type is not None diff --git a/zhaquirks/efekta/__init__.py b/zhaquirks/efekta/__init__.py new file mode 100644 index 0000000000..b718e2c950 --- /dev/null +++ b/zhaquirks/efekta/__init__.py @@ -0,0 +1 @@ +"""Module for EfektaLab devices.""" \ No newline at end of file diff --git a/zhaquirks/efekta/iaq3.py b/zhaquirks/efekta/iaq3.py new file mode 100644 index 0000000000..679438691c --- /dev/null +++ b/zhaquirks/efekta/iaq3.py @@ -0,0 +1,363 @@ +"""Quirk for EfektaLab EFEKTA_iAQ3 air quality sensor.""" + +from __future__ import annotations + +import logging +from typing import Final + +from zigpy.quirks import CustomCluster +from zigpy.quirks.v2 import EntityType, NumberDeviceClass, QuirkBuilder +from zigpy.quirks.v2.homeassistant import PERCENTAGE, UnitOfTemperature +from zigpy.quirks.v2.homeassistant.sensor import SensorDeviceClass, SensorStateClass +import zigpy.types as t +from zigpy.zcl.clusters.general import AnalogInput +from zigpy.zcl.clusters.measurement import ( + CarbonDioxideConcentration, + RelativeHumidity, + TemperatureMeasurement, +) +from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef + +from zhaquirks import LocalDataCluster + +_LOGGER = logging.getLogger(__name__) + +MEASURED_VALUE = 0x0000 + + +class DisplayRotation(t.enum16): + """Display rotation options.""" + + Degrees_0 = 0 + Degrees_90 = 90 + Degrees_180 = 180 + Degrees_270 = 270 + + +class TemperatureMeasurementConfig(CustomCluster, TemperatureMeasurement): + """Temperature measurement cluster with calibration offset.""" + + cluster_id = TemperatureMeasurement.cluster_id + + class AttributeDefs(TemperatureMeasurement.AttributeDefs): + """Attribute definitions.""" + + temperature_offset: Final = ZCLAttributeDef( + id=0x0210, type=t.int16s, access="rw" + ) + + +class RelativeHumidityConfig(CustomCluster, RelativeHumidity): + """Relative humidity cluster with calibration offset.""" + + cluster_id = RelativeHumidity.cluster_id + + class AttributeDefs(RelativeHumidity.AttributeDefs): + """Attribute definitions.""" + + humidity_offset: Final = ZCLAttributeDef(id=0x0210, type=t.int16s, access="rw") + + +class CO2ConcentrationConfig(CustomCluster, CarbonDioxideConcentration): + """CO2 concentration cluster with configuration attributes.""" + + cluster_id = CarbonDioxideConcentration.cluster_id + + class AttributeDefs(CarbonDioxideConcentration.AttributeDefs): + """Attribute definitions.""" + + # CO2 sensor settings + forced_recalibration: Final = ZCLAttributeDef( + id=0x0202, type=t.Bool, access="rw" + ) + auto_brightness: Final = ZCLAttributeDef(id=0x0203, type=t.Bool, access="rw") + long_chart_period: Final = ZCLAttributeDef(id=0x0204, type=t.Bool, access="rw") + set_altitude: Final = ZCLAttributeDef(id=0x0205, type=t.uint16_t, access="rw") + factory_reset_co2: Final = ZCLAttributeDef(id=0x0206, type=t.Bool, access="rw") + manual_forced_recalibration: Final = ZCLAttributeDef( + id=0x0207, type=t.uint16_t, access="rw" + ) + + # CO2 gas control settings + enable_co2: Final = ZCLAttributeDef(id=0x0220, type=t.Bool, access="rw") + high_co2: Final = ZCLAttributeDef(id=0x0221, type=t.uint16_t, access="rw") + low_co2: Final = ZCLAttributeDef(id=0x0222, type=t.uint16_t, access="rw") + invert_logic_co2: Final = ZCLAttributeDef(id=0x0225, type=t.Bool, access="rw") + + # Display settings + rotate: Final = ZCLAttributeDef(id=0x0285, type=t.uint16_t, access="rw") + internal_or_external: Final = ZCLAttributeDef( + id=0x0288, type=t.Bool, access="rw" + ) + night_onoff_backlight: Final = ZCLAttributeDef( + id=0x0401, type=t.Bool, access="rw" + ) + automatic_scal: Final = ZCLAttributeDef(id=0x0402, type=t.Bool, access="rw") + long_chart_period2: Final = ZCLAttributeDef(id=0x0404, type=t.Bool, access="rw") + night_on_backlight: Final = ZCLAttributeDef( + id=0x0405, type=t.uint8_t, access="rw" + ) + night_off_backlight: Final = ZCLAttributeDef( + id=0x0406, type=t.uint8_t, access="rw" + ) + + +class AnalogInputCluster(CustomCluster, AnalogInput): + """Analog input cluster that relays VOC index to emulated VOC measurement cluster.""" + + cluster_id = AnalogInput.cluster_id + PRESENT_VALUE = 0x0055 + + def _update_attribute(self, attrid, value): + """Intercept present_value updates and relay to VOC cluster.""" + super()._update_attribute(attrid, value) + if attrid == self.PRESENT_VALUE and value is not None: + self.endpoint.voc_level._update_attribute(MEASURED_VALUE, value) + + +class EmulatedVOCMeasurement(LocalDataCluster): + """VOC measurement cluster that receives relayed data from AnalogInput cluster. + + This cluster emulates a standard VOC Level cluster (0x042E) to expose + VOC index readings from the device's AnalogInput cluster to Home Assistant. + Using device_class=AQI ensures the value displays as dimensionless (0-500). + """ + + cluster_id = 0x042E # Standard VOC Level cluster + name = "VOC Level" + ep_attribute = "voc_level" + + class AttributeDefs(BaseAttributeDefs): + """Attribute definitions.""" + + measured_value: Final = ZCLAttributeDef( + id=MEASURED_VALUE, type=t.Single, access="rp" + ) + + async def bind(self): + """Bind cluster and configure reporting on the physical AnalogInput cluster.""" + result = await self.endpoint.analog_input.bind() + await self.endpoint.analog_input.configure_reporting( + AnalogInputCluster.PRESENT_VALUE, + 30, # min_interval: 30 seconds + 600, # max_interval: 10 minutes + 1.0, # reportable_change: 1 unit + ) + return result + + +( + QuirkBuilder("EfektaLab", "EFEKTA_iAQ3") + # Replace standard clusters with config-enabled versions + .replaces(TemperatureMeasurementConfig, endpoint_id=1) + .replaces(RelativeHumidityConfig, endpoint_id=1) + .replaces(CO2ConcentrationConfig, endpoint_id=1) + # VOC sensor support (Endpoint 2) + .replaces(AnalogInputCluster, endpoint_id=2) + .adds(EmulatedVOCMeasurement, endpoint_id=2) + # VOC sensor entity + .sensor( + EmulatedVOCMeasurement.AttributeDefs.measured_value.name, + EmulatedVOCMeasurement.cluster_id, + endpoint_id=2, + device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, + translation_key="voc_index", + fallback_name="VOC index", + ) + # Temperature/Humidity offset configuration + .number( + TemperatureMeasurementConfig.AttributeDefs.temperature_offset.name, + TemperatureMeasurementConfig.cluster_id, + endpoint_id=1, + min_value=-500, + max_value=500, + step=1, + multiplier=0.1, + unit=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + entity_type=EntityType.CONFIG, + translation_key="temperature_offset", + fallback_name="Temperature offset", + ) + .number( + RelativeHumidityConfig.AttributeDefs.humidity_offset.name, + RelativeHumidityConfig.cluster_id, + endpoint_id=1, + min_value=-50, + max_value=50, + step=1, + unit=PERCENTAGE, + device_class=NumberDeviceClass.HUMIDITY, + entity_type=EntityType.CONFIG, + translation_key="humidity_offset", + fallback_name="Humidity offset", + ) + # CO2 sensor settings + .number( + CO2ConcentrationConfig.AttributeDefs.set_altitude.name, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + min_value=0, + max_value=3000, + step=1, + entity_type=EntityType.CONFIG, + translation_key="set_altitude", + fallback_name="Altitude above sea level", + ) + .number( + CO2ConcentrationConfig.AttributeDefs.manual_forced_recalibration.name, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + min_value=0, + max_value=5000, + step=1, + entity_type=EntityType.CONFIG, + translation_key="manual_forced_recalibration", + fallback_name="Manual CO2 calibration (ppm)", + ) + .switch( + CO2ConcentrationConfig.AttributeDefs.forced_recalibration.name, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="forced_recalibration", + fallback_name="Force CO2 recalibration", + ) + .switch( + CO2ConcentrationConfig.AttributeDefs.factory_reset_co2.name, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="factory_reset_co2", + fallback_name="Factory reset CO2 sensor", + ) + .switch( + CO2ConcentrationConfig.AttributeDefs.automatic_scal.name, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="automatic_scal", + fallback_name="Automatic CO2 calibration", + ) + # Display settings + # Use internal temperature/humidity sensor for display if ON, external if OFF + .switch( + CO2ConcentrationConfig.AttributeDefs.internal_or_external.name, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="internal_or_external", + fallback_name="Use internal TH sensor", + off_value=0, + on_value=1, + ) + .enum( + CO2ConcentrationConfig.AttributeDefs.rotate.name, + DisplayRotation, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="rotate", + fallback_name="Display rotation", + ) + .switch( + CO2ConcentrationConfig.AttributeDefs.auto_brightness.name, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="auto_brightness", + fallback_name="Automatic brightness", + ) + .switch( + CO2ConcentrationConfig.AttributeDefs.night_onoff_backlight.name, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="night_onoff_backlight", + fallback_name="Night mode backlight off", + ) + .number( + CO2ConcentrationConfig.AttributeDefs.night_on_backlight.name, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + min_value=0, + max_value=23, + step=1, + entity_type=EntityType.CONFIG, + translation_key="night_on_backlight", + fallback_name="Night mode start hour", + ) + .number( + CO2ConcentrationConfig.AttributeDefs.night_off_backlight.name, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + min_value=0, + max_value=23, + step=1, + entity_type=EntityType.CONFIG, + translation_key="night_off_backlight", + fallback_name="Night mode end hour", + ) + # Chart period settings (OFF=1H, ON=24H) + .switch( + CO2ConcentrationConfig.AttributeDefs.long_chart_period.name, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="long_chart_period", + fallback_name="CO2 chart period 24H", + off_value=0, + on_value=1, + ) + .switch( + CO2ConcentrationConfig.AttributeDefs.long_chart_period2.name, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="long_chart_period2", + fallback_name="VOC chart period 24H", + off_value=0, + on_value=1, + ) + # CO2 gas/relay control settings + .switch( + CO2ConcentrationConfig.AttributeDefs.enable_co2.name, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="enable_co2", + fallback_name="Enable CO2-based relay control", + ) + .switch( + CO2ConcentrationConfig.AttributeDefs.invert_logic_co2.name, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="invert_logic_co2", + fallback_name="Invert CO2 relay logic", + ) + .number( + CO2ConcentrationConfig.AttributeDefs.high_co2.name, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + min_value=400, + max_value=5000, + step=1, + entity_type=EntityType.CONFIG, + translation_key="high_co2", + fallback_name="CO2 high threshold (ppm)", + ) + .number( + CO2ConcentrationConfig.AttributeDefs.low_co2.name, + CO2ConcentrationConfig.cluster_id, + endpoint_id=1, + min_value=400, + max_value=5000, + step=1, + entity_type=EntityType.CONFIG, + translation_key="low_co2", + fallback_name="CO2 low threshold (ppm)", + ) + .add_to_registry() +) From b1d6cb1a25d805fa4e4c687ee46299a7ca52f314 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 7 Nov 2025 10:23:36 +0000 Subject: [PATCH 2/6] Apply pre-commit auto fixes --- zhaquirks/efekta/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zhaquirks/efekta/__init__.py b/zhaquirks/efekta/__init__.py index b718e2c950..ab650f20fb 100644 --- a/zhaquirks/efekta/__init__.py +++ b/zhaquirks/efekta/__init__.py @@ -1 +1 @@ -"""Module for EfektaLab devices.""" \ No newline at end of file +"""Module for EfektaLab devices.""" From 83a038795da9220395b127cea65f6f1506af0288 Mon Sep 17 00:00:00 2001 From: Romancha Date: Fri, 7 Nov 2025 13:28:21 +0300 Subject: [PATCH 3/6] fix module path --- tests/test_efekta_iaq3.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_efekta_iaq3.py b/tests/test_efekta_iaq3.py index bbfe1e2737..b3ed26de1b 100644 --- a/tests/test_efekta_iaq3.py +++ b/tests/test_efekta_iaq3.py @@ -13,7 +13,7 @@ from tests.common import ClusterListener import zhaquirks -from zhaquirks.efecta.iaq3 import ( +from zhaquirks.efekta.iaq3 import ( AnalogInputCluster, CO2ConcentrationConfig, EmulatedVOCMeasurement, From bcd949145116f17821e179804eed2e678b6697c7 Mon Sep 17 00:00:00 2001 From: Romancha Date: Fri, 7 Nov 2025 18:28:00 +0300 Subject: [PATCH 4/6] Refactor Efekta iAQ3 integration --- tests/test_efekta_iaq3.py | 12 ++++++------ zhaquirks/efekta/iaq3.py | 22 ++++++++++------------ 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/tests/test_efekta_iaq3.py b/tests/test_efekta_iaq3.py index b3ed26de1b..3b1e92238e 100644 --- a/tests/test_efekta_iaq3.py +++ b/tests/test_efekta_iaq3.py @@ -14,8 +14,8 @@ from tests.common import ClusterListener import zhaquirks from zhaquirks.efekta.iaq3 import ( - AnalogInputCluster, CO2ConcentrationConfig, + EfektaVocAnalogInput, EmulatedVOCMeasurement, RelativeHumidityConfig, TemperatureMeasurementConfig, @@ -62,7 +62,7 @@ async def test_efekta_iaq3_device_creation(zigpy_device_from_v2_quirk): assert EmulatedVOCMeasurement.cluster_id in ep2.in_clusters analog_cluster = ep2.in_clusters[AnalogInput.cluster_id] - assert isinstance(analog_cluster, AnalogInputCluster) + assert isinstance(analog_cluster, EfektaVocAnalogInput) voc_cluster = ep2.in_clusters[EmulatedVOCMeasurement.cluster_id] assert isinstance(voc_cluster, EmulatedVOCMeasurement) @@ -88,7 +88,7 @@ async def test_voc_relay_functionality(zigpy_device_from_v2_quirk): for test_value in test_voc_values: # Update AnalogInput present_value - analog_cluster._update_attribute(AnalogInputCluster.PRESENT_VALUE, test_value) + analog_cluster._update_attribute(EfektaVocAnalogInput.PRESENT_VALUE, test_value) # Verify VOC cluster was updated with the same value assert len(voc_listener.attribute_updates) > 0 @@ -114,13 +114,13 @@ async def test_voc_relay_ignores_none_values(zigpy_device_from_v2_quirk): voc_listener = ClusterListener(voc_cluster) # Update with None value - should not trigger VOC update - analog_cluster._update_attribute(AnalogInputCluster.PRESENT_VALUE, None) + analog_cluster._update_attribute(EfektaVocAnalogInput.PRESENT_VALUE, None) # No updates should have occurred assert len(voc_listener.attribute_updates) == 0 # Now update with valid value - should trigger update - analog_cluster._update_attribute(AnalogInputCluster.PRESENT_VALUE, 100.0) + analog_cluster._update_attribute(EfektaVocAnalogInput.PRESENT_VALUE, 100.0) assert len(voc_listener.attribute_updates) == 1 assert voc_listener.attribute_updates[0] == (0x0000, 100.0) @@ -250,7 +250,7 @@ async def test_voc_bind_and_configure_reporting(zigpy_device_from_v2_quirk): # Verify configure_reporting was called with correct parameters assert analog_cluster.configure_reporting.called call_args = analog_cluster.configure_reporting.call_args[0] - assert call_args[0] == AnalogInputCluster.PRESENT_VALUE # attribute + assert call_args[0] == EfektaVocAnalogInput.PRESENT_VALUE # attribute assert call_args[1] == 30 # min_interval assert call_args[2] == 600 # max_interval assert call_args[3] == 1.0 # reportable_change diff --git a/zhaquirks/efekta/iaq3.py b/zhaquirks/efekta/iaq3.py index 679438691c..3e192129a1 100644 --- a/zhaquirks/efekta/iaq3.py +++ b/zhaquirks/efekta/iaq3.py @@ -22,8 +22,6 @@ _LOGGER = logging.getLogger(__name__) -MEASURED_VALUE = 0x0000 - class DisplayRotation(t.enum16): """Display rotation options.""" @@ -102,7 +100,7 @@ class AttributeDefs(CarbonDioxideConcentration.AttributeDefs): ) -class AnalogInputCluster(CustomCluster, AnalogInput): +class EfektaVocAnalogInput(CustomCluster, AnalogInput): """Analog input cluster that relays VOC index to emulated VOC measurement cluster.""" cluster_id = AnalogInput.cluster_id @@ -112,7 +110,9 @@ def _update_attribute(self, attrid, value): """Intercept present_value updates and relay to VOC cluster.""" super()._update_attribute(attrid, value) if attrid == self.PRESENT_VALUE and value is not None: - self.endpoint.voc_level._update_attribute(MEASURED_VALUE, value) + self.endpoint.voc_level._update_attribute( + EmulatedVOCMeasurement.AttributeDefs.measured_value.id, value + ) class EmulatedVOCMeasurement(LocalDataCluster): @@ -130,18 +130,16 @@ class EmulatedVOCMeasurement(LocalDataCluster): class AttributeDefs(BaseAttributeDefs): """Attribute definitions.""" - measured_value: Final = ZCLAttributeDef( - id=MEASURED_VALUE, type=t.Single, access="rp" - ) + measured_value: Final = ZCLAttributeDef(id=0x0000, type=t.Single, access="rp") async def bind(self): """Bind cluster and configure reporting on the physical AnalogInput cluster.""" result = await self.endpoint.analog_input.bind() await self.endpoint.analog_input.configure_reporting( - AnalogInputCluster.PRESENT_VALUE, - 30, # min_interval: 30 seconds - 600, # max_interval: 10 minutes - 1.0, # reportable_change: 1 unit + EfektaVocAnalogInput.AttributeDefs.present_value.id, + 30, + 600, + 1.0, ) return result @@ -153,7 +151,7 @@ async def bind(self): .replaces(RelativeHumidityConfig, endpoint_id=1) .replaces(CO2ConcentrationConfig, endpoint_id=1) # VOC sensor support (Endpoint 2) - .replaces(AnalogInputCluster, endpoint_id=2) + .replaces(EfektaVocAnalogInput, endpoint_id=2) .adds(EmulatedVOCMeasurement, endpoint_id=2) # VOC sensor entity .sensor( From 799d71b7ebb9f37cede40fbe9429c1d5730145b6 Mon Sep 17 00:00:00 2001 From: Romancha Date: Fri, 7 Nov 2025 18:33:35 +0300 Subject: [PATCH 5/6] Update iaq3 reporting configuration parameters --- zhaquirks/efekta/iaq3.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zhaquirks/efekta/iaq3.py b/zhaquirks/efekta/iaq3.py index 3e192129a1..a5ae68fb92 100644 --- a/zhaquirks/efekta/iaq3.py +++ b/zhaquirks/efekta/iaq3.py @@ -137,9 +137,9 @@ async def bind(self): result = await self.endpoint.analog_input.bind() await self.endpoint.analog_input.configure_reporting( EfektaVocAnalogInput.AttributeDefs.present_value.id, - 30, - 600, - 1.0, + min_interval=30, + max_interval=600, + reportable_change=1, ) return result From 6e1c42e6713be4aa58392c055f1814e1849899c8 Mon Sep 17 00:00:00 2001 From: Romancha Date: Fri, 7 Nov 2025 18:47:27 +0300 Subject: [PATCH 6/6] Update iaq3 test with named params --- tests/test_efekta_iaq3.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_efekta_iaq3.py b/tests/test_efekta_iaq3.py index 3b1e92238e..6b30b8ecc5 100644 --- a/tests/test_efekta_iaq3.py +++ b/tests/test_efekta_iaq3.py @@ -249,11 +249,11 @@ async def test_voc_bind_and_configure_reporting(zigpy_device_from_v2_quirk): # Verify configure_reporting was called with correct parameters assert analog_cluster.configure_reporting.called - call_args = analog_cluster.configure_reporting.call_args[0] - assert call_args[0] == EfektaVocAnalogInput.PRESENT_VALUE # attribute - assert call_args[1] == 30 # min_interval - assert call_args[2] == 600 # max_interval - assert call_args[3] == 1.0 # reportable_change + call_args = analog_cluster.configure_reporting.call_args + assert call_args[0][0] == AnalogInput.AttributeDefs.present_value.id + assert call_args[1]["min_interval"] == 30 + assert call_args[1]["max_interval"] == 600 + assert call_args[1]["reportable_change"] == 1 # Result should be from the AnalogInput bind assert result == [foundation.Status.SUCCESS]