Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Mi AirPurifier 3 #31729

Merged
merged 11 commits into from
Apr 3, 2020
67 changes: 62 additions & 5 deletions homeassistant/components/xiaomi_miio/fan.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
AirFresh,
AirHumidifier,
AirPurifier,
AirPurifierMiot,
Device,
DeviceException,
)
Expand All @@ -23,6 +24,10 @@
LedBrightness as AirpurifierLedBrightness,
OperationMode as AirpurifierOperationMode,
)
from miio.airpurifier_miot import ( # pylint: disable=import-error, import-error
LedBrightness as AirpurifierMiotLedBrightness,
OperationMode as AirpurifierMiotOperationMode,
)
import voluptuous as vol

from homeassistant.components.fan import PLATFORM_SCHEMA, SUPPORT_SET_SPEED, FanEntity
Expand Down Expand Up @@ -77,6 +82,8 @@
MODEL_AIRPURIFIER_SA1 = "zhimi.airpurifier.sa1"
MODEL_AIRPURIFIER_SA2 = "zhimi.airpurifier.sa2"
MODEL_AIRPURIFIER_2S = "zhimi.airpurifier.mc1"
MODEL_AIRPURIFIER_3 = "zhimi.airpurifier.ma4"
foxel marked this conversation as resolved.
Show resolved Hide resolved
MODEL_AIRPURIFIER_3H = "zhimi.airpurifier.mb3"

MODEL_AIRHUMIDIFIER_V1 = "zhimi.humidifier.v1"
MODEL_AIRHUMIDIFIER_CA1 = "zhimi.humidifier.ca1"
Expand Down Expand Up @@ -104,6 +111,8 @@
MODEL_AIRPURIFIER_SA1,
MODEL_AIRPURIFIER_SA2,
MODEL_AIRPURIFIER_2S,
MODEL_AIRPURIFIER_3,
MODEL_AIRPURIFIER_3H,
MODEL_AIRHUMIDIFIER_V1,
foxel marked this conversation as resolved.
Show resolved Hide resolved
MODEL_AIRHUMIDIFIER_CA1,
MODEL_AIRHUMIDIFIER_CB1,
Expand Down Expand Up @@ -131,6 +140,7 @@
ATTR_PURIFY_VOLUME = "purify_volume"
ATTR_BRIGHTNESS = "brightness"
ATTR_LEVEL = "level"
ATTR_FAN_LEVEL = "fan_level"
ATTR_MOTOR2_SPEED = "motor2_speed"
ATTR_ILLUMINANCE = "illuminance"
ATTR_FILTER_RFID_PRODUCT_ID = "filter_rfid_product_id"
Expand All @@ -154,13 +164,15 @@
ATTR_HARDWARE_VERSION = "hardware_version"

# Air Humidifier CA
ATTR_MOTOR_SPEED = "motor_speed"
# ATTR_MOTOR_SPEED = "motor_speed"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why change this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicates ATTR_MOTOR_SPEED on line 139. Didn't drop this entirely just to keep the notion of this attribute in this "Air Humidifier CA" section

ATTR_DEPTH = "depth"
ATTR_DRY = "dry"

# Air Fresh
ATTR_CO2 = "co2"

PURIFIER_MIOT = [MODEL_AIRPURIFIER_3, MODEL_AIRPURIFIER_3H]

# Map attributes to properties of the state object
AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON = {
ATTR_TEMPERATURE: "temperature",
Expand Down Expand Up @@ -227,6 +239,28 @@
ATTR_ILLUMINANCE: "illuminance",
}

