Improve Wemo config entry support, add device info (#30963)

* Improve config entry support, add device info

* async_dispatch_connect

* Fix I/O in event loop

* Do not raise PlatformNotReady inside dispatcher

* Make main discovery process async

* Do discovery as part of set up.

* Greatly simplify set up

* Add parallel updates to fan&switch

* mini cleanup

* Address comments
This commit is contained in:
Paulus Schoutsen 2020-01-19 12:56:31 -08:00 committed by GitHub
parent 90e811df20
commit f14d34560e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 206 additions and 189 deletions

View file

@ -1,4 +1,5 @@
"""Support for WeMo device discovery.""" """Support for WeMo device discovery."""
import asyncio
import logging import logging
import pywemo import pywemo
@ -6,9 +7,9 @@ import requests
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.discovery import SERVICE_WEMO from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DOMAIN from .const import DOMAIN
@ -26,9 +27,6 @@ WEMO_MODEL_DISPATCH = {
"Socket": "switch", "Socket": "switch",
} }
SUBSCRIPTION_REGISTRY = None
KNOWN_DEVICES = []
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -70,9 +68,13 @@ CONFIG_SCHEMA = vol.Schema(
) )
def setup(hass, config): async def async_setup(hass, config):
"""Set up for WeMo devices.""" """Set up for WeMo devices."""
hass.data[DOMAIN] = config hass.data[DOMAIN] = {
"config": config.get(DOMAIN, {}),
"registry": None,
"pending": {},
}
if DOMAIN in config: if DOMAIN in config:
hass.async_create_task( hass.async_create_task(
@ -86,106 +88,103 @@ def setup(hass, config):
async def async_setup_entry(hass, entry): async def async_setup_entry(hass, entry):
"""Set up a wemo config entry.""" """Set up a wemo config entry."""
config = hass.data[DOMAIN].pop("config")
config = hass.data[DOMAIN]
# Keep track of WeMo devices
devices = []
# Keep track of WeMo device subscriptions for push updates # Keep track of WeMo device subscriptions for push updates
global SUBSCRIPTION_REGISTRY registry = hass.data[DOMAIN]["registry"] = pywemo.SubscriptionRegistry()
SUBSCRIPTION_REGISTRY = pywemo.SubscriptionRegistry() await hass.async_add_executor_job(registry.start)
await hass.async_add_executor_job(SUBSCRIPTION_REGISTRY.start)
def stop_wemo(event): def stop_wemo(event):
"""Shutdown Wemo subscriptions and subscription thread on exit.""" """Shutdown Wemo subscriptions and subscription thread on exit."""
_LOGGER.debug("Shutting down WeMo event subscriptions") _LOGGER.debug("Shutting down WeMo event subscriptions")
SUBSCRIPTION_REGISTRY.stop() registry.stop()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_wemo) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_wemo)
def setup_url_for_device(device): devices = {}
"""Determine setup.xml url for given device."""
return f"http://{device.host}:{device.port}/setup.xml"
def setup_url_for_address(host, port): static_conf = config.get(CONF_STATIC, [])
"""Determine setup.xml url for given host and port pair.""" if static_conf:
if not port:
port = pywemo.ouimeaux_device.probe_wemo(host)
if not port:
return None
return f"http://{host}:{port}/setup.xml"
def discovery_dispatch(service, discovery_info):
"""Dispatcher for incoming WeMo discovery events."""
# name, model, location, mac
model_name = discovery_info.get("model_name")
serial = discovery_info.get("serial")
# Only register a device once
if serial in KNOWN_DEVICES:
_LOGGER.debug("Ignoring known device %s %s", service, discovery_info)
return
_LOGGER.debug("Discovered unique WeMo device: %s", serial)
KNOWN_DEVICES.append(serial)
component = WEMO_MODEL_DISPATCH.get(model_name, "switch")
discovery.load_platform(hass, component, DOMAIN, discovery_info, config)
discovery.async_listen(hass, SERVICE_WEMO, discovery_dispatch)
def discover_wemo_devices(now):
"""Run discovery for WeMo devices."""
_LOGGER.debug("Beginning WeMo device discovery...")
_LOGGER.debug("Adding statically configured WeMo devices...") _LOGGER.debug("Adding statically configured WeMo devices...")
for host, port in config.get(DOMAIN, {}).get(CONF_STATIC, []): for device in await asyncio.gather(
url = setup_url_for_address(host, port) *[
hass.async_add_executor_job(validate_static_config, host, port)
if not url: for host, port in static_conf
_LOGGER.error( ]
"Unable to get description url for WeMo at: %s", ):
f"{host}:{port}" if port else host, if device is None:
)
continue continue
try: devices.setdefault(device.serialnumber, device)
device = pywemo.discovery.device_from_description(url, None)
except (
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
) as err:
_LOGGER.error("Unable to access WeMo at %s (%s)", url, err)
continue
if not [d[1] for d in devices if d[1].serialnumber == device.serialnumber]: if config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY):
devices.append((url, device)) _LOGGER.debug("Scanning network for WeMo devices...")
for device in await hass.async_add_executor_job(pywemo.discover_devices):
devices.setdefault(
device.serialnumber, device,
)
if config.get(DOMAIN, {}).get(CONF_DISCOVERY, DEFAULT_DISCOVERY): loaded_components = set()
_LOGGER.debug("Scanning network for WeMo devices...")
for device in pywemo.discover_devices():
if not [
d[1] for d in devices if d[1].serialnumber == device.serialnumber
]:
devices.append((setup_url_for_device(device), device))
for url, device in devices: for device in devices.values():
_LOGGER.debug("Adding WeMo device at %s:%i", device.host, device.port) _LOGGER.debug(
"Adding WeMo device at %s:%i (%s)",
device.host,
device.port,
device.serialnumber,
)
discovery_info = { component = WEMO_MODEL_DISPATCH.get(device.model_name, "switch")
"model_name": device.model_name,
"serial": device.serialnumber,
"mac_address": device.mac,
"ssdp_description": url,
}
discovery_dispatch(SERVICE_WEMO, discovery_info) # Three cases:
# - First time we see component, we need to load it and initialize the backlog
# - Component is being loaded, add to backlog
# - Component is loaded, backlog is gone, dispatch discovery
_LOGGER.debug("WeMo device discovery has finished") if component not in loaded_components:
hass.data[DOMAIN]["pending"][component] = [device]
loaded_components.add(component)
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, discover_wemo_devices) elif component in hass.data[DOMAIN]["pending"]:
hass.data[DOMAIN]["pending"].append(device)
else:
async_dispatcher_send(
hass, f"{DOMAIN}.{component}", device,
)
return True return True
def validate_static_config(host, port):
"""Handle a static config."""
url = setup_url_for_address(host, port)
if not url:
_LOGGER.error(
"Unable to get description url for WeMo at: %s",
f"{host}:{port}" if port else host,
)
return None
try:
device = pywemo.discovery.device_from_description(url, None)
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout,) as err:
_LOGGER.error("Unable to access WeMo at %s (%s)", url, err)
return None
return device
def setup_url_for_address(host, port):
"""Determine setup.xml url for given host and port pair."""
if not port:
port = pywemo.ouimeaux_device.probe_wemo(host)
if not port:
return None
return f"http://{host}:{port}/setup.xml"

