From 256370afa83ea9a9c69a5c4fbf01680e210be019 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 14 May 2020 15:59:40 +0200 Subject: [PATCH] Xiaomi Miio zeroconf discovery (#35352) --- .../components/xiaomi_miio/__init__.py | 6 +- .../xiaomi_miio/alarm_control_panel.py | 4 +- .../components/xiaomi_miio/config_flow.py | 63 ++++++++++--- .../components/xiaomi_miio/manifest.json | 3 +- .../components/xiaomi_miio/strings.json | 3 +- .../xiaomi_miio/translations/en.json | 5 +- homeassistant/generated/zeroconf.py | 3 + .../xiaomi_miio/test_config_flow.py | 88 ++++++++++++++++++- 8 files changed, 151 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 0dd03e42e7d..9524406a1f9 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -38,7 +38,11 @@ async def async_setup_gateway_entry( host = entry.data[CONF_HOST] token = entry.data[CONF_TOKEN] name = entry.title - gateway_id = entry.data["gateway_id"] + gateway_id = entry.unique_id + + # For backwards compat + if entry.unique_id.endswith("-gateway"): + hass.config_entries.async_update_entry(entry, unique_id=entry.data["mac"]) # Connect to gateway gateway = ConnectXiaomiGateway(hass) diff --git a/homeassistant/components/xiaomi_miio/alarm_control_panel.py b/homeassistant/components/xiaomi_miio/alarm_control_panel.py index dccd94dc963..7df9dc54e5a 100644 --- a/homeassistant/components/xiaomi_miio/alarm_control_panel.py +++ b/homeassistant/components/xiaomi_miio/alarm_control_panel.py @@ -33,10 +33,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): f"{config_entry.title} Alarm", config_entry.data["model"], config_entry.data["mac"], - config_entry.data["gateway_id"], + config_entry.unique_id, ) entities.append(entity) - async_add_entities(entities) + async_add_entities(entities, update_before_add=True) class XiaomiGatewayAlarm(AlarmControlPanelEntity): diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index 092f5d85d30..e35aa0c8b10 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -5,6 +5,7 @@ import voluptuous as vol from homeassistant import config_entries 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 @@ -15,14 +16,13 @@ _LOGGER = logging.getLogger(__name__) CONF_FLOW_TYPE = "config_flow_device" CONF_GATEWAY = "gateway" DEFAULT_GATEWAY_NAME = "Xiaomi Gateway" +ZEROCONF_GATEWAY = "lumi-gateway" -GATEWAY_CONFIG = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME, default=DEFAULT_GATEWAY_NAME): str, - } -) +GATEWAY_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}) @@ -33,6 +33,10 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + def __init__(self): + """Initialize.""" + self.host = None + async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" errors = {} @@ -47,36 +51,67 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=CONFIG_SCHEMA, errors=errors ) + async def async_step_zeroconf(self, discovery_info): + """Handle zeroconf discovery.""" + name = discovery_info.get("name") + self.host = discovery_info.get("host") + mac_address = discovery_info.get("properties", {}).get("mac") + + if not name or not self.host or not mac_address: + return self.async_abort(reason="not_xiaomi_miio") + + # Check which device is discovered. + if name.startswith(ZEROCONF_GATEWAY): + unique_id = format_mac(mac_address) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) + + return await self.async_step_gateway() + + # Discovered device is not yet supported + _LOGGER.debug( + "Not yet supported Xiaomi Miio device '%s' discovered with host %s", + name, + self.host, + ) + 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.""" errors = {} if user_input is not None: - host = user_input[CONF_HOST] 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(host, token) + await connect_gateway_class.async_connect_gateway(self.host, token) gateway_info = connect_gateway_class.gateway_info if gateway_info is not None: - unique_id = f"{gateway_info.model}-{gateway_info.mac_address}-gateway" + 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: host, + CONF_HOST: self.host, CONF_TOKEN: token, - "gateway_id": unique_id, "model": gateway_info.model, - "mac": gateway_info.mac_address, + "mac": mac, }, ) errors["base"] = "connect_error" + if self.host: + schema = vol.Schema(GATEWAY_SETTINGS) + else: + schema = GATEWAY_CONFIG + return self.async_show_form( - step_id="gateway", data_schema=GATEWAY_CONFIG, errors=errors + step_id="gateway", data_schema=schema, errors=errors ) diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 468389b4626..e1ead8d966c 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -4,5 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", "requirements": ["construct==2.9.45", "python-miio==0.5.0.1"], - "codeowners": ["@rytilahti", "@syssi"] + "codeowners": ["@rytilahti", "@syssi"], + "zeroconf": ["_miio._udp.local."] } diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index 150cebe084a..0024ffdfe9b 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -23,7 +23,8 @@ "no_device_selected": "No device selected, please select one device." }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "Config flow for this Xiaomi Miio device is already in progress." } } } diff --git a/homeassistant/components/xiaomi_miio/translations/en.json b/homeassistant/components/xiaomi_miio/translations/en.json index f67df5b7826..3f081c682d6 100644 --- a/homeassistant/components/xiaomi_miio/translations/en.json +++ b/homeassistant/components/xiaomi_miio/translations/en.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "already_in_progress": "Config flow for this Xiaomi Miio device is already in progress." }, "error": { "connect_error": "Failed to connect", @@ -26,4 +27,4 @@ } } } -} \ No newline at end of file +} diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 880bfedf400..02f316c33bc 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -31,6 +31,9 @@ ZEROCONF = { "_ipps._tcp.local.": [ "ipp" ], + "_miio._udp.local.": [ + "xiaomi_miio" + ], "_printer._tcp.local.": [ "brother" ], diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index 619293be676..8ae2f424f2e 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -2,19 +2,25 @@ 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.const import CONF_HOST, CONF_NAME, CONF_TOKEN from tests.async_mock import Mock, patch +ZEROCONF_NAME = "name" +ZEROCONF_PROP = "properties" +ZEROCONF_MAC = "mac" + TEST_HOST = "1.2.3.4" TEST_TOKEN = "12345678901234567890123456789012" TEST_NAME = "Test_Gateway" TEST_MODEL = "model5" -TEST_MAC = "AB-CD-EF-GH-IJ-KL" -TEST_GATEWAY_ID = f"{TEST_MODEL}-{TEST_MAC}-gateway" +TEST_MAC = "ab:cd:ef:gh:ij:kl" +TEST_GATEWAY_ID = TEST_MAC TEST_HARDWARE_VERSION = "AB123" TEST_FIRMWARE_VERSION = "1.2.3_456" +TEST_ZEROCONF_NAME = "lumi-gateway-v3_miio12345678._miio._udp.local." def get_mock_info( @@ -119,7 +125,83 @@ async def test_config_flow_gateway_success(hass): config_flow.CONF_FLOW_TYPE: config_flow.CONF_GATEWAY, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, - "gateway_id": TEST_GATEWAY_ID, "model": TEST_MODEL, "mac": TEST_MAC, } + + +async def test_zeroconf_gateway_success(hass): + """Test a successful zeroconf discovery of a gateway.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + zeroconf.ATTR_HOST: TEST_HOST, + ZEROCONF_NAME: TEST_ZEROCONF_NAME, + ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC}, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "gateway" + assert result["errors"] == {} + + mock_info = get_mock_info() + + with patch( + "homeassistant.components.xiaomi_miio.gateway.gateway.Gateway.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_NAME: TEST_NAME, CONF_TOKEN: TEST_TOKEN}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + config_flow.CONF_FLOW_TYPE: config_flow.CONF_GATEWAY, + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + "model": TEST_MODEL, + "mac": TEST_MAC, + } + + +async def test_zeroconf_unknown_device(hass): + """Test a failed zeroconf discovery because of a unknown device.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + zeroconf.ATTR_HOST: TEST_HOST, + ZEROCONF_NAME: "not-a-xiaomi-miio-device", + ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC}, + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "not_xiaomi_miio" + + +async def test_zeroconf_no_data(hass): + """Test a failed zeroconf discovery because of no data.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data={} + ) + + assert result["type"] == "abort" + assert result["reason"] == "not_xiaomi_miio" + + +async def test_zeroconf_missing_data(hass): + """Test a failed zeroconf discovery because of missing data.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={zeroconf.ATTR_HOST: TEST_HOST, ZEROCONF_NAME: TEST_ZEROCONF_NAME}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "not_xiaomi_miio"