2023-07-08 07:19:44 +00:00
|
|
|
"""Manager for esphome devices."""
|
2024-03-08 13:15:26 +00:00
|
|
|
|
2023-07-08 07:19:44 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2023-11-15 09:28:49 +00:00
|
|
|
import asyncio
|
2024-01-04 00:47:49 +00:00
|
|
|
from functools import partial
|
2023-07-08 07:19:44 +00:00
|
|
|
import logging
|
2023-07-23 08:45:48 +00:00
|
|
|
from typing import TYPE_CHECKING, Any, NamedTuple
|
2023-07-08 07:19:44 +00:00
|
|
|
|
|
|
|
from aioesphomeapi import (
|
|
|
|
APIClient,
|
|
|
|
APIConnectionError,
|
|
|
|
APIVersion,
|
|
|
|
DeviceInfo as EsphomeDeviceInfo,
|
2023-11-22 22:27:17 +00:00
|
|
|
EntityInfo,
|
2023-07-08 07:19:44 +00:00
|
|
|
HomeassistantServiceCall,
|
|
|
|
InvalidAuthAPIError,
|
|
|
|
InvalidEncryptionKeyAPIError,
|
|
|
|
ReconnectLogic,
|
|
|
|
RequiresEncryptionAPIError,
|
|
|
|
UserService,
|
|
|
|
UserServiceArgType,
|
2023-09-26 21:27:26 +00:00
|
|
|
VoiceAssistantAudioSettings,
|
2024-04-09 14:55:59 +00:00
|
|
|
VoiceAssistantFeature,
|
2023-07-08 07:19:44 +00:00
|
|
|
)
|
|
|
|
from awesomeversion import AwesomeVersion
|
|
|
|
import voluptuous as vol
|
|
|
|
|
|
|
|
from homeassistant.components import tag, zeroconf
|
2024-05-28 18:57:58 +00:00
|
|
|
from homeassistant.components.intent import async_register_timer_handler
|
2023-07-08 07:19:44 +00:00
|
|
|
from homeassistant.config_entries import ConfigEntry
|
2023-11-25 20:00:04 +00:00
|
|
|
from homeassistant.const import (
|
|
|
|
ATTR_DEVICE_ID,
|
|
|
|
CONF_MODE,
|
2024-03-17 18:15:28 +00:00
|
|
|
EVENT_HOMEASSISTANT_CLOSE,
|
2023-11-25 20:00:04 +00:00
|
|
|
EVENT_LOGGING_CHANGED,
|
|
|
|
)
|
2024-04-04 21:48:36 +00:00
|
|
|
from homeassistant.core import (
|
|
|
|
Event,
|
|
|
|
EventStateChangedData,
|
|
|
|
HomeAssistant,
|
|
|
|
ServiceCall,
|
|
|
|
State,
|
|
|
|
callback,
|
|
|
|
)
|
2023-07-08 07:19:44 +00:00
|
|
|
from homeassistant.exceptions import TemplateError
|
|
|
|
from homeassistant.helpers import template
|
|
|
|
import homeassistant.helpers.config_validation as cv
|
|
|
|
import homeassistant.helpers.device_registry as dr
|
|
|
|
from homeassistant.helpers.device_registry import format_mac
|
2024-04-04 21:48:36 +00:00
|
|
|
from homeassistant.helpers.event import async_track_state_change_event
|
2023-07-08 07:19:44 +00:00
|
|
|
from homeassistant.helpers.issue_registry import (
|
|
|
|
IssueSeverity,
|
|
|
|
async_create_issue,
|
|
|
|
async_delete_issue,
|
|
|
|
)
|
|
|
|
from homeassistant.helpers.service import async_set_service_schema
|
|
|
|
from homeassistant.helpers.template import Template
|
2024-03-02 03:16:01 +00:00
|
|
|
from homeassistant.util.async_ import create_eager_task
|
2023-07-08 07:19:44 +00:00
|
|
|
|
|
|
|
from .bluetooth import async_connect_scanner
|
|
|
|
from .const import (
|
|
|
|
CONF_ALLOW_SERVICE_CALLS,
|
|
|
|
CONF_DEVICE_NAME,
|
|
|
|
DEFAULT_ALLOW_SERVICE_CALLS,
|
|
|
|
DEFAULT_URL,
|
|
|
|
DOMAIN,
|
|
|
|
PROJECT_URLS,
|
|
|
|
STABLE_BLE_VERSION,
|
|
|
|
STABLE_BLE_VERSION_STR,
|
|
|
|
)
|
|
|
|
from .dashboard import async_get_dashboard
|
|
|
|
from .domain_data import DomainData
|
|
|
|
|
|
|
|
# Import config flow so that it's added to the registry
|
|
|
|
from .entry_data import RuntimeEntryData
|
2024-04-09 14:55:59 +00:00
|
|
|
from .voice_assistant import (
|
|
|
|
VoiceAssistantAPIPipeline,
|
|
|
|
VoiceAssistantPipeline,
|
|
|
|
VoiceAssistantUDPPipeline,
|
2024-05-28 18:57:58 +00:00
|
|
|
handle_timer_event,
|
2024-04-09 14:55:59 +00:00
|
|
|
)
|
2023-07-08 07:19:44 +00:00
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
@callback
|
|
|
|
def _async_check_firmware_version(
|
|
|
|
hass: HomeAssistant, device_info: EsphomeDeviceInfo, api_version: APIVersion
|
|
|
|
) -> None:
|
|
|
|
"""Create or delete an the ble_firmware_outdated issue."""
|
|
|
|
# ESPHome device_info.mac_address is the unique_id
|
|
|
|
issue = f"ble_firmware_outdated-{device_info.mac_address}"
|
|
|
|
if (
|
|
|
|
not device_info.bluetooth_proxy_feature_flags_compat(api_version)
|
|
|
|
# If the device has a project name its up to that project
|
|
|
|
# to tell them about the firmware version update so we don't notify here
|
|
|
|
or (device_info.project_name and device_info.project_name not in PROJECT_URLS)
|
|
|
|
or AwesomeVersion(device_info.esphome_version) >= STABLE_BLE_VERSION
|
|
|
|
):
|
|
|
|
async_delete_issue(hass, DOMAIN, issue)
|
|
|
|
return
|
|
|
|
async_create_issue(
|
|
|
|
hass,
|
|
|
|
DOMAIN,
|
|
|
|
issue,
|
|
|
|
is_fixable=False,
|
|
|
|
severity=IssueSeverity.WARNING,
|
|
|
|
learn_more_url=PROJECT_URLS.get(device_info.project_name, DEFAULT_URL),
|
|
|
|
translation_key="ble_firmware_outdated",
|
|
|
|
translation_placeholders={
|
|
|
|
"name": device_info.name,
|
|
|
|
"version": STABLE_BLE_VERSION_STR,
|
|
|
|
},
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@callback
|
|
|
|
def _async_check_using_api_password(
|
|
|
|
hass: HomeAssistant, device_info: EsphomeDeviceInfo, has_password: bool
|
|
|
|
) -> None:
|
|
|
|
"""Create or delete an the api_password_deprecated issue."""
|
|
|
|
# ESPHome device_info.mac_address is the unique_id
|
|
|
|
issue = f"api_password_deprecated-{device_info.mac_address}"
|
|
|
|
if not has_password:
|
|
|
|
async_delete_issue(hass, DOMAIN, issue)
|
|
|
|
return
|
|
|
|
async_create_issue(
|
|
|
|
hass,
|
|
|
|
DOMAIN,
|
|
|
|
issue,
|
|
|
|
is_fixable=False,
|
|
|
|
severity=IssueSeverity.WARNING,
|
|
|
|
learn_more_url="https://esphome.io/components/api.html",
|
|
|
|
translation_key="api_password_deprecated",
|
|
|
|
translation_placeholders={
|
|
|
|
"name": device_info.name,
|
|
|
|
},
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
class ESPHomeManager:
|
|
|
|
"""Class to manage an ESPHome connection."""
|
|
|
|
|
|
|
|
__slots__ = (
|
|
|
|
"hass",
|
|
|
|
"host",
|
|
|
|
"password",
|
|
|
|
"entry",
|
|
|
|
"cli",
|
|
|
|
"device_id",
|
|
|
|
"domain_data",
|
2024-04-09 14:55:59 +00:00
|
|
|
"voice_assistant_pipeline",
|
2023-07-08 07:19:44 +00:00
|
|
|
"reconnect_logic",
|
|
|
|
"zeroconf_instance",
|
|
|
|
"entry_data",
|
|
|
|
)
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
hass: HomeAssistant,
|
|
|
|
entry: ConfigEntry,
|
|
|
|
host: str,
|
|
|
|
password: str | None,
|
|
|
|
cli: APIClient,
|
|
|
|
zeroconf_instance: zeroconf.HaZeroconf,
|
|
|
|
domain_data: DomainData,
|
|
|
|
entry_data: RuntimeEntryData,
|
|
|
|
) -> None:
|
|
|
|
"""Initialize the esphome manager."""
|
|
|
|
self.hass = hass
|
|
|
|
self.host = host
|
|
|
|
self.password = password
|
|
|
|
self.entry = entry
|
|
|
|
self.cli = cli
|
|
|
|
self.device_id: str | None = None
|
|
|
|
self.domain_data = domain_data
|
2024-04-09 14:55:59 +00:00
|
|
|
self.voice_assistant_pipeline: VoiceAssistantPipeline | None = None
|
2023-07-08 07:19:44 +00:00
|
|
|
self.reconnect_logic: ReconnectLogic | None = None
|
|
|
|
self.zeroconf_instance = zeroconf_instance
|
|
|
|
self.entry_data = entry_data
|
|
|
|
|
|
|
|
async def on_stop(self, event: Event) -> None:
|
2024-03-28 06:52:45 +00:00
|
|
|
"""Cleanup the socket client on HA close."""
|
2023-07-08 07:19:44 +00:00
|
|
|
await cleanup_instance(self.hass, self.entry)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def services_issue(self) -> str:
|
|
|
|
"""Return the services issue name for this entry."""
|
|
|
|
return f"service_calls_not_enabled-{self.entry.unique_id}"
|
|
|
|
|
|
|
|
@callback
|
|
|
|
def async_on_service_call(self, service: HomeassistantServiceCall) -> None:
|
|
|
|
"""Call service when user automation in ESPHome config is triggered."""
|
|
|
|
hass = self.hass
|
|
|
|
domain, service_name = service.service.split(".", 1)
|
|
|
|
service_data = service.data
|
|
|
|
|
|
|
|
if service.data_template:
|
|
|
|
try:
|
|
|
|
data_template = {
|
|
|
|
key: Template(value) for key, value in service.data_template.items()
|
|
|
|
}
|
|
|
|
template.attach(hass, data_template)
|
|
|
|
service_data.update(
|
|
|
|
template.render_complex(data_template, service.variables)
|
|
|
|
)
|
|
|
|
except TemplateError as ex:
|
2024-01-04 08:37:56 +00:00
|
|
|
_LOGGER.error(
|
|
|
|
"Error rendering data template %s for %s: %s",
|
|
|
|
service.data_template,
|
|
|
|
self.host,
|
|
|
|
ex,
|
|
|
|
)
|
2023-07-08 07:19:44 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
if service.is_event:
|
|
|
|
device_id = self.device_id
|
|
|
|
# ESPHome uses service call packet for both events and service calls
|
|
|
|
# Ensure the user can only send events of form 'esphome.xyz'
|
2024-01-04 08:37:56 +00:00
|
|
|
if domain != DOMAIN:
|
2023-07-08 07:19:44 +00:00
|
|
|
_LOGGER.error(
|
|
|
|
"Can only generate events under esphome domain! (%s)", self.host
|
|
|
|
)
|
|
|
|
return
|
|
|
|
|
|
|
|
# Call native tag scan
|
|
|
|
if service_name == "tag_scanned" and device_id is not None:
|
|
|
|
tag_id = service_data["tag_id"]
|
|
|
|
hass.async_create_task(tag.async_scan_tag(hass, tag_id, device_id))
|
|
|
|
return
|
|
|
|
|
|
|
|
hass.bus.async_fire(
|
|
|
|
service.service,
|
|
|
|
{
|
|
|
|
ATTR_DEVICE_ID: device_id,
|
|
|
|
**service_data,
|
|
|
|
},
|
|
|
|
)
|
|
|
|
elif self.entry.options.get(
|
|
|
|
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
|
|
|
|
):
|
|
|
|
hass.async_create_task(
|
|
|
|
hass.services.async_call(
|
|
|
|
domain, service_name, service_data, blocking=True
|
|
|
|
)
|
|
|
|
)
|
|
|
|
else:
|
|
|
|
device_info = self.entry_data.device_info
|
|
|
|
assert device_info is not None
|
|
|
|
async_create_issue(
|
|
|
|
hass,
|
|
|
|
DOMAIN,
|
|
|
|
self.services_issue,
|
|
|
|
is_fixable=False,
|
|
|
|
severity=IssueSeverity.WARNING,
|
|
|
|
translation_key="service_calls_not_allowed",
|
|
|
|
translation_placeholders={
|
|
|
|
"name": device_info.friendly_name or device_info.name,
|
|
|
|
},
|
|
|
|
)
|
|
|
|
_LOGGER.error(
|
|
|
|
"%s: Service call %s.%s: with data %s rejected; "
|
|
|
|
"If you trust this device and want to allow access for it to make "
|
|
|
|
"Home Assistant service calls, you can enable this "
|
|
|
|
"functionality in the options flow",
|
|
|
|
device_info.friendly_name or device_info.name,
|
|
|
|
domain,
|
|
|
|
service_name,
|
|
|
|
service_data,
|
|
|
|
)
|
|
|
|
|
2024-02-19 09:38:28 +00:00
|
|
|
@callback
|
|
|
|
def _send_home_assistant_state(
|
2023-07-08 07:19:44 +00:00
|
|
|
self, entity_id: str, attribute: str | None, state: State | None
|
|
|
|
) -> None:
|
|
|
|
"""Forward Home Assistant states to ESPHome."""
|
|
|
|
if state is None or (attribute and attribute not in state.attributes):
|
|
|
|
return
|
|
|
|
|
|
|
|
send_state = state.state
|
|
|
|
if attribute:
|
|
|
|
attr_val = state.attributes[attribute]
|
|
|
|
# ESPHome only handles "on"/"off" for boolean values
|
|
|
|
if isinstance(attr_val, bool):
|
|
|
|
send_state = "on" if attr_val else "off"
|
|
|
|
else:
|
|
|
|
send_state = attr_val
|
|
|
|
|
2024-02-19 09:38:28 +00:00
|
|
|
self.cli.send_home_assistant_state(entity_id, attribute, str(send_state))
|
2023-07-08 07:19:44 +00:00
|
|
|
|
2024-02-19 09:38:28 +00:00
|
|
|
@callback
|
|
|
|
def _send_home_assistant_state_event(
|
2024-01-05 18:51:49 +00:00
|
|
|
self,
|
|
|
|
attribute: str | None,
|
2024-03-08 18:35:17 +00:00
|
|
|
event: Event[EventStateChangedData],
|
2024-01-05 18:51:49 +00:00
|
|
|
) -> None:
|
|
|
|
"""Forward Home Assistant states updates to ESPHome."""
|
|
|
|
event_data = event.data
|
|
|
|
new_state = event_data["new_state"]
|
|
|
|
old_state = event_data["old_state"]
|
|
|
|
|
|
|
|
if new_state is None or old_state is None:
|
|
|
|
return
|
|
|
|
|
|
|
|
# Only communicate changes to the state or attribute tracked
|
|
|
|
if (not attribute and old_state.state == new_state.state) or (
|
|
|
|
attribute
|
|
|
|
and old_state.attributes.get(attribute)
|
|
|
|
== new_state.attributes.get(attribute)
|
|
|
|
):
|
|
|
|
return
|
|
|
|
|
2024-02-19 09:38:28 +00:00
|
|
|
self._send_home_assistant_state(event.data["entity_id"], attribute, new_state)
|
2024-01-05 18:51:49 +00:00
|
|
|
|
2023-07-08 07:19:44 +00:00
|
|
|
@callback
|
|
|
|
def async_on_state_subscription(
|
|
|
|
self, entity_id: str, attribute: str | None = None
|
|
|
|
) -> None:
|
|
|
|
"""Subscribe and forward states for requested entities."""
|
|
|
|
hass = self.hass
|
2023-11-21 06:58:22 +00:00
|
|
|
self.entry_data.disconnect_callbacks.add(
|
2023-07-08 07:19:44 +00:00
|
|
|
async_track_state_change_event(
|
2024-01-05 18:51:49 +00:00
|
|
|
hass,
|
|
|
|
[entity_id],
|
|
|
|
partial(self._send_home_assistant_state_event, attribute),
|
2023-07-08 07:19:44 +00:00
|
|
|
)
|
|
|
|
)
|
|
|
|
# Send initial state
|
2024-02-19 09:38:28 +00:00
|
|
|
self._send_home_assistant_state(
|
|
|
|
entity_id, attribute, hass.states.get(entity_id)
|
2023-07-08 07:19:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
def _handle_pipeline_finished(self) -> None:
|
|
|
|
self.entry_data.async_set_assist_pipeline_state(False)
|
|
|
|
|
2024-04-09 14:55:59 +00:00
|
|
|
if self.voice_assistant_pipeline is not None:
|
|
|
|
if isinstance(self.voice_assistant_pipeline, VoiceAssistantUDPPipeline):
|
|
|
|
self.voice_assistant_pipeline.close()
|
|
|
|
self.voice_assistant_pipeline = None
|
2023-07-08 07:19:44 +00:00
|
|
|
|
|
|
|
async def _handle_pipeline_start(
|
2023-09-26 21:27:26 +00:00
|
|
|
self,
|
|
|
|
conversation_id: str,
|
|
|
|
flags: int,
|
|
|
|
audio_settings: VoiceAssistantAudioSettings,
|
2024-02-27 04:29:03 +00:00
|
|
|
wake_word_phrase: str | None,
|
2023-07-08 07:19:44 +00:00
|
|
|
) -> int | None:
|
|
|
|
"""Start a voice assistant pipeline."""
|
2024-04-09 14:55:59 +00:00
|
|
|
if self.voice_assistant_pipeline is not None:
|
2023-10-11 18:32:00 +00:00
|
|
|
_LOGGER.warning("Voice assistant UDP server was not stopped")
|
2024-04-09 14:55:59 +00:00
|
|
|
self.voice_assistant_pipeline.stop()
|
|
|
|
self.voice_assistant_pipeline = None
|
2023-07-08 07:19:44 +00:00
|
|
|
|
|
|
|
hass = self.hass
|
2024-04-09 14:55:59 +00:00
|
|
|
assert self.entry_data.device_info is not None
|
|
|
|
if (
|
|
|
|
self.entry_data.device_info.voice_assistant_feature_flags_compat(
|
|
|
|
self.entry_data.api_version
|
|
|
|
)
|
|
|
|
& VoiceAssistantFeature.API_AUDIO
|
|
|
|
):
|
|
|
|
self.voice_assistant_pipeline = VoiceAssistantAPIPipeline(
|
|
|
|
hass,
|
|
|
|
self.entry_data,
|
|
|
|
self.cli.send_voice_assistant_event,
|
|
|
|
self._handle_pipeline_finished,
|
|
|
|
self.cli,
|
|
|
|
)
|
|
|
|
port = 0
|
|
|
|
else:
|
|
|
|
self.voice_assistant_pipeline = VoiceAssistantUDPPipeline(
|
|
|
|
hass,
|
|
|
|
self.entry_data,
|
|
|
|
self.cli.send_voice_assistant_event,
|
|
|
|
self._handle_pipeline_finished,
|
|
|
|
)
|
|
|
|
port = await self.voice_assistant_pipeline.start_server()
|
2023-07-08 07:19:44 +00:00
|
|
|
|
|
|
|
assert self.device_id is not None, "Device ID must be set"
|
|
|
|
hass.async_create_background_task(
|
2024-04-09 14:55:59 +00:00
|
|
|
self.voice_assistant_pipeline.run_pipeline(
|
2023-07-08 07:19:44 +00:00
|
|
|
device_id=self.device_id,
|
|
|
|
conversation_id=conversation_id or None,
|
2023-08-21 16:13:02 +00:00
|
|
|
flags=flags,
|
2023-09-26 21:27:26 +00:00
|
|
|
audio_settings=audio_settings,
|
2024-02-27 04:29:03 +00:00
|
|
|
wake_word_phrase=wake_word_phrase,
|
2023-07-08 07:19:44 +00:00
|
|
|
),
|
2024-04-09 14:55:59 +00:00
|
|
|
"esphome.voice_assistant_pipeline.run_pipeline",
|
2023-07-08 07:19:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
return port
|
|
|
|
|
|
|
|
async def _handle_pipeline_stop(self) -> None:
|
|
|
|
"""Stop a voice assistant pipeline."""
|
2024-04-09 14:55:59 +00:00
|
|
|
if self.voice_assistant_pipeline is not None:
|
|
|
|
self.voice_assistant_pipeline.stop()
|
|
|
|
|
|
|
|
async def _handle_audio(self, data: bytes) -> None:
|
|
|
|
if self.voice_assistant_pipeline is None:
|
|
|
|
return
|
|
|
|
assert isinstance(self.voice_assistant_pipeline, VoiceAssistantAPIPipeline)
|
|
|
|
self.voice_assistant_pipeline.receive_audio_bytes(data)
|
2023-07-08 07:19:44 +00:00
|
|
|
|
|
|
|
async def on_connect(self) -> None:
|
2024-01-08 08:10:58 +00:00
|
|
|
"""Subscribe to states and list entities on successful API login."""
|
|
|
|
try:
|
|
|
|
await self._on_connnect()
|
|
|
|
except APIConnectionError as err:
|
|
|
|
_LOGGER.warning(
|
|
|
|
"Error getting setting up connection for %s: %s", self.host, err
|
|
|
|
)
|
|
|
|
# Re-connection logic will trigger after this
|
|
|
|
await self.cli.disconnect()
|
|
|
|
|
|
|
|
async def _on_connnect(self) -> None:
|
2023-07-08 07:19:44 +00:00
|
|
|
"""Subscribe to states and list entities on successful API login."""
|
|
|
|
entry = self.entry
|
2023-08-22 21:02:23 +00:00
|
|
|
unique_id = entry.unique_id
|
2023-07-08 07:19:44 +00:00
|
|
|
entry_data = self.entry_data
|
|
|
|
reconnect_logic = self.reconnect_logic
|
2023-08-22 21:02:23 +00:00
|
|
|
assert reconnect_logic is not None, "Reconnect logic must be set"
|
2023-07-08 07:19:44 +00:00
|
|
|
hass = self.hass
|
|
|
|
cli = self.cli
|
2023-08-22 21:02:23 +00:00
|
|
|
stored_device_name = entry.data.get(CONF_DEVICE_NAME)
|
|
|
|
unique_id_is_mac_address = unique_id and ":" in unique_id
|
2024-01-08 08:10:58 +00:00
|
|
|
results = await asyncio.gather(
|
2024-03-02 03:16:01 +00:00
|
|
|
create_eager_task(cli.device_info()),
|
|
|
|
create_eager_task(cli.list_entities_services()),
|
2024-01-08 08:10:58 +00:00
|
|
|
)
|
2023-07-08 07:19:44 +00:00
|
|
|
|
2023-11-22 22:27:17 +00:00
|
|
|
device_info: EsphomeDeviceInfo = results[0]
|
|
|
|
entity_infos_services: tuple[list[EntityInfo], list[UserService]] = results[1]
|
|
|
|
entity_infos, services = entity_infos_services
|
|
|
|
|
2023-08-22 21:02:23 +00:00
|
|
|
device_mac = format_mac(device_info.mac_address)
|
|
|
|
mac_address_matches = unique_id == device_mac
|
|
|
|
#
|
|
|
|
# Migrate config entry to new unique ID if the current
|
|
|
|
# unique id is not a mac address.
|
|
|
|
#
|
|
|
|
# This was changed in 2023.1
|
|
|
|
if not mac_address_matches and not unique_id_is_mac_address:
|
|
|
|
hass.config_entries.async_update_entry(entry, unique_id=device_mac)
|
|
|
|
|
|
|
|
if not mac_address_matches and unique_id_is_mac_address:
|
|
|
|
# If the unique id is a mac address
|
|
|
|
# and does not match we have the wrong device and we need
|
|
|
|
# to abort the connection. This can happen if the DHCP
|
|
|
|
# server changes the IP address of the device and we end up
|
|
|
|
# connecting to the wrong device.
|
|
|
|
_LOGGER.error(
|
|
|
|
"Unexpected device found at %s; "
|
|
|
|
"expected `%s` with mac address `%s`, "
|
|
|
|
"found `%s` with mac address `%s`",
|
|
|
|
self.host,
|
|
|
|
stored_device_name,
|
|
|
|
unique_id,
|
|
|
|
device_info.name,
|
|
|
|
device_mac,
|
|
|
|
)
|
|
|
|
await cli.disconnect()
|
|
|
|
await reconnect_logic.stop()
|
|
|
|
# We don't want to reconnect to the wrong device
|
|
|
|
# so we stop the reconnect logic and disconnect
|
|
|
|
# the client. When discovery finds the new IP address
|
|
|
|
# for the device, the config entry will be updated
|
|
|
|
# and we will connect to the correct device when
|
|
|
|
# the config entry gets reloaded by the discovery
|
|
|
|
# flow.
|
|
|
|
return
|
2023-07-08 07:19:44 +00:00
|
|
|
|
2023-08-22 21:02:23 +00:00
|
|
|
# Make sure we have the correct device name stored
|
|
|
|
# so we can map the device to ESPHome Dashboard config
|
|
|
|
# If we got here, we know the mac address matches or we
|
|
|
|
# did a migration to the mac address so we can update
|
|
|
|
# the device name.
|
|
|
|
if stored_device_name != device_info.name:
|
|
|
|
hass.config_entries.async_update_entry(
|
|
|
|
entry, data={**entry.data, CONF_DEVICE_NAME: device_info.name}
|
|
|
|
)
|
2023-07-08 07:19:44 +00:00
|
|
|
|
2023-12-17 14:42:28 +00:00
|
|
|
api_version = cli.api_version
|
|
|
|
assert api_version is not None, "API version must be set"
|
|
|
|
entry_data.async_on_connect(device_info, api_version)
|
|
|
|
|
2023-08-22 21:02:23 +00:00
|
|
|
if device_info.name:
|
|
|
|
reconnect_logic.name = device_info.name
|
|
|
|
|
2023-11-22 22:27:17 +00:00
|
|
|
self.device_id = _async_setup_device_registry(hass, entry, entry_data)
|
2024-02-19 09:38:28 +00:00
|
|
|
|
2024-03-10 06:30:17 +00:00
|
|
|
entry_data.async_update_device_state()
|
2024-01-04 00:47:49 +00:00
|
|
|
await entry_data.async_update_static_infos(
|
Ensure config entries are not unloaded while their platforms are setting up (#118767)
* Report non-awaited/non-locked config entry platform forwards
Its currently possible for config entries to be reloaded while their platforms
are being forwarded if platform forwards are not awaited or done after the
config entry is setup since the lock will not be held in this case.
In https://developers.home-assistant.io/blog/2022/07/08/config_entry_forwards
we advised to await platform forwards to ensure this does not happen, however
for sleeping devices and late discovered devices, platform forwards may happen
later.
If config platform forwards are happening during setup, they should be awaited
If config entry platform forwards are not happening during setup, instead
async_late_forward_entry_setups should be used which will hold the lock to
prevent the config entry from being unloaded while its platforms are being
setup
* Report non-awaited/non-locked config entry platform forwards
Its currently possible for config entries to be reloaded while their platforms
are being forwarded if platform forwards are not awaited or done after the
config entry is setup since the lock will not be held in this case.
In https://developers.home-assistant.io/blog/2022/07/08/config_entry_forwards
we advised to await platform forwards to ensure this does not happen, however
for sleeping devices and late discovered devices, platform forwards may happen
later.
If config platform forwards are happening during setup, they should be awaited
If config entry platform forwards are not happening during setup, instead
async_late_forward_entry_setups should be used which will hold the lock to
prevent the config entry from being unloaded while its platforms are being
setup
* run with error on to find them
* cert_exp, hold lock
* cert_exp, hold lock
* shelly async_late_forward_entry_setups
* compact
* compact
* found another
* patch up mobileapp
* patch up hue tests
* patch up smartthings
* fix mqtt
* fix esphome
* zwave_js
* mqtt
* rework
* fixes
* fix mocking
* fix mocking
* do not call async_forward_entry_setup directly
* docstrings
* docstrings
* docstrings
* add comments
* doc strings
* fixed all in core, turn off strict
* coverage
* coverage
* missing
* coverage
2024-06-05 01:34:39 +00:00
|
|
|
hass, entry, entity_infos, device_info.mac_address, late=True
|
2023-11-22 22:27:17 +00:00
|
|
|
)
|
2024-01-04 00:47:49 +00:00
|
|
|
_setup_services(hass, entry_data, services)
|
2023-11-22 22:27:17 +00:00
|
|
|
|
2023-12-17 14:42:28 +00:00
|
|
|
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
|
2024-02-19 09:38:28 +00:00
|
|
|
entry_data.disconnect_callbacks.add(
|
2023-11-22 22:27:17 +00:00
|
|
|
async_connect_scanner(
|
2023-12-17 14:42:28 +00:00
|
|
|
hass, entry_data, cli, device_info, self.domain_data.bluetooth_cache
|
2023-07-08 07:19:44 +00:00
|
|
|
)
|
2023-08-22 21:02:23 +00:00
|
|
|
)
|
2023-07-08 07:19:44 +00:00
|
|
|
|
2024-04-09 14:55:59 +00:00
|
|
|
flags = device_info.voice_assistant_feature_flags_compat(api_version)
|
|
|
|
if flags:
|
|
|
|
if flags & VoiceAssistantFeature.API_AUDIO:
|
|
|
|
entry_data.disconnect_callbacks.add(
|
|
|
|
cli.subscribe_voice_assistant(
|
|
|
|
handle_start=self._handle_pipeline_start,
|
|
|
|
handle_stop=self._handle_pipeline_stop,
|
|
|
|
handle_audio=self._handle_audio,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
else:
|
|
|
|
entry_data.disconnect_callbacks.add(
|
|
|
|
cli.subscribe_voice_assistant(
|
|
|
|
handle_start=self._handle_pipeline_start,
|
|
|
|
handle_stop=self._handle_pipeline_stop,
|
|
|
|
)
|
2023-11-22 22:27:17 +00:00
|
|
|
)
|
2024-05-28 18:57:58 +00:00
|
|
|
if flags & VoiceAssistantFeature.TIMERS:
|
|
|
|
entry_data.disconnect_callbacks.add(
|
|
|
|
async_register_timer_handler(
|
|
|
|
hass, self.device_id, partial(handle_timer_event, cli)
|
|
|
|
)
|
|
|
|
)
|
2023-07-08 07:19:44 +00:00
|
|
|
|
2024-02-19 09:38:28 +00:00
|
|
|
cli.subscribe_states(entry_data.async_update_state)
|
|
|
|
cli.subscribe_service_calls(self.async_on_service_call)
|
|
|
|
cli.subscribe_home_assistant_states(self.async_on_state_subscription)
|
2023-11-22 22:27:17 +00:00
|
|
|
|
2024-02-19 09:38:28 +00:00
|
|
|
entry_data.async_save_to_store()
|
2023-12-17 14:42:28 +00:00
|
|
|
_async_check_firmware_version(hass, device_info, api_version)
|
2023-11-22 22:27:17 +00:00
|
|
|
_async_check_using_api_password(hass, device_info, bool(self.password))
|
2023-07-08 07:19:44 +00:00
|
|
|
|
|
|
|
async def on_disconnect(self, expected_disconnect: bool) -> None:
|
|
|
|
"""Run disconnect callbacks on API disconnect."""
|
|
|
|
entry_data = self.entry_data
|
|
|
|
hass = self.hass
|
|
|
|
host = self.host
|
|
|
|
name = entry_data.device_info.name if entry_data.device_info else host
|
|
|
|
_LOGGER.debug(
|
|
|
|
"%s: %s disconnected (expected=%s), running disconnected callbacks",
|
|
|
|
name,
|
|
|
|
host,
|
|
|
|
expected_disconnect,
|
|
|
|
)
|
2023-11-21 06:58:22 +00:00
|
|
|
entry_data.async_on_disconnect()
|
2023-07-08 07:19:44 +00:00
|
|
|
entry_data.expected_disconnect = expected_disconnect
|
|
|
|
# Mark state as stale so that we will always dispatch
|
|
|
|
# the next state update of that type when the device reconnects
|
|
|
|
entry_data.stale_state = {
|
|
|
|
(type(entity_state), key)
|
|
|
|
for state_dict in entry_data.state.values()
|
|
|
|
for key, entity_state in state_dict.items()
|
|
|
|
}
|
|
|
|
if not hass.is_stopping:
|
|
|
|
# Avoid marking every esphome entity as unavailable on shutdown
|
|
|
|
# since it generates a lot of state changed events and database
|
|
|
|
# writes when we already know we're shutting down and the state
|
|
|
|
# will be cleared anyway.
|
2024-03-10 06:30:17 +00:00
|
|
|
entry_data.async_update_device_state()
|
2023-07-08 07:19:44 +00:00
|
|
|
|
|
|
|
async def on_connect_error(self, err: Exception) -> None:
|
|
|
|
"""Start reauth flow if appropriate connect error type."""
|
|
|
|
if isinstance(
|
|
|
|
err,
|
|
|
|
(
|
|
|
|
RequiresEncryptionAPIError,
|
|
|
|
InvalidEncryptionKeyAPIError,
|
|
|
|
InvalidAuthAPIError,
|
|
|
|
),
|
|
|
|
):
|
|
|
|
self.entry.async_start_reauth(self.hass)
|
|
|
|
|
2023-11-25 20:00:04 +00:00
|
|
|
@callback
|
|
|
|
def _async_handle_logging_changed(self, _event: Event) -> None:
|
|
|
|
"""Handle when the logging level changes."""
|
|
|
|
self.cli.set_debug(_LOGGER.isEnabledFor(logging.DEBUG))
|
|
|
|
|
2023-07-08 07:19:44 +00:00
|
|
|
async def async_start(self) -> None:
|
|
|
|
"""Start the esphome connection manager."""
|
|
|
|
hass = self.hass
|
|
|
|
entry = self.entry
|
|
|
|
entry_data = self.entry_data
|
|
|
|
|
|
|
|
if entry.options.get(CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS):
|
|
|
|
async_delete_issue(hass, DOMAIN, self.services_issue)
|
|
|
|
|
|
|
|
# Use async_listen instead of async_listen_once so that we don't deregister
|
|
|
|
# the callback twice when shutting down Home Assistant.
|
|
|
|
# "Unable to remove unknown listener
|
|
|
|
# <function EventBus.async_listen_once.<locals>.onetime_listener>"
|
2024-03-17 18:15:28 +00:00
|
|
|
# We only close the connection at the last possible moment
|
|
|
|
# when the CLOSE event is fired so anything using a Bluetooth
|
|
|
|
# proxy has a chance to shut down properly.
|
2023-07-08 07:19:44 +00:00
|
|
|
entry_data.cleanup_callbacks.append(
|
2024-04-08 20:07:54 +00:00
|
|
|
hass.bus.async_listen(EVENT_HOMEASSISTANT_CLOSE, self.on_stop)
|
2023-07-08 07:19:44 +00:00
|
|
|
)
|
2023-11-25 20:00:04 +00:00
|
|
|
entry_data.cleanup_callbacks.append(
|
|
|
|
hass.bus.async_listen(
|
2024-03-12 20:49:31 +00:00
|
|
|
EVENT_LOGGING_CHANGED,
|
|
|
|
self._async_handle_logging_changed,
|
2023-11-25 20:00:04 +00:00
|
|
|
)
|
|
|
|
)
|
2023-07-08 07:19:44 +00:00
|
|
|
|
|
|
|
reconnect_logic = ReconnectLogic(
|
|
|
|
client=self.cli,
|
|
|
|
on_connect=self.on_connect,
|
|
|
|
on_disconnect=self.on_disconnect,
|
|
|
|
zeroconf_instance=self.zeroconf_instance,
|
2023-10-15 09:42:22 +00:00
|
|
|
name=entry.data.get(CONF_DEVICE_NAME, self.host),
|
2023-07-08 07:19:44 +00:00
|
|
|
on_connect_error=self.on_connect_error,
|
|
|
|
)
|
|
|
|
self.reconnect_logic = reconnect_logic
|
|
|
|
|
|
|
|
infos, services = await entry_data.async_load_from_store()
|
2023-10-16 03:05:20 +00:00
|
|
|
if entry.unique_id:
|
|
|
|
await entry_data.async_update_static_infos(
|
|
|
|
hass, entry, infos, entry.unique_id.upper()
|
|
|
|
)
|
2024-01-04 00:47:49 +00:00
|
|
|
_setup_services(hass, entry_data, services)
|
2023-07-08 07:19:44 +00:00
|
|
|
|
|
|
|
if entry_data.device_info is not None and entry_data.device_info.name:
|
|
|
|
reconnect_logic.name = entry_data.device_info.name
|
|
|
|
if entry.unique_id is None:
|
|
|
|
hass.config_entries.async_update_entry(
|
|
|
|
entry, unique_id=format_mac(entry_data.device_info.mac_address)
|
|
|
|
)
|
|
|
|
|
|
|
|
await reconnect_logic.start()
|
|
|
|
entry_data.cleanup_callbacks.append(reconnect_logic.stop_callback)
|
|
|
|
|
|
|
|
entry.async_on_unload(
|
|
|
|
entry.add_update_listener(entry_data.async_update_listener)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@callback
|
|
|
|
def _async_setup_device_registry(
|
2023-07-23 08:45:48 +00:00
|
|
|
hass: HomeAssistant, entry: ConfigEntry, entry_data: RuntimeEntryData
|
2023-07-08 07:19:44 +00:00
|
|
|
) -> str:
|
|
|
|
"""Set up device registry feature for a particular config entry."""
|
2023-07-23 08:45:48 +00:00
|
|
|
device_info = entry_data.device_info
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
assert device_info is not None
|
2023-07-08 07:19:44 +00:00
|
|
|
sw_version = device_info.esphome_version
|
|
|
|
if device_info.compilation_time:
|
|
|
|
sw_version += f" ({device_info.compilation_time})"
|
|
|
|
|
|
|
|
configuration_url = None
|
|
|
|
if device_info.webserver_port > 0:
|
|
|
|
configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}"
|
|
|
|
elif dashboard := async_get_dashboard(hass):
|
|
|
|
configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}"
|
|
|
|
|
|
|
|
manufacturer = "espressif"
|
|
|
|
if device_info.manufacturer:
|
|
|
|
manufacturer = device_info.manufacturer
|
|
|
|
model = device_info.model
|
|
|
|
hw_version = None
|
|
|
|
if device_info.project_name:
|
|
|
|
project_name = device_info.project_name.split(".")
|
|
|
|
manufacturer = project_name[0]
|
|
|
|
model = project_name[1]
|
|
|
|
hw_version = device_info.project_version
|
|
|
|
|
2023-10-26 07:19:31 +00:00
|
|
|
suggested_area = None
|
|
|
|
if device_info.suggested_area:
|
|
|
|
suggested_area = device_info.suggested_area
|
|
|
|
|
2023-07-08 07:19:44 +00:00
|
|
|
device_registry = dr.async_get(hass)
|
|
|
|
device_entry = device_registry.async_get_or_create(
|
|
|
|
config_entry_id=entry.entry_id,
|
|
|
|
configuration_url=configuration_url,
|
|
|
|
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)},
|
2023-07-23 08:45:48 +00:00
|
|
|
name=entry_data.friendly_name,
|
2023-07-08 07:19:44 +00:00
|
|
|
manufacturer=manufacturer,
|
|
|
|
model=model,
|
|
|
|
sw_version=sw_version,
|
|
|
|
hw_version=hw_version,
|
2023-10-26 07:19:31 +00:00
|
|
|
suggested_area=suggested_area,
|
2023-07-08 07:19:44 +00:00
|
|
|
)
|
|
|
|
return device_entry.id
|
|
|
|
|
|
|
|
|
|
|
|
class ServiceMetadata(NamedTuple):
|
|
|
|
"""Metadata for services."""
|
|
|
|
|
|
|
|
validator: Any
|
|
|
|
example: str
|
|
|
|
selector: dict[str, Any]
|
|
|
|
description: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
ARG_TYPE_METADATA = {
|
|
|
|
UserServiceArgType.BOOL: ServiceMetadata(
|
|
|
|
validator=cv.boolean,
|
|
|
|
example="False",
|
|
|
|
selector={"boolean": None},
|
|
|
|
),
|
|
|
|
UserServiceArgType.INT: ServiceMetadata(
|
|
|
|
validator=vol.Coerce(int),
|
|
|
|
example="42",
|
|
|
|
selector={"number": {CONF_MODE: "box"}},
|
|
|
|
),
|
|
|
|
UserServiceArgType.FLOAT: ServiceMetadata(
|
|
|
|
validator=vol.Coerce(float),
|
|
|
|
example="12.3",
|
|
|
|
selector={"number": {CONF_MODE: "box", "step": 1e-3}},
|
|
|
|
),
|
|
|
|
UserServiceArgType.STRING: ServiceMetadata(
|
|
|
|
validator=cv.string,
|
|
|
|
example="Example text",
|
|
|
|
selector={"text": None},
|
|
|
|
),
|
|
|
|
UserServiceArgType.BOOL_ARRAY: ServiceMetadata(
|
|
|
|
validator=[cv.boolean],
|
|
|
|
description="A list of boolean values.",
|
|
|
|
example="[True, False]",
|
|
|
|
selector={"object": {}},
|
|
|
|
),
|
|
|
|
UserServiceArgType.INT_ARRAY: ServiceMetadata(
|
|
|
|
validator=[vol.Coerce(int)],
|
|
|
|
description="A list of integer values.",
|
|
|
|
example="[42, 34]",
|
|
|
|
selector={"object": {}},
|
|
|
|
),
|
|
|
|
UserServiceArgType.FLOAT_ARRAY: ServiceMetadata(
|
|
|
|
validator=[vol.Coerce(float)],
|
|
|
|
description="A list of floating point numbers.",
|
|
|
|
example="[ 12.3, 34.5 ]",
|
|
|
|
selector={"object": {}},
|
|
|
|
),
|
|
|
|
UserServiceArgType.STRING_ARRAY: ServiceMetadata(
|
|
|
|
validator=[cv.string],
|
|
|
|
description="A list of strings.",
|
|
|
|
example="['Example text', 'Another example']",
|
|
|
|
selector={"object": {}},
|
|
|
|
),
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2024-02-19 09:38:28 +00:00
|
|
|
@callback
|
|
|
|
def execute_service(
|
2024-01-04 00:47:49 +00:00
|
|
|
entry_data: RuntimeEntryData, service: UserService, call: ServiceCall
|
2023-07-08 07:19:44 +00:00
|
|
|
) -> None:
|
2024-01-04 00:47:49 +00:00
|
|
|
"""Execute a service on a node."""
|
2024-02-19 09:38:28 +00:00
|
|
|
entry_data.client.execute_service(service, call.data)
|
2024-01-04 00:47:49 +00:00
|
|
|
|
|
|
|
|
|
|
|
def build_service_name(device_info: EsphomeDeviceInfo, service: UserService) -> str:
|
|
|
|
"""Build a service name for a node."""
|
|
|
|
return f"{device_info.name.replace('-', '_')}_{service.name}"
|
|
|
|
|
|
|
|
|
|
|
|
@callback
|
|
|
|
def _async_register_service(
|
|
|
|
hass: HomeAssistant,
|
|
|
|
entry_data: RuntimeEntryData,
|
|
|
|
device_info: EsphomeDeviceInfo,
|
|
|
|
service: UserService,
|
|
|
|
) -> None:
|
|
|
|
"""Register a service on a node."""
|
|
|
|
service_name = build_service_name(device_info, service)
|
2023-07-08 07:19:44 +00:00
|
|
|
schema = {}
|
|
|
|
fields = {}
|
|
|
|
|
|
|
|
for arg in service.args:
|
|
|
|
if arg.type not in ARG_TYPE_METADATA:
|
|
|
|
_LOGGER.error(
|
|
|
|
"Can't register service %s because %s is of unknown type %s",
|
|
|
|
service_name,
|
|
|
|
arg.name,
|
|
|
|
arg.type,
|
|
|
|
)
|
|
|
|
return
|
|
|
|
metadata = ARG_TYPE_METADATA[arg.type]
|
|
|
|
schema[vol.Required(arg.name)] = metadata.validator
|
|
|
|
fields[arg.name] = {
|
|
|
|
"name": arg.name,
|
|
|
|
"required": True,
|
|
|
|
"description": metadata.description,
|
|
|
|
"example": metadata.example,
|
|
|
|
"selector": metadata.selector,
|
|
|
|
}
|
|
|
|
|
|
|
|
hass.services.async_register(
|
2024-01-04 00:47:49 +00:00
|
|
|
DOMAIN,
|
|
|
|
service_name,
|
|
|
|
partial(execute_service, entry_data, service),
|
|
|
|
vol.Schema(schema),
|
|
|
|
)
|
|
|
|
async_set_service_schema(
|
|
|
|
hass,
|
|
|
|
DOMAIN,
|
|
|
|
service_name,
|
|
|
|
{
|
|
|
|
"description": (
|
|
|
|
f"Calls the service {service.name} of the node {device_info.name}"
|
|
|
|
),
|
|
|
|
"fields": fields,
|
|
|
|
},
|
2023-07-08 07:19:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
2024-01-04 00:47:49 +00:00
|
|
|
@callback
|
|
|
|
def _setup_services(
|
2023-07-08 07:19:44 +00:00
|
|
|
hass: HomeAssistant, entry_data: RuntimeEntryData, services: list[UserService]
|
|
|
|
) -> None:
|
2024-01-04 00:47:49 +00:00
|
|
|
device_info = entry_data.device_info
|
|
|
|
if device_info is None:
|
2023-07-08 07:19:44 +00:00
|
|
|
# Can happen if device has never connected or .storage cleared
|
|
|
|
return
|
|
|
|
old_services = entry_data.services.copy()
|
2024-01-04 00:47:49 +00:00
|
|
|
to_unregister: list[UserService] = []
|
|
|
|
to_register: list[UserService] = []
|
2023-07-08 07:19:44 +00:00
|
|
|
for service in services:
|
|
|
|
if service.key in old_services:
|
|
|
|
# Already exists
|
|
|
|
if (matching := old_services.pop(service.key)) != service:
|
|
|
|
# Need to re-register
|
|
|
|
to_unregister.append(matching)
|
|
|
|
to_register.append(service)
|
|
|
|
else:
|
|
|
|
# New service
|
|
|
|
to_register.append(service)
|
|
|
|
|
2024-03-12 17:42:43 +00:00
|
|
|
to_unregister.extend(old_services.values())
|
2023-07-08 07:19:44 +00:00
|
|
|
|
|
|
|
entry_data.services = {serv.key: serv for serv in services}
|
|
|
|
|
|
|
|
for service in to_unregister:
|
2024-01-04 00:47:49 +00:00
|
|
|
service_name = build_service_name(device_info, service)
|
2023-07-08 07:19:44 +00:00
|
|
|
hass.services.async_remove(DOMAIN, service_name)
|
|
|
|
|
|
|
|
for service in to_register:
|
2024-01-04 00:47:49 +00:00
|
|
|
_async_register_service(hass, entry_data, device_info, service)
|
2023-07-08 07:19:44 +00:00
|
|
|
|
|
|
|
|
|
|
|
async def cleanup_instance(hass: HomeAssistant, entry: ConfigEntry) -> RuntimeEntryData:
|
|
|
|
"""Cleanup the esphome client if it exists."""
|
|
|
|
domain_data = DomainData.get(hass)
|
|
|
|
data = domain_data.pop_entry_data(entry)
|
2023-11-21 06:58:22 +00:00
|
|
|
data.async_on_disconnect()
|
2023-07-08 07:19:44 +00:00
|
|
|
for cleanup_callback in data.cleanup_callbacks:
|
|
|
|
cleanup_callback()
|
|
|
|
await data.async_cleanup()
|
|
|
|
await data.client.disconnect()
|
|
|
|
return data
|