Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """The Matter integration."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from functools import cache
7 
8 from matter_server.client import MatterClient
9 from matter_server.client.exceptions import (
10  CannotConnect,
11  InvalidServerVersion,
12  NotConnected,
13  ServerVersionTooNew,
14  ServerVersionTooOld,
15 )
16 from matter_server.common.errors import MatterError, NodeNotExists
17 
18 from homeassistant.components.hassio import AddonError, AddonManager, AddonState
19 from homeassistant.config_entries import ConfigEntry, ConfigEntryState
20 from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
21 from homeassistant.core import Event, HomeAssistant, callback
22 from homeassistant.exceptions import ConfigEntryNotReady
23 from homeassistant.helpers import device_registry as dr
24 from homeassistant.helpers.aiohttp_client import async_get_clientsession
26  IssueSeverity,
27  async_create_issue,
28  async_delete_issue,
29 )
30 
31 from .adapter import MatterAdapter
32 from .addon import get_addon_manager
33 from .api import async_register_api
34 from .const import CONF_INTEGRATION_CREATED_ADDON, CONF_USE_ADDON, DOMAIN, LOGGER
35 from .discovery import SUPPORTED_PLATFORMS
36 from .helpers import (
37  MatterEntryData,
38  get_matter,
39  get_node_from_device_entry,
40  node_from_ha_device_id,
41 )
42 from .models import MatterDeviceInfo
43 
44 CONNECT_TIMEOUT = 10
45 LISTEN_READY_TIMEOUT = 30
46 
47 
48 @callback
49 @cache
51  hass: HomeAssistant, device_id: str
52 ) -> MatterDeviceInfo | None:
53  """Return Matter device info or None if device does not exist."""
54  # Test hass.data[DOMAIN] to ensure config entry is set up
55  if not hass.data.get(DOMAIN, False) or not (
56  node := node_from_ha_device_id(hass, device_id)
57  ):
58  return None
59 
60  return MatterDeviceInfo(
61  unique_id=node.device_info.uniqueID,
62  vendor_id=hex(node.device_info.vendorID),
63  product_id=hex(node.device_info.productID),
64  )
65 
66 
67 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
68  """Set up Matter from a config entry."""
69  if use_addon := entry.data.get(CONF_USE_ADDON):
70  await _async_ensure_addon_running(hass, entry)
71 
72  matter_client = MatterClient(entry.data[CONF_URL], async_get_clientsession(hass))
73  try:
74  async with asyncio.timeout(CONNECT_TIMEOUT):
75  await matter_client.connect()
76  except (CannotConnect, TimeoutError) as err:
77  raise ConfigEntryNotReady("Failed to connect to matter server") from err
78  except InvalidServerVersion as err:
79  if isinstance(err, ServerVersionTooOld):
80  if use_addon:
81  addon_manager = _get_addon_manager(hass)
82  addon_manager.async_schedule_update_addon(catch_error=True)
83  else:
85  hass,
86  DOMAIN,
87  "server_version_version_too_old",
88  is_fixable=False,
89  severity=IssueSeverity.ERROR,
90  translation_key="server_version_version_too_old",
91  )
92  elif isinstance(err, ServerVersionTooNew):
94  hass,
95  DOMAIN,
96  "server_version_version_too_new",
97  is_fixable=False,
98  severity=IssueSeverity.ERROR,
99  translation_key="server_version_version_too_new",
100  )
101  raise ConfigEntryNotReady(f"Invalid server version: {err}") from err
102 
103  except Exception as err:
104  LOGGER.exception("Failed to connect to matter server")
105  raise ConfigEntryNotReady(
106  "Unknown error connecting to the Matter server"
107  ) from err
108 
109  async_delete_issue(hass, DOMAIN, "server_version_version_too_old")
110  async_delete_issue(hass, DOMAIN, "server_version_version_too_new")
111 
112  async def on_hass_stop(event: Event) -> None:
113  """Handle incoming stop event from Home Assistant."""
114  await matter_client.disconnect()
115 
116  entry.async_on_unload(
117  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
118  )
119 
120  async_register_api(hass)
121 
122  # launch the matter client listen task in the background
123  # use the init_ready event to wait until initialization is done
124  init_ready = asyncio.Event()
125  listen_task = asyncio.create_task(
126  _client_listen(hass, entry, matter_client, init_ready)
127  )
128 
129  try:
130  async with asyncio.timeout(LISTEN_READY_TIMEOUT):
131  await init_ready.wait()
132  except TimeoutError as err:
133  listen_task.cancel()
134  raise ConfigEntryNotReady("Matter client not ready") from err
135 
136  # Set default fabric
137  try:
138  await matter_client.set_default_fabric_label(
139  hass.config.location_name or "Home"
140  )
141  except (NotConnected, MatterError) as err:
142  listen_task.cancel()
143  raise ConfigEntryNotReady("Failed to set default fabric label") from err
144 
145  if DOMAIN not in hass.data:
146  hass.data[DOMAIN] = {}
147 
148  # create an intermediate layer (adapter) which keeps track of the nodes
149  # and discovery of platform entities from the node attributes
150  matter = MatterAdapter(hass, matter_client, entry)
151  hass.data[DOMAIN][entry.entry_id] = MatterEntryData(matter, listen_task)
152 
153  await hass.config_entries.async_forward_entry_setups(entry, SUPPORTED_PLATFORMS)
154  await matter.setup_nodes()
155 
156  # If the listen task is already failed, we need to raise ConfigEntryNotReady
157  if listen_task.done() and (listen_error := listen_task.exception()) is not None:
158  await hass.config_entries.async_unload_platforms(entry, SUPPORTED_PLATFORMS)
159  hass.data[DOMAIN].pop(entry.entry_id)
160  try:
161  await matter_client.disconnect()
162  finally:
163  raise ConfigEntryNotReady(listen_error) from listen_error
164 
165  return True
166 
167 
168 async def _client_listen(
169  hass: HomeAssistant,
170  entry: ConfigEntry,
171  matter_client: MatterClient,
172  init_ready: asyncio.Event,
173 ) -> None:
174  """Listen with the client."""
175  try:
176  await matter_client.start_listening(init_ready)
177  except MatterError as err:
178  if entry.state != ConfigEntryState.LOADED:
179  raise
180  LOGGER.error("Failed to listen: %s", err)
181  except Exception as err: # noqa: BLE001
182  # We need to guard against unknown exceptions to not crash this task.
183  LOGGER.exception("Unexpected exception: %s", err)
184  if entry.state != ConfigEntryState.LOADED:
185  raise
186 
187  if not hass.is_stopping:
188  LOGGER.debug("Disconnected from server. Reloading integration")
189  hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
190 
191 
192 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
193  """Unload a config entry."""
194  unload_ok = await hass.config_entries.async_unload_platforms(
195  entry, SUPPORTED_PLATFORMS
196  )
197 
198  if unload_ok:
199  matter_entry_data: MatterEntryData = hass.data[DOMAIN].pop(entry.entry_id)
200  matter_entry_data.listen_task.cancel()
201  await matter_entry_data.adapter.matter_client.disconnect()
202 
203  if entry.data.get(CONF_USE_ADDON) and entry.disabled_by:
204  addon_manager: AddonManager = get_addon_manager(hass)
205  LOGGER.debug("Stopping Matter Server add-on")
206  try:
207  await addon_manager.async_stop_addon()
208  except AddonError as err:
209  LOGGER.error("Failed to stop the Matter Server add-on: %s", err)
210  return False
211 
212  return unload_ok
213 
214 
215 async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
216  """Config entry is being removed."""
217 
218  if not entry.data.get(CONF_INTEGRATION_CREATED_ADDON):
219  return
220 
221  addon_manager: AddonManager = get_addon_manager(hass)
222  try:
223  await addon_manager.async_stop_addon()
224  except AddonError as err:
225  LOGGER.error(err)
226  return
227  try:
228  await addon_manager.async_create_backup()
229  except AddonError as err:
230  LOGGER.error(err)
231  return
232  try:
233  await addon_manager.async_uninstall_addon()
234  except AddonError as err:
235  LOGGER.error(err)
236 
237 
239  hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
240 ) -> None:
241  """Remove all via devices associated with a device."""
242  device_registry = dr.async_get(hass)
243  devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)
244  for device in devices:
245  if device.via_device_id == device_entry.id:
246  device_registry.async_update_device(
247  device.id, remove_config_entry_id=config_entry.entry_id
248  )
249 
250 
252  hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
253 ) -> bool:
254  """Remove a config entry from a device."""
255  node = get_node_from_device_entry(hass, device_entry)
256 
257  if node is None:
258  # In case this was a bridge
259  _remove_via_devices(hass, config_entry, device_entry)
260  # Always allow users to remove orphan devices
261  return True
262 
263  if device_entry.via_device_id:
264  # Do not allow to delete devices that exposed via bridge.
265  return False
266 
267  matter = get_matter(hass)
268  try:
269  await matter.matter_client.remove_node(node.node_id)
270  except NodeNotExists:
271  # Ignore if the server has already removed the node.
272  LOGGER.debug("Node %s didn't exist on the Matter server", node.node_id)
273  finally:
274  # Make sure potentially orphan devices of a bridge are removed too.
275  if node.is_bridge_device:
276  _remove_via_devices(hass, config_entry, device_entry)
277 
278  return True
279 
280 
281 async def _async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None:
282  """Ensure that Matter Server add-on is installed and running."""
283  addon_manager = _get_addon_manager(hass)
284  try:
285  addon_info = await addon_manager.async_get_addon_info()
286  except AddonError as err:
287  raise ConfigEntryNotReady(err) from err
288 
289  addon_state = addon_info.state
290 
291  if addon_state == AddonState.NOT_INSTALLED:
292  addon_manager.async_schedule_install_setup_addon(
293  addon_info.options,
294  catch_error=True,
295  )
296  raise ConfigEntryNotReady
297 
298  if addon_state == AddonState.NOT_RUNNING:
299  addon_manager.async_schedule_start_addon(catch_error=True)
300  raise ConfigEntryNotReady
301 
302 
303 @callback
304 def _get_addon_manager(hass: HomeAssistant) -> AddonManager:
305  """Ensure that Matter Server add-on is updated and running.
306 
307  May only be used as part of async_setup_entry above.
308  """
309  addon_manager: AddonManager = get_addon_manager(hass)
310  if addon_manager.task_in_progress():
311  raise ConfigEntryNotReady
312  return addon_manager
AddonManager get_addon_manager(HomeAssistant hass)
Definition: addon.py:16
None async_register_api(HomeAssistant hass)
Definition: api.py:30
MatterAdapter get_matter(HomeAssistant hass)
Definition: helpers.py:35
MatterNode|None node_from_ha_device_id(HomeAssistant hass, str ha_device_id)
Definition: helpers.py:76
MatterNode|None get_node_from_device_entry(HomeAssistant hass, dr.DeviceEntry device)
Definition: helpers.py:88
None _client_listen(HomeAssistant hass, ConfigEntry entry, MatterClient matter_client, asyncio.Event init_ready)
Definition: __init__.py:173
None _async_ensure_addon_running(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:281
bool async_remove_config_entry_device(HomeAssistant hass, ConfigEntry config_entry, dr.DeviceEntry device_entry)
Definition: __init__.py:253
AddonManager _get_addon_manager(HomeAssistant hass)
Definition: __init__.py:304
MatterDeviceInfo|None get_matter_device_info(HomeAssistant hass, str device_id)
Definition: __init__.py:52
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:192
None async_remove_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:215
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:67
None _remove_via_devices(HomeAssistant hass, ConfigEntry config_entry, dr.DeviceEntry device_entry)
Definition: __init__.py:240
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
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)