Home Assistant Unofficial Reference 2024.12.1
manager.py
Go to the documentation of this file.
1 """Manager for esphome devices."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from functools import partial
7 import logging
8 from typing import TYPE_CHECKING, Any, NamedTuple
9 
10 from aioesphomeapi import (
11  APIClient,
12  APIConnectionError,
13  APIVersion,
14  DeviceInfo as EsphomeDeviceInfo,
15  EntityInfo,
16  HomeassistantServiceCall,
17  InvalidAuthAPIError,
18  InvalidEncryptionKeyAPIError,
19  ReconnectLogic,
20  RequiresEncryptionAPIError,
21  UserService,
22  UserServiceArgType,
23 )
24 from awesomeversion import AwesomeVersion
25 import voluptuous as vol
26 
27 from homeassistant.components import tag, zeroconf
28 from homeassistant.const import (
29  ATTR_DEVICE_ID,
30  CONF_MODE,
31  EVENT_HOMEASSISTANT_CLOSE,
32  EVENT_LOGGING_CHANGED,
33  Platform,
34 )
35 from homeassistant.core import (
36  Event,
37  EventStateChangedData,
38  HomeAssistant,
39  ServiceCall,
40  State,
41  callback,
42 )
43 from homeassistant.exceptions import TemplateError
44 from homeassistant.helpers import template
47 from homeassistant.helpers.device_registry import format_mac
48 from homeassistant.helpers.event import async_track_state_change_event
50  IssueSeverity,
51  async_create_issue,
52  async_delete_issue,
53 )
54 from homeassistant.helpers.service import async_set_service_schema
55 from homeassistant.helpers.template import Template
56 from homeassistant.util.async_ import create_eager_task
57 
58 from .bluetooth import async_connect_scanner
59 from .const import (
60  CONF_ALLOW_SERVICE_CALLS,
61  CONF_DEVICE_NAME,
62  DEFAULT_ALLOW_SERVICE_CALLS,
63  DEFAULT_URL,
64  DOMAIN,
65  PROJECT_URLS,
66  STABLE_BLE_VERSION,
67  STABLE_BLE_VERSION_STR,
68 )
69 from .dashboard import async_get_dashboard
70 from .domain_data import DomainData
71 
72 # Import config flow so that it's added to the registry
73 from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
74 
75 _LOGGER = logging.getLogger(__name__)
76 
77 
78 @callback
80  hass: HomeAssistant, device_info: EsphomeDeviceInfo, api_version: APIVersion
81 ) -> None:
82  """Create or delete an the ble_firmware_outdated issue."""
83  # ESPHome device_info.mac_address is the unique_id
84  issue = f"ble_firmware_outdated-{device_info.mac_address}"
85  if (
86  not device_info.bluetooth_proxy_feature_flags_compat(api_version)
87  # If the device has a project name its up to that project
88  # to tell them about the firmware version update so we don't notify here
89  or (device_info.project_name and device_info.project_name not in PROJECT_URLS)
90  or AwesomeVersion(device_info.esphome_version) >= STABLE_BLE_VERSION
91  ):
92  async_delete_issue(hass, DOMAIN, issue)
93  return
95  hass,
96  DOMAIN,
97  issue,
98  is_fixable=False,
99  severity=IssueSeverity.WARNING,
100  learn_more_url=PROJECT_URLS.get(device_info.project_name, DEFAULT_URL),
101  translation_key="ble_firmware_outdated",
102  translation_placeholders={
103  "name": device_info.name,
104  "version": STABLE_BLE_VERSION_STR,
105  },
106  )
107 
108 
109 @callback
111  hass: HomeAssistant, device_info: EsphomeDeviceInfo, has_password: bool
112 ) -> None:
113  """Create or delete an the api_password_deprecated issue."""
114  # ESPHome device_info.mac_address is the unique_id
115  issue = f"api_password_deprecated-{device_info.mac_address}"
116  if not has_password:
117  async_delete_issue(hass, DOMAIN, issue)
118  return
120  hass,
121  DOMAIN,
122  issue,
123  is_fixable=False,
124  severity=IssueSeverity.WARNING,
125  learn_more_url="https://esphome.io/components/api.html",
126  translation_key="api_password_deprecated",
127  translation_placeholders={
128  "name": device_info.name,
129  },
130  )
131 
132 
134  """Class to manage an ESPHome connection."""
135 
136  __slots__ = (
137  "hass",
138  "host",
139  "password",
140  "entry",
141  "cli",
142  "device_id",
143  "domain_data",
144  "reconnect_logic",
145  "zeroconf_instance",
146  "entry_data",
147  )
148 
149  def __init__(
150  self,
151  hass: HomeAssistant,
152  entry: ESPHomeConfigEntry,
153  host: str,
154  password: str | None,
155  cli: APIClient,
156  zeroconf_instance: zeroconf.HaZeroconf,
157  domain_data: DomainData,
158  ) -> None:
159  """Initialize the esphome manager."""
160  self.hasshass = hass
161  self.hosthost = host
162  self.passwordpassword = password
163  self.entryentry = entry
164  self.clicli = cli
165  self.device_iddevice_id: str | None = None
166  self.domain_datadomain_data = domain_data
167  self.reconnect_logicreconnect_logic: ReconnectLogic | None = None
168  self.zeroconf_instancezeroconf_instance = zeroconf_instance
169  self.entry_dataentry_data = entry.runtime_data
170 
171  async def on_stop(self, event: Event) -> None:
172  """Cleanup the socket client on HA close."""
173  await cleanup_instance(self.hasshass, self.entryentry)
174 
175  @property
176  def services_issue(self) -> str:
177  """Return the services issue name for this entry."""
178  return f"service_calls_not_enabled-{self.entry.unique_id}"
179 
180  @callback
181  def async_on_service_call(self, service: HomeassistantServiceCall) -> None:
182  """Call service when user automation in ESPHome config is triggered."""
183  hass = self.hasshass
184  domain, service_name = service.service.split(".", 1)
185  service_data = service.data
186 
187  if service.data_template:
188  try:
189  data_template = {
190  key: Template(value, hass)
191  for key, value in service.data_template.items()
192  }
193  service_data.update(
194  template.render_complex(data_template, service.variables)
195  )
196  except TemplateError as ex:
197  _LOGGER.error(
198  "Error rendering data template %s for %s: %s",
199  service.data_template,
200  self.hosthost,
201  ex,
202  )
203  return
204 
205  if service.is_event:
206  device_id = self.device_iddevice_id
207  # ESPHome uses service call packet for both events and service calls
208  # Ensure the user can only send events of form 'esphome.xyz'
209  if domain != DOMAIN:
210  _LOGGER.error(
211  "Can only generate events under esphome domain! (%s)", self.hosthost
212  )
213  return
214 
215  # Call native tag scan
216  if service_name == "tag_scanned" and device_id is not None:
217  tag_id = service_data["tag_id"]
218  hass.async_create_task(tag.async_scan_tag(hass, tag_id, device_id))
219  return
220 
221  hass.bus.async_fire(
222  service.service,
223  {
224  ATTR_DEVICE_ID: device_id,
225  **service_data,
226  },
227  )
228  elif self.entryentry.options.get(
229  CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
230  ):
231  hass.async_create_task(
232  hass.services.async_call(
233  domain, service_name, service_data, blocking=True
234  )
235  )
236  else:
237  device_info = self.entry_dataentry_data.device_info
238  assert device_info is not None
240  hass,
241  DOMAIN,
242  self.services_issueservices_issue,
243  is_fixable=False,
244  severity=IssueSeverity.WARNING,
245  translation_key="service_calls_not_allowed",
246  translation_placeholders={
247  "name": device_info.friendly_name or device_info.name,
248  },
249  )
250  _LOGGER.error(
251  "%s: Service call %s.%s: with data %s rejected; "
252  "If you trust this device and want to allow access for it to make "
253  "Home Assistant service calls, you can enable this "
254  "functionality in the options flow",
255  device_info.friendly_name or device_info.name,
256  domain,
257  service_name,
258  service_data,
259  )
260 
261  @callback
263  self, entity_id: str, attribute: str | None, state: State | None
264  ) -> None:
265  """Forward Home Assistant states to ESPHome."""
266  if state is None or (attribute and attribute not in state.attributes):
267  return
268 
269  send_state = state.state
270  if attribute:
271  attr_val = state.attributes[attribute]
272  # ESPHome only handles "on"/"off" for boolean values
273  if isinstance(attr_val, bool):
274  send_state = "on" if attr_val else "off"
275  else:
276  send_state = attr_val
277 
278  self.clicli.send_home_assistant_state(entity_id, attribute, str(send_state))
279 
280  @callback
282  self,
283  attribute: str | None,
284  event: Event[EventStateChangedData],
285  ) -> None:
286  """Forward Home Assistant states updates to ESPHome."""
287  event_data = event.data
288  new_state = event_data["new_state"]
289  old_state = event_data["old_state"]
290 
291  if new_state is None or old_state is None:
292  return
293 
294  # Only communicate changes to the state or attribute tracked
295  if (not attribute and old_state.state == new_state.state) or (
296  attribute
297  and old_state.attributes.get(attribute)
298  == new_state.attributes.get(attribute)
299  ):
300  return
301 
302  self._send_home_assistant_state_send_home_assistant_state(event.data["entity_id"], attribute, new_state)
303 
304  @callback
306  self, entity_id: str, attribute: str | None = None
307  ) -> None:
308  """Subscribe and forward states for requested entities."""
309  hass = self.hasshass
310  self.entry_dataentry_data.disconnect_callbacks.add(
312  hass,
313  [entity_id],
314  partial(self._send_home_assistant_state_event_send_home_assistant_state_event, attribute),
315  )
316  )
317  # Send initial state
318  self._send_home_assistant_state_send_home_assistant_state(
319  entity_id, attribute, hass.states.get(entity_id)
320  )
321 
322  @callback
324  self, entity_id: str, attribute: str | None = None
325  ) -> None:
326  """Forward state for requested entity."""
327  self._send_home_assistant_state_send_home_assistant_state(
328  entity_id, attribute, self.hasshass.states.get(entity_id)
329  )
330 
331  async def on_connect(self) -> None:
332  """Subscribe to states and list entities on successful API login."""
333  try:
334  await self._on_connnect_on_connnect()
335  except APIConnectionError as err:
336  _LOGGER.warning(
337  "Error getting setting up connection for %s: %s", self.hosthost, err
338  )
339  # Re-connection logic will trigger after this
340  await self.clicli.disconnect()
341 
342  async def _on_connnect(self) -> None:
343  """Subscribe to states and list entities on successful API login."""
344  entry = self.entryentry
345  unique_id = entry.unique_id
346  entry_data = self.entry_dataentry_data
347  reconnect_logic = self.reconnect_logicreconnect_logic
348  assert reconnect_logic is not None, "Reconnect logic must be set"
349  hass = self.hasshass
350  cli = self.clicli
351  stored_device_name = entry.data.get(CONF_DEVICE_NAME)
352  unique_id_is_mac_address = unique_id and ":" in unique_id
353  results = await asyncio.gather(
354  create_eager_task(cli.device_info()),
355  create_eager_task(cli.list_entities_services()),
356  )
357 
358  device_info: EsphomeDeviceInfo = results[0]
359  entity_infos_services: tuple[list[EntityInfo], list[UserService]] = results[1]
360  entity_infos, services = entity_infos_services
361 
362  device_mac = format_mac(device_info.mac_address)
363  mac_address_matches = unique_id == device_mac
364  #
365  # Migrate config entry to new unique ID if the current
366  # unique id is not a mac address.
367  #
368  # This was changed in 2023.1
369  if not mac_address_matches and not unique_id_is_mac_address:
370  hass.config_entries.async_update_entry(entry, unique_id=device_mac)
371 
372  if not mac_address_matches and unique_id_is_mac_address:
373  # If the unique id is a mac address
374  # and does not match we have the wrong device and we need
375  # to abort the connection. This can happen if the DHCP
376  # server changes the IP address of the device and we end up
377  # connecting to the wrong device.
378  _LOGGER.error(
379  "Unexpected device found at %s; "
380  "expected `%s` with mac address `%s`, "
381  "found `%s` with mac address `%s`",
382  self.hosthost,
383  stored_device_name,
384  unique_id,
385  device_info.name,
386  device_mac,
387  )
388  await cli.disconnect()
389  await reconnect_logic.stop()
390  # We don't want to reconnect to the wrong device
391  # so we stop the reconnect logic and disconnect
392  # the client. When discovery finds the new IP address
393  # for the device, the config entry will be updated
394  # and we will connect to the correct device when
395  # the config entry gets reloaded by the discovery
396  # flow.
397  return
398 
399  # Make sure we have the correct device name stored
400  # so we can map the device to ESPHome Dashboard config
401  # If we got here, we know the mac address matches or we
402  # did a migration to the mac address so we can update
403  # the device name.
404  if stored_device_name != device_info.name:
405  hass.config_entries.async_update_entry(
406  entry, data={**entry.data, CONF_DEVICE_NAME: device_info.name}
407  )
408 
409  api_version = cli.api_version
410  assert api_version is not None, "API version must be set"
411  entry_data.async_on_connect(device_info, api_version)
412 
413  if device_info.name:
414  reconnect_logic.name = device_info.name
415 
416  self.device_iddevice_id = _async_setup_device_registry(hass, entry, entry_data)
417 
418  entry_data.async_update_device_state()
419  await entry_data.async_update_static_infos(
420  hass, entry, entity_infos, device_info.mac_address
421  )
422  _setup_services(hass, entry_data, services)
423 
424  if device_info.bluetooth_proxy_feature_flags_compat(api_version):
425  entry_data.disconnect_callbacks.add(
427  hass, entry_data, cli, device_info, self.domain_datadomain_data.bluetooth_cache
428  )
429  )
430 
431  if device_info.voice_assistant_feature_flags_compat(api_version) and (
432  Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms
433  ):
434  # Create assist satellite entity
435  await self.hasshass.config_entries.async_forward_entry_setups(
436  self.entryentry, [Platform.ASSIST_SATELLITE]
437  )
438  entry_data.loaded_platforms.add(Platform.ASSIST_SATELLITE)
439 
440  cli.subscribe_states(entry_data.async_update_state)
441  cli.subscribe_service_calls(self.async_on_service_callasync_on_service_call)
442  cli.subscribe_home_assistant_states(
443  self.async_on_state_subscriptionasync_on_state_subscription,
444  self.async_on_state_requestasync_on_state_request,
445  )
446 
447  entry_data.async_save_to_store()
448  _async_check_firmware_version(hass, device_info, api_version)
449  _async_check_using_api_password(hass, device_info, bool(self.passwordpassword))
450 
451  async def on_disconnect(self, expected_disconnect: bool) -> None:
452  """Run disconnect callbacks on API disconnect."""
453  entry_data = self.entry_dataentry_data
454  hass = self.hasshass
455  host = self.hosthost
456  name = entry_data.device_info.name if entry_data.device_info else host
457  _LOGGER.debug(
458  "%s: %s disconnected (expected=%s), running disconnected callbacks",
459  name,
460  host,
461  expected_disconnect,
462  )
463  entry_data.async_on_disconnect()
464  entry_data.expected_disconnect = expected_disconnect
465  # Mark state as stale so that we will always dispatch
466  # the next state update of that type when the device reconnects
467  entry_data.stale_state = {
468  (type(entity_state), key)
469  for state_dict in entry_data.state.values()
470  for key, entity_state in state_dict.items()
471  }
472  if not hass.is_stopping:
473  # Avoid marking every esphome entity as unavailable on shutdown
474  # since it generates a lot of state changed events and database
475  # writes when we already know we're shutting down and the state
476  # will be cleared anyway.
477  entry_data.async_update_device_state()
478 
479  if Platform.ASSIST_SATELLITE in self.entry_dataentry_data.loaded_platforms:
480  await self.hasshass.config_entries.async_unload_platforms(
481  self.entryentry, [Platform.ASSIST_SATELLITE]
482  )
483 
484  self.entry_dataentry_data.loaded_platforms.remove(Platform.ASSIST_SATELLITE)
485 
486  async def on_connect_error(self, err: Exception) -> None:
487  """Start reauth flow if appropriate connect error type."""
488  if isinstance(
489  err,
490  (
491  RequiresEncryptionAPIError,
492  InvalidEncryptionKeyAPIError,
493  InvalidAuthAPIError,
494  ),
495  ):
496  self.entryentry.async_start_reauth(self.hasshass)
497 
498  @callback
499  def _async_handle_logging_changed(self, _event: Event) -> None:
500  """Handle when the logging level changes."""
501  self.clicli.set_debug(_LOGGER.isEnabledFor(logging.DEBUG))
502 
503  async def async_start(self) -> None:
504  """Start the esphome connection manager."""
505  hass = self.hasshass
506  entry = self.entryentry
507  entry_data = self.entry_dataentry_data
508 
509  if entry.options.get(CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS):
510  async_delete_issue(hass, DOMAIN, self.services_issueservices_issue)
511 
512  reconnect_logic = ReconnectLogic(
513  client=self.clicli,
514  on_connect=self.on_connecton_connect,
515  on_disconnect=self.on_disconnecton_disconnect,
516  zeroconf_instance=self.zeroconf_instancezeroconf_instance,
517  name=entry.data.get(CONF_DEVICE_NAME, self.hosthost),
518  on_connect_error=self.on_connect_erroron_connect_error,
519  )
520  self.reconnect_logicreconnect_logic = reconnect_logic
521 
522  # Use async_listen instead of async_listen_once so that we don't deregister
523  # the callback twice when shutting down Home Assistant.
524  # "Unable to remove unknown listener
525  # <function EventBus.async_listen_once.<locals>.onetime_listener>"
526  # We only close the connection at the last possible moment
527  # when the CLOSE event is fired so anything using a Bluetooth
528  # proxy has a chance to shut down properly.
529  bus = hass.bus
530  cleanups = (
531  bus.async_listen(EVENT_HOMEASSISTANT_CLOSE, self.on_stopon_stop),
532  bus.async_listen(EVENT_LOGGING_CHANGED, self._async_handle_logging_changed_async_handle_logging_changed),
533  reconnect_logic.stop_callback,
534  )
535  entry_data.cleanup_callbacks.extend(cleanups)
536 
537  infos, services = await entry_data.async_load_from_store()
538  if entry.unique_id:
539  await entry_data.async_update_static_infos(
540  hass, entry, infos, entry.unique_id.upper()
541  )
542  _setup_services(hass, entry_data, services)
543 
544  if entry_data.device_info is not None and entry_data.device_info.name:
545  reconnect_logic.name = entry_data.device_info.name
546  if entry.unique_id is None:
547  hass.config_entries.async_update_entry(
548  entry, unique_id=format_mac(entry_data.device_info.mac_address)
549  )
550 
551  await reconnect_logic.start()
552 
553  entry.async_on_unload(
554  entry.add_update_listener(entry_data.async_update_listener)
555  )
556 
557 
558 @callback
560  hass: HomeAssistant, entry: ESPHomeConfigEntry, entry_data: RuntimeEntryData
561 ) -> str:
562  """Set up device registry feature for a particular config entry."""
563  device_info = entry_data.device_info
564  if TYPE_CHECKING:
565  assert device_info is not None
566  sw_version = device_info.esphome_version
567  if device_info.compilation_time:
568  sw_version += f" ({device_info.compilation_time})"
569 
570  configuration_url = None
571  if device_info.webserver_port > 0:
572  configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}"
573  elif (
574  (dashboard := async_get_dashboard(hass))
575  and dashboard.data
576  and dashboard.data.get(device_info.name)
577  ):
578  configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}"
579 
580  manufacturer = "espressif"
581  if device_info.manufacturer:
582  manufacturer = device_info.manufacturer
583  model = device_info.model
584  if device_info.project_name:
585  project_name = device_info.project_name.split(".")
586  manufacturer = project_name[0]
587  model = project_name[1]
588  sw_version = (
589  f"{device_info.project_version} (ESPHome {device_info.esphome_version})"
590  )
591 
592  suggested_area = None
593  if device_info.suggested_area:
594  suggested_area = device_info.suggested_area
595 
596  device_registry = dr.async_get(hass)
597  device_entry = device_registry.async_get_or_create(
598  config_entry_id=entry.entry_id,
599  configuration_url=configuration_url,
600  connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)},
601  name=entry_data.friendly_name,
602  manufacturer=manufacturer,
603  model=model,
604  sw_version=sw_version,
605  suggested_area=suggested_area,
606  )
607  return device_entry.id
608 
609 
610 class ServiceMetadata(NamedTuple):
611  """Metadata for services."""
612 
613  validator: Any
614  example: str
615  selector: dict[str, Any]
616  description: str | None = None
617 
618 
619 ARG_TYPE_METADATA = {
620  UserServiceArgType.BOOL: ServiceMetadata(
621  validator=cv.boolean,
622  example="False",
623  selector={"boolean": None},
624  ),
625  UserServiceArgType.INT: ServiceMetadata(
626  validator=vol.Coerce(int),
627  example="42",
628  selector={"number": {CONF_MODE: "box"}},
629  ),
630  UserServiceArgType.FLOAT: ServiceMetadata(
631  validator=vol.Coerce(float),
632  example="12.3",
633  selector={"number": {CONF_MODE: "box", "step": 1e-3}},
634  ),
635  UserServiceArgType.STRING: ServiceMetadata(
636  validator=cv.string,
637  example="Example text",
638  selector={"text": None},
639  ),
640  UserServiceArgType.BOOL_ARRAY: ServiceMetadata(
641  validator=[cv.boolean],
642  description="A list of boolean values.",
643  example="[True, False]",
644  selector={"object": {}},
645  ),
646  UserServiceArgType.INT_ARRAY: ServiceMetadata(
647  validator=[vol.Coerce(int)],
648  description="A list of integer values.",
649  example="[42, 34]",
650  selector={"object": {}},
651  ),
652  UserServiceArgType.FLOAT_ARRAY: ServiceMetadata(
653  validator=[vol.Coerce(float)],
654  description="A list of floating point numbers.",
655  example="[ 12.3, 34.5 ]",
656  selector={"object": {}},
657  ),
658  UserServiceArgType.STRING_ARRAY: ServiceMetadata(
659  validator=[cv.string],
660  description="A list of strings.",
661  example="['Example text', 'Another example']",
662  selector={"object": {}},
663  ),
664 }
665 
666 
667 @callback
669  entry_data: RuntimeEntryData, service: UserService, call: ServiceCall
670 ) -> None:
671  """Execute a service on a node."""
672  entry_data.client.execute_service(service, call.data)
673 
674 
675 def build_service_name(device_info: EsphomeDeviceInfo, service: UserService) -> str:
676  """Build a service name for a node."""
677  return f"{device_info.name.replace('-', '_')}_{service.name}"
678 
679 
680 @callback
682  hass: HomeAssistant,
683  entry_data: RuntimeEntryData,
684  device_info: EsphomeDeviceInfo,
685  service: UserService,
686 ) -> None:
687  """Register a service on a node."""
688  service_name = build_service_name(device_info, service)
689  schema = {}
690  fields = {}
691 
692  for arg in service.args:
693  if arg.type not in ARG_TYPE_METADATA:
694  _LOGGER.error(
695  "Can't register service %s because %s is of unknown type %s",
696  service_name,
697  arg.name,
698  arg.type,
699  )
700  return
701  metadata = ARG_TYPE_METADATA[arg.type]
702  schema[vol.Required(arg.name)] = metadata.validator
703  fields[arg.name] = {
704  "name": arg.name,
705  "required": True,
706  "description": metadata.description,
707  "example": metadata.example,
708  "selector": metadata.selector,
709  }
710 
711  hass.services.async_register(
712  DOMAIN,
713  service_name,
714  partial(execute_service, entry_data, service),
715  vol.Schema(schema),
716  )
718  hass,
719  DOMAIN,
720  service_name,
721  {
722  "description": (
723  f"Calls the service {service.name} of the node {device_info.name}"
724  ),
725  "fields": fields,
726  },
727  )
728 
729 
730 @callback
732  hass: HomeAssistant, entry_data: RuntimeEntryData, services: list[UserService]
733 ) -> None:
734  device_info = entry_data.device_info
735  if device_info is None:
736  # Can happen if device has never connected or .storage cleared
737  return
738  old_services = entry_data.services.copy()
739  to_unregister: list[UserService] = []
740  to_register: list[UserService] = []
741  for service in services:
742  if service.key in old_services:
743  # Already exists
744  if (matching := old_services.pop(service.key)) != service:
745  # Need to re-register
746  to_unregister.append(matching)
747  to_register.append(service)
748  else:
749  # New service
750  to_register.append(service)
751 
752  to_unregister.extend(old_services.values())
753 
754  entry_data.services = {serv.key: serv for serv in services}
755 
756  for service in to_unregister:
757  service_name = build_service_name(device_info, service)
758  hass.services.async_remove(DOMAIN, service_name)
759 
760  for service in to_register:
761  _async_register_service(hass, entry_data, device_info, service)
762 
763 
765  hass: HomeAssistant, entry: ESPHomeConfigEntry
766 ) -> RuntimeEntryData:
767  """Cleanup the esphome client if it exists."""
768  data = entry.runtime_data
769  data.async_on_disconnect()
770  for cleanup_callback in data.cleanup_callbacks:
771  cleanup_callback()
772  await data.async_cleanup()
773  await data.client.disconnect()
774  return data
None _send_home_assistant_state_event(self, str|None attribute, Event[EventStateChangedData] event)
Definition: manager.py:285
None async_on_service_call(self, HomeassistantServiceCall service)
Definition: manager.py:181
None _send_home_assistant_state(self, str entity_id, str|None attribute, State|None state)
Definition: manager.py:264
None async_on_state_subscription(self, str entity_id, str|None attribute=None)
Definition: manager.py:307
None _async_handle_logging_changed(self, Event _event)
Definition: manager.py:499
None __init__(self, HomeAssistant hass, ESPHomeConfigEntry entry, str host, str|None password, APIClient cli, zeroconf.HaZeroconf zeroconf_instance, DomainData domain_data)
Definition: manager.py:158
None on_disconnect(self, bool expected_disconnect)
Definition: manager.py:451
None async_on_state_request(self, str entity_id, str|None attribute=None)
Definition: manager.py:325
CALLBACK_TYPE async_connect_scanner(HomeAssistant hass, RuntimeEntryData entry_data, APIClient cli, DeviceInfo device_info, ESPHomeBluetoothCache cache)
Definition: bluetooth.py:32
ESPHomeDashboardCoordinator|None async_get_dashboard(HomeAssistant hass)
Definition: dashboard.py:134
None _async_register_service(HomeAssistant hass, RuntimeEntryData entry_data, EsphomeDeviceInfo device_info, UserService service)
Definition: manager.py:686
str build_service_name(EsphomeDeviceInfo device_info, UserService service)
Definition: manager.py:675
RuntimeEntryData cleanup_instance(HomeAssistant hass, ESPHomeConfigEntry entry)
Definition: manager.py:766
None _async_check_using_api_password(HomeAssistant hass, EsphomeDeviceInfo device_info, bool has_password)
Definition: manager.py:112
None _setup_services(HomeAssistant hass, RuntimeEntryData entry_data, list[UserService] services)
Definition: manager.py:733
str _async_setup_device_registry(HomeAssistant hass, ESPHomeConfigEntry entry, RuntimeEntryData entry_data)
Definition: manager.py:561
None _async_check_firmware_version(HomeAssistant hass, EsphomeDeviceInfo device_info, APIVersion api_version)
Definition: manager.py:81
None execute_service(RuntimeEntryData entry_data, UserService service, ServiceCall call)
Definition: manager.py:670
None async_create_issue(HomeAssistant hass, str entry_id)
Definition: repairs.py:69
None async_delete_issue(HomeAssistant hass, str entry_id)
Definition: repairs.py:85
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
Definition: event.py:314
None async_set_service_schema(HomeAssistant hass, str domain, str service, dict[str, Any] schema)
Definition: service.py:844