Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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: |
Expand Down
72 changes: 60 additions & 12 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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~
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
7 changes: 6 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
149 changes: 145 additions & 4 deletions miio/integrations/zhimi/airpurifier/airpurifier_miot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -290,6 +329,7 @@
"zhimi.airp.mb5a",
"zhimi.airp.vb4",
"zhimi.airp.rmb1",
"zhimi.airp.meb1",
]


Expand All @@ -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.

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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"
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Loading
Loading