Add Xiaomi Aqara Config Flow (#35595)

* Xiaomi Aqara Config Flow

* Xiaomi Aqara Config Flow

* Xiaomi Aqara Config Flow

* Xiaomi Aqara Config Flow

* Xiaomi Aqara Config Flow

First tested and working version

* Remove depricated discovery

* Add Xiaomi Aqara Config Flow

* Add Xiaomi Aqara tests

* Update .coveragerc

* Update requirements_test_all.txt

* fix spelling mistake

* fix select scheme

* fix wrong conflict resolve

* add IP to zeroconf discovery title

* black styling

* add getmac requirement

Co-authored-by: Maciej Bieniek <bieniu@users.noreply.github.com>

* add getmac

* add getmac

* Clean up

* Update homeassistant/components/xiaomi_aqara/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/xiaomi_aqara/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/xiaomi_aqara/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/xiaomi_aqara/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/xiaomi_aqara/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* resolve data storage

* move format_mac down

* Remove discovery_retry from config flow

* remove unused strings

* fix styling

* fix black styling

* fix tests

* remove mac connection

This is needed to prevent a conflict with the Xiaomi Miio integration that I discovered during testing.

* fix flake8

* remove getmac depandance

* check for inavlid_interface + test

* Validate gateway key

* add invalid key tests

* Fix spelling

* Only set up sensors if no key

Co-authored-by: Maciej Bieniek <bieniu@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
starkillerOG 2020-06-22 11:54:17 +02:00 committed by GitHub
parent 6aba87f3a6
commit 1f9721bad3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1128 additions and 341 deletions

View file

@ -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

View file

@ -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 = (

View file

@ -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})

View file

@ -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):

View file

@ -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
)

View file

@ -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

View file

@ -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):

View file

@ -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):

View file

@ -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:

View file

@ -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."]
}

View file

@ -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):

View file

@ -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"
}
}
}

View file

@ -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):

View file

@ -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"
}
}
}

View file

@ -176,6 +176,7 @@ FLOWS = [
"wiffi",
"withings",
"wled",
"xiaomi_aqara",
"xiaomi_miio",
"zerproc",
"zha",

View file

@ -38,6 +38,7 @@ ZEROCONF = {
"ipp"
],
"_miio._udp.local.": [
"xiaomi_aqara",
"xiaomi_miio"
],
"_nut._tcp.local.": [

View file

@ -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

View file

@ -0,0 +1 @@
"""Tests for the Xiaomi Aqara integration."""

View file

@ -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"}