1 """Manager for esphome devices."""
3 from __future__
import annotations
6 from functools
import partial
8 from typing
import TYPE_CHECKING, Any, NamedTuple
10 from aioesphomeapi
import (
14 DeviceInfo
as EsphomeDeviceInfo,
16 HomeassistantServiceCall,
18 InvalidEncryptionKeyAPIError,
20 RequiresEncryptionAPIError,
24 from awesomeversion
import AwesomeVersion
25 import voluptuous
as vol
31 EVENT_HOMEASSISTANT_CLOSE,
32 EVENT_LOGGING_CHANGED,
37 EventStateChangedData,
58 from .bluetooth
import async_connect_scanner
60 CONF_ALLOW_SERVICE_CALLS,
62 DEFAULT_ALLOW_SERVICE_CALLS,
67 STABLE_BLE_VERSION_STR,
69 from .dashboard
import async_get_dashboard
70 from .domain_data
import DomainData
73 from .entry_data
import ESPHomeConfigEntry, RuntimeEntryData
75 _LOGGER = logging.getLogger(__name__)
80 hass: HomeAssistant, device_info: EsphomeDeviceInfo, api_version: APIVersion
82 """Create or delete an the ble_firmware_outdated issue."""
84 issue = f
"ble_firmware_outdated-{device_info.mac_address}"
86 not device_info.bluetooth_proxy_feature_flags_compat(api_version)
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
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,
111 hass: HomeAssistant, device_info: EsphomeDeviceInfo, has_password: bool
113 """Create or delete an the api_password_deprecated issue."""
115 issue = f
"api_password_deprecated-{device_info.mac_address}"
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,
134 """Class to manage an ESPHome connection."""
152 entry: ESPHomeConfigEntry,
154 password: str |
None,
157 domain_data: DomainData,
159 """Initialize the esphome manager."""
165 self.
device_iddevice_id: str |
None =
None
171 async
def on_stop(self, event: Event) ->
None:
172 """Cleanup the socket client on HA close."""
177 """Return the services issue name for this entry."""
178 return f
"service_calls_not_enabled-{self.entry.unique_id}"
182 """Call service when user automation in ESPHome config is triggered."""
184 domain, service_name = service.service.split(
".", 1)
185 service_data = service.data
187 if service.data_template:
191 for key, value
in service.data_template.items()
194 template.render_complex(data_template, service.variables)
196 except TemplateError
as ex:
198 "Error rendering data template %s for %s: %s",
199 service.data_template,
211 "Can only generate events under esphome domain! (%s)", self.
hosthost
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))
224 ATTR_DEVICE_ID: device_id,
228 elif self.
entryentry.options.get(
229 CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
231 hass.async_create_task(
232 hass.services.async_call(
233 domain, service_name, service_data, blocking=
True
237 device_info = self.
entry_dataentry_data.device_info
238 assert device_info
is not None
244 severity=IssueSeverity.WARNING,
245 translation_key=
"service_calls_not_allowed",
246 translation_placeholders={
247 "name": device_info.friendly_name
or device_info.name,
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,
263 self, entity_id: str, attribute: str |
None, state: State |
None
265 """Forward Home Assistant states to ESPHome."""
266 if state
is None or (attribute
and attribute
not in state.attributes):
269 send_state = state.state
271 attr_val = state.attributes[attribute]
273 if isinstance(attr_val, bool):
274 send_state =
"on" if attr_val
else "off"
276 send_state = attr_val
278 self.
clicli.send_home_assistant_state(entity_id, attribute,
str(send_state))
283 attribute: str |
None,
284 event: Event[EventStateChangedData],
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"]
291 if new_state
is None or old_state
is None:
295 if (
not attribute
and old_state.state == new_state.state)
or (
297 and old_state.attributes.get(attribute)
298 == new_state.attributes.get(attribute)
306 self, entity_id: str, attribute: str |
None =
None
308 """Subscribe and forward states for requested entities."""
310 self.
entry_dataentry_data.disconnect_callbacks.add(
319 entity_id, attribute, hass.states.get(entity_id)
324 self, entity_id: str, attribute: str |
None =
None
326 """Forward state for requested entity."""
328 entity_id, attribute, self.
hasshass.states.get(entity_id)
332 """Subscribe to states and list entities on successful API login."""
335 except APIConnectionError
as err:
337 "Error getting setting up connection for %s: %s", self.
hosthost, err
340 await self.
clicli.disconnect()
343 """Subscribe to states and list entities on successful API login."""
344 entry = self.
entryentry
345 unique_id = entry.unique_id
348 assert reconnect_logic
is not None,
"Reconnect logic must be set"
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()),
358 device_info: EsphomeDeviceInfo = results[0]
359 entity_infos_services: tuple[list[EntityInfo], list[UserService]] = results[1]
360 entity_infos, services = entity_infos_services
362 device_mac =
format_mac(device_info.mac_address)
363 mac_address_matches = unique_id == device_mac
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)
372 if not mac_address_matches
and unique_id_is_mac_address:
379 "Unexpected device found at %s; "
380 "expected `%s` with mac address `%s`, "
381 "found `%s` with mac address `%s`",
388 await cli.disconnect()
389 await reconnect_logic.stop()
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}
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)
414 reconnect_logic.name = device_info.name
418 entry_data.async_update_device_state()
419 await entry_data.async_update_static_infos(
420 hass, entry, entity_infos, device_info.mac_address
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
431 if device_info.voice_assistant_feature_flags_compat(api_version)
and (
432 Platform.ASSIST_SATELLITE
not in entry_data.loaded_platforms
435 await self.
hasshass.config_entries.async_forward_entry_setups(
436 self.
entryentry, [Platform.ASSIST_SATELLITE]
438 entry_data.loaded_platforms.add(Platform.ASSIST_SATELLITE)
440 cli.subscribe_states(entry_data.async_update_state)
442 cli.subscribe_home_assistant_states(
447 entry_data.async_save_to_store()
452 """Run disconnect callbacks on API disconnect."""
456 name = entry_data.device_info.name
if entry_data.device_info
else host
458 "%s: %s disconnected (expected=%s), running disconnected callbacks",
463 entry_data.async_on_disconnect()
464 entry_data.expected_disconnect = expected_disconnect
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()
472 if not hass.is_stopping:
477 entry_data.async_update_device_state()
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]
484 self.
entry_dataentry_data.loaded_platforms.remove(Platform.ASSIST_SATELLITE)
487 """Start reauth flow if appropriate connect error type."""
491 RequiresEncryptionAPIError,
492 InvalidEncryptionKeyAPIError,
496 self.
entryentry.async_start_reauth(self.
hasshass)
500 """Handle when the logging level changes."""
501 self.
clicli.set_debug(_LOGGER.isEnabledFor(logging.DEBUG))
504 """Start the esphome connection manager."""
506 entry = self.
entryentry
509 if entry.options.get(CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS):
512 reconnect_logic = ReconnectLogic(
517 name=entry.data.get(CONF_DEVICE_NAME, self.
hosthost),
531 bus.async_listen(EVENT_HOMEASSISTANT_CLOSE, self.
on_stopon_stop),
533 reconnect_logic.stop_callback,
535 entry_data.cleanup_callbacks.extend(cleanups)
537 infos, services = await entry_data.async_load_from_store()
539 await entry_data.async_update_static_infos(
540 hass, entry, infos, entry.unique_id.upper()
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)
551 await reconnect_logic.start()
553 entry.async_on_unload(
554 entry.add_update_listener(entry_data.async_update_listener)
560 hass: HomeAssistant, entry: ESPHomeConfigEntry, entry_data: RuntimeEntryData
562 """Set up device registry feature for a particular config entry."""
563 device_info = entry_data.device_info
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})"
570 configuration_url =
None
571 if device_info.webserver_port > 0:
572 configuration_url = f
"http://{entry.data['host']}:{device_info.webserver_port}"
576 and dashboard.data.get(device_info.name)
578 configuration_url = f
"homeassistant://hassio/ingress/{dashboard.addon_slug}"
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]
589 f
"{device_info.project_version} (ESPHome {device_info.esphome_version})"
592 suggested_area =
None
593 if device_info.suggested_area:
594 suggested_area = device_info.suggested_area
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,
604 sw_version=sw_version,
605 suggested_area=suggested_area,
607 return device_entry.id
611 """Metadata for services."""
615 selector: dict[str, Any]
616 description: str |
None =
None
619 ARG_TYPE_METADATA = {
621 validator=cv.boolean,
623 selector={
"boolean":
None},
626 validator=vol.Coerce(int),
628 selector={
"number": {CONF_MODE:
"box"}},
631 validator=vol.Coerce(float),
633 selector={
"number": {CONF_MODE:
"box",
"step": 1e-3}},
637 example=
"Example text",
638 selector={
"text":
None},
641 validator=[cv.boolean],
642 description=
"A list of boolean values.",
643 example=
"[True, False]",
644 selector={
"object": {}},
647 validator=[vol.Coerce(int)],
648 description=
"A list of integer values.",
650 selector={
"object": {}},
653 validator=[vol.Coerce(float)],
654 description=
"A list of floating point numbers.",
655 example=
"[ 12.3, 34.5 ]",
656 selector={
"object": {}},
659 validator=[cv.string],
660 description=
"A list of strings.",
661 example=
"['Example text', 'Another example']",
662 selector={
"object": {}},
669 entry_data: RuntimeEntryData, service: UserService, call: ServiceCall
671 """Execute a service on a node."""
672 entry_data.client.execute_service(service, call.data)
676 """Build a service name for a node."""
677 return f
"{device_info.name.replace('-', '_')}_{service.name}"
683 entry_data: RuntimeEntryData,
684 device_info: EsphomeDeviceInfo,
685 service: UserService,
687 """Register a service on a node."""
692 for arg
in service.args:
693 if arg.type
not in ARG_TYPE_METADATA:
695 "Can't register service %s because %s is of unknown type %s",
701 metadata = ARG_TYPE_METADATA[arg.type]
702 schema[vol.Required(arg.name)] = metadata.validator
706 "description": metadata.description,
707 "example": metadata.example,
708 "selector": metadata.selector,
711 hass.services.async_register(
714 partial(execute_service, entry_data, service),
723 f
"Calls the service {service.name} of the node {device_info.name}"
732 hass: HomeAssistant, entry_data: RuntimeEntryData, services: list[UserService]
734 device_info = entry_data.device_info
735 if device_info
is None:
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:
744 if (matching := old_services.pop(service.key)) != service:
746 to_unregister.append(matching)
747 to_register.append(service)
750 to_register.append(service)
752 to_unregister.extend(old_services.values())
754 entry_data.services = {serv.key: serv
for serv
in services}
756 for service
in to_unregister:
758 hass.services.async_remove(DOMAIN, service_name)
760 for service
in to_register:
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:
772 await data.async_cleanup()
773 await data.client.disconnect()
None _send_home_assistant_state_event(self, str|None attribute, Event[EventStateChangedData] event)
None async_on_service_call(self, HomeassistantServiceCall service)
None _send_home_assistant_state(self, str entity_id, str|None attribute, State|None state)
None on_connect_error(self, Exception err)
None on_stop(self, Event event)
None async_on_state_subscription(self, str entity_id, str|None attribute=None)
None _async_handle_logging_changed(self, Event _event)
None __init__(self, HomeAssistant hass, ESPHomeConfigEntry entry, str host, str|None password, APIClient cli, zeroconf.HaZeroconf zeroconf_instance, DomainData domain_data)
None on_disconnect(self, bool expected_disconnect)
None async_on_state_request(self, str entity_id, str|None attribute=None)
CALLBACK_TYPE async_connect_scanner(HomeAssistant hass, RuntimeEntryData entry_data, APIClient cli, DeviceInfo device_info, ESPHomeBluetoothCache cache)
ESPHomeDashboardCoordinator|None async_get_dashboard(HomeAssistant hass)
None _async_register_service(HomeAssistant hass, RuntimeEntryData entry_data, EsphomeDeviceInfo device_info, UserService service)
str build_service_name(EsphomeDeviceInfo device_info, UserService service)
RuntimeEntryData cleanup_instance(HomeAssistant hass, ESPHomeConfigEntry entry)
None _async_check_using_api_password(HomeAssistant hass, EsphomeDeviceInfo device_info, bool has_password)
None _setup_services(HomeAssistant hass, RuntimeEntryData entry_data, list[UserService] services)
str _async_setup_device_registry(HomeAssistant hass, ESPHomeConfigEntry entry, RuntimeEntryData entry_data)
None _async_check_firmware_version(HomeAssistant hass, EsphomeDeviceInfo device_info, APIVersion api_version)
None execute_service(RuntimeEntryData entry_data, UserService service, ServiceCall call)
None async_create_issue(HomeAssistant hass, str entry_id)
None async_delete_issue(HomeAssistant hass, str entry_id)
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
None async_set_service_schema(HomeAssistant hass, str domain, str service, dict[str, Any] schema)