Repository: zigpy/zha-device-handlers
Pull request #2540 (2023-08-23 09:25:46)

Add ThirdReality multifunction night light
## Proposed change
<!--
  Explain your proposed change below.
  Add ThirdReality multifunction night light.(Replace ias zone with private cluster)
-->


## Additional information
<!--
  Please include any additional information that is important to this PR.
  For example, if this PR is a potentially breaking change, mention that here.
  If this PR requires other PRs to be merged in HA Core or other projects, mention that.
  Lastly, if this PR fixes a specific issue, please include "Fixes #xxxx".
 #2461 
-->


## Checklist
<!--
  Put an 'x' in all boxes that apply.
  Note: You do not need to tick all boxes before creating a PR.
-->

- [ ] The changes are tested and work correctly
- [ ] `pre-commit` checks pass / the code has been formatted using Black
- [ ] Tests have been added to verify that the new code works


llhappier: Hi, 
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/2540?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) Report
Patch coverage: **`90.47%`** and project coverage change: **`+0.01%`** :tada:
> Comparison is base [(`e9099ed`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/e9099ed26eb129e960934965cf5f0b1273b38e4f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) 85.40% compared to head [(`321d4a7`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/2540?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) 85.41%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##              dev    #2540      +/-   ##
==========================================
+ Coverage   85.40%   85.41%   +0.01%     
==========================================
  Files         273      274       +1     
  Lines        8557     8578      +21     
==========================================
+ Hits         7308     7327      +19     
- Misses       1249     1251       +2     
```


| [Files Changed](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/2540?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) | Coverage Δ | |
|---|---|---|
| [zhaquirks/thirdreality/night\_light.py](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/2540?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL3RoaXJkcmVhbGl0eS9uaWdodF9saWdodC5weQ==) | `90.47% <90.47%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/2540?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).


Patch:
diff --git a/zhaquirks/thirdreality/night_light.py b/zhaquirks/thirdreality/night_light.py
new file mode 100644
index 0000000..990b2b1
--- /dev/null
+++ b/zhaquirks/thirdreality/night_light.py
@@ -0,0 +1,106 @@
+"""Third Reality night light zigbee devices."""
+from zigpy.profiles import zha
+from zigpy.quirks import CustomCluster, CustomDevice
+from zigpy.zcl.clusters.general import (
+    Basic,
+    Groups,
+    Identify,
+    LevelControl,
+    MultistateInput,
+    OnOff,
+    Ota,
+    Scenes,
+)
+from zigpy.zcl.clusters.lighting import Color
+from zigpy.zcl.clusters.lightlink import LightLink
+from zigpy.zcl.clusters.measurement import IlluminanceMeasurement
+from zigpy.zcl.clusters.security import IasZone
+
+from zhaquirks import LocalDataCluster
+from zhaquirks.const import (
+    DEVICE_TYPE,
+    ENDPOINTS,
+    INPUT_CLUSTERS,
+    MODELS_INFO,
+    OUTPUT_CLUSTERS,
+    PROFILE_ID,
+)
+from zhaquirks.thirdreality import THIRD_REALITY
+
+THIRD_REALITY_CLUSTER_ID = 0xFC00
+THIRD_REALITY_MOTION_EVENT_ATTR_ID = 0x0002
+
+
+class ThirdRealitySpecificCluster(CustomCluster):
+    """Manufacturer specific cluster to relay motion event to IAS Zone cluster."""
+
+    cluster_id = THIRD_REALITY_CLUSTER_ID
+
+    def _update_attribute(self, attrid, value):
+        super()._update_attribute(attrid, value)
+        if attrid == THIRD_REALITY_MOTION_EVENT_ATTR_ID:
+            self.endpoint.ias_zone.update_attribute(
+                IasZone.AttributeDefs.zone_status.id, value
+            )
+
+
+class LocalIasZone(LocalDataCluster, IasZone):
+    """Local IAS Zone cluster."""
+
+    _CONSTANT_ATTRIBUTES = {
+        IasZone.AttributeDefs.zone_type.id: IasZone.ZoneType.Motion_Sensor
+    }
+
+
+class Nightlight(CustomDevice):
+    """Custom device for 3RSNL02043Z."""
+
+    signature = {
+        MODELS_INFO: [(THIRD_REALITY, "3RSNL02043Z")],
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    MultistateInput.cluster_id,
+                    Color.cluster_id,
+                    IlluminanceMeasurement.cluster_id,
+                    LightLink.cluster_id,
+                    ThirdRealitySpecificCluster.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Ota.cluster_id,
+                ],
+            }
+        },
+    }
+    replacement = {
+        ENDPOINTS: {
+            1: {
+                DEVICE_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    MultistateInput.cluster_id,
+                    Color.cluster_id,
+                    IlluminanceMeasurement.cluster_id,
+                    LightLink.cluster_id,
+                    LocalIasZone,
+                    ThirdRealitySpecificCluster,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Ota.cluster_id,
+                ],
+            }
+        }
+    }
diff --git a/zhaquirks/tuya/ts0001_fingerbot.py b/zhaquirks/tuya/ts0001_fingerbot.py
new file mode 100644
index 0000000..8f32f1a
--- /dev/null
+++ b/zhaquirks/tuya/ts0001_fingerbot.py
@@ -0,0 +1,153 @@
+from typing import Any, Dict, Optional, Union
+
+from zigpy.profiles import zha
+import zigpy.types as t
+from zigpy.zcl import foundation
+from zigpy.zcl.clusters.general import Basic, Ota, Time
+
+from zhaquirks.const import (
+    DEVICE_TYPE,
+    ENDPOINTS,
+    INPUT_CLUSTERS,
+    MODELS_INFO,
+    OUTPUT_CLUSTERS,
+    PROFILE_ID,
+)
+from zhaquirks.tuya import TUYA_SEND_DATA
+from zhaquirks.tuya.mcu import (
+    DPToAttributeMapping,
+    EnchantedDevice,
+    TuyaMCUCluster,
+    TuyaOnOff,
+    TuyaPowerConfigurationCluster,
+)
+
+
+class FingerBotMode(t.enum8):
+    CLICK = 0x00
+    SWITCH = 0x01
+    PROGRAM = 0x02
+
+
+class FingerBotReverse(t.enum8):
+    UP_ON = 0x00
+    UP_OFF = 0x01
+
+
+class TuyaFingerbotCluster(TuyaMCUCluster):
+    attributes = TuyaMCUCluster.attributes.copy()
+    attributes.update(
+        {
+            101: ("mode", FingerBotMode),
+            102: ("down_movement", t.uint16_t),
+            103: ("sustain_time", t.uint16_t),
+            104: ("reverse", FingerBotReverse),
+            105: ("battery", t.uint16_t),
+            106: ("up_movement", t.uint16_t),
+            107: ("touch_control", t.Bool),
+        }
+    )
+
+    async def command(
+        self,
+        command_id: Union[foundation.GeneralCommand, int, t.uint8_t],
+        *args,
+        manufacturer: Optional[Union[int, t.uint16_t]] = None,
+        expect_reply: bool = True,
+        tsn: Optional[Union[int, t.uint8_t]] = None,
+        **kwargs: Any,
+    ):
+        """Override the default Cluster command."""
+
+        return await super().command(
+            TUYA_SEND_DATA,
+            *args,
+            manufacturer=foundation.ZCLHeader.NO_MANUFACTURER_ID,
+            expect_reply=expect_reply,
+            tsn=tsn,
+            **kwargs,
+        )
+
+    dp_to_attribute: Dict[int, DPToAttributeMapping] = {
+        1: DPToAttributeMapping(TuyaOnOff.ep_attribute, "on_off"),
+        # Mode
+        101: DPToAttributeMapping(
+            TuyaMCUCluster.ep_attribute,
+            "mode",
+            converter=lambda x: FingerBotMode(x),
+        ),
+        # Down Movement
+        102: DPToAttributeMapping(
+            TuyaMCUCluster.ep_attribute,
+            "down_movement",
+        ),
+        # Sustain Time
+        103: DPToAttributeMapping(TuyaMCUCluster.ep_attribute, "sustain_time"),
+        # Reverse
+        104: DPToAttributeMapping(
+            TuyaMCUCluster.ep_attribute,
+            "reverse",
+            converter=lambda x: FingerBotReverse(x),
+        ),
+        # Battery
+        105: DPToAttributeMapping(
+            TuyaPowerConfigurationCluster.ep_attribute,
+            "battery_percentage_remaining",
+        ),
+        # Up Movement
+        106: DPToAttributeMapping(
+            TuyaMCUCluster.ep_attribute,
+            "up_movement",
+        ),
+        107: DPToAttributeMapping(TuyaMCUCluster.ep_attribute, "touch_control"),
+    }
+
+    data_point_handlers = {
+        1: "_dp_2_attr_update",
+        101: "_dp_2_attr_update",
+        102: "_dp_2_attr_update",
+        103: "_dp_2_attr_update",
+        104: "_dp_2_attr_update",
+        105: "_dp_2_attr_update",
+        106: "_dp_2_attr_update",
+        107: "_dp_2_attr_update",
+    }
+
+
+class TuyaFingerbot(EnchantedDevice):
+    signature = {
+        MODELS_INFO: [("_TZ3210_dse8ogfy", "TS0001")],
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    TuyaOnOff.cluster_id,
+                    TuyaFingerbotCluster.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Time.cluster_id,
+                    Ota.cluster_id,
+                ],
+            }
+        },
+    }
+
+    replacement = {
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    TuyaOnOff,
+                    TuyaFingerbotCluster,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Time.cluster_id,
+                    Ota.cluster_id,
+                ],
+            }
+        }
+    }
diff --git a/zhaquirks/tuya/ts0601_smoke.py b/zhaquirks/tuya/ts0601_smoke.py
index f7ec98a..0f2b4a9 100644
--- a/zhaquirks/tuya/ts0601_smoke.py
+++ b/zhaquirks/tuya/ts0601_smoke.py
@@ -81,6 +81,7 @@ class TuyaSmokeDetector0601(CustomDevice):
             ("_TZE200_m9skfctm", "TS0601"),
             ("_TZE200_ntcy3xu1", "TS0601"),
             ("_TZE200_vzekyi4c", "TS0601"),
