From 2f9fda73f4e0f843c7a3b48ce7c33beac6cbcab8 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 15 Feb 2021 20:11:27 +0100 Subject: [PATCH] Add config flow to Xiaomi Miio switch (#46179) --- .coveragerc | 1 + .../components/xiaomi_miio/__init__.py | 36 ++- .../components/xiaomi_miio/config_flow.py | 144 ++++++---- homeassistant/components/xiaomi_miio/const.py | 21 ++ .../components/xiaomi_miio/device.py | 87 ++++++ homeassistant/components/xiaomi_miio/light.py | 13 +- .../components/xiaomi_miio/sensor.py | 3 +- .../components/xiaomi_miio/strings.json | 15 +- .../components/xiaomi_miio/switch.py | 267 +++++++++--------- .../xiaomi_miio/translations/en.json | 19 +- .../xiaomi_miio/test_config_flow.py | 252 ++++++++++++----- 11 files changed, 563 insertions(+), 295 deletions(-) create mode 100644 homeassistant/components/xiaomi_miio/device.py diff --git a/.coveragerc b/.coveragerc index 17dd078d15b..e64bfab280a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1085,6 +1085,7 @@ omit = homeassistant/components/xiaomi_miio/__init__.py homeassistant/components/xiaomi_miio/air_quality.py homeassistant/components/xiaomi_miio/alarm_control_panel.py + homeassistant/components/xiaomi_miio/device.py homeassistant/components/xiaomi_miio/device_tracker.py homeassistant/components/xiaomi_miio/fan.py homeassistant/components/xiaomi_miio/gateway.py diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 7ff1ed999c4..e81c35d39e4 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -3,11 +3,18 @@ from homeassistant import config_entries, core from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.helpers import device_registry as dr -from .config_flow import CONF_FLOW_TYPE, CONF_GATEWAY -from .const import DOMAIN +from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_GATEWAY, + CONF_MODEL, + DOMAIN, + MODELS_SWITCH, +) from .gateway import ConnectXiaomiGateway GATEWAY_PLATFORMS = ["alarm_control_panel", "sensor", "light"] +SWITCH_PLATFORMS = ["switch"] async def async_setup(hass: core.HomeAssistant, config: dict): @@ -19,10 +26,13 @@ async def async_setup_entry( hass: core.HomeAssistant, entry: config_entries.ConfigEntry ): """Set up the Xiaomi Miio components from a config entry.""" - hass.data[DOMAIN] = {} + hass.data.setdefault(DOMAIN, {}) if entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY: if not await async_setup_gateway_entry(hass, entry): return False + if entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + if not await async_setup_device_entry(hass, entry): + return False return True @@ -67,3 +77,23 @@ async def async_setup_gateway_entry( ) return True + + +async def async_setup_device_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +): + """Set up the Xiaomi Miio device component from a config entry.""" + model = entry.data[CONF_MODEL] + + # Identify platforms to setup + if model in MODELS_SWITCH: + platforms = SWITCH_PLATFORMS + else: + return False + + for component in platforms: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index 6ed5f422f7c..2a1532eaf9b 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -8,24 +8,27 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN from homeassistant.helpers.device_registry import format_mac # pylint: disable=unused-import -from .const import DOMAIN -from .gateway import ConnectXiaomiGateway +from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_GATEWAY, + CONF_MAC, + CONF_MODEL, + DOMAIN, + MODELS_GATEWAY, + MODELS_SWITCH, +) +from .device import ConnectXiaomiDevice _LOGGER = logging.getLogger(__name__) -CONF_FLOW_TYPE = "config_flow_device" -CONF_GATEWAY = "gateway" DEFAULT_GATEWAY_NAME = "Xiaomi Gateway" -ZEROCONF_GATEWAY = "lumi-gateway" -ZEROCONF_ACPARTNER = "lumi-acpartner" +DEFAULT_DEVICE_NAME = "Xiaomi Device" -GATEWAY_SETTINGS = { +DEVICE_SETTINGS = { vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME, default=DEFAULT_GATEWAY_NAME): str, } -GATEWAY_CONFIG = vol.Schema({vol.Required(CONF_HOST): str}).extend(GATEWAY_SETTINGS) - -CONFIG_SCHEMA = vol.Schema({vol.Optional(CONF_GATEWAY, default=False): bool}) +DEVICE_CONFIG = vol.Schema({vol.Required(CONF_HOST): str}).extend(DEVICE_SETTINGS) class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -38,19 +41,13 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize.""" self.host = None + async def async_step_import(self, conf: dict): + """Import a configuration from config.yaml.""" + return await self.async_step_device(user_input=conf) + async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" - errors = {} - if user_input is not None: - # Check which device needs to be connected. - if user_input[CONF_GATEWAY]: - return await self.async_step_gateway() - - errors["base"] = "no_device_selected" - - return self.async_show_form( - step_id="user", data_schema=CONFIG_SCHEMA, errors=errors - ) + return await self.async_step_device() async def async_step_zeroconf(self, discovery_info): """Handle zeroconf discovery.""" @@ -62,16 +59,28 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="not_xiaomi_miio") # Check which device is discovered. - if name.startswith(ZEROCONF_GATEWAY) or name.startswith(ZEROCONF_ACPARTNER): - unique_id = format_mac(mac_address) - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured({CONF_HOST: self.host}) + for gateway_model in MODELS_GATEWAY: + if name.startswith(gateway_model.replace(".", "-")): + unique_id = format_mac(mac_address) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) - self.context.update( - {"title_placeholders": {"name": f"Gateway {self.host}"}} - ) + self.context.update( + {"title_placeholders": {"name": f"Gateway {self.host}"}} + ) - return await self.async_step_gateway() + return await self.async_step_device() + for switch_model in MODELS_SWITCH: + if name.startswith(switch_model.replace(".", "-")): + unique_id = format_mac(mac_address) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) + + self.context.update( + {"title_placeholders": {"name": f"Miio Device {self.host}"}} + ) + + return await self.async_step_device() # Discovered device is not yet supported _LOGGER.debug( @@ -81,42 +90,63 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="not_xiaomi_miio") - async def async_step_gateway(self, user_input=None): - """Handle a flow initialized by the user to configure a gateway.""" + async def async_step_device(self, user_input=None): + """Handle a flow initialized by the user to configure a xiaomi miio device.""" errors = {} if user_input is not None: token = user_input[CONF_TOKEN] if user_input.get(CONF_HOST): self.host = user_input[CONF_HOST] - # Try to connect to a Xiaomi Gateway. - connect_gateway_class = ConnectXiaomiGateway(self.hass) - await connect_gateway_class.async_connect_gateway(self.host, token) - gateway_info = connect_gateway_class.gateway_info + # Try to connect to a Xiaomi Device. + connect_device_class = ConnectXiaomiDevice(self.hass) + await connect_device_class.async_connect_device(self.host, token) + device_info = connect_device_class.device_info - if gateway_info is not None: - mac = format_mac(gateway_info.mac_address) - unique_id = mac - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=user_input[CONF_NAME], - data={ - CONF_FLOW_TYPE: CONF_GATEWAY, - CONF_HOST: self.host, - CONF_TOKEN: token, - "model": gateway_info.model, - "mac": mac, - }, - ) + if device_info is not None: + # Setup Gateways + for gateway_model in MODELS_GATEWAY: + if device_info.model.startswith(gateway_model): + mac = format_mac(device_info.mac_address) + unique_id = mac + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=DEFAULT_GATEWAY_NAME, + data={ + CONF_FLOW_TYPE: CONF_GATEWAY, + CONF_HOST: self.host, + CONF_TOKEN: token, + CONF_MODEL: device_info.model, + CONF_MAC: mac, + }, + ) - errors["base"] = "cannot_connect" + # Setup all other Miio Devices + name = user_input.get(CONF_NAME, DEFAULT_DEVICE_NAME) + + if device_info.model in MODELS_SWITCH: + mac = format_mac(device_info.mac_address) + unique_id = mac + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=name, + data={ + CONF_FLOW_TYPE: CONF_DEVICE, + CONF_HOST: self.host, + CONF_TOKEN: token, + CONF_MODEL: device_info.model, + CONF_MAC: mac, + }, + ) + errors["base"] = "unknown_device" + else: + errors["base"] = "cannot_connect" if self.host: - schema = vol.Schema(GATEWAY_SETTINGS) + schema = vol.Schema(DEVICE_SETTINGS) else: - schema = GATEWAY_CONFIG + schema = DEVICE_CONFIG - return self.async_show_form( - step_id="gateway", data_schema=schema, errors=errors - ) + return self.async_show_form(step_id="device", data_schema=schema, errors=errors) diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 8de68cda97f..3726f7f709d 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -1,6 +1,27 @@ """Constants for the Xiaomi Miio component.""" DOMAIN = "xiaomi_miio" +CONF_FLOW_TYPE = "config_flow_device" +CONF_GATEWAY = "gateway" +CONF_DEVICE = "device" +CONF_MODEL = "model" +CONF_MAC = "mac" + +MODELS_GATEWAY = ["lumi.gateway", "lumi.acpartner"] +MODELS_SWITCH = [ + "chuangmi.plug.v1", + "chuangmi.plug.v3", + "chuangmi.plug.hmi208", + "qmi.powerstrip.v1", + "zimi.powerstrip.v2", + "chuangmi.plug.m1", + "chuangmi.plug.m3", + "chuangmi.plug.v2", + "chuangmi.plug.hmi205", + "chuangmi.plug.hmi206", + "lumi.acpartner.v3", +] + # Fan Services SERVICE_SET_BUZZER_ON = "fan_set_buzzer_on" SERVICE_SET_BUZZER_OFF = "fan_set_buzzer_off" diff --git a/homeassistant/components/xiaomi_miio/device.py b/homeassistant/components/xiaomi_miio/device.py new file mode 100644 index 00000000000..48bedbf0cc8 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/device.py @@ -0,0 +1,87 @@ +"""Code to handle a Xiaomi Device.""" +import logging + +from miio import Device, DeviceException + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import Entity + +from .const import CONF_MAC, CONF_MODEL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConnectXiaomiDevice: + """Class to async connect to a Xiaomi Device.""" + + def __init__(self, hass): + """Initialize the entity.""" + self._hass = hass + self._device = None + self._device_info = None + + @property + def device(self): + """Return the class containing all connections to the device.""" + return self._device + + @property + def device_info(self): + """Return the class containing device info.""" + return self._device_info + + async def async_connect_device(self, host, token): + """Connect to the Xiaomi Device.""" + _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) + try: + self._device = Device(host, token) + # get the device info + self._device_info = await self._hass.async_add_executor_job( + self._device.info + ) + except DeviceException: + _LOGGER.error( + "DeviceException during setup of xiaomi device with host %s", host + ) + return False + _LOGGER.debug( + "%s %s %s detected", + self._device_info.model, + self._device_info.firmware_version, + self._device_info.hardware_version, + ) + return True + + +class XiaomiMiioEntity(Entity): + """Representation of a base Xiaomi Miio Entity.""" + + def __init__(self, name, device, entry, unique_id): + """Initialize the Xiaomi Miio Device.""" + self._device = device + self._model = entry.data[CONF_MODEL] + self._mac = entry.data[CONF_MAC] + self._device_id = entry.unique_id + self._unique_id = unique_id + self._name = name + + @property + def unique_id(self): + """Return an unique ID.""" + return self._unique_id + + @property + def name(self): + """Return the name of this entity, if any.""" + return self._name + + @property + def device_info(self): + """Return the device info.""" + return { + "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac)}, + "identifiers": {(DOMAIN, self._device_id)}, + "manufacturer": "Xiaomi", + "name": self._name, + "model": self._model, + } diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index d1746fcd889..efe67a370c4 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -6,14 +6,8 @@ from functools import partial import logging from math import ceil -from miio import ( # pylint: disable=import-error - Ceil, - Device, - DeviceException, - PhilipsBulb, - PhilipsEyecare, - PhilipsMoonlight, -) +from miio import Ceil, DeviceException, PhilipsBulb, PhilipsEyecare, PhilipsMoonlight +from miio import Device # pylint: disable=import-error from miio.gateway import ( GATEWAY_MODEL_AC_V1, GATEWAY_MODEL_AC_V2, @@ -37,8 +31,9 @@ from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.util import color, dt -from .config_flow import CONF_FLOW_TYPE, CONF_GATEWAY from .const import ( + CONF_FLOW_TYPE, + CONF_GATEWAY, DOMAIN, SERVICE_EYECARE_MODE_OFF, SERVICE_EYECARE_MODE_ON, diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index d20c2dfac1e..ab4df8cd982 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -31,8 +31,7 @@ from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from .config_flow import CONF_FLOW_TYPE, CONF_GATEWAY -from .const import DOMAIN +from .const import CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN from .gateway import XiaomiGatewayDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index 68536de76e5..1ab0c6f51c6 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -2,26 +2,19 @@ "config": { "flow_title": "Xiaomi Miio: {name}", "step": { - "user": { - "title": "Xiaomi Miio", - "description": "Select to which device you want to connect.", - "data": { - "gateway": "Connect to a Xiaomi Gateway" - } - }, - "gateway": { - "title": "Connect to a Xiaomi Gateway", + "device": { + "title": "Connect to a Xiaomi Miio Device or Xiaomi Gateway", "description": "You will need the 32 character [%key:common::config_flow::data::api_token%], see https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token for instructions. Please note, that this [%key:common::config_flow::data::api_token%] is different from the key used by the Xiaomi Aqara integration.", "data": { "host": "[%key:common::config_flow::data::ip%]", "token": "[%key:common::config_flow::data::api_token%]", - "name": "Name of the Gateway" + "name": "Name of the device" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "no_device_selected": "No device selected, please select one device." + "unknown_device": "The device model is not known, not able to setup the device using config flow." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index b9e90cc5c23..3cc95572e6c 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -3,17 +3,13 @@ import asyncio from functools import partial import logging -from miio import ( # pylint: disable=import-error - AirConditioningCompanionV3, - ChuangmiPlug, - Device, - DeviceException, - PowerStrip, -) +from miio import AirConditioningCompanionV3 # pylint: disable=import-error +from miio import ChuangmiPlug, DeviceException, PowerStrip from miio.powerstrip import PowerMode # pylint: disable=import-error import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, @@ -21,23 +17,25 @@ from homeassistant.const import ( CONF_NAME, CONF_TOKEN, ) -from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_MODEL, DOMAIN, SERVICE_SET_POWER_MODE, SERVICE_SET_POWER_PRICE, SERVICE_SET_WIFI_LED_OFF, SERVICE_SET_WIFI_LED_ON, ) +from .device import XiaomiMiioEntity _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Xiaomi Miio Switch" DATA_KEY = "switch.xiaomi_miio" -CONF_MODEL = "model" MODEL_POWER_STRIP_V2 = "zimi.powerstrip.v2" MODEL_PLUG_V3 = "chuangmi.plug.v3" @@ -114,119 +112,124 @@ SERVICE_TO_METHOD = { async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the switch from config.""" - if DATA_KEY not in hass.data: - hass.data[DATA_KEY] = {} + """Import Miio configuration from YAML.""" + _LOGGER.warning( + "Loading Xiaomi Miio Switch via platform setup is deprecated. Please remove it from your configuration." + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) - host = config[CONF_HOST] - token = config[CONF_TOKEN] - name = config[CONF_NAME] - model = config.get(CONF_MODEL) - _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the switch from a config entry.""" + entities = [] - devices = [] - unique_id = None + if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} - if model is None: - try: - miio_device = Device(host, token) - device_info = await hass.async_add_executor_job(miio_device.info) - model = device_info.model - unique_id = f"{model}-{device_info.mac_address}" - _LOGGER.info( - "%s %s %s detected", - model, - device_info.firmware_version, - device_info.hardware_version, - ) - except DeviceException as ex: - raise PlatformNotReady from ex + host = config_entry.data[CONF_HOST] + token = config_entry.data[CONF_TOKEN] + name = config_entry.title + model = config_entry.data[CONF_MODEL] + unique_id = config_entry.unique_id - if model in ["chuangmi.plug.v1", "chuangmi.plug.v3", "chuangmi.plug.hmi208"]: - plug = ChuangmiPlug(host, token, model=model) + _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) - # The device has two switchable channels (mains and a USB port). - # A switch device per channel will be created. - for channel_usb in [True, False]: - device = ChuangMiPlugSwitch(name, plug, model, unique_id, channel_usb) - devices.append(device) + if model in ["chuangmi.plug.v1", "chuangmi.plug.v3", "chuangmi.plug.hmi208"]: + plug = ChuangmiPlug(host, token, model=model) + + # The device has two switchable channels (mains and a USB port). + # A switch device per channel will be created. + for channel_usb in [True, False]: + if channel_usb: + unique_id_ch = f"{unique_id}-USB" + else: + unique_id_ch = f"{unique_id}-mains" + device = ChuangMiPlugSwitch( + name, plug, config_entry, unique_id_ch, channel_usb + ) + entities.append(device) + hass.data[DATA_KEY][host] = device + elif model in ["qmi.powerstrip.v1", "zimi.powerstrip.v2"]: + plug = PowerStrip(host, token, model=model) + device = XiaomiPowerStripSwitch(name, plug, config_entry, unique_id) + entities.append(device) + hass.data[DATA_KEY][host] = device + elif model in [ + "chuangmi.plug.m1", + "chuangmi.plug.m3", + "chuangmi.plug.v2", + "chuangmi.plug.hmi205", + "chuangmi.plug.hmi206", + ]: + plug = ChuangmiPlug(host, token, model=model) + device = XiaomiPlugGenericSwitch(name, plug, config_entry, unique_id) + entities.append(device) + hass.data[DATA_KEY][host] = device + elif model in ["lumi.acpartner.v3"]: + plug = AirConditioningCompanionV3(host, token) + device = XiaomiAirConditioningCompanionSwitch( + name, plug, config_entry, unique_id + ) + entities.append(device) hass.data[DATA_KEY][host] = device - - elif model in ["qmi.powerstrip.v1", "zimi.powerstrip.v2"]: - plug = PowerStrip(host, token, model=model) - device = XiaomiPowerStripSwitch(name, plug, model, unique_id) - devices.append(device) - hass.data[DATA_KEY][host] = device - elif model in [ - "chuangmi.plug.m1", - "chuangmi.plug.m3", - "chuangmi.plug.v2", - "chuangmi.plug.hmi205", - "chuangmi.plug.hmi206", - ]: - plug = ChuangmiPlug(host, token, model=model) - device = XiaomiPlugGenericSwitch(name, plug, model, unique_id) - devices.append(device) - hass.data[DATA_KEY][host] = device - elif model in ["lumi.acpartner.v3"]: - plug = AirConditioningCompanionV3(host, token) - device = XiaomiAirConditioningCompanionSwitch(name, plug, model, unique_id) - devices.append(device) - hass.data[DATA_KEY][host] = device - else: - _LOGGER.error( - "Unsupported device found! Please create an issue at " - "https://github.com/rytilahti/python-miio/issues " - "and provide the following data: %s", - model, - ) - return False - - async_add_entities(devices, update_before_add=True) - - async def async_service_handler(service): - """Map services to methods on XiaomiPlugGenericSwitch.""" - 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: - devices = [ - device - for device in hass.data[DATA_KEY].values() - if device.entity_id in entity_ids - ] else: - devices = hass.data[DATA_KEY].values() + _LOGGER.error( + "Unsupported device found! Please create an issue at " + "https://github.com/rytilahti/python-miio/issues " + "and provide the following data: %s", + model, + ) - update_tasks = [] - for device in devices: - if not hasattr(device, method["method"]): - continue - await getattr(device, method["method"])(**params) - update_tasks.append(device.async_update_ha_state(True)) + async def async_service_handler(service): + """Map services to methods on XiaomiPlugGenericSwitch.""" + 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: + devices = [ + device + for device in hass.data[DATA_KEY].values() + if device.entity_id in entity_ids + ] + else: + devices = hass.data[DATA_KEY].values() - if update_tasks: - await asyncio.wait(update_tasks) + update_tasks = [] + for device in devices: + if not hasattr(device, method["method"]): + continue + await getattr(device, method["method"])(**params) + update_tasks.append(device.async_update_ha_state(True)) - for plug_service in SERVICE_TO_METHOD: - schema = SERVICE_TO_METHOD[plug_service].get("schema", SERVICE_SCHEMA) - hass.services.async_register( - DOMAIN, plug_service, async_service_handler, schema=schema - ) + if update_tasks: + await asyncio.wait(update_tasks) + + for plug_service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[plug_service].get("schema", SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, plug_service, async_service_handler, schema=schema + ) + + async_add_entities(entities, update_before_add=True) -class XiaomiPlugGenericSwitch(SwitchEntity): +class XiaomiPlugGenericSwitch(XiaomiMiioEntity, SwitchEntity): """Representation of a Xiaomi Plug Generic.""" - def __init__(self, name, plug, model, unique_id): + def __init__(self, name, device, entry, unique_id): """Initialize the plug switch.""" - self._name = name - self._plug = plug - self._model = model - self._unique_id = unique_id + super().__init__(name, device, entry, unique_id) self._icon = "mdi:power-socket" self._available = False @@ -235,16 +238,6 @@ class XiaomiPlugGenericSwitch(SwitchEntity): self._device_features = FEATURE_FLAGS_GENERIC self._skip_update = False - @property - def unique_id(self): - """Return an unique ID.""" - return self._unique_id - - @property - def name(self): - """Return the name of the device if any.""" - return self._name - @property def icon(self): """Return the icon to use for device if any.""" @@ -288,7 +281,7 @@ class XiaomiPlugGenericSwitch(SwitchEntity): async def async_turn_on(self, **kwargs): """Turn the plug on.""" - result = await self._try_command("Turning the plug on failed.", self._plug.on) + result = await self._try_command("Turning the plug on failed", self._device.on) if result: self._state = True @@ -296,7 +289,9 @@ class XiaomiPlugGenericSwitch(SwitchEntity): async def async_turn_off(self, **kwargs): """Turn the plug off.""" - result = await self._try_command("Turning the plug off failed.", self._plug.off) + result = await self._try_command( + "Turning the plug off failed", self._device.off + ) if result: self._state = False @@ -310,7 +305,7 @@ class XiaomiPlugGenericSwitch(SwitchEntity): return try: - state = await self.hass.async_add_executor_job(self._plug.status) + state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -328,7 +323,7 @@ class XiaomiPlugGenericSwitch(SwitchEntity): return await self._try_command( - "Turning the wifi led on failed.", self._plug.set_wifi_led, True + "Turning the wifi led on failed", self._device.set_wifi_led, True ) async def async_set_wifi_led_off(self): @@ -337,7 +332,7 @@ class XiaomiPlugGenericSwitch(SwitchEntity): return await self._try_command( - "Turning the wifi led off failed.", self._plug.set_wifi_led, False + "Turning the wifi led off failed", self._device.set_wifi_led, False ) async def async_set_power_price(self, price: int): @@ -346,8 +341,8 @@ class XiaomiPlugGenericSwitch(SwitchEntity): return await self._try_command( - "Setting the power price of the power strip failed.", - self._plug.set_power_price, + "Setting the power price of the power strip failed", + self._device.set_power_price, price, ) @@ -383,7 +378,7 @@ class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch): return try: - state = await self.hass.async_add_executor_job(self._plug.status) + state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -415,8 +410,8 @@ class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch): return await self._try_command( - "Setting the power mode of the power strip failed.", - self._plug.set_power_mode, + "Setting the power mode of the power strip failed", + self._device.set_power_mode, PowerMode(mode), ) @@ -424,14 +419,14 @@ class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch): class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch): """Representation of a Chuang Mi Plug V1 and V3.""" - def __init__(self, name, plug, model, unique_id, channel_usb): + def __init__(self, name, plug, entry, unique_id, channel_usb): """Initialize the plug switch.""" name = f"{name} USB" if channel_usb else name if unique_id is not None and channel_usb: unique_id = f"{unique_id}-usb" - super().__init__(name, plug, model, unique_id) + super().__init__(name, plug, entry, unique_id) self._channel_usb = channel_usb if self._model == MODEL_PLUG_V3: @@ -444,11 +439,11 @@ class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch): """Turn a channel on.""" if self._channel_usb: result = await self._try_command( - "Turning the plug on failed.", self._plug.usb_on + "Turning the plug on failed", self._device.usb_on ) else: result = await self._try_command( - "Turning the plug on failed.", self._plug.on + "Turning the plug on failed", self._device.on ) if result: @@ -459,11 +454,11 @@ class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch): """Turn a channel off.""" if self._channel_usb: result = await self._try_command( - "Turning the plug on failed.", self._plug.usb_off + "Turning the plug off failed", self._device.usb_off ) else: result = await self._try_command( - "Turning the plug on failed.", self._plug.off + "Turning the plug off failed", self._device.off ) if result: @@ -478,7 +473,7 @@ class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch): return try: - state = await self.hass.async_add_executor_job(self._plug.status) + state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -513,7 +508,7 @@ class XiaomiAirConditioningCompanionSwitch(XiaomiPlugGenericSwitch): async def async_turn_on(self, **kwargs): """Turn the socket on.""" result = await self._try_command( - "Turning the socket on failed.", self._plug.socket_on + "Turning the socket on failed", self._device.socket_on ) if result: @@ -523,7 +518,7 @@ class XiaomiAirConditioningCompanionSwitch(XiaomiPlugGenericSwitch): async def async_turn_off(self, **kwargs): """Turn the socket off.""" result = await self._try_command( - "Turning the socket off failed.", self._plug.socket_off + "Turning the socket off failed", self._device.socket_off ) if result: @@ -538,7 +533,7 @@ class XiaomiAirConditioningCompanionSwitch(XiaomiPlugGenericSwitch): return try: - state = await self.hass.async_add_executor_job(self._plug.status) + state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) self._available = True diff --git a/homeassistant/components/xiaomi_miio/translations/en.json b/homeassistant/components/xiaomi_miio/translations/en.json index 4d39a6d1137..c8ac63d1ea7 100644 --- a/homeassistant/components/xiaomi_miio/translations/en.json +++ b/homeassistant/components/xiaomi_miio/translations/en.json @@ -6,25 +6,18 @@ }, "error": { "cannot_connect": "Failed to connect", - "no_device_selected": "No device selected, please select one device." + "unknown_device": "The device model is not known, not able to setup the device using config flow." }, "flow_title": "Xiaomi Miio: {name}", "step": { - "gateway": { + "device": { "data": { - "host": "IP Address", - "name": "Name of the Gateway", - "token": "API Token" + "host": "IP Address", + "name": "Name of the device", + "token": "API Token" }, "description": "You will need the 32 character API Token, see https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token for instructions. Please note, that this API Token is different from the key used by the Xiaomi Aqara integration.", - "title": "Connect to a Xiaomi Gateway" - }, - "user": { - "data": { - "gateway": "Connect to a Xiaomi Gateway" - }, - "description": "Select to which device you want to connect.", - "title": "Xiaomi Miio" + "title": "Connect to a Xiaomi Miio Device or Xiaomi Gateway" } } } diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index dbe78957586..220c51034f1 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -5,7 +5,11 @@ from miio import DeviceException from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.components.xiaomi_miio import config_flow, const +from homeassistant.components.xiaomi_miio import const +from homeassistant.components.xiaomi_miio.config_flow import ( + DEFAULT_DEVICE_NAME, + DEFAULT_GATEWAY_NAME, +) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN ZEROCONF_NAME = "name" @@ -15,7 +19,7 @@ ZEROCONF_MAC = "mac" TEST_HOST = "1.2.3.4" TEST_TOKEN = "12345678901234567890123456789012" TEST_NAME = "Test_Gateway" -TEST_MODEL = "model5" +TEST_MODEL = const.MODELS_GATEWAY[0] TEST_MAC = "ab:cd:ef:gh:ij:kl" TEST_GATEWAY_ID = TEST_MAC TEST_HARDWARE_VERSION = "AB123" @@ -40,26 +44,6 @@ def get_mock_info( return gateway_info -async def test_config_flow_step_user_no_device(hass): - """Test config flow, user step with no device selected.""" - result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {"base": "no_device_selected"} - - async def test_config_flow_step_gateway_connect_error(hass): """Test config flow, gateway connection error.""" result = await hass.config_entries.flow.async_init( @@ -67,29 +51,20 @@ async def test_config_flow_step_gateway_connect_error(hass): ) assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {config_flow.CONF_GATEWAY: True}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "gateway" + assert result["step_id"] == "device" assert result["errors"] == {} with patch( - "homeassistant.components.xiaomi_miio.gateway.gateway.Gateway.info", + "homeassistant.components.xiaomi_miio.device.Device.info", side_effect=DeviceException({}), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: TEST_HOST, CONF_NAME: TEST_NAME, CONF_TOKEN: TEST_TOKEN}, + {CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, ) assert result["type"] == "form" - assert result["step_id"] == "gateway" + assert result["step_id"] == "device" assert result["errors"] == {"base": "cannot_connect"} @@ -100,42 +75,30 @@ async def test_config_flow_gateway_success(hass): ) assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {config_flow.CONF_GATEWAY: True}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "gateway" + assert result["step_id"] == "device" assert result["errors"] == {} mock_info = get_mock_info() with patch( - "homeassistant.components.xiaomi_miio.gateway.gateway.Gateway.info", + "homeassistant.components.xiaomi_miio.device.Device.info", return_value=mock_info, - ), patch( - "homeassistant.components.xiaomi_miio.gateway.gateway.Gateway.discover_devices", - return_value=TEST_SUB_DEVICE_LIST, ), patch( "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: TEST_HOST, CONF_NAME: TEST_NAME, CONF_TOKEN: TEST_TOKEN}, + {CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, ) assert result["type"] == "create_entry" - assert result["title"] == TEST_NAME + assert result["title"] == DEFAULT_GATEWAY_NAME assert result["data"] == { - config_flow.CONF_FLOW_TYPE: config_flow.CONF_GATEWAY, + const.CONF_FLOW_TYPE: const.CONF_GATEWAY, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, - "model": TEST_MODEL, - "mac": TEST_MAC, + const.CONF_MODEL: TEST_MODEL, + const.CONF_MAC: TEST_MAC, } @@ -152,33 +115,30 @@ async def test_zeroconf_gateway_success(hass): ) assert result["type"] == "form" - assert result["step_id"] == "gateway" + assert result["step_id"] == "device" assert result["errors"] == {} mock_info = get_mock_info() with patch( - "homeassistant.components.xiaomi_miio.gateway.gateway.Gateway.info", + "homeassistant.components.xiaomi_miio.device.Device.info", return_value=mock_info, - ), patch( - "homeassistant.components.xiaomi_miio.gateway.gateway.Gateway.discover_devices", - return_value=TEST_SUB_DEVICE_LIST, ), patch( "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_NAME: TEST_NAME, CONF_TOKEN: TEST_TOKEN}, + {CONF_TOKEN: TEST_TOKEN}, ) assert result["type"] == "create_entry" - assert result["title"] == TEST_NAME + assert result["title"] == DEFAULT_GATEWAY_NAME assert result["data"] == { - config_flow.CONF_FLOW_TYPE: config_flow.CONF_GATEWAY, + const.CONF_FLOW_TYPE: const.CONF_GATEWAY, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, - "model": TEST_MODEL, - "mac": TEST_MAC, + const.CONF_MODEL: TEST_MODEL, + const.CONF_MAC: TEST_MAC, } @@ -218,3 +178,167 @@ async def test_zeroconf_missing_data(hass): assert result["type"] == "abort" assert result["reason"] == "not_xiaomi_miio" + + +async def test_config_flow_step_device_connect_error(hass): + """Test config flow, device connection error.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "device" + assert result["errors"] == {} + + with patch( + "homeassistant.components.xiaomi_miio.device.Device.info", + side_effect=DeviceException({}), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "device" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_config_flow_step_unknown_device(hass): + """Test config flow, unknown device error.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "device" + assert result["errors"] == {} + + mock_info = get_mock_info(model="UNKNOWN") + + with patch( + "homeassistant.components.xiaomi_miio.device.Device.info", + return_value=mock_info, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "device" + assert result["errors"] == {"base": "unknown_device"} + + +async def test_import_flow_success(hass): + """Test a successful import form yaml for a device.""" + mock_info = get_mock_info(model=const.MODELS_SWITCH[0]) + + with patch( + "homeassistant.components.xiaomi_miio.device.Device.info", + return_value=mock_info, + ), patch( + "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_NAME: TEST_NAME, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + const.CONF_FLOW_TYPE: const.CONF_DEVICE, + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + const.CONF_MODEL: const.MODELS_SWITCH[0], + const.CONF_MAC: TEST_MAC, + } + + +async def config_flow_device_success(hass, model_to_test): + """Test a successful config flow for a device (base class).""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "device" + assert result["errors"] == {} + + mock_info = get_mock_info(model=model_to_test) + + with patch( + "homeassistant.components.xiaomi_miio.device.Device.info", + return_value=mock_info, + ), patch( + "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == DEFAULT_DEVICE_NAME + assert result["data"] == { + const.CONF_FLOW_TYPE: const.CONF_DEVICE, + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + const.CONF_MODEL: model_to_test, + const.CONF_MAC: TEST_MAC, + } + + +async def zeroconf_device_success(hass, zeroconf_name_to_test, model_to_test): + """Test a successful zeroconf discovery of a device (base class).""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + zeroconf.ATTR_HOST: TEST_HOST, + ZEROCONF_NAME: zeroconf_name_to_test, + ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC}, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "device" + assert result["errors"] == {} + + mock_info = get_mock_info(model=model_to_test) + + with patch( + "homeassistant.components.xiaomi_miio.device.Device.info", + return_value=mock_info, + ), patch( + "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: TEST_TOKEN}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == DEFAULT_DEVICE_NAME + assert result["data"] == { + const.CONF_FLOW_TYPE: const.CONF_DEVICE, + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + const.CONF_MODEL: model_to_test, + const.CONF_MAC: TEST_MAC, + } + + +async def test_config_flow_plug_success(hass): + """Test a successful config flow for a plug.""" + test_plug_model = const.MODELS_SWITCH[0] + await config_flow_device_success(hass, test_plug_model) + + +async def test_zeroconf_plug_success(hass): + """Test a successful zeroconf discovery of a plug.""" + test_plug_model = const.MODELS_SWITCH[0] + test_zeroconf_name = const.MODELS_SWITCH[0].replace(".", "-") + await zeroconf_device_success(hass, test_zeroconf_name, test_plug_model)