1
0
mirror of https://github.com/home-assistant/core.git synced 2026-06-02 05:34:15 +01:00
Files
core/homeassistant/components/wemo/__init__.py
T

346 lines
12 KiB
Python

"""Support for WeMo device discovery."""
from collections.abc import Callable, Coroutine, Sequence
from datetime import datetime
import logging
from typing import Any
import pywemo
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DISCOVERY, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.async_ import gather_with_limited_concurrency
from .const import DOMAIN
from .coordinator import DeviceCoordinator, async_register_device
from .models import DATA_WEMO, WemoConfigEntryData, WemoData
# Max number of devices to initialize at once. This limit is in place to
# avoid tying up too many executor threads with WeMo device setup.
MAX_CONCURRENCY = 3
# Mapping from Wemo model_name to domain.
WEMO_MODEL_DISPATCH = {
"Bridge": [Platform.LIGHT],
"CoffeeMaker": [Platform.SWITCH],
"Dimmer": [Platform.LIGHT],
"Humidifier": [Platform.FAN],
"Insight": [Platform.BINARY_SENSOR, Platform.SWITCH],
"LightSwitch": [Platform.SWITCH],
"Maker": [Platform.BINARY_SENSOR, Platform.SWITCH],
"Motion": [Platform.BINARY_SENSOR],
"OutdoorPlug": [Platform.SWITCH],
"Sensor": [Platform.BINARY_SENSOR],
"Socket": [Platform.SWITCH],
}
_LOGGER = logging.getLogger(__name__)
type DispatchCallback = Callable[[DeviceCoordinator], Coroutine[Any, Any, None]]
type HostPortTuple = tuple[str, int | None]
def coerce_host_port(value: str) -> HostPortTuple:
"""Validate that provided value is either just host or host:port.
Returns (host, None) or (host, port) respectively.
"""
host, _, port_str = value.partition(":")
if not host:
raise vol.Invalid("host cannot be empty")
port = cv.port(port_str) if port_str else None
return host, port
CONF_STATIC = "static"
DEFAULT_DISCOVERY = True
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Optional(CONF_STATIC, default=[]): vol.Schema(
[vol.All(cv.string, coerce_host_port)]
),
vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean,
}
)
},
extra=vol.ALLOW_EXTRA,
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up for WeMo devices."""
# Keep track of WeMo device subscriptions for push updates
registry = pywemo.SubscriptionRegistry()
await hass.async_add_executor_job(registry.start)
# Respond to discovery requests from WeMo devices.
discovery_responder = pywemo.ssdp.DiscoveryResponder(registry.port)
await hass.async_add_executor_job(discovery_responder.start)
def _on_hass_stop(_: Event) -> None:
discovery_responder.stop()
registry.stop()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_hass_stop)
yaml_config = config.get(DOMAIN, {})
hass.data[DATA_WEMO] = WemoData(
discovery_enabled=yaml_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY),
static_config=yaml_config.get(CONF_STATIC, []),
registry=registry,
)
if DOMAIN in config:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a wemo config entry."""
wemo_data = hass.data[DATA_WEMO]
dispatcher = WemoDispatcher(entry)
discovery = WemoDiscovery(hass, dispatcher, wemo_data.static_config, entry)
wemo_data.config_entry_data = WemoConfigEntryData(
device_coordinators={},
discovery=discovery,
dispatcher=dispatcher,
)
# Need to do this at least once in case statistics
# are defined and discovery is disabled
await discovery.discover_statics()
if wemo_data.discovery_enabled:
await discovery.async_discover_and_schedule()
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a wemo config entry."""
_LOGGER.debug("Unloading WeMo")
wemo_data = hass.data[DATA_WEMO]
wemo_data.config_entry_data.discovery.async_stop_discovery()
dispatcher = wemo_data.config_entry_data.dispatcher
if unload_ok := await dispatcher.async_unload_platforms(hass):
for coordinator in list(
wemo_data.config_entry_data.device_coordinators.values()
):
await coordinator.async_shutdown()
assert not wemo_data.config_entry_data.device_coordinators
wemo_data.config_entry_data = None # type: ignore[assignment]
return unload_ok
async def async_wemo_dispatcher_connect(
hass: HomeAssistant,
dispatch: DispatchCallback,
) -> None:
"""Connect a wemo platform with the WemoDispatcher."""
module = dispatch.__module__ # Example: "homeassistant.components.wemo.switch"
platform = Platform(module.rsplit(".", 1)[1])
dispatcher = hass.data[DATA_WEMO].config_entry_data.dispatcher
await dispatcher.async_connect_platform(platform, dispatch)
class WemoDispatcher:
"""Dispatch WeMo devices to the correct platform."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize the WemoDispatcher."""
self._config_entry = config_entry
self._added_serial_numbers: set[str] = set()
self._failed_serial_numbers: set[str] = set()
self._dispatch_backlog: dict[Platform, list[DeviceCoordinator]] = {}
self._dispatch_callbacks: dict[Platform, DispatchCallback] = {}
async def async_add_unique_device(
self, hass: HomeAssistant, wemo: pywemo.WeMoDevice
) -> None:
"""Add a WeMo device to hass if it has not already been added."""
if wemo.serial_number in self._added_serial_numbers:
return
try:
coordinator = await async_register_device(hass, self._config_entry, wemo)
except pywemo.PyWeMoException as err:
if wemo.serial_number not in self._failed_serial_numbers:
self._failed_serial_numbers.add(wemo.serial_number)
_LOGGER.error(
"Unable to add WeMo %s %s: %s", repr(wemo), wemo.host, err
)
return
platforms = set(WEMO_MODEL_DISPATCH.get(wemo.model_name, [Platform.SWITCH]))
platforms.add(Platform.SENSOR)
platforms_to_load: list[Platform] = []
for platform in platforms:
# Three cases:
# - Platform is loaded, dispatch discovery
# - Platform is being loaded, add to backlog
# - First time we see platform, we need to load
# it and initialize the backlog
if platform in self._dispatch_callbacks:
await self._dispatch_callbacks[platform](coordinator)
elif platform in self._dispatch_backlog:
self._dispatch_backlog[platform].append(coordinator)
else:
self._dispatch_backlog[platform] = [coordinator]
platforms_to_load.append(platform)
self._added_serial_numbers.add(wemo.serial_number)
self._failed_serial_numbers.discard(wemo.serial_number)
if platforms_to_load:
await hass.config_entries.async_forward_entry_setups(
self._config_entry, platforms_to_load
)
async def async_connect_platform(
self, platform: Platform, dispatch: DispatchCallback
) -> None:
"""Mark platform loaded and dispatch backlog of discovered devices."""
self._dispatch_callbacks[platform] = dispatch
await gather_with_limited_concurrency(
MAX_CONCURRENCY,
*(
dispatch(coordinator)
for coordinator in self._dispatch_backlog.pop(platform)
),
)
async def async_unload_platforms(self, hass: HomeAssistant) -> bool:
"""Forward the unloading of an entry to platforms."""
platforms: set[Platform] = set(self._dispatch_backlog.keys())
platforms.update(self._dispatch_callbacks.keys())
return await hass.config_entries.async_unload_platforms(
self._config_entry, platforms
)
class WemoDiscovery:
"""Use SSDP to discover WeMo devices."""
ADDITIONAL_SECONDS_BETWEEN_SCANS = 10
MAX_SECONDS_BETWEEN_SCANS = 300
def __init__(
self,
hass: HomeAssistant,
wemo_dispatcher: WemoDispatcher,
static_config: Sequence[HostPortTuple],
entry: ConfigEntry,
) -> None:
"""Initialize the WemoDiscovery."""
self._hass = hass
self._wemo_dispatcher = wemo_dispatcher
self._stop: CALLBACK_TYPE | None = None
self._scan_delay = 0
self._static_config = static_config
self._discover_job: HassJob[[datetime], None] | None = None
self._entry = entry
async def async_discover_and_schedule(
self, event_time: datetime | None = None
) -> None:
"""Periodically scan the network looking for WeMo devices."""
_LOGGER.debug("Scanning network for WeMo devices")
try:
for device in await self._hass.async_add_executor_job(
pywemo.discover_devices
):
await self._wemo_dispatcher.async_add_unique_device(self._hass, device)
await self.discover_statics()
finally:
# Run discovery more frequently after hass has just started.
self._scan_delay = min(
self._scan_delay + self.ADDITIONAL_SECONDS_BETWEEN_SCANS,
self.MAX_SECONDS_BETWEEN_SCANS,
)
if not self._discover_job:
self._discover_job = HassJob(self._async_discover_and_schedule_callback)
self._stop = async_call_later(
self._hass,
self._scan_delay,
self._discover_job,
)
@callback
def _async_discover_and_schedule_callback(self, event_time: datetime) -> None:
"""Run the periodic background scanning."""
self._entry.async_create_background_task(
self._hass,
self.async_discover_and_schedule(),
name="wemo_discovery",
eager_start=True,
)
@callback
def async_stop_discovery(self) -> None:
"""Stop the periodic background scanning."""
if self._stop:
self._stop()
self._stop = None
async def discover_statics(self) -> None:
"""Initialize or Re-Initialize connections to statically configured devices."""
if not self._static_config:
return
_LOGGER.debug("Adding statically configured WeMo devices")
for device in await gather_with_limited_concurrency(
MAX_CONCURRENCY,
*(
self._hass.async_add_executor_job(validate_static_config, host, port)
for host, port in self._static_config
),
):
if device:
await self._wemo_dispatcher.async_add_unique_device(self._hass, device)
def validate_static_config(host: str, port: int | None) -> pywemo.WeMoDevice | None:
"""Handle a static config."""
url = pywemo.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)
except (
pywemo.exceptions.ActionException,
pywemo.exceptions.HTTPException,
) as err:
_LOGGER.error("Unable to access WeMo at %s (%s)", url, err)
return None
return device