View file

@ -3,41 +3,36 @@ import asyncio
import logging import logging
import async_timeout import async_timeout
from pywemo import discovery
import requests
from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.binary_sensor import BinarySensorDevice
from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import SUBSCRIPTION_REGISTRY from .const import DOMAIN as WEMO_DOMAIN
_LOGGER = logging.getLogger(__name__) _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):
"""Register discovered WeMo binary sensors.""" """Set up WeMo binary sensors."""
if discovery_info is not None: async def _discovered_wemo(device):
location = discovery_info["ssdp_description"] """Handle a discovered Wemo device."""
mac = discovery_info["mac_address"] async_add_entities([WemoBinarySensor(device)])
try: async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.binary_sensor", _discovered_wemo)
device = discovery.device_from_description(location, mac)
except (
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
) as err:
_LOGGER.error("Unable to access %s (%s)", location, err)
raise PlatformNotReady
if device: await asyncio.gather(
add_entities([WemoBinarySensor(hass, device)]) *[
_discovered_wemo(device)
for device in hass.data[WEMO_DOMAIN]["pending"].pop("binary_sensor")
]
)
class WemoBinarySensor(BinarySensorDevice): class WemoBinarySensor(BinarySensorDevice):
"""Representation a WeMo binary sensor.""" """Representation a WeMo binary sensor."""
def __init__(self, hass, device): def __init__(self, device):
"""Initialize the WeMo sensor.""" """Initialize the WeMo sensor."""
self.wemo = device self.wemo = device
self._state = None self._state = None
@ -67,7 +62,7 @@ class WemoBinarySensor(BinarySensorDevice):
# Define inside async context so we know our event loop # Define inside async context so we know our event loop
self._update_lock = asyncio.Lock() self._update_lock = asyncio.Lock()
registry = SUBSCRIPTION_REGISTRY registry = self.hass.data[WEMO_DOMAIN]["registry"]
await self.hass.async_add_executor_job(registry.register, self.wemo) await self.hass.async_add_executor_job(registry.register, self.wemo)
registry.on(self.wemo, None, self._subscription_callback) registry.on(self.wemo, None, self._subscription_callback)
@ -126,3 +121,13 @@ class WemoBinarySensor(BinarySensorDevice):
def available(self): def available(self):
"""Return true if sensor is available.""" """Return true if sensor is available."""
return self._available return self._available
@property
def device_info(self):
"""Return the device info."""
return {
"name": self.wemo.name,
"identifiers": {(WEMO_DOMAIN, self.wemo.serialnumber)},
"model": self.wemo.model_name,
"manufacturer": "Belkin",
}

