Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for WeMo device discovery."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable, Coroutine, Sequence
6 from datetime import datetime
7 import logging
8 from typing import Any
9 
10 import pywemo
11 import voluptuous as vol
12 
13 from homeassistant import config_entries
14 from homeassistant.config_entries import ConfigEntry
15 from homeassistant.const import CONF_DISCOVERY, EVENT_HOMEASSISTANT_STOP, Platform
16 from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback
17 from homeassistant.helpers import config_validation as cv
18 from homeassistant.helpers.event import async_call_later
19 from homeassistant.helpers.typing import ConfigType
20 from homeassistant.util.async_ import gather_with_limited_concurrency
21 
22 from .const import DOMAIN
23 from .coordinator import DeviceCoordinator, async_register_device
24 from .models import WemoConfigEntryData, WemoData, async_wemo_data
25 
26 # Max number of devices to initialize at once. This limit is in place to
27 # avoid tying up too many executor threads with WeMo device setup.
28 MAX_CONCURRENCY = 3
29 
30 # Mapping from Wemo model_name to domain.
31 WEMO_MODEL_DISPATCH = {
32  "Bridge": [Platform.LIGHT],
33  "CoffeeMaker": [Platform.SWITCH],
34  "Dimmer": [Platform.LIGHT],
35  "Humidifier": [Platform.FAN],
36  "Insight": [Platform.BINARY_SENSOR, Platform.SWITCH],
37  "LightSwitch": [Platform.SWITCH],
38  "Maker": [Platform.BINARY_SENSOR, Platform.SWITCH],
39  "Motion": [Platform.BINARY_SENSOR],
40  "OutdoorPlug": [Platform.SWITCH],
41  "Sensor": [Platform.BINARY_SENSOR],
42  "Socket": [Platform.SWITCH],
43 }
44 
45 _LOGGER = logging.getLogger(__name__)
46 
47 type DispatchCallback = Callable[[DeviceCoordinator], Coroutine[Any, Any, None]]
48 type HostPortTuple = tuple[str, int | None]
49 
50 
51 def coerce_host_port(value: str) -> HostPortTuple:
52  """Validate that provided value is either just host or host:port.
53 
54  Returns (host, None) or (host, port) respectively.
55  """
56  host, _, port_str = value.partition(":")
57 
58  if not host:
59  raise vol.Invalid("host cannot be empty")
60 
61  port = cv.port(port_str) if port_str else None
62 
63  return host, port
64 
65 
66 CONF_STATIC = "static"
67 
68 DEFAULT_DISCOVERY = True
69 
70 CONFIG_SCHEMA = vol.Schema(
71  {
72  DOMAIN: vol.Schema(
73  {
74  vol.Optional(CONF_STATIC, default=[]): vol.Schema(
75  [vol.All(cv.string, coerce_host_port)]
76  ),
77  vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean,
78  }
79  )
80  },
81  extra=vol.ALLOW_EXTRA,
82 )
83 
84 
85 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
86  """Set up for WeMo devices."""
87  # Keep track of WeMo device subscriptions for push updates
88  registry = pywemo.SubscriptionRegistry()
89  await hass.async_add_executor_job(registry.start)
90 
91  # Respond to discovery requests from WeMo devices.
92  discovery_responder = pywemo.ssdp.DiscoveryResponder(registry.port)
93  await hass.async_add_executor_job(discovery_responder.start)
94 
95  def _on_hass_stop(_: Event) -> None:
96  discovery_responder.stop()
97  registry.stop()
98 
99  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_hass_stop)
100 
101  yaml_config = config.get(DOMAIN, {})
102  hass.data[DOMAIN] = WemoData(
103  discovery_enabled=yaml_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY),
104  static_config=yaml_config.get(CONF_STATIC, []),
105  registry=registry,
106  )
107 
108  if DOMAIN in config:
109  hass.async_create_task(
110  hass.config_entries.flow.async_init(
111  DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
112  )
113  )
114 
115  return True
116 
117 
118 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
119  """Set up a wemo config entry."""
120  wemo_data = async_wemo_data(hass)
121  dispatcher = WemoDispatcher(entry)
122  discovery = WemoDiscovery(hass, dispatcher, wemo_data.static_config, entry)
123  wemo_data.config_entry_data = WemoConfigEntryData(
124  device_coordinators={},
125  discovery=discovery,
126  dispatcher=dispatcher,
127  )
128 
129  # Need to do this at least once in case statistics are defined and discovery is disabled
130  await discovery.discover_statics()
131 
132  if wemo_data.discovery_enabled:
133  await discovery.async_discover_and_schedule()
134 
135  return True
136 
137 
138 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
139  """Unload a wemo config entry."""
140  _LOGGER.debug("Unloading WeMo")
141  wemo_data = async_wemo_data(hass)
142 
143  wemo_data.config_entry_data.discovery.async_stop_discovery()
144 
145  dispatcher = wemo_data.config_entry_data.dispatcher
146  if unload_ok := await dispatcher.async_unload_platforms(hass):
147  for coordinator in list(
148  wemo_data.config_entry_data.device_coordinators.values()
149  ):
150  await coordinator.async_shutdown()
151  assert not wemo_data.config_entry_data.device_coordinators
152  wemo_data.config_entry_data = None # type: ignore[assignment]
153  return unload_ok
154 
155 
157  hass: HomeAssistant,
158  dispatch: DispatchCallback,
159 ) -> None:
160  """Connect a wemo platform with the WemoDispatcher."""
161  module = dispatch.__module__ # Example: "homeassistant.components.wemo.switch"
162  platform = Platform(module.rsplit(".", 1)[1])
163 
164  dispatcher = async_wemo_data(hass).config_entry_data.dispatcher
165  await dispatcher.async_connect_platform(platform, dispatch)
166 
167 
169  """Dispatch WeMo devices to the correct platform."""
170 
171  def __init__(self, config_entry: ConfigEntry) -> None:
172  """Initialize the WemoDispatcher."""
173  self._config_entry_config_entry = config_entry
174  self._added_serial_numbers: set[str] = set()
175  self._failed_serial_numbers: set[str] = set()
176  self._dispatch_backlog: dict[Platform, list[DeviceCoordinator]] = {}
177  self._dispatch_callbacks: dict[Platform, DispatchCallback] = {}
178 
180  self, hass: HomeAssistant, wemo: pywemo.WeMoDevice
181  ) -> None:
182  """Add a WeMo device to hass if it has not already been added."""
183  if wemo.serial_number in self._added_serial_numbers:
184  return
185 
186  try:
187  coordinator = await async_register_device(hass, self._config_entry_config_entry, wemo)
188  except pywemo.PyWeMoException as err:
189  if wemo.serial_number not in self._failed_serial_numbers:
190  self._failed_serial_numbers.add(wemo.serial_number)
191  _LOGGER.error(
192  "Unable to add WeMo %s %s: %s", repr(wemo), wemo.host, err
193  )
194  return
195 
196  platforms = set(WEMO_MODEL_DISPATCH.get(wemo.model_name, [Platform.SWITCH]))
197  platforms.add(Platform.SENSOR)
198  platforms_to_load: list[Platform] = []
199  for platform in platforms:
200  # Three cases:
201  # - Platform is loaded, dispatch discovery
202  # - Platform is being loaded, add to backlog
203  # - First time we see platform, we need to load it and initialize the backlog
204 
205  if platform in self._dispatch_callbacks:
206  await self._dispatch_callbacks[platform](coordinator)
207  elif platform in self._dispatch_backlog:
208  self._dispatch_backlog[platform].append(coordinator)
209  else:
210  self._dispatch_backlog[platform] = [coordinator]
211  platforms_to_load.append(platform)
212 
213  self._added_serial_numbers.add(wemo.serial_number)
214  self._failed_serial_numbers.discard(wemo.serial_number)
215 
216  if platforms_to_load:
217  await hass.config_entries.async_forward_entry_setups(
218  self._config_entry_config_entry, platforms_to_load
219  )
220 
222  self, platform: Platform, dispatch: DispatchCallback
223  ) -> None:
224  """Consider a platform as loaded and dispatch any backlog of discovered devices."""
225  self._dispatch_callbacks[platform] = dispatch
226 
228  MAX_CONCURRENCY,
229  *(
230  dispatch(coordinator)
231  for coordinator in self._dispatch_backlog.pop(platform)
232  ),
233  )
234 
235  async def async_unload_platforms(self, hass: HomeAssistant) -> bool:
236  """Forward the unloading of an entry to platforms."""
237  platforms: set[Platform] = set(self._dispatch_backlog.keys())
238  platforms.update(self._dispatch_callbacks.keys())
239  return await hass.config_entries.async_unload_platforms(
240  self._config_entry_config_entry, platforms
241  )
242 
243 
245  """Use SSDP to discover WeMo devices."""
246 
247  ADDITIONAL_SECONDS_BETWEEN_SCANS = 10
248  MAX_SECONDS_BETWEEN_SCANS = 300
249 
250  def __init__(
251  self,
252  hass: HomeAssistant,
253  wemo_dispatcher: WemoDispatcher,
254  static_config: Sequence[HostPortTuple],
255  entry: ConfigEntry,
256  ) -> None:
257  """Initialize the WemoDiscovery."""
258  self._hass_hass = hass
259  self._wemo_dispatcher_wemo_dispatcher = wemo_dispatcher
260  self._stop_stop: CALLBACK_TYPE | None = None
261  self._scan_delay_scan_delay = 0
262  self._static_config_static_config = static_config
263  self._discover_job_discover_job: HassJob[[datetime], None] | None = None
264  self._entry_entry = entry
265 
267  self, event_time: datetime | None = None
268  ) -> None:
269  """Periodically scan the network looking for WeMo devices."""
270  _LOGGER.debug("Scanning network for WeMo devices")
271  try:
272  for device in await self._hass_hass.async_add_executor_job(
273  pywemo.discover_devices
274  ):
275  await self._wemo_dispatcher_wemo_dispatcher.async_add_unique_device(self._hass_hass, device)
276  await self.discover_staticsdiscover_statics()
277 
278  finally:
279  # Run discovery more frequently after hass has just started.
280  self._scan_delay_scan_delay = min(
281  self._scan_delay_scan_delay + self.ADDITIONAL_SECONDS_BETWEEN_SCANSADDITIONAL_SECONDS_BETWEEN_SCANS,
282  self.MAX_SECONDS_BETWEEN_SCANSMAX_SECONDS_BETWEEN_SCANS,
283  )
284  if not self._discover_job_discover_job:
285  self._discover_job_discover_job = HassJob(self._async_discover_and_schedule_callback_async_discover_and_schedule_callback)
286  self._stop_stop = async_call_later(
287  self._hass_hass,
288  self._scan_delay_scan_delay,
289  self._discover_job_discover_job,
290  )
291 
292  @callback
293  def _async_discover_and_schedule_callback(self, event_time: datetime) -> None:
294  """Run the periodic background scanning."""
295  self._entry_entry.async_create_background_task(
296  self._hass_hass,
297  self.async_discover_and_scheduleasync_discover_and_schedule(),
298  name="wemo_discovery",
299  eager_start=True,
300  )
301 
302  @callback
303  def async_stop_discovery(self) -> None:
304  """Stop the periodic background scanning."""
305  if self._stop_stop:
306  self._stop_stop()
307  self._stop_stop = None
308 
309  async def discover_statics(self) -> None:
310  """Initialize or Re-Initialize connections to statically configured devices."""
311  if not self._static_config_static_config:
312  return
313  _LOGGER.debug("Adding statically configured WeMo devices")
314  for device in await gather_with_limited_concurrency(
315  MAX_CONCURRENCY,
316  *(
317  self._hass_hass.async_add_executor_job(validate_static_config, host, port)
318  for host, port in self._static_config_static_config
319  ),
320  ):
321  if device:
322  await self._wemo_dispatcher_wemo_dispatcher.async_add_unique_device(self._hass_hass, device)
323 
324 
325 def validate_static_config(host: str, port: int | None) -> pywemo.WeMoDevice | None:
326  """Handle a static config."""
327  url = pywemo.setup_url_for_address(host, port)
328 
329  if not url:
330  _LOGGER.error(
331  "Unable to get description url for WeMo at: %s",
332  f"{host}:{port}" if port else host,
333  )
334  return None
335 
336  try:
337  device = pywemo.discovery.device_from_description(url)
338  except (
339  pywemo.exceptions.ActionException,
340  pywemo.exceptions.HTTPException,
341  ) as err:
342  _LOGGER.error("Unable to access WeMo at %s (%s)", url, err)
343  return None
344 
345  return device
None async_discover_and_schedule(self, datetime|None event_time=None)
Definition: __init__.py:268
None __init__(self, HomeAssistant hass, WemoDispatcher wemo_dispatcher, Sequence[HostPortTuple] static_config, ConfigEntry entry)
Definition: __init__.py:256
None _async_discover_and_schedule_callback(self, datetime event_time)
Definition: __init__.py:293
None __init__(self, ConfigEntry config_entry)
Definition: __init__.py:171
None async_connect_platform(self, Platform platform, DispatchCallback dispatch)
Definition: __init__.py:223
None async_add_unique_device(self, HomeAssistant hass, pywemo.WeMoDevice wemo)
Definition: __init__.py:181
bool async_unload_platforms(self, HomeAssistant hass)
Definition: __init__.py:235
bool add(self, _T matcher)
Definition: match.py:185
DeviceCoordinator async_register_device(HomeAssistant hass, ConfigEntry config_entry, WeMoDevice wemo)
Definition: coordinator.py:286
WemoData async_wemo_data(HomeAssistant hass)
Definition: models.py:43
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:118
pywemo.WeMoDevice|None validate_static_config(str host, int|None port)
Definition: __init__.py:325
HostPortTuple coerce_host_port(str value)
Definition: __init__.py:51
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:138
None async_wemo_dispatcher_connect(HomeAssistant hass, DispatchCallback dispatch)
Definition: __init__.py:159
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:85
CALLBACK_TYPE async_call_later(HomeAssistant hass, float|timedelta delay, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action)
Definition: event.py:1597
Any gather_with_limited_concurrency(int limit, *Any tasks, bool return_exceptions=False)
Definition: async_.py:103