AVAILABLE_ATTRIBUTES_AIRPURIFIER_3 = {
ATTR_TEMPERATURE: "temperature",
ATTR_HUMIDITY: "humidity",
ATTR_AIR_QUALITY_INDEX: "aqi",
ATTR_MODE: "mode",
ATTR_FILTER_HOURS_USED: "filter_hours_used",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relative times are not allowed in the state machine. We only allow absolute utc timestamps.

Times should preferably be represented as sensors with device class timestamp.

https://developers.home-assistant.io/docs/entity_sensor#available-device-classes

Copy link
Member

@rytilahti rytilahti Apr 3, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, should this be completely removed then? The problem with this value is, I presume, that there is no specific timestamp as it depends on the usage. ping @foxel - do you have an idea on this?

P.S. I think we need to have a checklist for reviewers, much like what we have for PR creators, to list things to check. It is sometimes hard to spot all mistakes, and I'm very sorry for not catching this one..

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should update this dev docs page with the allowed fan speeds:
https://developers.home-assistant.io/docs/entity_fan

I'll put it on my todo.

In general we need to make sure that we follow our architecture docs and ADRs. But those are not complete yet, so that's a problem.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's interesting that relative timestamps are not allowed in state attributes. Didn't know that. Where can I find more docs about it?
I have MQTT devices, Esphome which are breaking this rule. This cannot be found in HA code as HA just puts received json in state_attrs.
my vacuum state attributes:

cleanTime: 202.8
cleanArea: 12530.2
cleanCount: 1349
last_run_stats: 
  startTime: 1585934330000
  endTime: 1585935335000
duration: 1005

@rytilahti solution for it is to move attributes to independent sensors.

ATTR_FILTER_LIFE: "filter_life_remaining",
ATTR_FAVORITE_LEVEL: "favorite_level",
ATTR_CHILD_LOCK: "child_lock",
ATTR_LED: "led",
ATTR_MOTOR_SPEED: "motor_speed",
ATTR_AVERAGE_AIR_QUALITY_INDEX: "average_aqi",
ATTR_PURIFY_VOLUME: "purify_volume",
ATTR_USE_TIME: "use_time",
ATTR_BUZZER: "buzzer",
ATTR_LED_BRIGHTNESS: "led_brightness",
ATTR_FILTER_RFID_PRODUCT_ID: "filter_rfid_product_id",
ATTR_FILTER_RFID_TAG: "filter_rfid_tag",
ATTR_FILTER_TYPE: "filter_type",
ATTR_FAN_LEVEL: "fan_level",
}

AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = {
# Common set isn't used here. It's a very basic version of the device.
ATTR_AIR_QUALITY_INDEX: "aqi",
Expand Down Expand Up @@ -302,6 +336,7 @@
OPERATION_MODES_AIRPURIFIER_PRO = ["Auto", "Silent", "Favorite"]
OPERATION_MODES_AIRPURIFIER_PRO_V7 = OPERATION_MODES_AIRPURIFIER_PRO
OPERATION_MODES_AIRPURIFIER_2S = ["Auto", "Silent", "Favorite"]
OPERATION_MODES_AIRPURIFIER_3 = ["Auto", "Silent", "Favorite", "Fan"]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only allowed speed modes for fans are off, low, medium and high.

SPEED_OFF = "off"
SPEED_LOW = "low"
SPEED_MEDIUM = "medium"
SPEED_HIGH = "high"

We should not continue to extend this platform with architecture design breaking changes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I didn't notice that... How can we make the situation better? Should the tests be improved to capture such misuses?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how we can test this generally. Some platforms are already breaking the rules which might also complicate any general tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MartinHjelmare what if a device does not fall into such a specification?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The platform can try to translate between home assistant mode and device mode. The platform can also register custom services.

The current design does not cater well to all fans but until we have a better design we have to follow it. We have an open architecture issue on fan speeds. You're welcome to contribute to that and try to bring it forward. We need to figure out a design that can cater for a majority of fans but is still compatible with our frontend and voice assistants.

OPERATION_MODES_AIRPURIFIER_V3 = [
"Auto",
"Silent",
Expand Down Expand Up @@ -361,6 +396,14 @@
| FEATURE_SET_FAVORITE_LEVEL
)

FEATURE_FLAGS_AIRPURIFIER_3 = (
FEATURE_SET_BUZZER
| FEATURE_SET_CHILD_LOCK
| FEATURE_SET_LED
| FEATURE_SET_FAVORITE_LEVEL
foxel marked this conversation as resolved.
Show resolved Hide resolved
| FEATURE_SET_LED_BRIGHTNESS
)

FEATURE_FLAGS_AIRPURIFIER_V3 = (
FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED
)
Expand Down Expand Up @@ -472,7 +515,10 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
except DeviceException:
raise PlatformNotReady

if model.startswith("zhimi.airpurifier."):
if model in PURIFIER_MIOT:
air_purifier = AirPurifierMiot(host, token)
device = XiaomiAirPurifier(name, air_purifier, model, unique_id)
elif model.startswith("zhimi.airpurifier."):
air_purifier = AirPurifier(host, token)
device = XiaomiAirPurifier(name, air_purifier, model, unique_id)
elif model.startswith("zhimi.humidifier."):
Expand Down Expand Up @@ -690,6 +736,10 @@ def __init__(self, name, device, model, unique_id):
self._device_features = FEATURE_FLAGS_AIRPURIFIER_2S
self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_2S
self._speed_list = OPERATION_MODES_AIRPURIFIER_2S
elif self._model == MODEL_AIRPURIFIER_3 or self._model == MODEL_AIRPURIFIER_3H:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was rechecking this PR and saw these and wondered why the one below is not merged here, before I realized that one is _3 and one _V3. Do you think it would make sense to add MIOT somewhere in there to make it easier to differentiate?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't ever see V3 version but it's something older and different from 3/3h from 2019...
I don't think adding MIOT would be the best solution. I think in not so distant future we'll need to have 2/2s/Pro support with MiOT protocol...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or simply use some part of the real model number? It's mildly confusing to differentiate between those constants. Anyway, this is not a blocking issue.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One last thing to do before merging, please update the documentation (for the model listing & the new service): https://github.com/home-assistant/home-assistant.io/blob/current/source/_integrations/fan.xiaomi_miio.markdown

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made one

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, let's get this merged, the docs will follow as soon as someone reviews it! Thanks for your hard work and patience on this! 🥇

self._device_features = FEATURE_FLAGS_AIRPURIFIER_3
self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_3
self._speed_list = OPERATION_MODES_AIRPURIFIER_3
foxel marked this conversation as resolved.
Show resolved Hide resolved
elif self._model == MODEL_AIRPURIFIER_V3:
self._device_features = FEATURE_FLAGS_AIRPURIFIER_V3
self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3
Expand Down Expand Up @@ -736,7 +786,10 @@ def speed_list(self) -> list:
def speed(self):
"""Return the current speed."""
if self._state:
return AirpurifierOperationMode(self._state_attrs[ATTR_MODE]).name
if isinstance(self._device, AirPurifierMiot):
return AirpurifierMiotOperationMode(self._state_attrs[ATTR_MODE]).name
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make more sense to save a reference to the mode as given by the library and simply use _speed.name? Usage of isinstance is generally speaking not a good thing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed this to get rid of runtime instance checking

else:
return AirpurifierOperationMode(self._state_attrs[ATTR_MODE]).name

return None

Expand All @@ -750,7 +803,9 @@ async def async_set_speed(self, speed: str) -> None:
await self._try_command(
"Setting operation mode of the miio device failed.",
self._device.set_mode,
AirpurifierOperationMode[speed.title()],
AirpurifierMiotOperationMode[speed.title()]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to find a better solution for this, too.

if isinstance(self._device, AirPurifierMiot)
else AirpurifierOperationMode[speed.title()],
)

async def async_set_led_on(self):
Expand Down Expand Up @@ -781,7 +836,9 @@ async def async_set_led_brightness(self, brightness: int = 2):
await self._try_command(
"Setting the led brightness of the miio device failed.",
self._device.set_led_brightness,
AirpurifierLedBrightness(brightness),
AirpurifierMiotLedBrightness(brightness)
if isinstance(self._device, AirPurifierMiot)
else AirpurifierLedBrightness(brightness),
)

async def async_set_favorite_level(self, level: int = 1):
Expand Down
5 changes: 4 additions & 1 deletion homeassistant/components/xiaomi_miio/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
"domain": "xiaomi_miio",
"name": "Xiaomi miio",
"documentation": "https://www.home-assistant.io/integrations/xiaomi_miio",
"requirements": ["construct==2.9.45", "python-miio==0.4.8"],
"requirements": [
"construct==2.9.45",
"https://github.com/foxel/python-miio/archive/air-purifier-3h-support.zip#python-miio==0.5.0"
Copy link
Member

@Kane610 Kane610 Feb 13, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not allowed. The package must be published to pypi.

Sorry didn't see it was a draft.

],
"dependencies": [],
"codeowners": ["@rytilahti", "@syssi"]
}