View file

@ -4,8 +4,6 @@ from datetime import timedelta
import logging import logging
import async_timeout import async_timeout
from pywemo import discovery
import requests
import voluptuous as vol import voluptuous as vol
from homeassistant.components.fan import ( from homeassistant.components.fan import (
@ -17,14 +15,17 @@ from homeassistant.components.fan import (
FanEntity, FanEntity,
) )
from homeassistant.const import ATTR_ENTITY_ID from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import SUBSCRIPTION_REGISTRY from .const import (
from .const import DOMAIN, SERVICE_RESET_FILTER_LIFE, SERVICE_SET_HUMIDITY DOMAIN as WEMO_DOMAIN,
SERVICE_RESET_FILTER_LIFE,
SERVICE_SET_HUMIDITY,
)
SCAN_INTERVAL = timedelta(seconds=10) SCAN_INTERVAL = timedelta(seconds=10)
DATA_KEY = "fan.wemo" PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -91,36 +92,30 @@ SET_HUMIDITY_SCHEMA = vol.Schema(
RESET_FILTER_LIFE_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.entity_ids}) RESET_FILTER_LIFE_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.entity_ids})
def setup_platform(hass, config, add_entities, discovery_info=None): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up discovered WeMo humidifiers.""" """Set up WeMo binary sensors."""
entities = []
if DATA_KEY not in hass.data: async def _discovered_wemo(device):
hass.data[DATA_KEY] = {} """Handle a discovered Wemo device."""
entity = WemoHumidifier(device)
entities.append(entity)
async_add_entities([entity])
if discovery_info is None: async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.fan", _discovered_wemo)
return
location = discovery_info["ssdp_description"] await asyncio.gather(
mac = discovery_info["mac_address"] *[
_discovered_wemo(device)
try: for device in hass.data[WEMO_DOMAIN]["pending"].pop("fan")
device = WemoHumidifier(discovery.device_from_description(location, mac)) ]
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as err: )
_LOGGER.error("Unable to access %s (%s)", location, err)
raise PlatformNotReady
hass.data[DATA_KEY][device.entity_id] = device
add_entities([device])
def service_handle(service): def service_handle(service):
"""Handle the WeMo humidifier services.""" """Handle the WeMo humidifier services."""
entity_ids = service.data.get(ATTR_ENTITY_ID) entity_ids = service.data.get(ATTR_ENTITY_ID)
humidifiers = [ humidifiers = [entity for entity in entities if entity.entity_id in entity_ids]
device
for device in hass.data[DATA_KEY].values()
if device.entity_id in entity_ids
]
if service.service == SERVICE_SET_HUMIDITY: if service.service == SERVICE_SET_HUMIDITY:
target_humidity = service.data.get(ATTR_TARGET_HUMIDITY) target_humidity = service.data.get(ATTR_TARGET_HUMIDITY)
@ -132,12 +127,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
humidifier.reset_filter_life() humidifier.reset_filter_life()
# Register service(s) # Register service(s)
hass.services.register( hass.services.async_register(
DOMAIN, SERVICE_SET_HUMIDITY, service_handle, schema=SET_HUMIDITY_SCHEMA WEMO_DOMAIN, SERVICE_SET_HUMIDITY, service_handle, schema=SET_HUMIDITY_SCHEMA
) )
hass.services.register( hass.services.async_register(
DOMAIN, WEMO_DOMAIN,
SERVICE_RESET_FILTER_LIFE, SERVICE_RESET_FILTER_LIFE,
service_handle, service_handle,
schema=RESET_FILTER_LIFE_SCHEMA, schema=RESET_FILTER_LIFE_SCHEMA,
@ -199,6 +194,16 @@ class WemoHumidifier(FanEntity):
"""Return true if switch is available.""" """Return true if switch is available."""
return self._available return self._available
@property
def device_info(self):
"""Return the device info."""
return {
"name": self.wemo.name,
"identifiers": {(WEMO_DOMAIN, self.wemo.serialnumber)},
"model": self.wemo.model_name,
"manufacturer": "Belkin",
}
@property @property
def icon(self): def icon(self):
"""Return the icon of device based on its type.""" """Return the icon of device based on its type."""
@ -236,7 +241,7 @@ class WemoHumidifier(FanEntity):
# Define inside async context so we know our event loop # Define inside async context so we know our event loop
self._update_lock = asyncio.Lock() self._update_lock = asyncio.Lock()
registry = SUBSCRIPTION_REGISTRY registry = self.hass.data[WEMO_DOMAIN]["registry"]
await self.hass.async_add_executor_job(registry.register, self.wemo) await self.hass.async_add_executor_job(registry.register, self.wemo)
registry.on(self.wemo, None, self._subscription_callback) registry.on(self.wemo, None, self._subscription_callback)

View file

@ -4,8 +4,6 @@ from datetime import timedelta
import logging import logging
import async_timeout import async_timeout
from pywemo import discovery
import requests
from homeassistant import util from homeassistant import util
from homeassistant.components.light import ( from homeassistant.components.light import (
@ -19,10 +17,10 @@ from homeassistant.components.light import (
SUPPORT_TRANSITION, SUPPORT_TRANSITION,
Light, Light,
) )
from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect
import homeassistant.util.color as color_util import homeassistant.util.color as color_util
from . import SUBSCRIPTION_REGISTRY from .const import DOMAIN as WEMO_DOMAIN
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100)
@ -34,29 +32,29 @@ SUPPORT_WEMO = (
) )
def setup_platform(hass, config, add_entities, discovery_info=None): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up discovered WeMo switches.""" """Set up WeMo lights."""
if discovery_info is not None:
location = discovery_info["ssdp_description"]
mac = discovery_info["mac_address"]
try:
device = discovery.device_from_description(location, mac)
except (
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
) as err:
_LOGGER.error("Unable to access %s (%s)", location, err)
raise PlatformNotReady
async def _discovered_wemo(device):
"""Handle a discovered Wemo device."""
if device.model_name == "Dimmer": if device.model_name == "Dimmer":
add_entities([WemoDimmer(device)]) async_add_entities([WemoDimmer(device)])
else: else:
setup_bridge(device, add_entities) await hass.async_add_executor_job(
setup_bridge, hass, device, async_add_entities
)
async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.light", _discovered_wemo)
await asyncio.gather(
*[
_discovered_wemo(device)
for device in hass.data[WEMO_DOMAIN]["pending"].pop("light")
]
)
def setup_bridge(bridge, add_entities): def setup_bridge(hass, bridge, async_add_entities):
"""Set up a WeMo link.""" """Set up a WeMo link."""
lights = {} lights = {}
@ -73,7 +71,7 @@ def setup_bridge(bridge, add_entities):
new_lights.append(lights[light_id]) new_lights.append(lights[light_id])
if new_lights: if new_lights:
add_entities(new_lights) hass.add_job(async_add_entities, new_lights)
update_lights() update_lights()
@ -110,6 +108,16 @@ class WemoLight(Light):
"""Return the name of the light.""" """Return the name of the light."""
return self._name return self._name
@property
def device_info(self):
"""Return the device info."""
return {
"name": self.wemo.name,
"identifiers": {(WEMO_DOMAIN, self.wemo.serialnumber)},
"model": self.wemo.model_name,
"manufacturer": "Belkin",
}
@property @property
def brightness(self): def brightness(self):
"""Return the brightness of this light between 0..255.""" """Return the brightness of this light between 0..255."""
@ -235,7 +243,7 @@ class WemoDimmer(Light):
# Define inside async context so we know our event loop # Define inside async context so we know our event loop
self._update_lock = asyncio.Lock() self._update_lock = asyncio.Lock()
registry = SUBSCRIPTION_REGISTRY registry = self.hass.data[WEMO_DOMAIN]["registry"]
await self.hass.async_add_executor_job(registry.register, self.wemo) await self.hass.async_add_executor_job(registry.register, self.wemo)
registry.on(self.wemo, None, self._subscription_callback) registry.on(self.wemo, None, self._subscription_callback)

