diff --git a/.coveragerc b/.coveragerc index 0cce8526784..87f3eeabcac 100644 --- a/.coveragerc +++ b/.coveragerc @@ -909,7 +909,14 @@ omit = homeassistant/components/xeoma/camera.py homeassistant/components/xfinity/device_tracker.py homeassistant/components/xiaomi/camera.py - homeassistant/components/xiaomi_aqara/* + homeassistant/components/xiaomi_aqara/__init__.py + homeassistant/components/xiaomi_aqara/binary_sensor.py + homeassistant/components/xiaomi_aqara/const.py + homeassistant/components/xiaomi_aqara/cover.py + homeassistant/components/xiaomi_aqara/light.py + homeassistant/components/xiaomi_aqara/lock.py + homeassistant/components/xiaomi_aqara/sensor.py + homeassistant/components/xiaomi_aqara/switch.py homeassistant/components/xiaomi_miio/__init__.py homeassistant/components/xiaomi_miio/air_quality.py homeassistant/components/xiaomi_miio/alarm_control_panel.py diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index d6462e2d259..0144b3b8280 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -57,7 +57,6 @@ SERVICE_HANDLERS = { SERVICE_APPLE_TV: ("apple_tv", None), SERVICE_ENIGMA2: ("media_player", "enigma2"), SERVICE_WINK: ("wink", None), - SERVICE_XIAOMI_GW: ("xiaomi_aqara", None), SERVICE_SABNZBD: ("sabnzbd", None), SERVICE_SAMSUNG_PRINTER: ("sensor", "syncthru"), SERVICE_KONNECTED: ("konnected", None), @@ -92,6 +91,7 @@ MIGRATED_SERVICE_HANDLERS = [ "sonos", "songpal", SERVICE_WEMO, + SERVICE_XIAOMI_GW, ] DEFAULT_ENABLED = ( diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index 450a6e4c862..d759785f49f 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -1,11 +1,12 @@ """Support for Xiaomi Gateways.""" +import asyncio from datetime import timedelta import logging import voluptuous as vol -from xiaomi_gateway import XiaomiGatewayDiscovery +from xiaomi_gateway import XiaomiGateway, XiaomiGatewayDiscovery -from homeassistant.components.discovery import SERVICE_XIAOMI_GW +from homeassistant import config_entries, core from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, @@ -15,29 +16,34 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import callback -from homeassistant.helpers import discovery +from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow +from .const import ( + CONF_INTERFACE, + CONF_KEY, + CONF_PROTOCOL, + CONF_SID, + DEFAULT_DISCOVERY_RETRY, + DOMAIN, + GATEWAYS_KEY, + LISTENER_KEY, +) + _LOGGER = logging.getLogger(__name__) +GATEWAY_PLATFORMS = ["binary_sensor", "sensor", "switch", "light", "cover", "lock"] +GATEWAY_PLATFORMS_NO_KEY = ["binary_sensor", "sensor"] + ATTR_GW_MAC = "gw_mac" ATTR_RINGTONE_ID = "ringtone_id" ATTR_RINGTONE_VOL = "ringtone_vol" ATTR_DEVICE_ID = "device_id" -CONF_DISCOVERY_RETRY = "discovery_retry" -CONF_GATEWAYS = "gateways" -CONF_INTERFACE = "interface" -CONF_KEY = "key" -CONF_DISABLE = "disable" - -DOMAIN = "xiaomi_aqara" - -PY_XIAOMI_GATEWAY = "xiaomi_gw" - TIME_TILL_UNAVAILABLE = timedelta(minutes=150) SERVICE_PLAY_RINGTONE = "play_ringtone" @@ -45,10 +51,6 @@ SERVICE_STOP_RINGTONE = "stop_ringtone" SERVICE_ADD_DEVICE = "add_device" SERVICE_REMOVE_DEVICE = "remove_device" -GW_MAC = vol.All( - cv.string, lambda value: value.replace(":", "").lower(), vol.Length(min=12, max=12) -) - SERVICE_SCHEMA_PLAY_RINGTONE = vol.Schema( { vol.Required(ATTR_RINGTONE_ID): vol.All( @@ -65,102 +67,8 @@ SERVICE_SCHEMA_REMOVE_DEVICE = vol.Schema( ) -GATEWAY_CONFIG = vol.Schema( - { - vol.Optional(CONF_KEY): vol.All(cv.string, vol.Length(min=16, max=16)), - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=9898): cv.port, - vol.Optional(CONF_DISABLE, default=False): cv.boolean, - } -) - -GATEWAY_CONFIG_MAC_OPTIONAL = GATEWAY_CONFIG.extend({vol.Optional(CONF_MAC): GW_MAC}) - -GATEWAY_CONFIG_MAC_REQUIRED = GATEWAY_CONFIG.extend({vol.Required(CONF_MAC): GW_MAC}) - - -def _fix_conf_defaults(config): - """Update some configuration defaults.""" - config["sid"] = config.pop(CONF_MAC, None) - - if config.get(CONF_KEY) is None: - _LOGGER.warning( - "Key is not provided for gateway %s. Controlling the gateway " - "will not be possible", - config["sid"], - ) - - if config.get(CONF_HOST) is None: - config.pop(CONF_PORT) - - return config - - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Optional(CONF_GATEWAYS, default={}): vol.All( - cv.ensure_list, - vol.Any( - vol.All([GATEWAY_CONFIG_MAC_OPTIONAL], vol.Length(max=1)), - vol.All([GATEWAY_CONFIG_MAC_REQUIRED], vol.Length(min=2)), - ), - [_fix_conf_defaults], - ), - vol.Optional(CONF_INTERFACE, default="any"): cv.string, - vol.Optional(CONF_DISCOVERY_RETRY, default=3): cv.positive_int, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - def setup(hass, config): """Set up the Xiaomi component.""" - gateways = [] - interface = "any" - discovery_retry = 3 - if DOMAIN in config: - gateways = config[DOMAIN][CONF_GATEWAYS] - interface = config[DOMAIN][CONF_INTERFACE] - discovery_retry = config[DOMAIN][CONF_DISCOVERY_RETRY] - - async def xiaomi_gw_discovered(service, discovery_info): - """Perform action when Xiaomi Gateway device(s) has been found.""" - # We don't need to do anything here, the purpose of Home Assistant's - # discovery service is to just trigger loading of this - # component, and then its own discovery process kicks in. - - discovery.listen(hass, SERVICE_XIAOMI_GW, xiaomi_gw_discovered) - - xiaomi = hass.data[PY_XIAOMI_GATEWAY] = XiaomiGatewayDiscovery( - hass.add_job, gateways, interface - ) - - _LOGGER.debug("Expecting %s gateways", len(gateways)) - for k in range(discovery_retry): - _LOGGER.info("Discovering Xiaomi Gateways (Try %s)", k + 1) - xiaomi.discover_gateways() - if len(xiaomi.gateways) >= len(gateways): - break - - if not xiaomi.gateways: - _LOGGER.error("No gateway discovered") - return False - xiaomi.listen() - _LOGGER.debug("Gateways discovered. Listening for broadcasts") - - for component in ["binary_sensor", "sensor", "switch", "light", "cover", "lock"]: - discovery.load_platform(hass, component, DOMAIN, {}, config) - - def stop_xiaomi(event): - """Stop Xiaomi Socket.""" - _LOGGER.info("Shutting down Xiaomi Hub") - xiaomi.stop_listen() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_xiaomi) def play_ringtone_service(call): """Service to play ringtone through Gateway.""" @@ -196,13 +104,13 @@ def setup(hass, config): gateway = call.data.get(ATTR_GW_MAC) gateway.write_to_hub(gateway.sid, remove_device=device_id) - gateway_only_schema = _add_gateway_to_schema(xiaomi, vol.Schema({})) + gateway_only_schema = _add_gateway_to_schema(hass, vol.Schema({})) hass.services.register( DOMAIN, SERVICE_PLAY_RINGTONE, play_ringtone_service, - schema=_add_gateway_to_schema(xiaomi, SERVICE_SCHEMA_PLAY_RINGTONE), + schema=_add_gateway_to_schema(hass, SERVICE_SCHEMA_PLAY_RINGTONE), ) hass.services.register( @@ -217,21 +125,119 @@ def setup(hass, config): DOMAIN, SERVICE_REMOVE_DEVICE, remove_device_service, - schema=_add_gateway_to_schema(xiaomi, SERVICE_SCHEMA_REMOVE_DEVICE), + schema=_add_gateway_to_schema(hass, SERVICE_SCHEMA_REMOVE_DEVICE), ) return True +async def async_setup_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +): + """Set up the xiaomi aqara components from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault(GATEWAYS_KEY, {}) + + # Connect to Xiaomi Aqara Gateway + xiaomi_gateway = await hass.async_add_executor_job( + XiaomiGateway, + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_SID], + entry.data[CONF_KEY], + DEFAULT_DISCOVERY_RETRY, + entry.data[CONF_INTERFACE], + entry.data[CONF_PROTOCOL], + ) + hass.data[DOMAIN][GATEWAYS_KEY][entry.entry_id] = xiaomi_gateway + + gateway_discovery = hass.data[DOMAIN].setdefault( + LISTENER_KEY, + XiaomiGatewayDiscovery(hass.add_job, [], entry.data[CONF_INTERFACE]), + ) + + if len(hass.data[DOMAIN][GATEWAYS_KEY]) == 1: + # start listining for local pushes (only once) + await hass.async_add_executor_job(gateway_discovery.listen) + + # register stop callback to shutdown listining for local pushes + def stop_xiaomi(event): + """Stop Xiaomi Socket.""" + _LOGGER.debug("Shutting down Xiaomi Gateway Listener") + gateway_discovery.stop_listen() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_xiaomi) + + gateway_discovery.gateways[entry.data[CONF_HOST]] = xiaomi_gateway + _LOGGER.debug( + "Gateway with host '%s' connected, listening for broadcasts", + entry.data[CONF_HOST], + ) + + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.unique_id)}, + manufacturer="Xiaomi Aqara", + name=entry.title, + sw_version=entry.data[CONF_PROTOCOL], + ) + + if entry.data[CONF_KEY] is not None: + platforms = GATEWAY_PLATFORMS + else: + platforms = GATEWAY_PLATFORMS_NO_KEY + + for component in platforms: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +): + """Unload a config entry.""" + if entry.data[CONF_KEY] is not None: + platforms = GATEWAY_PLATFORMS + else: + platforms = GATEWAY_PLATFORMS_NO_KEY + + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in platforms + ] + ) + ) + if unload_ok: + hass.data[DOMAIN][GATEWAYS_KEY].pop(entry.entry_id) + + if len(hass.data[DOMAIN][GATEWAYS_KEY]) == 0: + # No gateways left, stop Xiaomi socket + hass.data[DOMAIN].pop(GATEWAYS_KEY) + _LOGGER.debug("Shutting down Xiaomi Gateway Listener") + gateway_discovery = hass.data[DOMAIN].pop(LISTENER_KEY) + await hass.async_add_executor_job(gateway_discovery.stop_listen) + + return unload_ok + + class XiaomiDevice(Entity): """Representation a base Xiaomi device.""" - def __init__(self, device, device_type, xiaomi_hub): + def __init__(self, device, device_type, xiaomi_hub, config_entry): """Initialize the Xiaomi device.""" self._state = None self._is_available = True self._sid = device["sid"] + self._model = device["model"] + self._protocol = device["proto"] self._name = f"{device_type}_{self._sid}" + self._device_name = f"{self._model}_{self._sid}" self._type = device_type self._write_to_hub = xiaomi_hub.write_to_hub self._get_from_hub = xiaomi_hub.get_from_hub @@ -248,6 +254,16 @@ class XiaomiDevice(Entity): else: self._unique_id = f"{self._type}{self._sid}" + self._gateway_id = config_entry.unique_id + if config_entry.data[CONF_MAC] == format_mac(self._sid): + # this entity belongs to the gateway itself + self._is_gateway = True + self._device_id = config_entry.unique_id + else: + # this entity is connected through zigbee + self._is_gateway = False + self._device_id = self._sid + def _add_push_data_job(self, *args): self.hass.add_job(self.push_data, *args) @@ -266,6 +282,32 @@ class XiaomiDevice(Entity): """Return a unique ID.""" return self._unique_id + @property + def device_id(self): + """Return the device id of the Xiaomi Aqara device.""" + return self._device_id + + @property + def device_info(self): + """Return the device info of the Xiaomi Aqara device.""" + if self._is_gateway: + device_info = { + "identifiers": {(DOMAIN, self._device_id)}, + "model": self._model, + } + else: + device_info = { + "connections": {(dr.CONNECTION_ZIGBEE, self._device_id)}, + "identifiers": {(DOMAIN, self._device_id)}, + "manufacturer": "Xiaomi Aqara", + "model": self._model, + "name": self._device_name, + "sw_version": self._protocol, + "via_device": (DOMAIN, self._gateway_id), + } + + return device_info + @property def available(self): """Return True if entity is available.""" @@ -334,24 +376,26 @@ class XiaomiDevice(Entity): raise NotImplementedError() -def _add_gateway_to_schema(xiaomi, schema): +def _add_gateway_to_schema(hass, schema): """Extend a voluptuous schema with a gateway validator.""" def gateway(sid): """Convert sid to a gateway.""" sid = str(sid).replace(":", "").lower() - for gateway in xiaomi.gateways.values(): + for gateway in hass.data[DOMAIN][GATEWAYS_KEY].values(): if gateway.sid == sid: return gateway raise vol.Invalid(f"Unknown gateway sid {sid}") - gateways = list(xiaomi.gateways.values()) kwargs = {} + xiaomi_data = hass.data.get(DOMAIN) + if xiaomi_data is not None: + gateways = list(xiaomi_data[GATEWAYS_KEY].values()) - # If the user has only 1 gateway, make it the default for services. - if len(gateways) == 1: - kwargs["default"] = gateways[0].sid + # If the user has only 1 gateway, make it the default for services. + if len(gateways) == 1: + kwargs["default"] = gateways[0].sid return schema.extend({vol.Required(ATTR_GW_MAC, **kwargs): gateway}) diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index 01caddb7eb5..44dc6706d57 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -5,7 +5,8 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import callback from homeassistant.helpers.event import async_call_later -from . import PY_XIAOMI_GATEWAY, XiaomiDevice +from . import XiaomiDevice +from .const import DOMAIN, GATEWAYS_KEY _LOGGER = logging.getLogger(__name__) @@ -21,94 +22,115 @@ DENSITY = "density" ATTR_DENSITY = "Density" -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Perform the setup for Xiaomi devices.""" - devices = [] - for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): - for device in gateway.devices["binary_sensor"]: - model = device["model"] - if model in ["motion", "sensor_motion", "sensor_motion.aq2"]: - devices.append(XiaomiMotionSensor(device, hass, gateway)) - elif model in ["magnet", "sensor_magnet", "sensor_magnet.aq2"]: - devices.append(XiaomiDoorSensor(device, gateway)) - elif model == "sensor_wleak.aq1": - devices.append(XiaomiWaterLeakSensor(device, gateway)) - elif model in ["smoke", "sensor_smoke"]: - devices.append(XiaomiSmokeSensor(device, gateway)) - elif model in ["natgas", "sensor_natgas"]: - devices.append(XiaomiNatgasSensor(device, gateway)) - elif model in [ - "switch", - "sensor_switch", - "sensor_switch.aq2", - "sensor_switch.aq3", - "remote.b1acn01", - ]: - if "proto" not in device or int(device["proto"][0:1]) == 1: - data_key = "status" - else: - data_key = "button_0" - devices.append(XiaomiButton(device, "Switch", data_key, hass, gateway)) - elif model in [ - "86sw1", - "sensor_86sw1", - "sensor_86sw1.aq1", - "remote.b186acn01", - ]: - if "proto" not in device or int(device["proto"][0:1]) == 1: - data_key = "channel_0" - else: - data_key = "button_0" - devices.append( - XiaomiButton(device, "Wall Switch", data_key, hass, gateway) - ) - elif model in [ - "86sw2", - "sensor_86sw2", - "sensor_86sw2.aq1", - "remote.b286acn01", - ]: - if "proto" not in device or int(device["proto"][0:1]) == 1: - data_key_left = "channel_0" - data_key_right = "channel_1" - else: - data_key_left = "button_0" - data_key_right = "button_1" - devices.append( - XiaomiButton( - device, "Wall Switch (Left)", data_key_left, hass, gateway - ) - ) - devices.append( - XiaomiButton( - device, "Wall Switch (Right)", data_key_right, hass, gateway - ) - ) - devices.append( - XiaomiButton( - device, "Wall Switch (Both)", "dual_channel", hass, gateway - ) - ) - elif model in ["cube", "sensor_cube", "sensor_cube.aqgl01"]: - devices.append(XiaomiCube(device, hass, gateway)) - elif model in ["vibration", "vibration.aq1"]: - devices.append(XiaomiVibration(device, "Vibration", "status", gateway)) + entities = [] + gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + for entity in gateway.devices["binary_sensor"]: + model = entity["model"] + if model in ["motion", "sensor_motion", "sensor_motion.aq2"]: + entities.append(XiaomiMotionSensor(entity, hass, gateway, config_entry)) + elif model in ["magnet", "sensor_magnet", "sensor_magnet.aq2"]: + entities.append(XiaomiDoorSensor(entity, gateway, config_entry)) + elif model == "sensor_wleak.aq1": + entities.append(XiaomiWaterLeakSensor(entity, gateway, config_entry)) + elif model in ["smoke", "sensor_smoke"]: + entities.append(XiaomiSmokeSensor(entity, gateway, config_entry)) + elif model in ["natgas", "sensor_natgas"]: + entities.append(XiaomiNatgasSensor(entity, gateway, config_entry)) + elif model in [ + "switch", + "sensor_switch", + "sensor_switch.aq2", + "sensor_switch.aq3", + "remote.b1acn01", + ]: + if "proto" not in entity or int(entity["proto"][0:1]) == 1: + data_key = "status" else: - _LOGGER.warning("Unmapped Device Model %s", model) + data_key = "button_0" + entities.append( + XiaomiButton(entity, "Switch", data_key, hass, gateway, config_entry) + ) + elif model in [ + "86sw1", + "sensor_86sw1", + "sensor_86sw1.aq1", + "remote.b186acn01", + ]: + if "proto" not in entity or int(entity["proto"][0:1]) == 1: + data_key = "channel_0" + else: + data_key = "button_0" + entities.append( + XiaomiButton( + entity, "Wall Switch", data_key, hass, gateway, config_entry + ) + ) + elif model in [ + "86sw2", + "sensor_86sw2", + "sensor_86sw2.aq1", + "remote.b286acn01", + ]: + if "proto" not in entity or int(entity["proto"][0:1]) == 1: + data_key_left = "channel_0" + data_key_right = "channel_1" + else: + data_key_left = "button_0" + data_key_right = "button_1" + entities.append( + XiaomiButton( + entity, + "Wall Switch (Left)", + data_key_left, + hass, + gateway, + config_entry, + ) + ) + entities.append( + XiaomiButton( + entity, + "Wall Switch (Right)", + data_key_right, + hass, + gateway, + config_entry, + ) + ) + entities.append( + XiaomiButton( + entity, + "Wall Switch (Both)", + "dual_channel", + hass, + gateway, + config_entry, + ) + ) + elif model in ["cube", "sensor_cube", "sensor_cube.aqgl01"]: + entities.append(XiaomiCube(entity, hass, gateway, config_entry)) + elif model in ["vibration", "vibration.aq1"]: + entities.append( + XiaomiVibration(entity, "Vibration", "status", gateway, config_entry) + ) + else: + _LOGGER.warning("Unmapped Device Model %s", model) - add_entities(devices) + async_add_entities(entities) class XiaomiBinarySensor(XiaomiDevice, BinarySensorEntity): """Representation of a base XiaomiBinarySensor.""" - def __init__(self, device, name, xiaomi_hub, data_key, device_class): + def __init__(self, device, name, xiaomi_hub, data_key, device_class, config_entry): """Initialize the XiaomiSmokeSensor.""" self._data_key = data_key self._device_class = device_class self._should_poll = False self._density = 0 - XiaomiDevice.__init__(self, device, name, xiaomi_hub) + super().__init__(device, name, xiaomi_hub, config_entry) @property def should_poll(self): @@ -134,11 +156,11 @@ class XiaomiBinarySensor(XiaomiDevice, BinarySensorEntity): class XiaomiNatgasSensor(XiaomiBinarySensor): """Representation of a XiaomiNatgasSensor.""" - def __init__(self, device, xiaomi_hub): + def __init__(self, device, xiaomi_hub, config_entry): """Initialize the XiaomiSmokeSensor.""" self._density = None - XiaomiBinarySensor.__init__( - self, device, "Natgas Sensor", xiaomi_hub, "alarm", "gas" + super().__init__( + device, "Natgas Sensor", xiaomi_hub, "alarm", "gas", config_entry ) @property @@ -172,7 +194,7 @@ class XiaomiNatgasSensor(XiaomiBinarySensor): class XiaomiMotionSensor(XiaomiBinarySensor): """Representation of a XiaomiMotionSensor.""" - def __init__(self, device, hass, xiaomi_hub): + def __init__(self, device, hass, xiaomi_hub, config_entry): """Initialize the XiaomiMotionSensor.""" self._hass = hass self._no_motion_since = 0 @@ -181,8 +203,8 @@ class XiaomiMotionSensor(XiaomiBinarySensor): data_key = "status" else: data_key = "motion_status" - XiaomiBinarySensor.__init__( - self, device, "Motion Sensor", xiaomi_hub, data_key, "motion" + super().__init__( + device, "Motion Sensor", xiaomi_hub, data_key, "motion", config_entry ) @property @@ -263,15 +285,15 @@ class XiaomiMotionSensor(XiaomiBinarySensor): class XiaomiDoorSensor(XiaomiBinarySensor): """Representation of a XiaomiDoorSensor.""" - def __init__(self, device, xiaomi_hub): + def __init__(self, device, xiaomi_hub, config_entry): """Initialize the XiaomiDoorSensor.""" self._open_since = 0 if "proto" not in device or int(device["proto"][0:1]) == 1: data_key = "status" else: data_key = "window_status" - XiaomiBinarySensor.__init__( - self, device, "Door Window Sensor", xiaomi_hub, data_key, "opening" + super().__init__( + device, "Door Window Sensor", xiaomi_hub, data_key, "opening", config_entry, ) @property @@ -309,14 +331,14 @@ class XiaomiDoorSensor(XiaomiBinarySensor): class XiaomiWaterLeakSensor(XiaomiBinarySensor): """Representation of a XiaomiWaterLeakSensor.""" - def __init__(self, device, xiaomi_hub): + def __init__(self, device, xiaomi_hub, config_entry): """Initialize the XiaomiWaterLeakSensor.""" if "proto" not in device or int(device["proto"][0:1]) == 1: data_key = "status" else: data_key = "wleak_status" - XiaomiBinarySensor.__init__( - self, device, "Water Leak Sensor", xiaomi_hub, data_key, "moisture" + super().__init__( + device, "Water Leak Sensor", xiaomi_hub, data_key, "moisture", config_entry, ) def parse_data(self, data, raw_data): @@ -343,11 +365,11 @@ class XiaomiWaterLeakSensor(XiaomiBinarySensor): class XiaomiSmokeSensor(XiaomiBinarySensor): """Representation of a XiaomiSmokeSensor.""" - def __init__(self, device, xiaomi_hub): + def __init__(self, device, xiaomi_hub, config_entry): """Initialize the XiaomiSmokeSensor.""" self._density = 0 - XiaomiBinarySensor.__init__( - self, device, "Smoke Sensor", xiaomi_hub, "alarm", "smoke" + super().__init__( + device, "Smoke Sensor", xiaomi_hub, "alarm", "smoke", config_entry ) @property @@ -380,10 +402,10 @@ class XiaomiSmokeSensor(XiaomiBinarySensor): class XiaomiVibration(XiaomiBinarySensor): """Representation of a Xiaomi Vibration Sensor.""" - def __init__(self, device, name, data_key, xiaomi_hub): + def __init__(self, device, name, data_key, xiaomi_hub, config_entry): """Initialize the XiaomiVibration.""" self._last_action = None - super().__init__(device, name, xiaomi_hub, data_key, None) + super().__init__(device, name, xiaomi_hub, data_key, None, config_entry) @property def device_state_attributes(self): @@ -414,11 +436,11 @@ class XiaomiVibration(XiaomiBinarySensor): class XiaomiButton(XiaomiBinarySensor): """Representation of a Xiaomi Button.""" - def __init__(self, device, name, data_key, hass, xiaomi_hub): + def __init__(self, device, name, data_key, hass, xiaomi_hub, config_entry): """Initialize the XiaomiButton.""" self._hass = hass self._last_action = None - XiaomiBinarySensor.__init__(self, device, name, xiaomi_hub, data_key, None) + super().__init__(device, name, xiaomi_hub, data_key, None, config_entry) @property def device_state_attributes(self): @@ -469,7 +491,7 @@ class XiaomiButton(XiaomiBinarySensor): class XiaomiCube(XiaomiBinarySensor): """Representation of a Xiaomi Cube.""" - def __init__(self, device, hass, xiaomi_hub): + def __init__(self, device, hass, xiaomi_hub, config_entry): """Initialize the Xiaomi Cube.""" self._hass = hass self._last_action = None @@ -478,7 +500,7 @@ class XiaomiCube(XiaomiBinarySensor): data_key = "status" else: data_key = "cube_status" - XiaomiBinarySensor.__init__(self, device, "Cube", xiaomi_hub, data_key, None) + super().__init__(device, "Cube", xiaomi_hub, data_key, None, config_entry) @property def device_state_attributes(self): diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py new file mode 100644 index 00000000000..b9cfe58ac4b --- /dev/null +++ b/homeassistant/components/xiaomi_aqara/config_flow.py @@ -0,0 +1,183 @@ +"""Config flow to configure Xiaomi Aqara.""" +import logging +from socket import gaierror + +import voluptuous as vol +from xiaomi_gateway import XiaomiGatewayDiscovery + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT +from homeassistant.helpers.device_registry import format_mac + +# pylint: disable=unused-import +from .const import ( + CONF_INTERFACE, + CONF_KEY, + CONF_PROTOCOL, + CONF_SID, + DOMAIN, + ZEROCONF_GATEWAY, +) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_GATEWAY_NAME = "Xiaomi Aqara Gateway" +DEFAULT_INTERFACE = "any" + + +GATEWAY_CONFIG = vol.Schema( + {vol.Optional(CONF_INTERFACE, default=DEFAULT_INTERFACE): str} +) +GATEWAY_SETTINGS = vol.Schema( + { + vol.Optional(CONF_KEY): vol.All(str, vol.Length(min=16, max=16)), + vol.Optional(CONF_NAME, default=DEFAULT_GATEWAY_NAME): str, + } +) + + +class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Xiaomi Aqara config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize.""" + self.host = None + self.interface = DEFAULT_INTERFACE + self.gateways = None + self.selected_gateway = None + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + if user_input is not None: + self.interface = user_input[CONF_INTERFACE] + + # Discover Xiaomi Aqara Gateways in the netwerk to get required SIDs. + xiaomi = XiaomiGatewayDiscovery(self.hass.add_job, [], self.interface) + try: + await self.hass.async_add_executor_job(xiaomi.discover_gateways) + except gaierror: + errors[CONF_INTERFACE] = "invalid_interface" + + if not errors: + self.gateways = xiaomi.gateways + + # if host is already known by zeroconf discovery + if self.host is not None: + self.selected_gateway = self.gateways.get(self.host) + if self.selected_gateway is not None: + return await self.async_step_settings() + + errors["base"] = "not_found_error" + else: + if len(self.gateways) == 1: + self.selected_gateway = list(self.gateways.values())[0] + return await self.async_step_settings() + if len(self.gateways) > 1: + return await self.async_step_select() + + errors["base"] = "discovery_error" + + return self.async_show_form( + step_id="user", data_schema=GATEWAY_CONFIG, errors=errors + ) + + async def async_step_select(self, user_input=None): + """Handle multiple aqara gateways found.""" + errors = {} + if user_input is not None: + ip_adress = user_input["select_ip"] + self.selected_gateway = self.gateways[ip_adress] + return await self.async_step_settings() + + select_schema = vol.Schema( + { + vol.Required("select_ip"): vol.In( + [gateway.ip_adress for gateway in self.gateways.values()] + ) + } + ) + + return self.async_show_form( + step_id="select", data_schema=select_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_aqara") + + # Check if the discovered device is an xiaomi aqara gateway. + if not name.startswith(ZEROCONF_GATEWAY): + _LOGGER.debug( + "Xiaomi device '%s' discovered with host %s, not identified as xiaomi aqara gateway", + name, + self.host, + ) + return self.async_abort(reason="not_xiaomi_aqara") + + # format mac (include semicolns and make uppercase) + mac_address = format_mac(mac_address) + + unique_id = mac_address + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context.update({"title_placeholders": {"name": self.host}}) + + return await self.async_step_user() + + async def async_step_settings(self, user_input=None): + """Specify settings and connect aqara gateway.""" + errors = {} + if user_input is not None: + # get all required data + name = user_input[CONF_NAME] + key = user_input.get(CONF_KEY) + ip_adress = self.selected_gateway.ip_adress + port = self.selected_gateway.port + sid = self.selected_gateway.sid + protocol = self.selected_gateway.proto + + if key is not None: + # validate key by issuing stop ringtone playback command. + self.selected_gateway.key = key + valid_key = self.selected_gateway.write_to_hub(sid, mid=10000) + else: + valid_key = True + + if valid_key: + # format_mac, for a gateway the sid equels the mac address + mac_address = format_mac(sid) + + # set unique_id + unique_id = mac_address + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=name, + data={ + CONF_HOST: ip_adress, + CONF_PORT: port, + CONF_MAC: mac_address, + CONF_INTERFACE: self.interface, + CONF_PROTOCOL: protocol, + CONF_KEY: key, + CONF_SID: sid, + }, + ) + + errors[CONF_KEY] = "invalid_key" + + return self.async_show_form( + step_id="settings", data_schema=GATEWAY_SETTINGS, errors=errors + ) diff --git a/homeassistant/components/xiaomi_aqara/const.py b/homeassistant/components/xiaomi_aqara/const.py new file mode 100644 index 00000000000..ab214cb13cc --- /dev/null +++ b/homeassistant/components/xiaomi_aqara/const.py @@ -0,0 +1,15 @@ +"""Constants of the Xiaomi Aqara component.""" + +DOMAIN = "xiaomi_aqara" + +GATEWAYS_KEY = "gateways" +LISTENER_KEY = "listener" + +ZEROCONF_GATEWAY = "lumi-gateway" + +CONF_INTERFACE = "interface" +CONF_PROTOCOL = "protocol" +CONF_KEY = "key" +CONF_SID = "sid" + +DEFAULT_DISCOVERY_RETRY = 5 diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py index 52d2487e74f..fbe7ae334e6 100644 --- a/homeassistant/components/xiaomi_aqara/cover.py +++ b/homeassistant/components/xiaomi_aqara/cover.py @@ -3,7 +3,8 @@ import logging from homeassistant.components.cover import ATTR_POSITION, CoverEntity -from . import PY_XIAOMI_GATEWAY, XiaomiDevice +from . import XiaomiDevice +from .const import DOMAIN, GATEWAYS_KEY _LOGGER = logging.getLogger(__name__) @@ -13,29 +14,31 @@ DATA_KEY_PROTO_V1 = "status" DATA_KEY_PROTO_V2 = "curtain_status" -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Perform the setup for Xiaomi devices.""" - devices = [] - for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): - for device in gateway.devices["cover"]: - model = device["model"] - if model in ["curtain", "curtain.aq2", "curtain.hagl04"]: - if "proto" not in device or int(device["proto"][0:1]) == 1: - data_key = DATA_KEY_PROTO_V1 - else: - data_key = DATA_KEY_PROTO_V2 - devices.append(XiaomiGenericCover(device, "Curtain", data_key, gateway)) - add_entities(devices) + entities = [] + gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + for device in gateway.devices["cover"]: + model = device["model"] + if model in ["curtain", "curtain.aq2", "curtain.hagl04"]: + if "proto" not in device or int(device["proto"][0:1]) == 1: + data_key = DATA_KEY_PROTO_V1 + else: + data_key = DATA_KEY_PROTO_V2 + entities.append( + XiaomiGenericCover(device, "Curtain", data_key, gateway, config_entry) + ) + async_add_entities(entities) class XiaomiGenericCover(XiaomiDevice, CoverEntity): """Representation of a XiaomiGenericCover.""" - def __init__(self, device, name, data_key, xiaomi_hub): + def __init__(self, device, name, data_key, xiaomi_hub, config_entry): """Initialize the XiaomiGenericCover.""" self._data_key = data_key self._pos = 0 - XiaomiDevice.__init__(self, device, name, xiaomi_hub) + super().__init__(device, name, xiaomi_hub, config_entry) @property def current_cover_position(self): diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py index f1cd17f9dee..494c9af920e 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -12,32 +12,35 @@ from homeassistant.components.light import ( ) import homeassistant.util.color as color_util -from . import PY_XIAOMI_GATEWAY, XiaomiDevice +from . import XiaomiDevice +from .const import DOMAIN, GATEWAYS_KEY _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Perform the setup for Xiaomi devices.""" - devices = [] - for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): - for device in gateway.devices["light"]: - model = device["model"] - if model in ["gateway", "gateway.v3"]: - devices.append(XiaomiGatewayLight(device, "Gateway Light", gateway)) - add_entities(devices) + entities = [] + gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + for device in gateway.devices["light"]: + model = device["model"] + if model in ["gateway", "gateway.v3"]: + entities.append( + XiaomiGatewayLight(device, "Gateway Light", gateway, config_entry) + ) + async_add_entities(entities) class XiaomiGatewayLight(XiaomiDevice, LightEntity): """Representation of a XiaomiGatewayLight.""" - def __init__(self, device, name, xiaomi_hub): + def __init__(self, device, name, xiaomi_hub, config_entry): """Initialize the XiaomiGatewayLight.""" self._data_key = "rgb" self._hs = (0, 0) self._brightness = 100 - XiaomiDevice.__init__(self, device, name, xiaomi_hub) + super().__init__(device, name, xiaomi_hub, config_entry) @property def is_on(self): diff --git a/homeassistant/components/xiaomi_aqara/lock.py b/homeassistant/components/xiaomi_aqara/lock.py index c3835f83391..db858729995 100644 --- a/homeassistant/components/xiaomi_aqara/lock.py +++ b/homeassistant/components/xiaomi_aqara/lock.py @@ -6,7 +6,8 @@ from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED from homeassistant.core import callback from homeassistant.helpers.event import async_call_later -from . import PY_XIAOMI_GATEWAY, XiaomiDevice +from . import XiaomiDevice +from .const import DOMAIN, GATEWAYS_KEY _LOGGER = logging.getLogger(__name__) @@ -20,27 +21,26 @@ ATTR_VERIFIED_WRONG_TIMES = "verified_wrong_times" UNLOCK_MAINTAIN_TIME = 5 -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Perform the setup for Xiaomi devices.""" - devices = [] - - for gateway in hass.data[PY_XIAOMI_GATEWAY].gateways.values(): - for device in gateway.devices["lock"]: - model = device["model"] - if model == "lock.aq1": - devices.append(XiaomiAqaraLock(device, "Lock", gateway)) - async_add_entities(devices) + entities = [] + gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + for device in gateway.devices["lock"]: + model = device["model"] + if model == "lock.aq1": + entities.append(XiaomiAqaraLock(device, "Lock", gateway, config_entry)) + async_add_entities(entities) class XiaomiAqaraLock(LockEntity, XiaomiDevice): """Representation of a XiaomiAqaraLock.""" - def __init__(self, device, name, xiaomi_hub): + def __init__(self, device, name, xiaomi_hub, config_entry): """Initialize the XiaomiAqaraLock.""" self._changed_by = 0 self._verified_wrong_times = 0 - super().__init__(device, name, xiaomi_hub) + super().__init__(device, name, xiaomi_hub, config_entry) @property def is_locked(self) -> bool: diff --git a/homeassistant/components/xiaomi_aqara/manifest.json b/homeassistant/components/xiaomi_aqara/manifest.json index e604b225fc4..cb6bb376e3b 100644 --- a/homeassistant/components/xiaomi_aqara/manifest.json +++ b/homeassistant/components/xiaomi_aqara/manifest.json @@ -1,8 +1,10 @@ { "domain": "xiaomi_aqara", "name": "Xiaomi Gateway (Aqara)", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_aqara", "requirements": ["PyXiaomiGateway==0.12.4"], "after_dependencies": ["discovery"], - "codeowners": ["@danielhiversen", "@syssi"] + "codeowners": ["@danielhiversen", "@syssi"], + "zeroconf": ["_miio._udp.local."] } diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index d793f920349..fe1eb5a80fe 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -10,7 +10,8 @@ from homeassistant.const import ( UNIT_PERCENTAGE, ) -from . import PY_XIAOMI_GATEWAY, XiaomiDevice +from . import XiaomiDevice +from .const import DOMAIN, GATEWAYS_KEY _LOGGER = logging.getLogger(__name__) @@ -24,50 +25,70 @@ SENSOR_TYPES = { } -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Perform the setup for Xiaomi devices.""" - devices = [] - for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): - for device in gateway.devices["sensor"]: - if device["model"] == "sensor_ht": - devices.append( - XiaomiSensor(device, "Temperature", "temperature", gateway) + entities = [] + gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + for device in gateway.devices["sensor"]: + if device["model"] == "sensor_ht": + entities.append( + XiaomiSensor( + device, "Temperature", "temperature", gateway, config_entry ) - devices.append(XiaomiSensor(device, "Humidity", "humidity", gateway)) - elif device["model"] in ["weather", "weather.v1"]: - devices.append( - XiaomiSensor(device, "Temperature", "temperature", gateway) + ) + entities.append( + XiaomiSensor(device, "Humidity", "humidity", gateway, config_entry) + ) + elif device["model"] in ["weather", "weather.v1"]: + entities.append( + XiaomiSensor( + device, "Temperature", "temperature", gateway, config_entry ) - devices.append(XiaomiSensor(device, "Humidity", "humidity", gateway)) - devices.append(XiaomiSensor(device, "Pressure", "pressure", gateway)) - elif device["model"] == "sensor_motion.aq2": - devices.append(XiaomiSensor(device, "Illumination", "lux", gateway)) - elif device["model"] in ["gateway", "gateway.v3", "acpartner.v3"]: - devices.append( - XiaomiSensor(device, "Illumination", "illumination", gateway) + ) + entities.append( + XiaomiSensor(device, "Humidity", "humidity", gateway, config_entry) + ) + entities.append( + XiaomiSensor(device, "Pressure", "pressure", gateway, config_entry) + ) + elif device["model"] == "sensor_motion.aq2": + entities.append( + XiaomiSensor(device, "Illumination", "lux", gateway, config_entry) + ) + elif device["model"] in ["gateway", "gateway.v3", "acpartner.v3"]: + entities.append( + XiaomiSensor( + device, "Illumination", "illumination", gateway, config_entry ) - elif device["model"] in ["vibration"]: - devices.append( - XiaomiSensor(device, "Bed Activity", "bed_activity", gateway) + ) + elif device["model"] in ["vibration"]: + entities.append( + XiaomiSensor( + device, "Bed Activity", "bed_activity", gateway, config_entry ) - devices.append( - XiaomiSensor(device, "Tilt Angle", "final_tilt_angle", gateway) + ) + entities.append( + XiaomiSensor( + device, "Tilt Angle", "final_tilt_angle", gateway, config_entry ) - devices.append( - XiaomiSensor(device, "Coordination", "coordination", gateway) + ) + entities.append( + XiaomiSensor( + device, "Coordination", "coordination", gateway, config_entry ) - else: - _LOGGER.warning("Unmapped Device Model ") - add_entities(devices) + ) + else: + _LOGGER.warning("Unmapped Device Model") + async_add_entities(entities) class XiaomiSensor(XiaomiDevice): """Representation of a XiaomiSensor.""" - def __init__(self, device, name, data_key, xiaomi_hub): + def __init__(self, device, name, data_key, xiaomi_hub, config_entry): """Initialize the XiaomiSensor.""" self._data_key = data_key - XiaomiDevice.__init__(self, device, name, xiaomi_hub) + super().__init__(device, name, xiaomi_hub, config_entry) @property def icon(self): diff --git a/homeassistant/components/xiaomi_aqara/strings.json b/homeassistant/components/xiaomi_aqara/strings.json new file mode 100644 index 00000000000..87e1d37cb93 --- /dev/null +++ b/homeassistant/components/xiaomi_aqara/strings.json @@ -0,0 +1,40 @@ +{ + "config": { + "flow_title": "Xiaomi Aqara Gateway: {name}", + "step": { + "user": { + "title": "Xiaomi Aqara Gateway", + "description": "Connect to your Xiaomi Aqara Gateway", + "data": { + "interface": "The network interface to use" + } + }, + "settings": { + "title": "Xiaomi Aqara Gateway, optional settings", + "description": "The key (password) can be retrieved using this tutorial: https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)#Adding_the_Xiaomi_Gateway_to_Domoticz. If the key is not provided only sensors will be accessible", + "data": { + "key": "The key of your gateway", + "name": "Name of the Gateway" + } + }, + "select": { + "title": "Select the Xiaomi Aqara Gateway that you wish to connect", + "description": "Run the setup again if you want to connect aditional gateways", + "data": { + "select_ip": "Gateway IP" + } + } + }, + "error": { + "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant as interface", + "not_found_error": "Zeroconf discovered Gateway could not be located to get the necessary information, try using the IP of the device running HomeAssistant as interface", + "invalid_interface": "Invalid network interface", + "invalid_key": "Invalid gateway key" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "Config flow for this gateway is already in progress", + "not_xiaomi_aqara": "Not a Xiaomi Aqara Gateway, discovered device did not match known gateways" + } + } +} diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py index e711eab46fb..36dadefee1f 100644 --- a/homeassistant/components/xiaomi_aqara/switch.py +++ b/homeassistant/components/xiaomi_aqara/switch.py @@ -3,7 +3,8 @@ import logging from homeassistant.components.switch import SwitchEntity -from . import PY_XIAOMI_GATEWAY, XiaomiDevice +from . import XiaomiDevice +from .const import DOMAIN, GATEWAYS_KEY _LOGGER = logging.getLogger(__name__) @@ -20,76 +21,108 @@ ENERGY_CONSUMED = "energy_consumed" IN_USE = "inuse" -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Perform the setup for Xiaomi devices.""" - devices = [] - for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): - for device in gateway.devices["switch"]: - model = device["model"] - if model == "plug": - if "proto" not in device or int(device["proto"][0:1]) == 1: - data_key = "status" - else: - data_key = "channel_0" - devices.append( - XiaomiGenericSwitch(device, "Plug", data_key, True, gateway) + entities = [] + gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + for device in gateway.devices["switch"]: + model = device["model"] + if model == "plug": + if "proto" not in device or int(device["proto"][0:1]) == 1: + data_key = "status" + else: + data_key = "channel_0" + entities.append( + XiaomiGenericSwitch( + device, "Plug", data_key, True, gateway, config_entry ) - elif model in ["ctrl_neutral1", "ctrl_neutral1.aq1"]: - devices.append( - XiaomiGenericSwitch( - device, "Wall Switch", "channel_0", False, gateway - ) + ) + elif model in ["ctrl_neutral1", "ctrl_neutral1.aq1"]: + entities.append( + XiaomiGenericSwitch( + device, "Wall Switch", "channel_0", False, gateway, config_entry ) - elif model in ["ctrl_ln1", "ctrl_ln1.aq1"]: - devices.append( - XiaomiGenericSwitch( - device, "Wall Switch LN", "channel_0", False, gateway - ) + ) + elif model in ["ctrl_ln1", "ctrl_ln1.aq1"]: + entities.append( + XiaomiGenericSwitch( + device, "Wall Switch LN", "channel_0", False, gateway, config_entry ) - elif model in ["ctrl_neutral2", "ctrl_neutral2.aq1"]: - devices.append( - XiaomiGenericSwitch( - device, "Wall Switch Left", "channel_0", False, gateway - ) + ) + elif model in ["ctrl_neutral2", "ctrl_neutral2.aq1"]: + entities.append( + XiaomiGenericSwitch( + device, + "Wall Switch Left", + "channel_0", + False, + gateway, + config_entry, ) - devices.append( - XiaomiGenericSwitch( - device, "Wall Switch Right", "channel_1", False, gateway - ) + ) + entities.append( + XiaomiGenericSwitch( + device, + "Wall Switch Right", + "channel_1", + False, + gateway, + config_entry, ) - elif model in ["ctrl_ln2", "ctrl_ln2.aq1"]: - devices.append( - XiaomiGenericSwitch( - device, "Wall Switch LN Left", "channel_0", False, gateway - ) + ) + elif model in ["ctrl_ln2", "ctrl_ln2.aq1"]: + entities.append( + XiaomiGenericSwitch( + device, + "Wall Switch LN Left", + "channel_0", + False, + gateway, + config_entry, ) - devices.append( - XiaomiGenericSwitch( - device, "Wall Switch LN Right", "channel_1", False, gateway - ) + ) + entities.append( + XiaomiGenericSwitch( + device, + "Wall Switch LN Right", + "channel_1", + False, + gateway, + config_entry, ) - elif model in ["86plug", "ctrl_86plug", "ctrl_86plug.aq1"]: - if "proto" not in device or int(device["proto"][0:1]) == 1: - data_key = "status" - else: - data_key = "channel_0" - devices.append( - XiaomiGenericSwitch(device, "Wall Plug", data_key, True, gateway) + ) + elif model in ["86plug", "ctrl_86plug", "ctrl_86plug.aq1"]: + if "proto" not in device or int(device["proto"][0:1]) == 1: + data_key = "status" + else: + data_key = "channel_0" + entities.append( + XiaomiGenericSwitch( + device, "Wall Plug", data_key, True, gateway, config_entry ) - add_entities(devices) + ) + async_add_entities(entities) class XiaomiGenericSwitch(XiaomiDevice, SwitchEntity): """Representation of a XiaomiPlug.""" - def __init__(self, device, name, data_key, supports_power_consumption, xiaomi_hub): + def __init__( + self, + device, + name, + data_key, + supports_power_consumption, + xiaomi_hub, + config_entry, + ): """Initialize the XiaomiPlug.""" self._data_key = data_key self._in_use = None self._load_power = None self._power_consumed = None self._supports_power_consumption = supports_power_consumption - XiaomiDevice.__init__(self, device, name, xiaomi_hub) + super().__init__(device, name, xiaomi_hub, config_entry) @property def icon(self): diff --git a/homeassistant/components/xiaomi_aqara/translations/en.json b/homeassistant/components/xiaomi_aqara/translations/en.json new file mode 100644 index 00000000000..fae0f56f82b --- /dev/null +++ b/homeassistant/components/xiaomi_aqara/translations/en.json @@ -0,0 +1,40 @@ +{ + "config": { + "flow_title": "Xiaomi Aqara Gateway: {name}", + "step": { + "user": { + "title": "Xiaomi Aqara Gateway", + "description": "Connect to your Xiaomi Aqara Gateway", + "data": { + "interface": "The network interface to use" + } + }, + "settings": { + "title": "Xiaomi Aqara Gateway, optional settings", + "description": "The key (password) can be retrieved using this tutorial: https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)#Adding_the_Xiaomi_Gateway_to_Domoticz. If the key is not provided only sensors will be accessible", + "data": { + "key": "The key of your gateway", + "name": "Name of the Gateway" + } + }, + "select": { + "title": "Select the Xiaomi Aqara Gateway that you wish to connect", + "description": "Run the setup again if you want to connect aditional gateways", + "data": { + "select_ip": "Gateway IP" + } + } + }, + "error": { + "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant as interface", + "not_found_error": "Zeroconf discovered Gateway could not be located to get the necessary information, try using the IP of the device running HomeAssistant as interface", + "invalid_interface": "Invalid network interface", + "invalid_key": "Invalid gateway key" + }, + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Config flow for this gateway is already in progress", + "not_xiaomi_aqara": "Not a Xiaomi Aqara Gateway, discovered device did not match known gateways" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1b40ec9e5b1..29f2883cf19 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -176,6 +176,7 @@ FLOWS = [ "wiffi", "withings", "wled", + "xiaomi_aqara", "xiaomi_miio", "zerproc", "zha", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 45e5ad12e04..a4bd268199f 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -38,6 +38,7 @@ ZEROCONF = { "ipp" ], "_miio._udp.local.": [ + "xiaomi_aqara", "xiaomi_miio" ], "_nut._tcp.local.": [ diff --git a/requirements_test_all.txt b/requirements_test_all.txt index adaadc4ef68..5ad6bb4f429 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -29,6 +29,9 @@ PyTransportNSW==0.1.1 # homeassistant.components.homekit PyTurboJPEG==1.4.0 +# homeassistant.components.xiaomi_aqara +PyXiaomiGateway==0.12.4 + # homeassistant.components.remember_the_milk RtmAPI==0.7.2 diff --git a/tests/components/xiaomi_aqara/__init__.py b/tests/components/xiaomi_aqara/__init__.py new file mode 100644 index 00000000000..c8f1dbe6a13 --- /dev/null +++ b/tests/components/xiaomi_aqara/__init__.py @@ -0,0 +1 @@ +"""Tests for the Xiaomi Aqara integration.""" diff --git a/tests/components/xiaomi_aqara/test_config_flow.py b/tests/components/xiaomi_aqara/test_config_flow.py new file mode 100644 index 00000000000..b7762317fdf --- /dev/null +++ b/tests/components/xiaomi_aqara/test_config_flow.py @@ -0,0 +1,368 @@ +"""Test the Xiaomi Aqara config flow.""" +from socket import gaierror + +import pytest + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.components.xiaomi_aqara import config_flow, const +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT + +from tests.async_mock import Mock, patch + +ZEROCONF_NAME = "name" +ZEROCONF_PROP = "properties" +ZEROCONF_MAC = "mac" + +TEST_HOST = "1.2.3.4" +TEST_HOST_2 = "5.6.7.8" +TEST_KEY = "1234567890123456" +TEST_PORT = 1234 +TEST_NAME = "Test_Aqara_Gateway" +TEST_SID = "abcdefghijkl" +TEST_PROTOCOL = "1.1.1" +TEST_MAC = "ab:cd:ef:gh:ij:kl" +TEST_GATEWAY_ID = TEST_MAC +TEST_ZEROCONF_NAME = "lumi-gateway-v3_miio12345678._miio._udp.local." + + +@pytest.fixture(name="xiaomi_aqara", autouse=True) +def xiaomi_aqara_fixture(): + """Mock xiaomi_aqara discovery and entry setup.""" + mock_gateway_discovery = get_mock_discovery([TEST_HOST]) + + with patch( + "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGatewayDiscovery", + return_value=mock_gateway_discovery, + ), patch( + "homeassistant.components.xiaomi_aqara.async_setup_entry", return_value=True + ): + yield + + +def get_mock_discovery(host_list, invalid_interface=False, invalid_key=False): + """Return a mock gateway info instance.""" + gateway_discovery = Mock() + + gateway_dict = {} + for host in host_list: + gateway = Mock() + + gateway.ip_adress = host + gateway.port = TEST_PORT + gateway.sid = TEST_SID + gateway.proto = TEST_PROTOCOL + + if invalid_key: + gateway.write_to_hub = Mock(return_value=False) + + gateway_dict[host] = gateway + + gateway_discovery.gateways = gateway_dict + + if invalid_interface: + gateway_discovery.discover_gateways = Mock(side_effect=gaierror) + + return gateway_discovery + + +async def test_config_flow_user_success(hass): + """Test a successful config flow initialized by the user.""" + 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"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "settings" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {const.CONF_KEY: TEST_KEY, CONF_NAME: TEST_NAME}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_MAC: TEST_MAC, + const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, + const.CONF_PROTOCOL: TEST_PROTOCOL, + const.CONF_KEY: TEST_KEY, + const.CONF_SID: TEST_SID, + } + + +async def test_config_flow_user_multiple_success(hass): + """Test a successful config flow initialized by the user with multiple gateways discoverd.""" + 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"] == {} + + mock_gateway_discovery = get_mock_discovery([TEST_HOST, TEST_HOST_2]) + + with patch( + "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGatewayDiscovery", + return_value=mock_gateway_discovery, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "select" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"select_ip": TEST_HOST_2}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "settings" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {const.CONF_KEY: TEST_KEY, CONF_NAME: TEST_NAME}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST_2, + CONF_PORT: TEST_PORT, + CONF_MAC: TEST_MAC, + const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, + const.CONF_PROTOCOL: TEST_PROTOCOL, + const.CONF_KEY: TEST_KEY, + const.CONF_SID: TEST_SID, + } + + +async def test_config_flow_user_no_key_success(hass): + """Test a successful config flow initialized by the user without a key.""" + 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"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "settings" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_NAME: TEST_NAME}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_MAC: TEST_MAC, + const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, + const.CONF_PROTOCOL: TEST_PROTOCOL, + const.CONF_KEY: None, + const.CONF_SID: TEST_SID, + } + + +async def test_config_flow_user_discovery_error(hass): + """Test a failed config flow initialized by the user with no gateways discoverd.""" + 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"] == {} + + mock_gateway_discovery = get_mock_discovery([]) + + with patch( + "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGatewayDiscovery", + return_value=mock_gateway_discovery, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "discovery_error"} + + +async def test_config_flow_user_invalid_interface(hass): + """Test a failed config flow initialized by the user with an invalid interface.""" + 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"] == {} + + mock_gateway_discovery = get_mock_discovery([], invalid_interface=True) + + with patch( + "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGatewayDiscovery", + return_value=mock_gateway_discovery, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {const.CONF_INTERFACE: "invalid_interface"} + + +async def test_config_flow_user_invalid_key(hass): + """Test a failed config flow initialized by the user with an invalid key.""" + 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"] == {} + + mock_gateway_discovery = get_mock_discovery([TEST_HOST], invalid_key=True) + + with patch( + "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGatewayDiscovery", + return_value=mock_gateway_discovery, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "settings" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {const.CONF_KEY: TEST_KEY, CONF_NAME: TEST_NAME}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "settings" + assert result["errors"] == {const.CONF_KEY: "invalid_key"} + + +async def test_zeroconf_success(hass): + """Test a successful zeroconf discovery of a xiaomi aqara 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"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "settings" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {const.CONF_KEY: TEST_KEY, CONF_NAME: TEST_NAME}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_MAC: TEST_MAC, + const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, + const.CONF_PROTOCOL: TEST_PROTOCOL, + const.CONF_KEY: TEST_KEY, + const.CONF_SID: TEST_SID, + } + + +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_aqara" + + +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-aqara-gateway", + ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC}, + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "not_xiaomi_aqara" + + +async def test_zeroconf_not_found_error(hass): + """Test a failed zeroconf discovery because the correct gateway could not be found.""" + 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"] == "user" + assert result["errors"] == {} + + mock_gateway_discovery = get_mock_discovery([TEST_HOST_2]) + + with patch( + "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGatewayDiscovery", + return_value=mock_gateway_discovery, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "not_found_error"}