Files
core/homeassistant/components/matter/__init__.py
Stefan Agner 44aad2b821 Improve Matter Server version incompatibility handling (#120416)
* Improve Matter Server version incompatibility handling

Improve the handling of Matter Server version. Noteably fix the issues
raised (add strings for the issue) and split the version check into
two cases: One if the server is too old and one if the server is too
new.

* Bump Python Matter Server library to 6.2.0b1

* Address review feedback
2024-06-26 11:43:51 +02:00

289 lines
9.7 KiB
Python

"""The Matter integration."""
from __future__ import annotations
import asyncio
from contextlib import suppress
from functools import cache
from matter_server.client import MatterClient
from matter_server.client.exceptions import (
CannotConnect,
InvalidServerVersion,
ServerVersionTooNew,
ServerVersionTooOld,
)
from matter_server.common.errors import MatterError, NodeNotExists
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from .adapter import MatterAdapter
from .addon import get_addon_manager
from .api import async_register_api
from .const import CONF_INTEGRATION_CREATED_ADDON, CONF_USE_ADDON, DOMAIN, LOGGER
from .discovery import SUPPORTED_PLATFORMS
from .helpers import (
MatterEntryData,
get_matter,
get_node_from_device_entry,
node_from_ha_device_id,
)
from .models import MatterDeviceInfo
CONNECT_TIMEOUT = 10
LISTEN_READY_TIMEOUT = 30
@callback
@cache
def get_matter_device_info(
hass: HomeAssistant, device_id: str
) -> MatterDeviceInfo | None:
"""Return Matter device info or None if device does not exist."""
# Test hass.data[DOMAIN] to ensure config entry is set up
if not hass.data.get(DOMAIN, False) or not (
node := node_from_ha_device_id(hass, device_id)
):
return None
return MatterDeviceInfo(
unique_id=node.device_info.uniqueID,
vendor_id=hex(node.device_info.vendorID),
product_id=hex(node.device_info.productID),
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Matter from a config entry."""
if use_addon := entry.data.get(CONF_USE_ADDON):
await _async_ensure_addon_running(hass, entry)
matter_client = MatterClient(entry.data[CONF_URL], async_get_clientsession(hass))
try:
async with asyncio.timeout(CONNECT_TIMEOUT):
await matter_client.connect()
except (CannotConnect, TimeoutError) as err:
raise ConfigEntryNotReady("Failed to connect to matter server") from err
except InvalidServerVersion as err:
if isinstance(err, ServerVersionTooOld):
if use_addon:
addon_manager = _get_addon_manager(hass)
addon_manager.async_schedule_update_addon(catch_error=True)
else:
async_create_issue(
hass,
DOMAIN,
"server_version_version_too_old",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key="server_version_version_too_old",
)
elif isinstance(err, ServerVersionTooNew):
async_create_issue(
hass,
DOMAIN,
"server_version_version_too_new",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key="server_version_version_too_new",
)
raise ConfigEntryNotReady(f"Invalid server version: {err}") from err
except Exception as err:
LOGGER.exception("Failed to connect to matter server")
raise ConfigEntryNotReady(
"Unknown error connecting to the Matter server"
) from err
async_delete_issue(hass, DOMAIN, "server_version_version_too_old")
async_delete_issue(hass, DOMAIN, "server_version_version_too_new")
async def on_hass_stop(event: Event) -> None:
"""Handle incoming stop event from Home Assistant."""
await matter_client.disconnect()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
)
async_register_api(hass)
# launch the matter client listen task in the background
# use the init_ready event to wait until initialization is done
init_ready = asyncio.Event()
listen_task = asyncio.create_task(
_client_listen(hass, entry, matter_client, init_ready)
)
try:
async with asyncio.timeout(LISTEN_READY_TIMEOUT):
await init_ready.wait()
except TimeoutError as err:
listen_task.cancel()
raise ConfigEntryNotReady("Matter client not ready") from err
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
# create an intermediate layer (adapter) which keeps track of the nodes
# and discovery of platform entities from the node attributes
matter = MatterAdapter(hass, matter_client, entry)
hass.data[DOMAIN][entry.entry_id] = MatterEntryData(matter, listen_task)
await hass.config_entries.async_forward_entry_setups(entry, SUPPORTED_PLATFORMS)
await matter.setup_nodes()
# If the listen task is already failed, we need to raise ConfigEntryNotReady
if listen_task.done() and (listen_error := listen_task.exception()) is not None:
await hass.config_entries.async_unload_platforms(entry, SUPPORTED_PLATFORMS)
hass.data[DOMAIN].pop(entry.entry_id)
try:
await matter_client.disconnect()
finally:
raise ConfigEntryNotReady(listen_error) from listen_error
return True
async def _client_listen(
hass: HomeAssistant,
entry: ConfigEntry,
matter_client: MatterClient,
init_ready: asyncio.Event,
) -> None:
"""Listen with the client."""
try:
await matter_client.start_listening(init_ready)
except MatterError as err:
if entry.state != ConfigEntryState.LOADED:
raise
LOGGER.error("Failed to listen: %s", err)
except Exception as err: # noqa: BLE001
# We need to guard against unknown exceptions to not crash this task.
LOGGER.exception("Unexpected exception: %s", err)
if entry.state != ConfigEntryState.LOADED:
raise
if not hass.is_stopping:
LOGGER.debug("Disconnected from server. Reloading integration")
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
entry, SUPPORTED_PLATFORMS
)
if unload_ok:
matter_entry_data: MatterEntryData = hass.data[DOMAIN].pop(entry.entry_id)
matter_entry_data.listen_task.cancel()
await matter_entry_data.adapter.matter_client.disconnect()
if entry.data.get(CONF_USE_ADDON) and entry.disabled_by:
addon_manager: AddonManager = get_addon_manager(hass)
LOGGER.debug("Stopping Matter Server add-on")
try:
await addon_manager.async_stop_addon()
except AddonError as err:
LOGGER.error("Failed to stop the Matter Server add-on: %s", err)
return False
return unload_ok
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Config entry is being removed."""
if not entry.data.get(CONF_INTEGRATION_CREATED_ADDON):
return
addon_manager: AddonManager = get_addon_manager(hass)
try:
await addon_manager.async_stop_addon()
except AddonError as err:
LOGGER.error(err)
return
try:
await addon_manager.async_create_backup()
except AddonError as err:
LOGGER.error(err)
return
try:
await addon_manager.async_uninstall_addon()
except AddonError as err:
LOGGER.error(err)
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
node = get_node_from_device_entry(hass, device_entry)
if node is None:
return True
if node.is_bridge_device:
device_registry = dr.async_get(hass)
devices = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)
for device in devices:
if device.via_device_id == device_entry.id:
device_registry.async_update_device(
device.id, remove_config_entry_id=config_entry.entry_id
)
matter = get_matter(hass)
with suppress(NodeNotExists):
# ignore if the server has already removed the node.
await matter.matter_client.remove_node(node.node_id)
return True
async def _async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Ensure that Matter Server add-on is installed and running."""
addon_manager = _get_addon_manager(hass)
try:
addon_info = await addon_manager.async_get_addon_info()
except AddonError as err:
raise ConfigEntryNotReady(err) from err
addon_state = addon_info.state
if addon_state == AddonState.NOT_INSTALLED:
addon_manager.async_schedule_install_setup_addon(
addon_info.options,
catch_error=True,
)
raise ConfigEntryNotReady
if addon_state == AddonState.NOT_RUNNING:
addon_manager.async_schedule_start_addon(catch_error=True)
raise ConfigEntryNotReady
@callback
def _get_addon_manager(hass: HomeAssistant) -> AddonManager:
"""Ensure that Matter Server add-on is updated and running.
May only be used as part of async_setup_entry above.
"""
addon_manager: AddonManager = get_addon_manager(hass)
if addon_manager.task_in_progress():
raise ConfigEntryNotReady
return addon_manager