diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f39757bbf..809059d95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,8 @@ jobs: python-version: "${{ matrix.python-version }}" - name: "Install dependencies" run: | - python -m pip install --upgrade pip poetry + python -m pip install --upgrade pip + python -m pip install "poetry==1.8.2" poetry install --extras docs - name: "Run pre-commit hooks" run: | @@ -48,7 +49,8 @@ jobs: python-version: "${{ matrix.python-version }}" - name: "Install dependencies" run: | - python -m pip install --upgrade pip poetry + python -m pip install --upgrade pip + python -m pip install "poetry==1.8.2" poetry install --all-extras - name: "Run tests" run: | diff --git a/.gitignore b/.gitignore index 6b0e61774..d45541166 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,71 @@ +# Byte-compiled files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +*.egg *.egg-info/ +dist/ +build/ +eggs/ +sdist/ +wheels/ -__pycache__ -.idea/ -.cache/ -.mypy_cache/ -.tox/ -.venv/ +# Installer logs +pip-log.txt +pip-delete-this-directory.txt -.coverage +# Virtual environments +.env/ +.venv/ +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ -# generated apidocs -docs/_build/ -docs/api/ +# Pytest +.cache/ +.tox/ +# IDE configurations +.idea/ +.vscode/ .vscode/settings.json - -# pycharm shenanigans *.orig *_BACKUP_* *_BASE_* *_LOCAL_* *_REMOTE_* + +# Coverage reports +.coverage +*.cover +*.coverage.* + +# Testing files +test-results/ +.nox/ + +# MyPy +.mypy_cache/ + +# Sphinx documentation +docs/_build/ +docs/api/ + +# Local files +*.log +*.swp +.DS_Store +*.tmp +*.temp +*.bak + +# Git metadata +*.rej +*.un~ diff --git a/README.md b/README.md index c5acc8aa3..da2a67500 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,7 @@ integration, this library supports also the following devices: * Xiaomi Smart Pet Water Dispenser (mmgg.pet_waterer.s1, s4, wi11) * Xiaomi Mi Smart Humidifer S (jsqs, jsq5) * Xiaomi Mi Robot Vacuum Mop 2 (Pro+, Ultra) +* Xiaomi Air Purifier Elite (zhimi.airp.meb1) *Feel free to create a pull request to add support for new devices as well as additional features for already supported ones.* diff --git a/docs/conf.py b/docs/conf.py index c4f7d6a02..bb7cade5a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -193,6 +193,11 @@ autodoc_member_order = "groupwise" autodoc_inherit_docstrings = True -autodoc_default_options = {"inherited-members": True} +autodoc_default_options = { + "inherited-members": True, + # construct-typed inherited enum helpers have malformed upstream docstrings + # that trigger Sphinx parser warnings on RTD with fail_on_warning enabled. + "exclude-members": "from_bytes,to_bytes", +} myst_heading_anchors = 2 diff --git a/miio/integrations/zhimi/airpurifier/airpurifier_miot.py b/miio/integrations/zhimi/airpurifier/airpurifier_miot.py index 46e48408e..3ad704ca7 100644 --- a/miio/integrations/zhimi/airpurifier/airpurifier_miot.py +++ b/miio/integrations/zhimi/airpurifier/airpurifier_miot.py @@ -222,7 +222,7 @@ # Screen "led_brightness": {"siid": 13, "piid": 2}, # Device Display Unit - "device-display-unit": {"siid": 14, "piid": 1}, + "device_display_unit": {"siid": 14, "piid": 1}, } # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-za1:2 @@ -258,11 +258,49 @@ "filter_rfid_tag": {"siid": 14, "piid": 1}, "filter_rfid_product_id": {"siid": 14, "piid": 3}, # Device Display Unit - "device-display-unit": {"siid": 16, "piid": 1}, + "device_display_unit": {"siid": 16, "piid": 1}, # Other "gestures": {"siid": 15, "piid": 13}, } +# https://home.miot-spec.com/spec/zhimi.airp.meb1 +_MAPPING_MEB1 = { + # Air Purifier (siid=2) + "power": {"siid": 2, "piid": 1}, + "fault": {"siid": 2, "piid": 2}, + "mode": {"siid": 2, "piid": 4}, + "fan_level": {"siid": 2, "piid": 5}, + "plasma": {"siid": 2, "piid": 6}, + "uv": {"siid": 2, "piid": 7}, + # Environment (siid=3) + "pm2_5_density": {"siid": 3, "piid": 4}, + "pm10_density": {"siid": 3, "piid": 8}, + "aqi": {"siid": 3, "piid": 9}, + "humidity": {"siid": 3, "piid": 1}, + "temperature": {"siid": 3, "piid": 7}, + # Filter (siid=4) + "filter_life_remaining": {"siid": 4, "piid": 1}, + "filter_hours_used": {"siid": 4, "piid": 3}, + # Alarm (siid=6) + "buzzer": {"siid": 6, "piid": 1}, + "buzzer_volume": {"siid": 6, "piid": 2}, + # Physical Control Locked (siid=8) + "child_lock": {"siid": 8, "piid": 1}, + # Custom Service (siid=9) + "motor_speed": {"siid": 9, "piid": 1}, + "reboot_cause": {"siid": 9, "piid": 8}, + "country_code": {"siid": 9, "piid": 11}, + # AQI (siid=11) + "aqi_realtime_update_duration": {"siid": 11, "piid": 4}, + # RFID (siid=12) + "filter_rfid_tag": {"siid": 12, "piid": 1}, + "filter_rfid_product_id": {"siid": 12, "piid": 3}, + # Screen (siid=13) + "led_brightness": {"siid": 13, "piid": 2}, + # Device Display Unit (siid=15) + "temperature_display_unit": {"siid": 15, "piid": 1}, +} + _MAPPINGS = { "zhimi.airpurifier.ma4": _MAPPING, # airpurifier 3 @@ -281,6 +319,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 + "zhimi.airp.meb1": _MAPPING_MEB1, # air purifier elite } # Models requiring reversed led brightness value @@ -290,6 +329,7 @@ "zhimi.airp.mb5a", "zhimi.airp.vb4", "zhimi.airp.rmb1", + "zhimi.airp.meb1", ] @@ -307,6 +347,14 @@ class LedBrightness(enum.Enum): Off = 2 +class FaultCode(enum.Enum): + NO_FAULT = 0 + SENSOR_PM_ERROR = 1 + SENSOR_HUM_ERROR = 2 + NO_FILTER = 4 + UNKNOWN_ERROR = -1 + + class AirPurifierMiotStatus(DeviceStatus): """Container for status reports from the air purifier. @@ -429,11 +477,66 @@ def temperature(self) -> Optional[float]: return round(temperate, 1) if temperate is not None else None @property + @sensor("PM10 Density", unit="μg/m³") def pm10_density(self) -> Optional[float]: - """Current temperature, if available.""" + """Current PM10 density, if available.""" pm10_density = self.data.get("pm10_density") return round(pm10_density, 1) if pm10_density is not None else None + @property + @sensor("PM2.5 Density", unit="μg/m³") + def pm25_density(self) -> Optional[float]: + """Return the PM2.5 density.""" + return self.data.get("pm2_5_density") + + @property + def is_plasma_on(self) -> bool: + """Return True if plasma is on.""" + return bool(self.data.get("plasma")) + + @property + @setting("Plasma", setter_name="set_plasma") + def plasma(self) -> str: + """Plasma state.""" + return "on" if self.is_plasma_on else "off" + + @property + def is_uv_on(self) -> bool: + """Return True if UV is on.""" + return bool(self.data.get("uv")) + + @property + @setting("UV", setter_name="set_uv") + def uv(self) -> str: + """UV state.""" + return "on" if self.is_uv_on else "off" + + @property + @sensor("Fault Code", unit="") + def fault(self) -> Optional[FaultCode]: + """Return fault code if any.""" + fault_code = self.data.get("fault") + try: + return FaultCode(fault_code) if fault_code is not None else None + except ValueError: + _LOGGER.warning("Unknown fault code: %s", fault_code) + return None + + @property + def temperature_display_unit(self) -> Optional[int]: + """Return temperature display unit.""" + return self.data.get("temperature_display_unit") + + @property + def country_code(self) -> Optional[int]: + """Return country code.""" + return self.data.get("country_code") + + @property + def reboot_cause(self) -> Optional[int]: + """Return reboot cause.""" + return self.data.get("reboot_cause") + @property def fan_level(self) -> Optional[int]: """Current fan level.""" @@ -530,13 +633,19 @@ class AirPurifierMiot(MiotDevice): default_output=format_output( "", "Power: {result.power}\n" + "Fault Code: {result.fault}\n" + "Fault Description: {result.fault_description}\n" + "Plasma: {result.plasma}\n" + "UV: {result.uv}\n" "Anion: {result.anion}\n" "AQI: {result.aqi} μg/m³\n" "TVOC: {result.tvoc}\n" "Average AQI: {result.average_aqi} μg/m³\n" "Humidity: {result.humidity} %\n" "Temperature: {result.temperature} °C\n" + "Temperature Unit: {result.temperature_display_unit}\n" "PM10 Density: {result.pm10_density} μg/m³\n" + "PM2.5 Density: {result.pm25_density} μg/m³\n" "Fan Level: {result.fan_level}\n" "Mode: {result.mode}\n" "LED: {result.led}\n" @@ -555,7 +664,9 @@ class AirPurifierMiot(MiotDevice): "Motor speed: {result.motor_speed} rpm\n" "Filter RFID product id: {result.filter_rfid_product_id}\n" "Filter RFID tag: {result.filter_rfid_tag}\n" - "Filter type: {result.filter_type}\n", + "Filter type: {result.filter_type}\n" + "Country Code: {result.country_code}\n" + "Reboot Cause: {result.reboot_cause}\n", ) ) def status(self) -> AirPurifierMiotStatus: @@ -764,3 +875,33 @@ def set_led_brightness_level(self, level: int): raise ValueError("Invalid brightness level: %s" % level) return self.set_property("led_brightness_level", level) + + @command( + click.argument("plasma", type=bool), + default_output=format_output( + lambda plasma: "Turning on plasma" if plasma else "Turning off plasma" + ), + ) + def set_plasma(self, plasma: bool): + """Set plasma on/off.""" + if "plasma" not in self._get_mapping(): + raise UnsupportedFeatureException( + "Unsupported plasma for model '%s'" % self.model + ) + + return self.set_property("plasma", plasma) + + @command( + click.argument("uv", type=bool), + default_output=format_output( + lambda uv: "Turning on UV" if uv else "Turning off UV" + ), + ) + def set_uv(self, uv: bool): + """Set UV on/off.""" + if "uv" not in self._get_mapping(): + raise UnsupportedFeatureException( + "Unsupported UV for model '%s'" % self.model + ) + + return self.set_property("uv", uv) diff --git a/miio/integrations/zhimi/airpurifier/tests/test_airpurifier_miot.py b/miio/integrations/zhimi/airpurifier/tests/test_airpurifier_miot.py index b4b4411c3..ad93efe5f 100644 --- a/miio/integrations/zhimi/airpurifier/tests/test_airpurifier_miot.py +++ b/miio/integrations/zhimi/airpurifier/tests/test_airpurifier_miot.py @@ -7,7 +7,7 @@ from .. import AirPurifierMiot from ..airfilter_util import FilterType -from ..airpurifier_miot import LedBrightness, OperationMode +from ..airpurifier_miot import FaultCode, LedBrightness, OperationMode _INITIAL_STATE = { "power": True, @@ -69,6 +69,33 @@ "button_pressed": "power", } +_INITIAL_STATE_MEB1 = { + "power": True, + "fault": 0, + "mode": 0, + "fan_level": 2, + "plasma": True, + "uv": True, + "pm2_5_density": 9, + "pm10_density": 12.7, + "aqi": 10, + "humidity": 62, + "temperature": 18.599999, + "filter_life_remaining": 80, + "filter_hours_used": 682, + "buzzer": False, + "buzzer_volume": 1, + "child_lock": False, + "motor_speed": 354, + "aqi_realtime_update_duration": 5, + "filter_rfid_tag": "10:20:30:40:50:60:7", + "filter_rfid_product_id": "0:0:41:30", + "led_brightness": 1, + "temperature_display_unit": 0, + "country_code": 44, + "reboot_cause": 3, +} + class DummyAirPurifierMiot(DummyMiotDevice, AirPurifierMiot): def __init__(self, *args, **kwargs): @@ -313,6 +340,13 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) +class DummyAirPurifierMiotMEB1(DummyAirPurifierMiot): + def __init__(self, *args, **kwargs): + self._model = "zhimi.airp.meb1" + self.state = _INITIAL_STATE_MEB1 + super().__init__(*args, **kwargs) + + @pytest.fixture(scope="function") def airpurifierVA2(request): request.cls.device = DummyAirPurifierMiotVA2() @@ -373,3 +407,54 @@ def anion(): self.device.set_anion(False) assert anion() is False + + +@pytest.fixture(scope="function") +def airpurifierMEB1(request): + request.cls.device = DummyAirPurifierMiotMEB1() + + +@pytest.mark.usefixtures("airpurifierMEB1") +class TestAirPurifierMEB1(TestCase): + def test_status(self): + status = self.device.status() + assert status.is_on is _INITIAL_STATE_MEB1["power"] + assert status.mode == OperationMode(_INITIAL_STATE_MEB1["mode"]) + assert status.pm10_density == 12.7 + assert status.pm25_density == _INITIAL_STATE_MEB1["pm2_5_density"] + assert status.plasma == "on" + assert status.uv == "on" + assert status.fault == FaultCode.NO_FAULT + assert ( + status.temperature_display_unit + == _INITIAL_STATE_MEB1["temperature_display_unit"] + ) + assert status.country_code == _INITIAL_STATE_MEB1["country_code"] + assert status.reboot_cause == _INITIAL_STATE_MEB1["reboot_cause"] + + def test_fault_unknown(self): + self.device.set_property("fault", 99) + assert self.device.status().fault is None + + def test_set_plasma(self): + self.device.set_plasma(False) + assert self.device.status().plasma == "off" + self.device.set_plasma(True) + assert self.device.status().plasma == "on" + + def test_set_uv(self): + self.device.set_uv(False) + assert self.device.status().uv == "off" + self.device.set_uv(True) + assert self.device.status().uv == "on" + + +@pytest.mark.usefixtures("airpurifierMB4") +class TestAirPurifierMB4UnsupportedFeatures(TestCase): + def test_set_plasma_unsupported(self): + with pytest.raises(UnsupportedFeatureException): + self.device.set_plasma(True) + + def test_set_uv_unsupported(self): + with pytest.raises(UnsupportedFeatureException): + self.device.set_uv(True)