From e8f5445acc88b4e08b6143af2a2e19bd2f30e9d0 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Wed, 25 Oct 2017 08:50:01 +0200 Subject: [PATCH] Xiaomi MiIO Fan: Xiaomi Air Purifier 2 integration (#9837) * Xiaomi Air Purifier 2 integration * Flake8 errors fixed. Changes based on review. * Service domain ("fan") updated and services properly prefixed by xiaomi_miio. * The underlying library is called python-miio now. Imports and requirements updated. * Version bumped. The underlying library is called python-miio now. --- .coveragerc | 1 + CODEOWNERS | 4 +- homeassistant/components/fan/xiaomi_miio.py | 332 ++++++++++++++++++ .../components/fan/xiaomi_miio_services.yaml | 56 +++ homeassistant/components/light/xiaomi_miio.py | 2 +- .../components/switch/xiaomi_miio.py | 2 +- .../components/vacuum/xiaomi_miio.py | 2 +- requirements_all.txt | 3 +- 8 files changed, 396 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/fan/xiaomi_miio.py create mode 100644 homeassistant/components/fan/xiaomi_miio_services.yaml diff --git a/.coveragerc b/.coveragerc index 334b9a995e0..11f3e837ef8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -326,6 +326,7 @@ omit = homeassistant/components/emoncms_history.py homeassistant/components/emulated_hue/upnp.py homeassistant/components/fan/mqtt.py + homeassistant/components/fan/xiaomi_miio.py homeassistant/components/feedreader.py homeassistant/components/foursquare.py homeassistant/components/ifttt.py diff --git a/CODEOWNERS b/CODEOWNERS index b3233fb9513..66944c138eb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -64,5 +64,5 @@ homeassistant/components/*/broadlink.py @danielhiversen homeassistant/components/*/rfxtrx.py @danielhiversen homeassistant/components/tesla.py @zabuldon homeassistant/components/*/tesla.py @zabuldon -homeassistant/components/*/xiaomi_aqara.py @danielhiversen -homeassistant/components/*/xiaomi_miio.py @rytilahti +homeassistant/components/*/xiaomi_aqara.py @danielhiversen @syssi +homeassistant/components/*/xiaomi_miio.py @rytilahti @syssi diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py new file mode 100644 index 00000000000..a69e069ffea --- /dev/null +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -0,0 +1,332 @@ +""" +Support for Xiaomi Mi Air Purifier 2. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/fan.xiaomi_miio/ +""" +import asyncio +from functools import partial +import logging +import os + +import voluptuous as vol + +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.components.fan import (FanEntity, PLATFORM_SCHEMA, + SUPPORT_SET_SPEED, DOMAIN) +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, + ATTR_ENTITY_ID, ) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Xiaomi Air Purifier' +PLATFORM = 'xiaomi_miio' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + +REQUIREMENTS = ['python-miio==0.3.0'] + +ATTR_TEMPERATURE = 'temperature' +ATTR_HUMIDITY = 'humidity' +ATTR_AIR_QUALITY_INDEX = 'aqi' +ATTR_MODE = 'mode' +ATTR_FILTER_HOURS_USED = 'filter_hours_used' +ATTR_FILTER_LIFE = 'filter_life_remaining' +ATTR_FAVORITE_LEVEL = 'favorite_level' +ATTR_BUZZER = 'buzzer' +ATTR_CHILD_LOCK = 'child_lock' +ATTR_LED = 'led' +ATTR_LED_BRIGHTNESS = 'led_brightness' +ATTR_MOTOR_SPEED = 'motor_speed' + +ATTR_BRIGHTNESS = 'brightness' +ATTR_LEVEL = 'level' + +SUCCESS = ['ok'] + +SERVICE_SET_BUZZER_ON = 'xiaomi_miio_set_buzzer_on' +SERVICE_SET_BUZZER_OFF = 'xiaomi_miio_set_buzzer_off' +SERVICE_SET_LED_ON = 'xiaomi_miio_set_led_on' +SERVICE_SET_LED_OFF = 'xiaomi_miio_set_led_off' +SERVICE_SET_FAVORITE_LEVEL = 'xiaomi_miio_set_favorite_level' +SERVICE_SET_LED_BRIGHTNESS = 'xiaomi_miio_set_led_brightness' + +AIRPURIFIER_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +SERVICE_SCHEMA_LED_BRIGHTNESS = AIRPURIFIER_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_BRIGHTNESS): + vol.All(vol.Coerce(int), vol.Clamp(min=0, max=2)) +}) + +SERVICE_SCHEMA_FAVORITE_LEVEL = AIRPURIFIER_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_LEVEL): + vol.All(vol.Coerce(int), vol.Clamp(min=0, max=17)) +}) + +SERVICE_TO_METHOD = { + SERVICE_SET_BUZZER_ON: {'method': 'async_set_buzzer_on'}, + SERVICE_SET_BUZZER_OFF: {'method': 'async_set_buzzer_off'}, + SERVICE_SET_LED_ON: {'method': 'async_set_led_on'}, + SERVICE_SET_LED_OFF: {'method': 'async_set_led_off'}, + SERVICE_SET_FAVORITE_LEVEL: { + 'method': 'async_set_favorite_level', + 'schema': SERVICE_SCHEMA_FAVORITE_LEVEL}, + SERVICE_SET_LED_BRIGHTNESS: { + 'method': 'async_set_led_brightness', + 'schema': SERVICE_SCHEMA_LED_BRIGHTNESS}, +} + + +# pylint: disable=unused-argument +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the air purifier from config.""" + from miio import AirPurifier, DeviceException + if PLATFORM not in hass.data: + hass.data[PLATFORM] = {} + + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + token = config.get(CONF_TOKEN) + + _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + + try: + air_purifier = AirPurifier(host, token) + + xiaomi_air_purifier = XiaomiAirPurifier(name, air_purifier) + hass.data[PLATFORM][host] = xiaomi_air_purifier + except DeviceException: + raise PlatformNotReady + + async_add_devices([xiaomi_air_purifier], update_before_add=True) + + @asyncio.coroutine + def async_service_handler(service): + """Map services to methods on XiaomiAirPurifier.""" + method = SERVICE_TO_METHOD.get(service.service) + params = {key: value for key, value in service.data.items() + if key != ATTR_ENTITY_ID} + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + target_air_purifiers = [air for air in hass.data[PLATFORM].values() + if air.entity_id in entity_ids] + else: + target_air_purifiers = hass.data[PLATFORM].values() + + update_tasks = [] + for air_purifier in target_air_purifiers: + yield from getattr(air_purifier, method['method'])(**params) + update_tasks.append(air_purifier.async_update_ha_state(True)) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) + + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'xiaomi_miio_services.yaml')) + + for air_purifier_service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[air_purifier_service].get( + 'schema', AIRPURIFIER_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, air_purifier_service, async_service_handler, + description=descriptions.get(air_purifier_service), schema=schema) + + +class XiaomiAirPurifier(FanEntity): + """Representation of a Xiaomi Air Purifier.""" + + def __init__(self, name, air_purifier): + """Initialize the air purifier.""" + self._name = name + + self._air_purifier = air_purifier + self._state = None + self._state_attrs = { + ATTR_AIR_QUALITY_INDEX: None, + ATTR_TEMPERATURE: None, + ATTR_HUMIDITY: None, + ATTR_MODE: None, + ATTR_FILTER_HOURS_USED: None, + ATTR_FILTER_LIFE: None, + ATTR_FAVORITE_LEVEL: None, + ATTR_BUZZER: None, + ATTR_CHILD_LOCK: None, + ATTR_LED: None, + ATTR_LED_BRIGHTNESS: None, + ATTR_MOTOR_SPEED: None + } + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_SET_SPEED + + @property + def should_poll(self): + """Poll the fan.""" + return True + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def available(self): + """Return true when state is known.""" + return self._state is not None + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._state_attrs + + @property + def is_on(self): + """Return true if fan is on.""" + return self._state + + @asyncio.coroutine + def _try_command(self, mask_error, func, *args, **kwargs): + """Call a air purifier command handling error messages.""" + from miio import DeviceException + try: + result = yield from self.hass.async_add_job( + partial(func, *args, **kwargs)) + + _LOGGER.debug("Response received from air purifier: %s", result) + + return result == SUCCESS + except DeviceException as exc: + _LOGGER.error(mask_error, exc) + return False + + @asyncio.coroutine + def async_turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + """Turn the fan on.""" + if speed: + # If operation mode was set the device must not be turned on. + yield from self.async_set_speed(speed) + return + + yield from self._try_command( + "Turning the air purifier on failed.", self._air_purifier.on) + + @asyncio.coroutine + def async_turn_off(self: ToggleEntity, **kwargs) -> None: + """Turn the fan off.""" + yield from self._try_command( + "Turning the air purifier off failed.", self._air_purifier.off) + + @asyncio.coroutine + def async_update(self): + """Fetch state from the device.""" + from miio import DeviceException + + try: + state = yield from self.hass.async_add_job( + self._air_purifier.status) + _LOGGER.debug("Got new state: %s", state) + + self._state = state.is_on + self._state_attrs = { + ATTR_TEMPERATURE: state.temperature, + ATTR_HUMIDITY: state.humidity, + ATTR_AIR_QUALITY_INDEX: state.aqi, + ATTR_MODE: state.mode.value, + ATTR_FILTER_HOURS_USED: state.filter_hours_used, + ATTR_FILTER_LIFE: state.filter_life_remaining, + ATTR_FAVORITE_LEVEL: state.favorite_level, + ATTR_BUZZER: state.buzzer, + ATTR_CHILD_LOCK: state.child_lock, + ATTR_LED: state.led, + ATTR_MOTOR_SPEED: state.motor_speed + } + + if state.led_brightness: + self._state_attrs[ + ATTR_LED_BRIGHTNESS] = state.led_brightness.value + + except DeviceException as ex: + _LOGGER.error("Got exception while fetching the state: %s", ex) + + @property + def speed_list(self: ToggleEntity) -> list: + """Get the list of available speeds.""" + from miio.airpurifier import OperationMode + return [mode.name for mode in OperationMode] + + @property + def speed(self): + """Return the current speed.""" + if self._state: + from miio.airpurifier import OperationMode + + return OperationMode(self._state_attrs[ATTR_MODE]).name + + return None + + @asyncio.coroutine + def async_set_speed(self: ToggleEntity, speed: str) -> None: + """Set the speed of the fan.""" + _LOGGER.debug("Setting the operation mode to: " + speed) + from miio.airpurifier import OperationMode + + yield from self._try_command( + "Setting operation mode of the air purifier failed.", + self._air_purifier.set_mode, OperationMode[speed]) + + @asyncio.coroutine + def async_set_buzzer_on(self): + """Turn the buzzer on.""" + yield from self._try_command( + "Turning the buzzer of air purifier on failed.", + self._air_purifier.set_buzzer, True) + + @asyncio.coroutine + def async_set_buzzer_off(self): + """Turn the buzzer on.""" + yield from self._try_command( + "Turning the buzzer of air purifier off failed.", + self._air_purifier.set_buzzer, False) + + @asyncio.coroutine + def async_set_led_on(self): + """Turn the led on.""" + yield from self._try_command( + "Turning the led of air purifier off failed.", + self._air_purifier.set_led, True) + + @asyncio.coroutine + def async_set_led_off(self): + """Turn the led off.""" + yield from self._try_command( + "Turning the led of air purifier off failed.", + self._air_purifier.set_led, False) + + @asyncio.coroutine + def async_set_led_brightness(self, brightness: int=2): + """Set the led brightness.""" + from miio.airpurifier import LedBrightness + + yield from self._try_command( + "Setting the led brightness of the air purifier failed.", + self._air_purifier.set_led_brightness, LedBrightness(brightness)) + + @asyncio.coroutine + def async_set_favorite_level(self, level: int=1): + """Set the favorite level.""" + yield from self._try_command( + "Setting the favorite level of the air purifier failed.", + self._air_purifier.set_favorite_level, level) diff --git a/homeassistant/components/fan/xiaomi_miio_services.yaml b/homeassistant/components/fan/xiaomi_miio_services.yaml new file mode 100644 index 00000000000..10a6009d9f1 --- /dev/null +++ b/homeassistant/components/fan/xiaomi_miio_services.yaml @@ -0,0 +1,56 @@ + +xiaomi_miio_set_buzzer_on: + description: Turn the buzzer on. + + fields: + entity_id: + description: Name of the air purifier entity. + example: 'fan.xiaomi_air_purifier' + +xiaomi_miio_set_buzzer_off: + description: Turn the buzzer off. + + fields: + entity_id: + description: Name of the air purifier entity. + example: 'fan.xiaomi_air_purifier' + +xiaomi_miio_set_led_on: + description: Turn the led on. + + fields: + entity_id: + description: Name of the air purifier entity. + example: 'fan.xiaomi_air_purifier' + +xiaomi_miio_set_led_off: + description: Turn the led off. + + fields: + entity_id: + description: Name of the air purifier entity. + example: 'fan.xiaomi_air_purifier' + +xiaomi_miio_set_favorite_level: + description: Set the favorite level. + + fields: + entity_id: + description: Name of the air purifier entity. + example: 'fan.xiaomi_air_purifier' + + level: + description: Level, between 0 and 17. + example: '1' + +xiaomi_miio_set_led_brightness: + description: Set the led brightness. + + fields: + entity_id: + description: Name of the air purifier entity. + example: 'fan.xiaomi_air_purifier' + + brightness: + description: Brightness (0 = Bright, 1 = Dim, 2 = Off) + example: '1' diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index cebd1670c4a..ee8dc0bd161 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -28,7 +28,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-mirobo==0.2.0'] +REQUIREMENTS = ['python-miio==0.3.0'] # The light does not accept cct values < 1 CCT_MIN = 1 diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index a7cb8681791..bca9e393f8e 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -25,7 +25,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-mirobo==0.2.0'] +REQUIREMENTS = ['python-miio==0.3.0'] ATTR_POWER = 'power' ATTR_TEMPERATURE = 'temperature' diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index 37d7be38f9d..ed19e220008 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-mirobo==0.2.0'] +REQUIREMENTS = ['python-miio==0.3.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b78e8834a2e..ba88ebd7aae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -803,10 +803,11 @@ python-juicenet==0.0.5 # homeassistant.components.lirc # python-lirc==1.2.3 +# homeassistant.components.fan.xiaomi_miio # homeassistant.components.light.xiaomi_miio # homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio -python-mirobo==0.2.0 +python-miio==0.3.0 # homeassistant.components.media_player.mpd python-mpd2==0.5.5