Home Assistant Unofficial Reference 2024.12.1
diagnostics.py
Go to the documentation of this file.
1 """Diagnostics support for Thread networks.
2 
3 When triaging Matter and HomeKit issues you often need to check for problems with the Thread network.
4 
5 This report helps spot and rule out:
6 
7 * Is the users border router visible at all?
8 * Is the border router actually announcing any routes? The user could have a network boundary like
9  VLANs or WiFi isolation that is blocking the RA packets.
10 * Alternatively, if user isn't on HAOS they could have accept_ra_rt_info_max_plen set incorrectly.
11 * Are there any bogus routes that could be interfering. If routes don't expire they can build up.
12  When you have 10 routes and only 2 border routers something has gone wrong.
13 
14 This does not do any connectivity checks. So user could have all their border routers visible, but
15 some of their thread accessories can't be pinged, but it's still a thread problem.
16 """
17 
18 from __future__ import annotations
19 
20 from typing import TYPE_CHECKING, Any, TypedDict
21 
22 from python_otbr_api.tlv_parser import MeshcopTLVType
23 
24 from homeassistant.components import zeroconf
25 from homeassistant.config_entries import ConfigEntry
26 from homeassistant.core import HomeAssistant
27 
28 from .dataset_store import async_get_store
29 from .discovery import async_read_zeroconf_cache
30 
31 if TYPE_CHECKING:
32  from pyroute2 import NDB
33 
34 
35 class Neighbour(TypedDict):
36  """A neighbour cache entry (ip neigh)."""
37 
38  lladdr: str
39  state: int
40  probes: int
41 
42 
43 class Route(TypedDict):
44  """A route table entry (ip -6 route)."""
45 
46  metrics: int
47  priority: int
48  is_nexthop: bool
49 
50 
51 class Router(TypedDict):
52  """A border router."""
53 
54  server: str | None
55  addresses: list[str]
56  neighbours: dict[str, Neighbour]
57  thread_version: str | None
58  model: str | None
59  vendor: str | None
60  routes: dict[str, Route]
61 
62 
63 class Network(TypedDict):
64  """A thread network."""
65 
66  name: str | None
67  routers: dict[str, Router]
68  prefixes: set[str]
69  unexpected_routers: set[str]
70 
71 
73  ndb: NDB,
74 ) -> tuple[dict[str, dict[str, Route]], dict[str, set[str]]]:
75  # Build a list of possible thread routes
76  # Right now, this is ipv6 /64's that have a gateway
77  # We cross reference with zerconf data to confirm which via's are known border routers
78  routes: dict[str, dict[str, Route]] = {}
79  reverse_routes: dict[str, set[str]] = {}
80 
81  for record in ndb.routes:
82  # Limit to IPV6 routes
83  if record.family != 10:
84  continue
85  # Limit to /64 prefixes
86  if record.dst_len != 64:
87  continue
88  # Limit to routes with a via
89  if not record.gateway and not record.nh_gateway:
90  continue
91  gateway = record.gateway or record.nh_gateway
92  route = routes.setdefault(gateway, {})
93  route[record.dst] = {
94  "metrics": record.metrics,
95  "priority": record.priority,
96  # NM creates "nexthop" routes - a single route with many via's
97  # Kernel creates many routes with a single via
98  "is_nexthop": record.nh_gateway is not None,
99  }
100  reverse_routes.setdefault(record.dst, set()).add(gateway)
101  return routes, reverse_routes
102 
103 
104 def _get_neighbours(ndb: NDB) -> dict[str, Neighbour]:
105  # Build a list of neighbours
106  neighbours: dict[str, Neighbour] = {
107  record.dst: {
108  "lladdr": record.lladdr,
109  "state": record.state,
110  "probes": record.probes,
111  }
112  for record in ndb.neighbours
113  }
114  return neighbours
115 
116 
118  """Get the routes and neighbours from pyroute2."""
119  # Import in the executor since import NDB can take a while
120  from pyroute2 import ( # pylint: disable=no-name-in-module, import-outside-toplevel
121  NDB,
122  )
123 
124  with NDB() as ndb:
125  routes, reverse_routes = _get_possible_thread_routes(ndb)
126  neighbours = _get_neighbours(ndb)
127 
128  return routes, reverse_routes, neighbours
129 
130 
132  hass: HomeAssistant, entry: ConfigEntry
133 ) -> dict[str, Any]:
134  """Return diagnostics for all known thread networks."""
135  networks: dict[str, Network] = {}
136 
137  # Start with all networks that HA knows about
138  store = await async_get_store(hass)
139  for record in store.datasets.values():
140  if not record.extended_pan_id:
141  continue
142  network = networks.setdefault(
143  record.extended_pan_id,
144  {
145  "name": record.network_name,
146  "routers": {},
147  "prefixes": set(),
148  "unexpected_routers": set(),
149  },
150  )
151  if mlp_item := record.dataset.get(MeshcopTLVType.MESHLOCALPREFIX):
152  mlp = str(mlp_item)
153  network["prefixes"].add(f"{mlp[0:4]}:{mlp[4:8]}:{mlp[8:12]}:{mlp[12:16]}")
154 
155  # Find all routes currently act that might be thread related, so we can match them to
156  # border routers as we process the zeroconf data.
157  #
158  # Also find all neighbours
159  routes, reverse_routes, neighbours = await hass.async_add_executor_job(
160  _get_routes_and_neighbors
161  )
162 
163  aiozc = await zeroconf.async_get_async_instance(hass)
164  for data in async_read_zeroconf_cache(aiozc):
165  if not data.extended_pan_id:
166  continue
167 
168  network = networks.setdefault(
169  data.extended_pan_id,
170  {
171  "name": data.network_name,
172  "routers": {},
173  "prefixes": set(),
174  "unexpected_routers": set(),
175  },
176  )
177 
178  if not data.server:
179  continue
180 
181  router = network["routers"][data.server] = {
182  "server": data.server,
183  "addresses": data.addresses or [],
184  "neighbours": {},
185  "thread_version": data.thread_version,
186  "model": data.model_name,
187  "vendor": data.vendor_name,
188  "routes": {},
189  }
190 
191  # For every address this border router hass, see if we have seen
192  # it in the route table as a via - these are the routes its
193  # announcing via RA
194  if data.addresses:
195  for address in data.addresses:
196  if address in routes:
197  router["routes"].update(routes[address])
198 
199  if address in neighbours:
200  router["neighbours"][address] = neighbours[address]
201 
202  network["prefixes"].update(router["routes"].keys())
203 
204  # Find unexpected via's.
205  # Collect all router addresses and then for each prefix, find via's that aren't
206  # a known router for that prefix.
207  for network in networks.values():
208  routers = set()
209 
210  for router in network["routers"].values():
211  routers.update(router["addresses"])
212 
213  for prefix in network["prefixes"]:
214  if prefix not in reverse_routes:
215  continue
216  if ghosts := reverse_routes[prefix] - routers:
217  network["unexpected_routers"] = ghosts
218 
219  return {
220  "networks": networks,
221  }
bool add(self, _T matcher)
Definition: match.py:185
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
DatasetStore async_get_store(HomeAssistant hass)
dict[str, Neighbour] _get_neighbours(NDB ndb)
Definition: diagnostics.py:104
dict[str, Any] async_get_config_entry_diagnostics(HomeAssistant hass, ConfigEntry entry)
Definition: diagnostics.py:133
tuple[dict[str, dict[str, Route]], dict[str, set[str]]] _get_possible_thread_routes(NDB ndb)
Definition: diagnostics.py:74
list[ThreadRouterDiscoveryData] async_read_zeroconf_cache(AsyncZeroconf aiozc)
Definition: discovery.py:114