+            ("_TZE204_ntcy3xu1", "TS0601"),
         ],
         ENDPOINTS: {
             1: {
diff --git a/zhaquirks/xiaomi/__init__.py b/zhaquirks/xiaomi/__init__.py
index 52148ae..c945b36 100644
--- a/zhaquirks/xiaomi/__init__.py
+++ b/zhaquirks/xiaomi/__init__.py
@@ -25,13 +25,13 @@ from zigpy.zcl.clusters.measurement import (
     RelativeHumidity,
     TemperatureMeasurement,
 )
+from zigpy.zcl.clusters.security import IasZone
 from zigpy.zcl.clusters.smartenergy import Metering
 import zigpy.zcl.foundation as foundation
 import zigpy.zdo
 from zigpy.zdo.types import NodeDescriptor
 
 from zhaquirks import (
-    Bus,
     LocalDataCluster,
     MotionOnEvent,
     OccupancyWithReset,
@@ -45,19 +45,16 @@ from zhaquirks.const import (
     UNKNOWN,
     VALUE,
     ZHA_SEND_EVENT,
-    ZONE_STATUS,
 )
 
 BATTERY_LEVEL = "battery_level"
 BATTERY_PERCENTAGE_REMAINING = 0x0021
 BATTERY_PERCENTAGE_REMAINING_ATTRIBUTE = "battery_percentage"
-BATTERY_REPORTED = "battery_reported"
 BATTERY_SIZE = "battery_size"
 BATTERY_SIZE_ATTR = 0x0031
 BATTERY_QUANTITY_ATTR = 0x0033
 BATTERY_VOLTAGE_MV = "battery_voltage_mV"
 HUMIDITY_MEASUREMENT = "humidity_measurement"
-HUMIDITY_REPORTED = "humidity_reported"
 LUMI = "LUMI"
 MODEL = 5
 MOTION_SENSITIVITY = "motion_sensitivity"
@@ -69,22 +66,16 @@ POWER = "power"
 CONSUMPTION = "consumption"
 VOLTAGE = "voltage"
 PRESSURE_MEASUREMENT = "pressure_measurement"
-PRESSURE_REPORTED = "pressure_reported"
 STATE = "state"
 TEMPERATURE = "temperature"
 TEMPERATURE_MEASUREMENT = "temperature_measurement"
 TVOC_MEASUREMENT = "tvoc_measurement"
-TEMPERATURE_REPORTED = "temperature_reported"
-POWER_REPORTED = "power_reported"
 POWER_OUTAGE_COUNT = "power_outage_count"
 PRESENCE_DETECTED = "presence_detected"
 PRESENCE_EVENT = "presence_event"
 MONITORING_MODE = "monitoring_mode"
 APPROACH_DISTANCE = "approach_distance"
-CONSUMPTION_REPORTED = "consumption_reported"
-VOLTAGE_REPORTED = "voltage_reported"
 ILLUMINANCE_MEASUREMENT = "illuminance_measurement"
-ILLUMINANCE_REPORTED = "illuminance_reported"
 SMOKE = "smoke"
 SMOKE_DENSITY = "smoke_density"
 SELF_TEST = "self_test"
@@ -110,7 +101,6 @@ XIAOMI_NODE_DESC = NodeDescriptor(
     maximum_outgoing_transfer_size=100,
     descriptor_capability_field=0,
 )
-ZONE_TYPE = 0x0001
 
 
 _LOGGER = logging.getLogger(__name__)
@@ -121,7 +111,6 @@ class XiaomiCustomDevice(CustomDevice):
 
     def __init__(self, *args, **kwargs):
         """Init."""
-        self.battery_bus = Bus()
         if not hasattr(self, BATTERY_SIZE):
             self.battery_size = 10
         super().__init__(*args, **kwargs)
@@ -137,7 +126,7 @@ class XiaomiCluster(CustomCluster):
     def _iter_parse_attr_report(
         self, data: bytes
     ) -> Iterator[tuple[foundation.Attribute, bytes]]:
-        """Yield all interpretations of the first attribute in an Xiaomi report."""
+        """Yield all interpretations of the first attribute in a Xiaomi report."""
 
         # Peek at the attribute report
         attr_id, data = t.uint16_t.deserialize(data)
@@ -255,63 +244,95 @@ class XiaomiCluster(CustomCluster):
             return
 
         _LOGGER.debug(
-            "%s - Attribute report. attribute_id: [%s] value: [%s]",
+            "%s - Xiaomi attribute report. attribute_id: [%s] value: [%s]",
             self.endpoint.device.ieee,
             attrid,
             attributes,
         )
         if BATTERY_VOLTAGE_MV in attributes:
-            self.endpoint.device.battery_bus.listener_event(
-                BATTERY_REPORTED, attributes[BATTERY_VOLTAGE_MV]
-            )
+            # many Xiaomi devices report this, but not all quirks implement the XiaomiPowerConfiguration cluster,
+            # so we might error out if the method doesn't exist
+            if hasattr(self.endpoint.power, "battery_reported") and callable(
+                self.endpoint.power.battery_reported
+            ):
+                self.endpoint.power.battery_reported(attributes[BATTERY_VOLTAGE_MV])
+            else:
+                # log a debug message if the cluster is not implemented
+                _LOGGER.debug(
+                    "%s - Xiaomi battery voltage attribute received but XiaomiPowerConfiguration not used",
+                    self.endpoint.device.ieee,
+                )
 
         if TEMPERATURE_MEASUREMENT in attributes:
-            self.endpoint.device.temperature_bus.listener_event(
-                TEMPERATURE_REPORTED, attributes[TEMPERATURE_MEASUREMENT]
+            self.endpoint.temperature.update_attribute(
+                TemperatureMeasurement.AttributeDefs.measured_value.id,
+                attributes[TEMPERATURE_MEASUREMENT],
             )
+
         if HUMIDITY_MEASUREMENT in attributes:
-            self.endpoint.device.humidity_bus.listener_event(
-                HUMIDITY_REPORTED, attributes[HUMIDITY_MEASUREMENT]
+            self.endpoint.humidity.update_attribute(
+                RelativeHumidity.AttributeDefs.measured_value.id,
+                attributes[HUMIDITY_MEASUREMENT],
             )
+
         if PRESSURE_MEASUREMENT in attributes:
-            self.endpoint.device.pressure_bus.listener_event(
-                PRESSURE_REPORTED, attributes[PRESSURE_MEASUREMENT] / 100
+            self.endpoint.pressure.update_attribute(
+                PressureMeasurement.AttributeDefs.measured_value.id,
+                attributes[PRESSURE_MEASUREMENT] / 100,
             )
+
         if POWER in attributes:
-            self.endpoint.device.power_bus.listener_event(
-                POWER_REPORTED, attributes[POWER]
+            self.endpoint.electrical_measurement.update_attribute(
+                ElectricalMeasurement.AttributeDefs.active_power.id,
+                round(attributes[POWER] * 10),
             )
+
         if CONSUMPTION in attributes:
-            self.endpoint.device.consumption_bus.listener_event(
-                CONSUMPTION_REPORTED, attributes[CONSUMPTION]
+            zcl_consumption = round(attributes[CONSUMPTION] * 1000)
+            self.endpoint.electrical_measurement.update_attribute(
+                ElectricalMeasurement.AttributeDefs.total_active_power.id,
+                zcl_consumption,
+            )
+            self.endpoint.smartenergy_metering.update_attribute(
+                Metering.AttributeDefs.current_summ_delivered.id, zcl_consumption
             )
+
         if VOLTAGE in attributes:
-            self.endpoint.device.voltage_bus.listener_event(
-                VOLTAGE_REPORTED, attributes[VOLTAGE] * 0.1
+            self.endpoint.electrical_measurement.update_attribute(
+                ElectricalMeasurement.AttributeDefs.rms_voltage.id,
+                attributes[VOLTAGE] * 0.1,
             )
+
         if ILLUMINANCE_MEASUREMENT in attributes:
-            self.endpoint.device.illuminance_bus.listener_event(
-                ILLUMINANCE_REPORTED, attributes[ILLUMINANCE_MEASUREMENT]
+            self.endpoint.illuminance.update_attribute(
+                IlluminanceMeasurement.AttributeDefs.measured_value.id,
+                attributes[ILLUMINANCE_MEASUREMENT],
             )
+
         if TVOC_MEASUREMENT in attributes:
             self.endpoint.voc_level.update_attribute(
                 0x0000, attributes[TVOC_MEASUREMENT]
             )
+
         if TEMPERATURE in attributes:
             if hasattr(self.endpoint, "device_temperature"):
                 self.endpoint.device_temperature.update_attribute(
-                    0x0000, attributes[TEMPERATURE] * 100
+                    DeviceTemperature.AttributeDefs.current_temperature.id,
+                    attributes[TEMPERATURE] * 100,
                 )
+
         if BATTERY_PERCENTAGE_REMAINING_ATTRIBUTE in attributes:
-            self.endpoint.device.power_bus_percentage.listener_event(
-                "update_battery_percentage",
-                attributes[BATTERY_PERCENTAGE_REMAINING_ATTRIBUTE],
+            self.endpoint.power.battery_percent_reported(
+                attributes[BATTERY_PERCENTAGE_REMAINING_ATTRIBUTE]
             )
+
         if SMOKE in attributes:
-            self.endpoint.ias_zone.update_attribute(ZONE_STATUS, attributes[SMOKE])
+            self.endpoint.ias_zone.update_attribute(
+                IasZone.AttributeDefs.zone_status.id, attributes[SMOKE]
+            )
 
     def _parse_aqara_attributes(self, value):
-        """Parse non standard attributes."""
+        """Parse non-standard attributes."""
         attributes = {}
         attribute_names = {
             1: BATTERY_VOLTAGE_MV,
@@ -346,6 +367,8 @@ class XiaomiCluster(CustomCluster):
             "lumi.plug.maeu01",
             "lumi.plug.mmeu01",
             "lumi.relay.c2acn01",
+            "lumi.switch.n0agl1",
+            "lumi.switch.n0acn2",
         ]:
             attribute_names.update({149: CONSUMPTION, 150: VOLTAGE, 152: POWER})
         elif self.endpoint.device.model == "lumi.sensor_motion.aq2":
@@ -398,7 +421,7 @@ class XiaomiCluster(CustomCluster):
         return attributes
 
     def _parse_mija_attributes(self, value):
-        """Parse non standard attributes."""
+        """Parse non-standard attributes."""
         attribute_names = (
             STATE,
             BATTERY_VOLTAGE_MV,
@@ -436,15 +459,16 @@ class BinaryOutputInterlock(CustomCluster, BinaryOutput):
 class XiaomiPowerConfiguration(PowerConfiguration, LocalDataCluster):
     """Xiaomi power configuration cluster implementation."""
 
-    BATTERY_VOLTAGE_ATTR = 0x0020
-    BATTERY_PERCENTAGE_REMAINING = 0x0021
+    BATTERY_VOLTAGE_ATTR = PowerConfiguration.AttributeDefs.battery_voltage.id
+    BATTERY_PERCENTAGE_REMAINING = (
+        PowerConfiguration.AttributeDefs.battery_percentage_remaining.id
+    )
     MAX_VOLTS_MV = 3100
     MIN_VOLTS_MV = 2820
 
     def __init__(self, *args, **kwargs):
         """Init."""
         super().__init__(*args, **kwargs)
-        self.endpoint.device.battery_bus.add_listener(self)
         self._CONSTANT_ATTRIBUTES = {
             BATTERY_QUANTITY_ATTR: 1,
             BATTERY_SIZE_ATTR: getattr(self.endpoint.device, BATTERY_SIZE, 0xFF),
@@ -466,7 +490,7 @@ class XiaomiPowerConfiguration(PowerConfiguration, LocalDataCluster):
 
         percent = round((voltage_mv - self.MIN_VOLTS_MV) * self._slope)
 
-        self.debug(
+        _LOGGER.debug(
             "Voltage mV: [Min]:%s < [RAW]:%s < [Max]:%s, Battery Percent: %s",
             self.MIN_VOLTS_MV,
             voltage_mv,
@@ -484,7 +508,7 @@ class OccupancyCluster(OccupancyWithReset):
 class MotionCluster(LocalDataCluster, MotionOnEvent):
     """Motion cluster."""
 
-    _CONSTANT_ATTRIBUTES = {ZONE_TYPE: MOTION_TYPE}
+    _CONSTANT_ATTRIBUTES = {IasZone.AttributeDefs.zone_type.id: MOTION_TYPE}
     reset_s: int = 70
 
 
@@ -499,105 +523,70 @@ class XiaomiMeteringCluster(LocalDataCluster, Metering):
 class TemperatureMeasurementCluster(CustomCluster, TemperatureMeasurement):
     """Temperature cluster that filters out invalid temperature readings."""
 
-    cluster_id = TemperatureMeasurement.cluster_id
-    ATTR_ID = 0
-
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        super().__init__(*args, **kwargs)
-        self.endpoint.device.temperature_bus.add_listener(self)
-
     def _update_attribute(self, attrid, value):
         # drop values above and below documented range for this sensor
         # value is in centi degrees
-        if attrid == self.ATTR_ID and (-6000 <= value <= 6000):
+        if attrid == self.AttributeDefs.measured_value.id and (-6000 <= value <= 6000):
             super()._update_attribute(attrid, value)
 
-    def temperature_reported(self, value):
-        """Temperature reported."""
-        self._update_attribute(self.ATTR_ID, value)
-
 
 class RelativeHumidityCluster(CustomCluster, RelativeHumidity):
     """Humidity cluster that filters out invalid humidity readings."""
 
-    cluster_id = RelativeHumidity.cluster_id
-    ATTR_ID = 0
-
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        super().__init__(*args, **kwargs)
-        self.endpoint.device.humidity_bus.add_listener(self)
-
     def _update_attribute(self, attrid, value):
         # drop values above and below documented range for this sensor
-        if attrid == self.ATTR_ID and (0 <= value <= 9999):
+        if attrid == self.AttributeDefs.measured_value.id and (0 <= value <= 9999):
             super()._update_attribute(attrid, value)
 
-    def humidity_reported(self, value):
-        """Humidity reported."""
-        self._update_attribute(self.ATTR_ID, value)
-
 
 class PressureMeasurementCluster(CustomCluster, PressureMeasurement):
     """Pressure cluster to receive reports that are sent to the basic cluster."""
 
-    cluster_id = PressureMeasurement.cluster_id
-    ATTR_ID = 0
-
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        super().__init__(*args, **kwargs)
-        self.endpoint.device.pressure_bus.add_listener(self)
-
     def _update_attribute(self, attrid, value):
         # drop unreasonable values
         # value is in hectopascals
-        if attrid == self.ATTR_ID and (0 <= value <= 1100):
+        if attrid == self.AttributeDefs.measured_value.id and (0 <= value <= 1100):
             super()._update_attribute(attrid, value)
 
-    def pressure_reported(self, value):
-        """Pressure reported."""
-        self._update_attribute(self.ATTR_ID, value)
-
 
 class AnalogInputCluster(CustomCluster, AnalogInput):
-    """Analog input cluster, only used to relay power consumption information to ElectricalMeasurementCluster."""
+    """Analog input cluster, only used to relay power consumption information to ElectricalMeasurementCluster.
 
-    cluster_id = AnalogInput.cluster_id
-
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        self._current_state = {}
-        super().__init__(*args, **kwargs)
+    The AnalogInput cluster responsible for reporting power consumption seems to be on endpoint 21 for newer devices
+    and either on endpoint 1 or 2 for older devices.
+    """
 
     def _update_attribute(self, attrid, value):
         super()._update_attribute(attrid, value)
-        if value is not None and value >= 0:
-            self.endpoint.device.power_bus.listener_event(POWER_REPORTED, value)
+        if (
+            attrid == self.AttributeDefs.present_value.id
+            and value is not None
+            and value >= 0
+        ):
+            # ElectricalMeasurementCluster is assumed to be on endpoint 1
+            self.endpoint.device.endpoints[1].electrical_measurement.update_attribute(
+                ElectricalMeasurement.AttributeDefs.active_power.id,
+                round(value * 10),
+            )
 
 
 class ElectricalMeasurementCluster(LocalDataCluster, ElectricalMeasurement):
     """Electrical measurement cluster to receive reports that are sent to the basic cluster."""
 
-    cluster_id = ElectricalMeasurement.cluster_id
-    POWER_ID = 0x050B
-    VOLTAGE_ID = 0x0505
-    CONSUMPTION_ID = 0x0304
+    POWER_ID = ElectricalMeasurement.AttributeDefs.active_power.id
+    VOLTAGE_ID = ElectricalMeasurement.AttributeDefs.rms_voltage.id
+    CONSUMPTION_ID = ElectricalMeasurement.AttributeDefs.total_active_power.id
+
     _CONSTANT_ATTRIBUTES = {
-        0x0402: 1,  # power_multiplier
-        0x0403: 1,  # power_divisor
-        0x0604: 1,  # ac_power_multiplier
-        0x0605: 10,  # ac_power_divisor
+        ElectricalMeasurement.AttributeDefs.power_multiplier.id: 1,
+        ElectricalMeasurement.AttributeDefs.power_divisor.id: 1,
+        ElectricalMeasurement.AttributeDefs.ac_power_multiplier.id: 1,
+        ElectricalMeasurement.AttributeDefs.ac_power_divisor.id: 10,
     }
 
     def __init__(self, *args, **kwargs):
         """Init."""
         super().__init__(*args, **kwargs)
-        self.endpoint.device.voltage_bus.add_listener(self)
-        self.endpoint.device.consumption_bus.add_listener(self)
-        self.endpoint.device.power_bus.add_listener(self)
-
         # put a default value so the sensors are created
         if self.POWER_ID not in self._attr_cache:
             self._update_attribute(self.POWER_ID, 0)
@@ -606,66 +595,35 @@ class ElectricalMeasurementCluster(LocalDataCluster, ElectricalMeasurement):
         if self.CONSUMPTION_ID not in self._attr_cache:
             self._update_attribute(self.CONSUMPTION_ID, 0)
 
-    def power_reported(self, value):
-        """Power reported."""
-        self._update_attribute(self.POWER_ID, round(value * 10))
-
-    def voltage_reported(self, value):
-        """Voltage reported."""
-        self._update_attribute(self.VOLTAGE_ID, value)
-
-    def consumption_reported(self, value):
-        """Consumption reported."""
-        self._update_attribute(self.CONSUMPTION_ID, round(value * 1000))
-
 
 class MeteringCluster(LocalDataCluster, Metering):
     """Metering cluster to receive reports that are sent to the basic cluster."""
 
-    cluster_id = Metering.cluster_id
-    CURRENT_SUMM_DELIVERED_ID = 0x0000
+    CURRENT_SUMM_DELIVERED_ID = Metering.AttributeDefs.current_summ_delivered.id
     _CONSTANT_ATTRIBUTES = {
-        0x0300: 0,  # unit_of_measure: kWh
-        0x0301: 1,  # multiplier
-        0x0302: 1000,  # divisor
-        0x0303: 0b0_0100_011,  # summation_formatting (read from plug)
-        0x0306: 0,  # metering_device_type: electric
+        Metering.AttributeDefs.unit_of_measure.id: 0,  # kWh
+        Metering.AttributeDefs.multiplier.id: 1,
+        Metering.AttributeDefs.divisor.id: 1000,
+        Metering.AttributeDefs.summation_formatting.id: 0b0_0100_011,  # read from plug
+        Metering.AttributeDefs.metering_device_type.id: 0,  # electric
     }
 
     def __init__(self, *args, **kwargs):
         """Init."""
         super().__init__(*args, **kwargs)
-        self.endpoint.device.consumption_bus.add_listener(self)
-
         # put a default value so the sensor is created
         if self.CURRENT_SUMM_DELIVERED_ID not in self._attr_cache:
             self._update_attribute(self.CURRENT_SUMM_DELIVERED_ID, 0)
 
-    def consumption_reported(self, value):
-        """Consumption reported."""
-        self._update_attribute(self.CURRENT_SUMM_DELIVERED_ID, round(value * 1000))
-
 
 class IlluminanceMeasurementCluster(CustomCluster, IlluminanceMeasurement):
-    """Multistate input cluster."""
-
-    cluster_id = IlluminanceMeasurement.cluster_id
-    ATTR_ID = 0
-
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        super().__init__(*args, **kwargs)
-        self.endpoint.device.illuminance_bus.add_listener(self)
+    """Illuminance measurement cluster."""
 
     def _update_attribute(self, attrid, value):
-        if attrid == self.ATTR_ID and value > 0:
+        if attrid == self.AttributeDefs.measured_value.id and value > 0:
             value = 10000 * math.log10(value) + 1
         super()._update_attribute(attrid, value)
 
-    def illuminance_reported(self, value):
-        """Illuminance reported."""
-        self._update_attribute(self.ATTR_ID, value)
-
 
 class OnOffCluster(OnOff, CustomCluster):
     """Aqara wall switch cluster."""
diff --git a/zhaquirks/xiaomi/aqara/ctrl_ln.py b/zhaquirks/xiaomi/aqara/ctrl_ln.py
index 0d76688..4be61fa 100644
--- a/zhaquirks/xiaomi/aqara/ctrl_ln.py
+++ b/zhaquirks/xiaomi/aqara/ctrl_ln.py
@@ -16,7 +16,7 @@ from zigpy.zcl.clusters.general import (
     Time,
 )
 
-from zhaquirks import Bus, EventableCluster
+from zhaquirks import EventableCluster
 from zhaquirks.const import (
     ARGS,
     ATTRIBUTE_ID,
@@ -71,11 +71,6 @@ class CtrlLn(XiaomiCustomDevice):
     class WallSwitchMultistateInputCluster(EventableCluster, MultistateInput):
         """WallSwitchMultistateInputCluster: fire events corresponding to press type."""
 
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        self.power_bus = Bus()
-        super().__init__(*args, **kwargs)
-
     signature = {
         MODELS_INFO: [(LUMI, "lumi.ctrl_ln1.aq1"), (LUMI, "lumi.ctrl_ln2.aq1")],
         ENDPOINTS: {
diff --git a/zhaquirks/xiaomi/aqara/motion_ac02.py b/zhaquirks/xiaomi/aqara/motion_ac02.py
index 27085d7..c44f254 100644
--- a/zhaquirks/xiaomi/aqara/motion_ac02.py
+++ b/zhaquirks/xiaomi/aqara/motion_ac02.py
@@ -8,6 +8,7 @@ from zigpy.profiles import zha
 from zigpy.quirks import CustomDevice
 import zigpy.types as types
 from zigpy.zcl.clusters.general import Basic, Identify, Ota, PowerConfiguration
+from zigpy.zcl.clusters.measurement import IlluminanceMeasurement, OccupancySensing
 
 from zhaquirks import Bus, LocalDataCluster
 from zhaquirks.const import (
@@ -26,8 +27,6 @@ from zhaquirks.xiaomi import (
     XiaomiPowerConfiguration,
 )
 
-OCCUPANCY = 0
-ON = 1
 MOTION_ATTRIBUTE = 274
 DETECTION_INTERVAL = 0x0102
 MOTION_SENSITIVITY = 0x010C
@@ -49,8 +48,13 @@ class OppleCluster(XiaomiAqaraE1Cluster):
         super()._update_attribute(attrid, value)
         if attrid == MOTION_ATTRIBUTE:
             value = value - 65536
-            self.endpoint.illuminance.illuminance_reported(value)
-            self.endpoint.occupancy.update_attribute(OCCUPANCY, ON)
+            self.endpoint.illuminance.update_attribute(
+                IlluminanceMeasurement.AttributeDefs.measured_value.id, value
+            )
+            self.endpoint.occupancy.update_attribute(
+                OccupancySensing.AttributeDefs.occupancy.id,
+                OccupancySensing.Occupancy.Occupied,
+            )
 
     async def write_attributes(
         self, attributes: dict[str | int, Any], manufacturer: int | None = None
@@ -60,7 +64,7 @@ class OppleCluster(XiaomiAqaraE1Cluster):
         interval = attributes.get(
             "detection_interval", attributes.get(DETECTION_INTERVAL)
         )
-        _LOGGER.debug("interval: %s", interval)
+        _LOGGER.debug("detection interval: %s", interval)
         if interval is not None:
             self.endpoint.ias_zone.reset_s = int(interval)
         return result
@@ -69,24 +73,25 @@ class OppleCluster(XiaomiAqaraE1Cluster):
 class LocalIlluminanceMeasurementCluster(
     LocalDataCluster, IlluminanceMeasurementCluster
 ):
-    """Local lluminance measurement cluster."""
+    """Local illuminance measurement cluster."""
 
     def __init__(self, *args, **kwargs):
         """Init."""
         super().__init__(*args, **kwargs)
-        if self.ATTR_ID not in self._attr_cache:
+        if self.AttributeDefs.measured_value.id not in self._attr_cache:
             # put a default value so the sensor is created
-            self._update_attribute(self.ATTR_ID, 0)
+            self._update_attribute(self.AttributeDefs.measured_value.id, 0)
 
-    def illuminance_reported(self, value):
-        """Illuminance reported."""
-        if value < 0 or value > 0xFFDC:
-            _LOGGER.debug(
+    def _update_attribute(self, attrid, value):
+        if attrid == self.AttributeDefs.measured_value.id and (
+            value < 0 or value > 0xFFDC
+        ):
+            self.debug(
                 "Received invalid illuminance value: %s - setting illuminance to 0",
                 value,
             )
             value = 0
-        super().illuminance_reported(value)
+        super()._update_attribute(attrid, value)
 
 
 class LocalOccupancyCluster(LocalDataCluster, OccupancyCluster):
@@ -106,8 +111,6 @@ class LumiMotionAC02(CustomDevice):
         """Init."""
         self.battery_size = 11
         self.battery_quantity = 2
-        self.battery_bus = Bus()
-        self.illuminance_bus = Bus()
         self.motion_bus = Bus()
         super().__init__(*args, **kwargs)
 
diff --git a/zhaquirks/xiaomi/aqara/motion_agl02.py b/zhaquirks/xiaomi/aqara/motion_agl02.py
index a0d6bdc..b31e453 100644
--- a/zhaquirks/xiaomi/aqara/motion_agl02.py
+++ b/zhaquirks/xiaomi/aqara/motion_agl02.py
@@ -44,7 +44,6 @@ class MotionT1(XiaomiCustomDevice):
         """Init."""
         self.battery_size = 11
         self.motion_bus = Bus()
-        self.illuminance_bus = Bus()
         super().__init__(*args, **kwargs)
 
     signature = {
diff --git a/zhaquirks/xiaomi/aqara/motion_aq2.py b/zhaquirks/xiaomi/aqara/motion_aq2.py
index fcaf042..e3647cc 100644
--- a/zhaquirks/xiaomi/aqara/motion_aq2.py
+++ b/zhaquirks/xiaomi/aqara/motion_aq2.py
@@ -38,7 +38,6 @@ class MotionAQ2(XiaomiQuickInitDevice):
         """Init."""
         self.battery_size = 9
         self.motion_bus = Bus()
-        self.illuminance_bus = Bus()
         super().__init__(*args, **kwargs)
 
     signature = {
diff --git a/zhaquirks/xiaomi/aqara/motion_aq2b.py b/zhaquirks/xiaomi/aqara/motion_aq2b.py
index d404278..212b896 100644
--- a/zhaquirks/xiaomi/aqara/motion_aq2b.py
+++ b/zhaquirks/xiaomi/aqara/motion_aq2b.py
@@ -34,7 +34,6 @@ class MotionAQ2(XiaomiCustomDevice):
         """Init."""
         self.battery_size = 9
         self.motion_bus = Bus()
-        self.illuminance_bus = Bus()
         super().__init__(*args, **kwargs)
 
     signature = {
diff --git a/zhaquirks/xiaomi/aqara/plug.py b/zhaquirks/xiaomi/aqara/plug.py
index 6c8d63b..547b68b 100644
--- a/zhaquirks/xiaomi/aqara/plug.py
+++ b/zhaquirks/xiaomi/aqara/plug.py
@@ -17,7 +17,6 @@ from zigpy.zcl.clusters.general import (
     Time,
 )
 
-from zhaquirks import Bus
 from zhaquirks.const import (
     DEVICE_TYPE,
     ENDPOINTS,
@@ -41,13 +40,6 @@ _LOGGER = logging.getLogger(__name__)
 class Plug(XiaomiCustomDevice):
     """lumi.plug plug."""
 
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        self.voltage_bus = Bus()
-        self.consumption_bus = Bus()
-        self.power_bus = Bus()
-        super().__init__(*args, **kwargs)
-
     signature = {
         MODELS_INFO: [(LUMI, "lumi.plug")],
         ENDPOINTS: {
diff --git a/zhaquirks/xiaomi/aqara/plug_eu.py b/zhaquirks/xiaomi/aqara/plug_eu.py
index 7159e00..cf1fd84 100644
--- a/zhaquirks/xiaomi/aqara/plug_eu.py
+++ b/zhaquirks/xiaomi/aqara/plug_eu.py
@@ -20,7 +20,6 @@ from zigpy.zcl.clusters.general import (
 from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement
 from zigpy.zcl.clusters.smartenergy import Metering
 
-from zhaquirks import Bus
 from zhaquirks.const import (
     DEVICE_TYPE,
     ENDPOINTS,
@@ -84,13 +83,6 @@ class OppleCluster(XiaomiAqaraE1Cluster):
 class PlugMMEU01(XiaomiCustomDevice):
     """lumi.plug.mmeu01 plug."""
 
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        self.voltage_bus = Bus()
-        self.consumption_bus = Bus()
-        self.power_bus = Bus()
-        super().__init__(*args, **kwargs)
-
     signature = {
         MODELS_INFO: [
             (LUMI, "lumi.plug.mmeu01"),
diff --git a/zhaquirks/xiaomi/aqara/plug_maus01.py b/zhaquirks/xiaomi/aqara/plug_maus01.py
index 1d8132d..a110891 100644
--- a/zhaquirks/xiaomi/aqara/plug_maus01.py
+++ b/zhaquirks/xiaomi/aqara/plug_maus01.py
@@ -18,7 +18,6 @@ from zigpy.zcl.clusters.general import (
 )
 from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement
 
-from zhaquirks import Bus
 from zhaquirks.const import (
     DEVICE_TYPE,
     ENDPOINTS,
@@ -43,13 +42,6 @@ _LOGGER = logging.getLogger(__name__)
 class Plug(XiaomiCustomDevice):
     """lumi.plug.maus01 plug."""
 
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        self.voltage_bus = Bus()
-        self.consumption_bus = Bus()
-        self.power_bus = Bus()
-        super().__init__(*args, **kwargs)
-
     signature = {
         MODELS_INFO: [(LUMI, "lumi.plug.maus01"), (LUMI, "lumi.plug.mitw01")],
         ENDPOINTS: {
diff --git a/zhaquirks/xiaomi/aqara/relay_c2acn01.py b/zhaquirks/xiaomi/aqara/relay_c2acn01.py
index ed0426e..3d93532 100644
--- a/zhaquirks/xiaomi/aqara/relay_c2acn01.py
+++ b/zhaquirks/xiaomi/aqara/relay_c2acn01.py
@@ -17,7 +17,6 @@ from zigpy.zcl.clusters.general import (
 )
 from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement
 
-from zhaquirks import Bus
 from zhaquirks.const import (
     DEVICE_TYPE,
     ENDPOINTS,
@@ -42,13 +41,6 @@ _LOGGER = logging.getLogger(__name__)
 class Relay(XiaomiCustomDevice):
     """lumi.relay.c2acn01 relay."""
 
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        self.voltage_bus = Bus()
-        self.consumption_bus = Bus()
-        self.power_bus = Bus()
-        super().__init__(*args, **kwargs)
-
     signature = {
         MODELS_INFO: [(LUMI, "lumi.relay.c2acn01")],
         ENDPOINTS: {
diff --git a/zhaquirks/xiaomi/aqara/roller_curtain_e1.py b/zhaquirks/xiaomi/aqara/roller_curtain_e1.py
index cbeb2d5..e962feb 100644
--- a/zhaquirks/xiaomi/aqara/roller_curtain_e1.py
+++ b/zhaquirks/xiaomi/aqara/roller_curtain_e1.py
@@ -18,13 +18,12 @@ from zigpy.zcl.clusters.general import (
     MultistateOutput,
     OnOff,
     Ota,
-    PowerConfiguration,
     Scenes,
     Time,
 )
 from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster
 
-from zhaquirks import Bus, CustomCluster, LocalDataCluster
+from zhaquirks import CustomCluster
 from zhaquirks.const import (
     DEVICE_TYPE,
     ENDPOINTS,
@@ -33,7 +32,13 @@ from zhaquirks.const import (
     OUTPUT_CLUSTERS,
     PROFILE_ID,
 )
-from zhaquirks.xiaomi import LUMI, BasicCluster, XiaomiCluster, XiaomiCustomDevice
+from zhaquirks.xiaomi import (
+    LUMI,
+    BasicCluster,
+    XiaomiCluster,
+    XiaomiCustomDevice,
+    XiaomiPowerConfiguration,
+)
 
 PRESENT_VALUE = 0x0055
 CURRENT_POSITION_LIFT_PERCENTAGE = 0x0008
@@ -152,32 +157,20 @@ class MultistateOutputRollerE1(CustomCluster, MultistateOutput):
     )
 
 
-class PowerConfigurationRollerE1(PowerConfiguration, LocalDataCluster):
-    """Xiaomi power configuration cluster implementation."""
-
-    BATTERY_PERCENTAGE_REMAINING = 0x0021
+class PowerConfigurationRollerE1(XiaomiPowerConfiguration):
+    """Power cluster which ignores Xiaomi voltage reports."""
 
-    def __init__(self, *args: Any, **kwargs: Any) -> None:
-        """Init."""
-        super().__init__(*args, **kwargs)
-        self.endpoint.device.power_bus_percentage.add_listener(self)
-
-    def update_battery_percentage(self, value: int) -> None:
-        """Doubles the battery percentage to the Zigbee spec's expected 200% maximum."""
-        super()._update_attribute(
-            self.BATTERY_PERCENTAGE_REMAINING,
-            (value * 2),
-        )
+    def _update_battery_percentage(self, voltage_mv: int) -> None:
+        """Ignore Xiaomi voltage reports, so they're not used to calculate battery percentage."""
+        # This device sends battery percentage reports which are handled using a XiaomiCluster and
+        # the inherited XiaomiPowerConfiguration cluster.
+        # This device might also send Xiaomi battery reports, so we only want to use those for the voltage attribute,
+        # but not for the battery percentage. XiaomiPowerConfiguration.battery_reported() still updates the voltage.
 
 
 class RollerE1AQ(XiaomiCustomDevice):
     """Aqara Roller Shade Driver E1 device."""
 
-    def __init__(self, *args: Any, **kwargs: Any) -> None:
-        """Init."""
-        self.power_bus_percentage: Bus = Bus()  # type: ignore
-        super().__init__(*args, **kwargs)  # type: ignore
-
     signature = {
         MODELS_INFO: [(LUMI, "lumi.curtain.acn002")],
         ENDPOINTS: {
diff --git a/zhaquirks/xiaomi/aqara/sensor_ht_agl02.py b/zhaquirks/xiaomi/aqara/sensor_ht_agl02.py
index 5bcc251..332177e 100644
--- a/zhaquirks/xiaomi/aqara/sensor_ht_agl02.py
+++ b/zhaquirks/xiaomi/aqara/sensor_ht_agl02.py
@@ -9,7 +9,6 @@ from zigpy.zcl.clusters.measurement import (
 )
 from zigpy.zdo.types import NodeDescriptor
 
-from zhaquirks import Bus
 from zhaquirks.const import (
     DEVICE_TYPE,
     ENDPOINTS,
@@ -32,13 +31,6 @@ from zhaquirks.xiaomi import (
 class LumiSensorHtAgl02(XiaomiCustomDevice):
     """Lumi lumi.sensor_ht.agl02 custom device implementation."""
 
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        self.temperature_bus = Bus()
-        self.humidity_bus = Bus()
-        self.pressure_bus = Bus()
-        super().__init__(*args, **kwargs)
-
     signature = {
         MODELS_INFO: [("LUMI", "lumi.sensor_ht.agl02")],
         ENDPOINTS: {
diff --git a/zhaquirks/xiaomi/aqara/smoke.py b/zhaquirks/xiaomi/aqara/smoke.py
index 77f9c32..c668897 100644
--- a/zhaquirks/xiaomi/aqara/smoke.py
+++ b/zhaquirks/xiaomi/aqara/smoke.py
@@ -8,7 +8,7 @@ from zigpy.zcl.clusters.general import Basic, Identify, Ota, PowerConfiguration
 from zigpy.zcl.clusters.security import IasZone
 from zigpy.zdo.types import NodeDescriptor
 
-from zhaquirks import Bus, LocalDataCluster
+from zhaquirks import LocalDataCluster
 from zhaquirks.const import (
     DEVICE_TYPE,
     ENDPOINTS,
@@ -84,11 +84,6 @@ class LocalIasZone(LocalDataCluster, IasZone):
 class LumiSensorSmokeAcn03(CustomDevice):
     """lumi.sensor_smoke.acn03 smoke sensor."""
 
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        self.battery_bus = Bus()
-        super().__init__(*args, **kwargs)
-
     signature = {
         MODELS_INFO: [(LUMI, "lumi.sensor_smoke.acn03")],
         ENDPOINTS: {
diff --git a/zhaquirks/xiaomi/aqara/switch_t1.py b/zhaquirks/xiaomi/aqara/switch_t1.py
new file mode 100644
index 0000000..63ac57c
--- /dev/null
+++ b/zhaquirks/xiaomi/aqara/switch_t1.py
@@ -0,0 +1,202 @@
+"""Aqara T1 (with neutral) relays."""
+from zigpy import types as t
+from zigpy.profiles import zgp, zha
+from zigpy.zcl.clusters.general import (
+    Alarms,
+    AnalogInput,
+    Basic,
+    DeviceTemperature,
+    GreenPowerProxy,
+    Groups,
+    Identify,
+    MultistateInput,
+    OnOff,
+    Ota,
+    Scenes,
+    Time,
+)
+
+from zhaquirks.const import (
+    DEVICE_TYPE,
+    ENDPOINTS,
+    INPUT_CLUSTERS,
+    MODELS_INFO,
+    OUTPUT_CLUSTERS,
+    PROFILE_ID,
+)
+from zhaquirks.xiaomi import (
+    LUMI,
+    AnalogInputCluster,
+    BasicCluster,
+    DeviceTemperatureCluster,
+    ElectricalMeasurementCluster,
+    MeteringCluster,
+    XiaomiAqaraE1Cluster,
+    XiaomiCustomDevice,
+)
+from zhaquirks.xiaomi.aqara.opple_remote import MultistateInputCluster
+
+
+class OppleCluster(XiaomiAqaraE1Cluster):
+    """Xiaomi Aqara T1 relay cluster."""
+
+    ep_attribute = "opple_cluster"
+    attributes = {
+        0x000A: ("switch_type", t.uint8_t, True),
+        0x0201: ("power_outage_memory", t.Bool, True),
+        0x0203: ("do_not_disturb", t.Bool, True),
+    }
+
+
+class SwitchT1(XiaomiCustomDevice):
+    """Aqara T1 relay switch."""
+
+    signature = {
+        MODELS_INFO: [(LUMI, "lumi.switch.n0agl1"), (LUMI, "lumi.switch.n0acn2")],
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    DeviceTemperature.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    Alarms.cluster_id,
+                    Time.cluster_id,
+                    OppleCluster.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Ota.cluster_id,
+                ],
+            },
+            21: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
+                INPUT_CLUSTERS: [AnalogInput.cluster_id],
+                OUTPUT_CLUSTERS: [],
+            },
+            31: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
+                INPUT_CLUSTERS: [AnalogInput.cluster_id],
+                OUTPUT_CLUSTERS: [],
+            },
+            41: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
+                INPUT_CLUSTERS: [MultistateInput.cluster_id],
+                OUTPUT_CLUSTERS: [],
+            },
+            242: {
+                PROFILE_ID: zgp.PROFILE_ID,
+                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
+                INPUT_CLUSTERS: [],
+                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
+            },
+        },
+    }
+
+    replacement = {
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
+                INPUT_CLUSTERS: [
+                    BasicCluster,
+                    DeviceTemperatureCluster,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    OnOff.cluster_id,
+                    Alarms.cluster_id,
+                    Time.cluster_id,
+                    MeteringCluster,
+                    ElectricalMeasurementCluster,
+                    OppleCluster,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Time.cluster_id,
+                    Ota.cluster_id,
+                ],
+            },
+            21: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
+                INPUT_CLUSTERS: [AnalogInputCluster],
+                OUTPUT_CLUSTERS: [],
+            },
+            31: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
+                INPUT_CLUSTERS: [AnalogInput.cluster_id],
+                OUTPUT_CLUSTERS: [],
+            },
+            41: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
+                INPUT_CLUSTERS: [MultistateInputCluster],
+                OUTPUT_CLUSTERS: [],
+            },
+            242: {
+                PROFILE_ID: zgp.PROFILE_ID,
+                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
+                INPUT_CLUSTERS: [],
+                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
+            },
+        }
+    }
+
+
+class SwitchT1Alt1(SwitchT1):
+    """Aqara T1 relay switch with alternative signature."""
+
+    signature = {
+        MODELS_INFO: [(LUMI, "lumi.switch.n0agl1"), (LUMI, "lumi.switch.n0acn2")],
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    DeviceTemperature.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    Alarms.cluster_id,
+                    Time.cluster_id,
+                    OppleCluster.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Time.cluster_id,
+                    Ota.cluster_id,
+                    0xFFFF,
+                ],
+            },
+            21: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
+                INPUT_CLUSTERS: [AnalogInput.cluster_id],
+                OUTPUT_CLUSTERS: [],
+            },
+            31: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
+                INPUT_CLUSTERS: [AnalogInput.cluster_id],
+                OUTPUT_CLUSTERS: [],
+            },
+            41: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
+                INPUT_CLUSTERS: [MultistateInput.cluster_id],
+                OUTPUT_CLUSTERS: [],
+            },
+            242: {
+                PROFILE_ID: zgp.PROFILE_ID,
+                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
+                INPUT_CLUSTERS: [],
+                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
+            },
+        },
+    }
diff --git a/zhaquirks/xiaomi/aqara/thermostat_agl001.py b/zhaquirks/xiaomi/aqara/thermostat_agl001.py
index 84ff889..c06ca51 100644
--- a/zhaquirks/xiaomi/aqara/thermostat_agl001.py
+++ b/zhaquirks/xiaomi/aqara/thermostat_agl001.py
@@ -149,9 +149,7 @@ class AqaraThermostatSpecificCluster(XiaomiAqaraE1Cluster):
     def _update_attribute(self, attrid, value):
         self.debug("Updating attribute on Xiaomi cluster %s with %s", attrid, value)
         if attrid == BATTERY_PERCENTAGE:
-            self.endpoint.device.battery_bus.listener_event(
-                "battery_percent_reported", value
-            )
+            self.endpoint.power.battery_percent_reported(value)
         elif attrid == SYSTEM_MODE:
             # update ZCL system_mode attribute (e.g. on attribute reports)
             self.endpoint.thermostat.update_attribute(
diff --git a/zhaquirks/xiaomi/aqara/tvoc.py b/zhaquirks/xiaomi/aqara/tvoc.py
index 8a4bb87..34058e1 100644
--- a/zhaquirks/xiaomi/aqara/tvoc.py
+++ b/zhaquirks/xiaomi/aqara/tvoc.py
@@ -8,7 +8,7 @@ from zigpy.zcl.clusters.measurement import RelativeHumidity, TemperatureMeasurem
 from zigpy.zcl.clusters.security import IasZone
 from zigpy.zdo.types import NodeDescriptor
 
-from zhaquirks import Bus, LocalDataCluster, PowerConfigurationCluster
+from zhaquirks import LocalDataCluster, PowerConfigurationCluster
 from zhaquirks.const import (
     DEVICE_TYPE,
     ENDPOINTS,
@@ -88,12 +88,6 @@ class TVOCCluster(XiaomiAqaraE1Cluster):
 class TVOCMonitor(XiaomiCustomDevice):
     """Aqara LUMI lumi.airmonitor.acn01."""
 
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        self.temperature_bus = Bus()
-        self.humidity_bus = Bus()
-        super().__init__(*args, **kwargs)
-
     signature = {
         # <SimpleDescriptor endpoint=1 profile=260 device_type=770
         # device_version=1
@@ -144,12 +138,6 @@ class TVOCMonitor(XiaomiCustomDevice):
 class TVOCMonitor2(XiaomiCustomDevice):
     """Aqara LUMI lumi.airmonitor.acn01."""
 
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        self.temperature_bus = Bus()
-        self.humidity_bus = Bus()
-        super().__init__(*args, **kwargs)
-
     signature = {
         # <SimpleDescriptor endpoint=1 profile=260 device_type=1026
         # device_version=1
diff --git a/zhaquirks/xiaomi/aqara/weather.py b/zhaquirks/xiaomi/aqara/weather.py
index 2ceabbc..bfb259d 100644
--- a/zhaquirks/xiaomi/aqara/weather.py
+++ b/zhaquirks/xiaomi/aqara/weather.py
@@ -5,7 +5,6 @@ from zigpy.profiles import zha
 from zigpy.zcl.clusters.general import Groups, Identify
 from zigpy.zcl.clusters.measurement import PressureMeasurement
 
-from zhaquirks import Bus
 from zhaquirks.const import (
     DEVICE_TYPE,
     ENDPOINTS,
@@ -36,13 +35,6 @@ _LOGGER = logging.getLogger(__name__)
 class Weather(XiaomiQuickInitDevice):
     """Xiaomi weather sensor device."""
 
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        self.temperature_bus = Bus()
-        self.humidity_bus = Bus()
-        self.pressure_bus = Bus()
-        super().__init__(*args, **kwargs)
-
     signature = {
         #  <SimpleDescriptor endpoint=1 profile=260 device_type=24321
         #  device_version=1
diff --git a/zhaquirks/xiaomi/mija/sensor_ht.py b/zhaquirks/xiaomi/mija/sensor_ht.py
index 5ad7dc2..3740e36 100644
--- a/zhaquirks/xiaomi/mija/sensor_ht.py
+++ b/zhaquirks/xiaomi/mija/sensor_ht.py
@@ -11,7 +11,6 @@ from zigpy.zcl.clusters.general import (
     Scenes,
 )
 
-from zhaquirks import Bus
 from zhaquirks.const import (
     DEVICE_TYPE,
     ENDPOINTS,
@@ -41,12 +40,6 @@ _LOGGER = logging.getLogger(__name__)
 class Weather(XiaomiCustomDevice):
     """Xiaomi mija weather sensor device."""
 
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        self.temperature_bus = Bus()
-        self.humidity_bus = Bus()
-        super().__init__(*args, **kwargs)
-
     signature = {
         #  <SimpleDescriptor endpoint=1 profile=260 device_type=24321
         #  device_version=1


Test patch:
diff --git a/tests/test_thirdreality.py b/tests/test_thirdreality.py
new file mode 100644
index 0000000..0d2af26
--- /dev/null
+++ b/tests/test_thirdreality.py
@@ -0,0 +1,40 @@
+"""Tests for Third Reality quirks."""
+
+
+import pytest
+from zigpy.zcl.clusters.security import IasZone
+
+import zhaquirks
+import zhaquirks.thirdreality.night_light
+
+from tests.common import ClusterListener
+
+zhaquirks.setup()
+
+
+@pytest.mark.parametrize("quirk", (zhaquirks.thirdreality.night_light.Nightlight,))
+async def test_third_reality_nightlight(zigpy_device_from_quirk, quirk):
+    """Test Third Reality night light forwarding motion attribute to IasZone cluster."""
+
+    device = zigpy_device_from_quirk(quirk)
+
+    ias_zone_cluster = device.endpoints[1].ias_zone
+    ias_zone_listener = ClusterListener(ias_zone_cluster)
+
+    ias_zone_status_id = IasZone.AttributeDefs.zone_status.id
+
+    third_reality_cluster = device.endpoints[1].in_clusters[0xFC00]
+
+    # 0x0002 is also used on manufacturer specific cluster for motion events
+    third_reality_cluster.update_attribute(0x0002, IasZone.ZoneStatus.Alarm_1)
+
+    assert len(ias_zone_listener.attribute_updates) == 1
+    assert ias_zone_listener.attribute_updates[0][0] == ias_zone_status_id
+    assert ias_zone_listener.attribute_updates[0][1] == IasZone.ZoneStatus.Alarm_1
+
+    # turn off motion alarm
+    third_reality_cluster.update_attribute(0x0002, 0)
+
+    assert len(ias_zone_listener.attribute_updates) == 2
+    assert ias_zone_listener.attribute_updates[1][0] == ias_zone_status_id
+    assert ias_zone_listener.attribute_updates[1][1] == 0
diff --git a/tests/test_xiaomi.py b/tests/test_xiaomi.py
index bfc3bed..624f19e 100644
--- a/tests/test_xiaomi.py
+++ b/tests/test_xiaomi.py
@@ -1,14 +1,29 @@
 """Tests for xiaomi."""
 import asyncio
+import logging
+import math
 from unittest import mock
 
 import pytest
 import zigpy.device
 import zigpy.types as t
 from zigpy.zcl import foundation
-from zigpy.zcl.clusters.general import PowerConfiguration
+from zigpy.zcl.clusters.general import (
+    AnalogInput,
+    DeviceTemperature,
+    PowerConfiguration,
+)
+from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement
 from zigpy.zcl.clusters.hvac import Thermostat
+from zigpy.zcl.clusters.measurement import (
+    IlluminanceMeasurement,
+    OccupancySensing,
+    PressureMeasurement,
+    RelativeHumidity,
+    TemperatureMeasurement,
+)
 from zigpy.zcl.clusters.security import IasZone
+from zigpy.zcl.clusters.smartenergy import Metering
 
 import zhaquirks
 from zhaquirks.const import (
@@ -25,10 +40,9 @@ from zhaquirks.const import (
     ZONE_STATUS_CHANGE_COMMAND,
 )
 from zhaquirks.xiaomi import (
-    CONSUMPTION_REPORTED,
     LUMI,
-    POWER_REPORTED,
-    VOLTAGE_REPORTED,
+    XIAOMI_AQARA_ATTRIBUTE,
+    XIAOMI_AQARA_ATTRIBUTE_E1,
     XIAOMI_NODE_DESC,
     BasicCluster,
     XiaomiCustomDevice,
@@ -51,10 +65,15 @@ from zhaquirks.xiaomi.aqara.feeder_acn001 import (
     AqaraFeederAcn001,
     OppleCluster,
 )
+import zhaquirks.xiaomi.aqara.motion_ac02
 import zhaquirks.xiaomi.aqara.motion_aq2
 import zhaquirks.xiaomi.aqara.motion_aq2b
+import zhaquirks.xiaomi.aqara.plug
 import zhaquirks.xiaomi.aqara.plug_eu
+import zhaquirks.xiaomi.aqara.roller_curtain_e1
 import zhaquirks.xiaomi.aqara.smoke
+import zhaquirks.xiaomi.aqara.switch_t1
+import zhaquirks.xiaomi.aqara.weather
 import zhaquirks.xiaomi.mija.motion
 
 from tests.common import ZCL_OCC_ATTR_RPT_OCC, ClusterListener
@@ -62,6 +81,15 @@ from tests.common import ZCL_OCC_ATTR_RPT_OCC, ClusterListener
 zhaquirks.setup()
 
 
+def create_aqara_attr_report(attributes):
+    """Creates a special Aqara attriubte report with t.Single as a type for all values."""
+    serialized_data = b""
+    for key, value in attributes.items():
+        tv = foundation.TypeValue(0x39, t.Single(value))  # mostly used
+        serialized_data += bytes([key]) + tv.serialize()
+    return serialized_data
+
+
 def test_basic_cluster_deserialize_wrong_len():
     """Test attr report with model and xiaomi attr."""
     cluster = BasicCluster(mock.MagicMock())
@@ -488,46 +516,72 @@ async def test_xiaomi_eu_plug_binding(zigpy_device_from_quirk, quirk):
         )
 
 
-@pytest.mark.parametrize("quirk", (zhaquirks.xiaomi.aqara.plug_eu.PlugMAEU01,))
-async def test_xiaomi_eu_plug_power(zigpy_device_from_quirk, quirk):
-    """Test current power consumption, total power consumption, and current voltage on Xiaomi EU plug."""
+@pytest.mark.parametrize(
+    "quirk",
+    (
+        zhaquirks.xiaomi.aqara.plug_eu.PlugMAEU01,
+        zhaquirks.xiaomi.aqara.switch_t1.SwitchT1,
+    ),
+)
+async def test_xiaomi_plug_power(zigpy_device_from_quirk, quirk):
+    """Test current power consumption, total power consumption, and current voltage on Xiaomi EU plug and T1 relay."""
 
     device = zigpy_device_from_quirk(quirk)
+    basic_cluster = device.endpoints[1].basic
 
     em_cluster = device.endpoints[1].electrical_measurement
     em_listener = ClusterListener(em_cluster)
 
     # Test voltage on ElectricalMeasurement cluster
-    em_cluster.endpoint.device.voltage_bus.listener_event(VOLTAGE_REPORTED, 230)
+    zcl_em_voltage = ElectricalMeasurement.AttributeDefs.rms_voltage.id
+    basic_cluster.update_attribute(
+        XIAOMI_AQARA_ATTRIBUTE, create_aqara_attr_report({150: 2300})
+    )
     assert len(em_listener.attribute_updates) == 1
-    assert em_listener.attribute_updates[0][0] == 1285
+    assert em_listener.attribute_updates[0][0] == zcl_em_voltage
     assert em_listener.attribute_updates[0][1] == 230
 
     # Test current power consumption on ElectricalMeasurement cluster
-    em_cluster.endpoint.device.power_bus.listener_event(POWER_REPORTED, 15)
+    zcl_em_current_power = ElectricalMeasurement.AttributeDefs.active_power.id
+    basic_cluster.update_attribute(
+        XIAOMI_AQARA_ATTRIBUTE, create_aqara_attr_report({152: 15})
+    )
     assert len(em_listener.attribute_updates) == 2
-    assert em_listener.attribute_updates[1][0] == 1291
+    assert em_listener.attribute_updates[1][0] == zcl_em_current_power
     assert em_listener.attribute_updates[1][1] == 150  # multiplied by 10
 
-    # Test total power consumption on ElectricalMeasurement cluster
-    em_cluster.endpoint.device.consumption_bus.listener_event(
-        CONSUMPTION_REPORTED, 0.001
-    )
-    assert len(em_listener.attribute_updates) == 3
-    assert em_listener.attribute_updates[2][0] == 772
-    assert em_listener.attribute_updates[2][1] == 1  # multiplied by 1000
-
-    # Test total power consumption on SmartEnergy cluster
+    # Test total power consumption on ElectricalMeasurement cluster and SmartEnergy cluster
+    zcl_em_total_power = ElectricalMeasurement.AttributeDefs.total_active_power.id
+    zcl_se_total_power = Metering.AttributeDefs.current_summ_delivered.id
     se_cluster = device.endpoints[1].smartenergy_metering
     se_listener = ClusterListener(se_cluster)
 
-    se_cluster.endpoint.device.consumption_bus.listener_event(
-        CONSUMPTION_REPORTED, 0.001
+    basic_cluster.update_attribute(
+        XIAOMI_AQARA_ATTRIBUTE, create_aqara_attr_report({149: 0.001})
     )
+    # electrical measurement cluster
+    assert len(em_listener.attribute_updates) == 3
+    assert em_listener.attribute_updates[2][0] == zcl_em_total_power
+    assert em_listener.attribute_updates[2][1] == 1  # multiplied by 1000
+
+    # smart energy cluster
     assert len(se_listener.attribute_updates) == 1
-    assert se_listener.attribute_updates[0][0] == 0
+    assert se_listener.attribute_updates[0][0] == zcl_se_total_power
     assert se_listener.attribute_updates[0][1] == 1  # multiplied by 1000
 
+    # test current power consumption attribute report on AnalogInput is forwarded to ElectricalMeasurement
+    analog_input_cluster = device.endpoints[21].analog_input
+    analog_input_listener = ClusterListener(analog_input_cluster)
+    zcl_analog_input_value = AnalogInput.AttributeDefs.present_value.id
+
+    analog_input_cluster.update_attribute(zcl_analog_input_value, 40)
+    assert len(analog_input_listener.attribute_updates) == 1
+    assert analog_input_listener.attribute_updates[0][0] == zcl_analog_input_value
+    assert analog_input_listener.attribute_updates[0][1] == 40
+
+    assert em_listener.attribute_updates[3][0] == zcl_em_current_power
+    assert em_listener.attribute_updates[3][1] == 400  # multiplied by 10
+
 
 @pytest.mark.parametrize(
     "attribute, value, expected_bytes",
@@ -724,26 +778,26 @@ async def test_aqara_smoke_sensor_attribute_update(zigpy_device_from_quirk, quir
     ias_cluster = device.endpoints[1].ias_zone
     ias_listener = ClusterListener(ias_cluster)
 
-    zone_status_id = IasZone.attributes_by_name["zone_status"].id
+    zone_status_id = IasZone.AttributeDefs.zone_status.id
 
     # check that updating Xiaomi smoke attribute also updates zone status on the Ias Zone cluster
 
     # turn on smoke alarm
-    opple_cluster._update_attribute(0x013A, 1)
+    opple_cluster.update_attribute(0x013A, 1)
     assert len(opple_listener.attribute_updates) == 1
     assert len(ias_listener.attribute_updates) == 1
     assert ias_listener.attribute_updates[0][0] == zone_status_id
     assert ias_listener.attribute_updates[0][1] == IasZone.ZoneStatus.Alarm_1
 
     # turn off smoke alarm
-    opple_cluster._update_attribute(0x013A, 0)
+    opple_cluster.update_attribute(0x013A, 0)
     assert len(opple_listener.attribute_updates) == 2
     assert len(ias_listener.attribute_updates) == 2
     assert ias_listener.attribute_updates[1][0] == zone_status_id
     assert ias_listener.attribute_updates[1][1] == 0
 
     # check if fake dB/m smoke density attribute is also updated
-    opple_cluster._update_attribute(0x013B, 10)
+    opple_cluster.update_attribute(0x013B, 10)
     assert len(opple_listener.attribute_updates) == 4
     assert opple_listener.attribute_updates[2][0] == 0x013B
     assert opple_listener.attribute_updates[2][1] == 10
@@ -789,10 +843,7 @@ async def test_aqara_smoke_sensor_xiaomi_attribute_report(
 
     # check that Xiaomi attribute report resets smoke zone status
     assert len(ias_listener.attribute_updates) == 1
-    assert (
-        ias_listener.attribute_updates[0][0]
-        == IasZone.attributes_by_name["zone_status"].id
-    )
+    assert ias_listener.attribute_updates[0][0] == IasZone.AttributeDefs.zone_status.id
     assert ias_listener.attribute_updates[0][1] == expected_zone_status
 
 
@@ -801,8 +852,8 @@ async def test_aqara_smoke_sensor_xiaomi_attribute_report(
     [
         ("system_mode", "unoccupied_heating_setpoint"),
         (
-            Thermostat.attributes_by_name["system_mode"].id,
-            Thermostat.attributes_by_name["unoccupied_heating_setpoint"].id,
+            Thermostat.AttributeDefs.system_mode.id,
+            Thermostat.AttributeDefs.unoccupied_heating_setpoint.id,
         ),
     ],
 )
@@ -874,7 +925,7 @@ async def test_xiaomi_e1_thermostat_rw_redirection(
             0x0271
         ]  # Opple system_mode attribute
         assert thermostat_listener.attribute_updates[0] == (
-            Thermostat.attributes_by_name["system_mode"].id,
+            Thermostat.AttributeDefs.system_mode.id,
             Thermostat.SystemMode.Heat,
         )  # check that attributes are correctly mapped and updated on ZCL thermostat cluster
 
@@ -903,7 +954,7 @@ async def test_xiaomi_e1_thermostat_rw_redirection(
         assert opple_listener.attribute_updates[1] == (0x0271, 1)  # Opple system_mode
 
         assert thermostat_listener.attribute_updates[2] == (
-            Thermostat.attributes_by_name["system_mode"].id,
+            Thermostat.AttributeDefs.system_mode.id,
             Thermostat.SystemMode.Heat,
         )  # check ZCL attribute is in correct mode
 
@@ -932,30 +983,303 @@ async def test_xiaomi_e1_thermostat_attribute_update(zigpy_device_from_quirk, qu
     power_config_cluster = device.endpoints[1].power
     power_config_listener = ClusterListener(power_config_cluster)
 
-    zcl_system_mode_id = Thermostat.attributes_by_name["system_mode"].id
-    zcl_battery_percentage_id = PowerConfiguration.attributes_by_name[
-        "battery_percentage_remaining"
-    ].id
+    zcl_system_mode_id = Thermostat.AttributeDefs.system_mode.id
+    zcl_battery_percentage_id = (
+        PowerConfiguration.AttributeDefs.battery_percentage_remaining.id
+    )
 
     # check that updating Xiaomi system_mode also updates an attribute on the Thermostat cluster
 
     # turn off heating
-    opple_cluster._update_attribute(0x0271, 0)
+    opple_cluster.update_attribute(0x0271, 0)
     assert len(opple_listener.attribute_updates) == 1
     assert len(thermostat_listener.attribute_updates) == 1
     assert thermostat_listener.attribute_updates[0][0] == zcl_system_mode_id
     assert thermostat_listener.attribute_updates[0][1] == Thermostat.SystemMode.Off
 
     # turn on heating
-    opple_cluster._update_attribute(0x0271, 1)
+    opple_cluster.update_attribute(0x0271, 1)
     assert len(opple_listener.attribute_updates) == 2
     assert len(thermostat_listener.attribute_updates) == 2
     assert thermostat_listener.attribute_updates[1][0] == zcl_system_mode_id
     assert thermostat_listener.attribute_updates[1][1] == Thermostat.SystemMode.Heat
 
     # check that updating battery_percentage on the OppleCluster also updates the PowerConfiguration cluster
-    opple_cluster._update_attribute(0x040A, 50)  # 50% battery
+    opple_cluster.update_attribute(0x040A, 50)  # 50% battery
     assert len(opple_listener.attribute_updates) == 3
     assert len(power_config_listener.attribute_updates) == 1
     assert power_config_listener.attribute_updates[0][0] == zcl_battery_percentage_id
     assert power_config_listener.attribute_updates[0][1] == 100  # ZCL is doubled
+
+
+@pytest.mark.parametrize("quirk", (zhaquirks.xiaomi.aqara.motion_ac02.LumiMotionAC02,))
+async def test_xiaomi_p1_motion_sensor(zigpy_device_from_quirk, quirk):
+    """Test Aqara P1 motion sensor."""
+
+    device = zigpy_device_from_quirk(quirk)
+
+    opple_cluster = device.endpoints[1].opple_cluster
+    opple_listener = ClusterListener(opple_cluster)
+
+    ias_cluster = device.endpoints[1].ias_zone
+    ias_listener = ClusterListener(ias_cluster)
+
+    occupancy_cluster = device.endpoints[1].occupancy
+    occupancy_listener = ClusterListener(occupancy_cluster)
+
+    illuminance_cluster = device.endpoints[1].illuminance
+    illuminance_listener = ClusterListener(illuminance_cluster)
+
+    zcl_zone_status_change_cmd_id = (
+        IasZone.ClientCommandDefs.status_change_notification.id
+    )
+    zcl_occupancy_id = OccupancySensing.AttributeDefs.occupancy.id
+    zcl_iilluminance_id = IlluminanceMeasurement.AttributeDefs.measured_value.id
+
+    # send motion and illuminance report 10
+    opple_cluster.update_attribute(274, 10 + 65536)
+
+    # confirm manufacturer specific attribute report
+    assert len(opple_listener.attribute_updates) == 1
+    assert opple_listener.attribute_updates[0][0] == 274
+    assert opple_listener.attribute_updates[0][1] == 10 + 65536
+
+    # confirm zone status change notification command
+    assert len(ias_listener.cluster_commands) == 1
+    assert ias_listener.cluster_commands[0][1] == zcl_zone_status_change_cmd_id
+    assert ias_listener.cluster_commands[0][2][0] == IasZone.ZoneStatus.Alarm_1
+
+    # confirm occupancy report
+    assert len(occupancy_listener.attribute_updates) == 1
+    assert occupancy_listener.attribute_updates[0][0] == zcl_occupancy_id
+    assert (
+        occupancy_listener.attribute_updates[0][1]
+        == OccupancySensing.Occupancy.Occupied
+    )
+
+    # confirm illuminance report (with conversion)
+    assert len(illuminance_listener.attribute_updates) == 1
+    assert illuminance_listener.attribute_updates[0][0] == zcl_iilluminance_id
+    assert illuminance_listener.attribute_updates[0][1] == 10000 * math.log10(10) + 1
+
+    # send invalid illuminance report 0xFFFF (and motion)
+    opple_cluster.update_attribute(274, 0xFFFF)
+
+    # confirm invalid illuminance report is interpreted as 0
+    assert len(illuminance_listener.attribute_updates) == 2
+    assert illuminance_listener.attribute_updates[1][0] == zcl_iilluminance_id
+    assert illuminance_listener.attribute_updates[1][1] == 0
+
+    # send illuminance report only
+    opple_cluster.update_attribute(
+        XIAOMI_AQARA_ATTRIBUTE_E1, create_aqara_attr_report({101: 20})
+    )
+    assert len(illuminance_listener.attribute_updates) == 3
+    assert illuminance_listener.attribute_updates[2][0] == zcl_iilluminance_id
+    assert illuminance_listener.attribute_updates[2][1] == 10000 * math.log10(20) + 1
+
+
+@pytest.mark.parametrize(
+    "raw_report, expected_results",
+    (
+        [
+            "18200A01FF412501214F0B0421A84305214E020624010000000064299B096521BE1B662B138D01000A21900D",
+            [
+                2459,  # temperature
+                7102,  # humidity
+                1016.51,  # pressure
+                28.9,  # battery voltage
+                54,  # battery percent * 2
+            ],
+        ],
+    ),
+)
+async def test_xiaomi_weather(zigpy_device_from_quirk, raw_report, expected_results):
+    """Test Aqara weather sensor."""
+    raw_report = bytes.fromhex(raw_report)
+
+    device = zigpy_device_from_quirk(zhaquirks.xiaomi.aqara.weather.Weather2)
+
+    basic_cluster = device.endpoints[1].basic
+
+    temperature_cluster = device.endpoints[1].temperature
+    temperature_listener = ClusterListener(temperature_cluster)
+
+    humidity_cluster = device.endpoints[1].humidity
+    humidity_listener = ClusterListener(humidity_cluster)
+
+    pressure_cluster = device.endpoints[1].pressure
+    pressure_listener = ClusterListener(pressure_cluster)
+
+    power_cluster = device.endpoints[1].power
+    power_listener = ClusterListener(power_cluster)
+
+    zcl_temperature_id = TemperatureMeasurement.AttributeDefs.measured_value.id
+    zcl_humidity_id = RelativeHumidity.AttributeDefs.measured_value.id
+    zcl_pressure_id = PressureMeasurement.AttributeDefs.measured_value.id
+    zcl_power_voltage_id = PowerConfiguration.AttributeDefs.battery_voltage.id
+    zcl_power_percent_id = (
+        PowerConfiguration.AttributeDefs.battery_percentage_remaining.id
+    )
+
+    device.handle_message(
+        260,
+        basic_cluster.cluster_id,
+        basic_cluster.endpoint.endpoint_id,
+        basic_cluster.endpoint.endpoint_id,
+        raw_report,
+    )
+
+    assert len(temperature_listener.attribute_updates) == 1
+    assert temperature_listener.attribute_updates[0][0] == zcl_temperature_id
+    assert temperature_listener.attribute_updates[0][1] == expected_results[0]
+
+    assert len(humidity_listener.attribute_updates) == 1
+    assert humidity_listener.attribute_updates[0][0] == zcl_humidity_id
+    assert humidity_listener.attribute_updates[0][1] == expected_results[1]
+
+    assert len(pressure_listener.attribute_updates) == 1
+    assert pressure_listener.attribute_updates[0][0] == zcl_pressure_id
+    assert pressure_listener.attribute_updates[0][1] == expected_results[2]
+
+    assert len(power_listener.attribute_updates) == 2
+    assert power_listener.attribute_updates[0][0] == zcl_power_voltage_id
+    assert power_listener.attribute_updates[0][1] == expected_results[3]
+    assert power_listener.attribute_updates[1][0] == zcl_power_percent_id
+    assert power_listener.attribute_updates[1][1] == expected_results[4]
+
+
+@pytest.mark.parametrize(
+    "raw_report, expected_results",
+    (
+        [
+            "1C5F11C10A01FF41210121DB0B03281F0421A8430521B60006240B000000000A21CA356410000B210800",
+            [
+                3100,  # temperature
+                9031.899869919436,  # illuminance
+                30.4,  # battery voltage
+                154,  # battery percent * 2
+            ],
+        ],
+    ),
+)
+async def test_xiaomi_motion_sensor_misc(
+    zigpy_device_from_quirk, raw_report, expected_results
+):
+    """Test device temperature, illuminance, and power from old Aqara motion sensor models."""
+    raw_report = bytes.fromhex(raw_report)
+
+    device = zigpy_device_from_quirk(zhaquirks.xiaomi.aqara.motion_aq2.MotionAQ2)
+
+    basic_cluster = device.endpoints[1].basic
+
+    device_temperature_cluster = device.endpoints[1].device_temperature
+    device_temperature_listener = ClusterListener(device_temperature_cluster)
+
+    illuminance_cluster = device.endpoints[1].illuminance
+    illuminance_listener = ClusterListener(illuminance_cluster)
+
+    power_cluster = device.endpoints[1].power
+    power_listener = ClusterListener(power_cluster)
+
+    zcl_device_temperature_id = DeviceTemperature.AttributeDefs.current_temperature.id
+    zcl_illuminance_id = IlluminanceMeasurement.AttributeDefs.measured_value.id
+    zcl_power_voltage_id = PowerConfiguration.AttributeDefs.battery_voltage.id
+    zcl_power_percent_id = (
+        PowerConfiguration.AttributeDefs.battery_percentage_remaining.id
+    )
+
+    device.handle_message(
+        260,
+        basic_cluster.cluster_id,
+        basic_cluster.endpoint.endpoint_id,
+        basic_cluster.endpoint.endpoint_id,
+        raw_report,
+    )
+
+    assert len(device_temperature_listener.attribute_updates) == 1
+    assert (
+        device_temperature_listener.attribute_updates[0][0] == zcl_device_temperature_id
+    )
+    assert device_temperature_listener.attribute_updates[0][1] == expected_results[0]
+
+    assert len(illuminance_listener.attribute_updates) == 1
+    assert illuminance_listener.attribute_updates[0][0] == zcl_illuminance_id
+    assert illuminance_listener.attribute_updates[0][1] == expected_results[1]
+
+    assert len(power_listener.attribute_updates) == 2
+    assert power_listener.attribute_updates[0][0] == zcl_power_voltage_id
+    assert power_listener.attribute_updates[0][1] == expected_results[2]
+    assert power_listener.attribute_updates[1][0] == zcl_power_percent_id
+    assert power_listener.attribute_updates[1][1] == expected_results[3]
+
+
+@pytest.mark.parametrize("quirk", (zhaquirks.xiaomi.aqara.plug.Plug,))
+async def test_xiaomi_power_cluster_not_used(zigpy_device_from_quirk, caplog, quirk):
+    """Test that a log is printed which warns when a device reports battery mV readout,
+    even though XiaomiPowerConfigurationCluster is not used.
+
+    This explicitly uses the Plug quirk which will always report this message, as this shouldn't have a battery readout.
+    Other battery-powered devices might implement the XiaomiPowerConfigurationCluster in the future,
+    so they would no longer report this message.
+    """
+    caplog.set_level(logging.DEBUG)  # relevant message is currently DEBUG level
+
+    device = zigpy_device_from_quirk(quirk)
+    basic_cluster = device.endpoints[1].basic
+
+    power_cluster = device.endpoints[1].power
+    power_listener = ClusterListener(power_cluster)
+
+    # fake a battery voltage attribute report
+    basic_cluster.update_attribute(
+        XIAOMI_AQARA_ATTRIBUTE, create_aqara_attr_report({1: 2300})
+    )
+
+    # confirm that no battery voltage attribute was updated
+    assert len(power_listener.attribute_updates) == 0
+
+    # confirm that a debug message was logged
+    assert (
+        "Xiaomi battery voltage attribute received but XiaomiPowerConfiguration not used"
+        in caplog.text
+    )
+
+
+@pytest.mark.parametrize(
+    "quirk", (zhaquirks.xiaomi.aqara.roller_curtain_e1.RollerE1AQ,)
+)
+async def test_xiaomi_e1_roller_curtain_battery(zigpy_device_from_quirk, quirk):
+    """Test Aqara E1 roller curtain battery reporting."""
+    # Ideally, get a real Xiaomi "heartbeat" message to test.
+    # For now, fake the heartbeat message and check if battery parsing works.
+
+    device = zigpy_device_from_quirk(quirk)
+
+    basic_cluster = device.endpoints[1].basic
+    ClusterListener(basic_cluster)
+
+    power_cluster = device.endpoints[1].power
+    power_listener = ClusterListener(power_cluster)
+
+    zcl_power_voltage_id = PowerConfiguration.AttributeDefs.battery_voltage.id
+    zcl_power_percent_id = (
+        PowerConfiguration.AttributeDefs.battery_percentage_remaining.id
+    )
+
+    # battery voltage: 2895 mV
+    # battery percentage: 80%
+    basic_cluster.update_attribute(
+        XIAOMI_AQARA_ATTRIBUTE, create_aqara_attr_report({1: 2895, 101: 60})
+    )
+
+    # confirm that battery voltage attribute and percentage were each updated just once,
+    # so we verify the percent value sent was used,
+    # and the voltage value sent was only used for the voltage and not also for the percentage
+    assert len(power_listener.attribute_updates) == 2
+
+    # verify voltage and percentage values match the values sent
+    assert power_listener.attribute_updates[0][0] == zcl_power_voltage_id
+    assert power_listener.attribute_updates[0][1] == 28.9
+    assert power_listener.attribute_updates[1][0] == zcl_power_percent_id
+    assert power_listener.attribute_updates[1][1] == 120