From b8e632c9b2cb7fa53b600caa93e2b46bee5d6760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jocke=20Sand=C3=A9n?= Date: Thu, 23 Jul 2026 12:05:09 +0200 Subject: [PATCH 1/2] Add support for xiaomi.airp.cpa5 (Smart Pet Care Air Purifier) Adds the MIoT property mapping for this model, sourced from the authoritative spec at home.miot-spec.com. The model needs to be included in REVERSED_LED_BRIGHTNESS since its led_brightness value order (Off=0, Dim=1, Bright=2) is reversed relative to the common (Bright=0, Dim=1, Off=2) order. Verified against physical hardware: status reading, led_brightness (all three states), buzzer, child_lock (confirmed it actually locks the physical buttons without affecting the buzzer), favorite_level and mode. --- .../zhimi/airpurifier/airpurifier_miot.py | 28 ++++++++ .../tests/test_airpurifier_miot.py | 69 +++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/miio/integrations/zhimi/airpurifier/airpurifier_miot.py b/miio/integrations/zhimi/airpurifier/airpurifier_miot.py index 021dd10ad..9d99584dd 100644 --- a/miio/integrations/zhimi/airpurifier/airpurifier_miot.py +++ b/miio/integrations/zhimi/airpurifier/airpurifier_miot.py @@ -225,6 +225,32 @@ "device-display-unit": {"siid": 14, "piid": 1}, } +# https://home.miot-spec.com/spec/xiaomi.airp.cpa5 +_MAPPING_CPA5 = { + # Air Purifier (siid=2) + "power": {"siid": 2, "piid": 1}, + "fault": {"siid": 2, "piid": 2}, + "mode": {"siid": 2, "piid": 3}, + # Environment (siid=3) - no temperature/humidity sensor on this model + "aqi": {"siid": 3, "piid": 4}, + # Filter (siid=4) + "filter_life_remaining": {"siid": 4, "piid": 1}, + "filter_hours_used": {"siid": 4, "piid": 3}, + "filter_left_time": {"siid": 4, "piid": 4}, + # Screen (siid=6) + "led_brightness": {"siid": 6, "piid": 2}, + # Alarm (siid=7) + "buzzer": {"siid": 7, "piid": 1}, + # Physical Control Locked (siid=8) + "child_lock": {"siid": 8, "piid": 1}, + # Favorite level, lives under the "air-purifier-favorite" service (siid=9) + "favorite_level": {"siid": 9, "piid": 1}, + # custom-service (siid=10) + "motor_speed": {"siid": 10, "piid": 1}, + # AQI realtime refresh heartbeat (siid=11) + "aqi_realtime_update_duration": {"siid": 11, "piid": 1}, +} + # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-za1:2 _MAPPING_ZA1 = { # Air Purifier (siid=2) @@ -281,6 +307,7 @@ "zhimi.airpurifier.rma2": _MAPPING_RMA2, # airpurifier 4 lite "zhimi.airp.rmb1": _MAPPING_RMB1, # airpurifier 4 lite "zhimi.airpurifier.za1": _MAPPING_ZA1, # smartmi air purifier + "xiaomi.airp.cpa5": _MAPPING_CPA5, # Smart Pet Care Air Purifier } # Models requiring reversed led brightness value @@ -290,6 +317,7 @@ "zhimi.airp.mb5a", "zhimi.airp.vb4", "zhimi.airp.rmb1", + "xiaomi.airp.cpa5", ] diff --git a/miio/integrations/zhimi/airpurifier/tests/test_airpurifier_miot.py b/miio/integrations/zhimi/airpurifier/tests/test_airpurifier_miot.py index c4046e039..e5b6f669b 100644 --- a/miio/integrations/zhimi/airpurifier/tests/test_airpurifier_miot.py +++ b/miio/integrations/zhimi/airpurifier/tests/test_airpurifier_miot.py @@ -299,6 +299,75 @@ def test_set_led(self): self.device.set_led(True) +_INITIAL_STATE_CPA5 = { + "power": True, + "fault": 0, + "mode": 0, + "aqi": 10, + "filter_life_remaining": 80, + "filter_hours_used": 682, + "filter_left_time": 309, + "led_brightness": 1, + "buzzer": False, + "child_lock": False, + "favorite_level": 10, + "motor_speed": 354, +} + + +class DummyAirPurifierMiotCPA5(DummyAirPurifierMiot): + def __init__(self, *args, **kwargs): + self._model = "xiaomi.airp.cpa5" + self.state = _INITIAL_STATE_CPA5 + super().__init__(*args, **kwargs) + + +@pytest.fixture +def airpurifierCPA5(request): + request.cls.device = DummyAirPurifierMiotCPA5() + + +@pytest.mark.usefixtures("airpurifierCPA5") +class TestAirPurifierCPA5(TestCase): + def test_status(self): + status = self.device.status() + assert status.is_on is _INITIAL_STATE_CPA5["power"] + assert status.aqi == _INITIAL_STATE_CPA5["aqi"] + assert status.average_aqi is None + assert status.humidity is None + assert status.temperature is None + assert status.fan_level is None + assert status.led is None + assert status.mode == OperationMode(_INITIAL_STATE_CPA5["mode"]) + assert status.led_brightness == LedBrightness( + _INITIAL_STATE_CPA5["led_brightness"] + ) + assert status.led_brightness_level is None + assert status.buzzer == _INITIAL_STATE_CPA5["buzzer"] + assert status.child_lock == _INITIAL_STATE_CPA5["child_lock"] + assert status.favorite_level == _INITIAL_STATE_CPA5["favorite_level"] + assert ( + status.filter_life_remaining + == _INITIAL_STATE_CPA5["filter_life_remaining"] + ) + assert status.filter_hours_used == _INITIAL_STATE_CPA5["filter_hours_used"] + assert status.filter_left_time == _INITIAL_STATE_CPA5["filter_left_time"] + assert status.motor_speed == _INITIAL_STATE_CPA5["motor_speed"] + + def test_set_led_brightness(self): + def led_brightness(): + return self.device.status().led_brightness + + self.device.set_led_brightness(LedBrightness.Bright) + assert led_brightness() == LedBrightness.Bright + + self.device.set_led_brightness(LedBrightness.Dim) + assert led_brightness() == LedBrightness.Dim + + self.device.set_led_brightness(LedBrightness.Off) + assert led_brightness() == LedBrightness.Off + + class DummyAirPurifierMiotVA2(DummyAirPurifierMiot): def __init__(self, *args, **kwargs): self._model = "zhimi.airp.va2" From e2346a35368c12270f2c0d9d450c71ed468d3dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jocke=20Sand=C3=A9n?= Date: Thu, 23 Jul 2026 19:32:49 +0200 Subject: [PATCH 2/2] Apply ruff format --- .../zhimi/airpurifier/tests/test_airpurifier_miot.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/miio/integrations/zhimi/airpurifier/tests/test_airpurifier_miot.py b/miio/integrations/zhimi/airpurifier/tests/test_airpurifier_miot.py index e5b6f669b..c5e755257 100644 --- a/miio/integrations/zhimi/airpurifier/tests/test_airpurifier_miot.py +++ b/miio/integrations/zhimi/airpurifier/tests/test_airpurifier_miot.py @@ -347,8 +347,7 @@ def test_status(self): assert status.child_lock == _INITIAL_STATE_CPA5["child_lock"] assert status.favorite_level == _INITIAL_STATE_CPA5["favorite_level"] assert ( - status.filter_life_remaining - == _INITIAL_STATE_CPA5["filter_life_remaining"] + status.filter_life_remaining == _INITIAL_STATE_CPA5["filter_life_remaining"] ) assert status.filter_hours_used == _INITIAL_STATE_CPA5["filter_hours_used"] assert status.filter_left_time == _INITIAL_STATE_CPA5["filter_left_time"]