View file

@ -4,18 +4,16 @@ from datetime import datetime, timedelta
import logging import logging
import async_timeout import async_timeout
from pywemo import discovery
import requests
from homeassistant.components.switch import SwitchDevice from homeassistant.components.switch import SwitchDevice
from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN
from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util import convert from homeassistant.util import convert
from . import SUBSCRIPTION_REGISTRY from .const import DOMAIN as WEMO_DOMAIN
from .const import DOMAIN
SCAN_INTERVAL = timedelta(seconds=10) SCAN_INTERVAL = timedelta(seconds=10)
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -32,24 +30,21 @@ WEMO_OFF = 0
WEMO_STANDBY = 8 WEMO_STANDBY = 8
def setup_platform(hass, config, add_entities, discovery_info=None): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up discovered WeMo switches.""" """Set up WeMo switches."""
if discovery_info is not None: async def _discovered_wemo(device):
location = discovery_info["ssdp_description"] """Handle a discovered Wemo device."""
mac = discovery_info["mac_address"] async_add_entities([WemoSwitch(device)])
try: async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.switch", _discovered_wemo)
device = discovery.device_from_description(location, mac)
except (
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
) as err:
_LOGGER.error("Unable to access %s (%s)", location, err)
raise PlatformNotReady
if device: await asyncio.gather(
add_entities([WemoSwitch(device)]) *[
_discovered_wemo(device)
for device in hass.data[WEMO_DOMAIN]["pending"].pop("switch")
]
)
class WemoSwitch(SwitchDevice): class WemoSwitch(SwitchDevice):
@ -97,7 +92,12 @@ class WemoSwitch(SwitchDevice):
@property @property
def device_info(self): def device_info(self):
"""Return the device info.""" """Return the device info."""
return {"name": self._name, "identifiers": {(DOMAIN, self._serialnumber)}} return {
"name": self.wemo.name,
"identifiers": {(WEMO_DOMAIN, self.wemo.serialnumber)},
"model": self.wemo.model_name,
"manufacturer": "Belkin",
}
@property @property
def device_state_attributes(self): def device_state_attributes(self):
@ -200,7 +200,7 @@ class WemoSwitch(SwitchDevice):
# Define inside async context so we know our event loop # Define inside async context so we know our event loop
self._update_lock = asyncio.Lock() self._update_lock = asyncio.Lock()
registry = SUBSCRIPTION_REGISTRY registry = self.hass.data[WEMO_DOMAIN]["registry"]
await self.hass.async_add_job(registry.register, self.wemo) await self.hass.async_add_job(registry.register, self.wemo)
registry.on(self.wemo, None, self._subscription_callback) registry.on(self.wemo, None, self._subscription_callback)