Home Assistant Unofficial Reference 2024.12.1
router.py
Go to the documentation of this file.
1 """Represent the AsusWrt router."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 from datetime import datetime, timedelta
7 import logging
8 from types import MappingProxyType
9 from typing import Any
10 
11 from pyasuswrt import AsusWrtError
12 
14  CONF_CONSIDER_HOME,
15  DEFAULT_CONSIDER_HOME,
16  DOMAIN as TRACKER_DOMAIN,
17 )
18 from homeassistant.config_entries import ConfigEntry
19 from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
20 from homeassistant.exceptions import ConfigEntryNotReady
21 from homeassistant.helpers import entity_registry as er
22 from homeassistant.helpers.device_registry import DeviceInfo, format_mac
23 from homeassistant.helpers.dispatcher import async_dispatcher_send
24 from homeassistant.helpers.event import async_track_time_interval
25 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
26 from homeassistant.util import dt as dt_util, slugify
27 
28 from .bridge import AsusWrtBridge, WrtDevice
29 from .const import (
30  CONF_DNSMASQ,
31  CONF_INTERFACE,
32  CONF_REQUIRE_IP,
33  CONF_TRACK_UNKNOWN,
34  DEFAULT_DNSMASQ,
35  DEFAULT_INTERFACE,
36  DEFAULT_TRACK_UNKNOWN,
37  DOMAIN,
38  KEY_COORDINATOR,
39  KEY_METHOD,
40  KEY_SENSORS,
41  SENSORS_CONNECTED_DEVICE,
42 )
43 
44 CONF_REQ_RELOAD = [CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP]
45 
46 SCAN_INTERVAL = timedelta(seconds=30)
47 
48 SENSORS_TYPE_COUNT = "sensors_count"
49 
50 _LOGGER = logging.getLogger(__name__)
51 
52 
54  """Data handler for AsusWrt sensor."""
55 
56  def __init__(self, hass: HomeAssistant, api: AsusWrtBridge) -> None:
57  """Initialize a AsusWrt sensor data handler."""
58  self._hass_hass = hass
59  self._api_api = api
60  self._connected_devices_connected_devices = 0
61 
62  async def _get_connected_devices(self) -> dict[str, int]:
63  """Return number of connected devices."""
64  return {SENSORS_CONNECTED_DEVICE[0]: self._connected_devices_connected_devices}
65 
66  def update_device_count(self, conn_devices: int) -> bool:
67  """Update connected devices attribute."""
68  if self._connected_devices_connected_devices == conn_devices:
69  return False
70  self._connected_devices_connected_devices = conn_devices
71  return True
72 
73  async def get_coordinator(
74  self,
75  sensor_type: str,
76  update_method: Callable[[], Any] | None = None,
77  ) -> DataUpdateCoordinator:
78  """Get the coordinator for a specific sensor type."""
79  should_poll = True
80  if sensor_type == SENSORS_TYPE_COUNT:
81  should_poll = False
82  method = self._get_connected_devices_get_connected_devices
83  elif update_method is not None:
84  method = update_method
85  else:
86  raise RuntimeError(f"Invalid sensor type: {sensor_type}")
87 
88  coordinator = DataUpdateCoordinator(
89  self._hass_hass,
90  _LOGGER,
91  name=sensor_type,
92  update_method=method,
93  # Polling interval. Will only be polled if there are subscribers.
94  update_interval=SCAN_INTERVAL if should_poll else None,
95  )
96  await coordinator.async_refresh()
97 
98  return coordinator
99 
100 
102  """Representation of a AsusWrt device info."""
103 
104  def __init__(self, mac: str, name: str | None = None) -> None:
105  """Initialize a AsusWrt device info."""
106  self._mac_mac = mac
107  self._name_name = name
108  self._ip_address_ip_address: str | None = None
109  self._last_activity_last_activity: datetime | None = None
110  self._connected_connected = False
111 
112  def update(self, dev_info: WrtDevice | None = None, consider_home: int = 0) -> None:
113  """Update AsusWrt device info."""
114  utc_point_in_time = dt_util.utcnow()
115  if dev_info:
116  if not self._name_name:
117  self._name_name = dev_info.name or self._mac_mac.replace(":", "_")
118  self._ip_address_ip_address = dev_info.ip
119  self._last_activity_last_activity = utc_point_in_time
120  self._connected_connected = True
121 
122  elif self._connected_connected:
123  self._connected_connected = (
124  self._last_activity_last_activity is not None
125  and (utc_point_in_time - self._last_activity_last_activity).total_seconds()
126  < consider_home
127  )
128  self._ip_address_ip_address = None
129 
130  @property
131  def is_connected(self) -> bool:
132  """Return connected status."""
133  return self._connected_connected
134 
135  @property
136  def mac(self) -> str:
137  """Return device mac address."""
138  return self._mac_mac
139 
140  @property
141  def name(self) -> str | None:
142  """Return device name."""
143  return self._name_name
144 
145  @property
146  def ip_address(self) -> str | None:
147  """Return device ip address."""
148  return self._ip_address_ip_address
149 
150  @property
151  def last_activity(self) -> datetime | None:
152  """Return device last activity."""
153  return self._last_activity_last_activity
154 
155 
157  """Representation of a AsusWrt router."""
158 
159  def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
160  """Initialize a AsusWrt router."""
161  self.hasshass = hass
162  self._entry_entry = entry
163 
164  self._devices: dict[str, AsusWrtDevInfo] = {}
165  self._connected_devices_connected_devices: int = 0
166  self._connect_error_connect_error: bool = False
167 
168  self._sensors_data_handler_sensors_data_handler: AsusWrtSensorDataHandler | None = None
169  self._sensors_coordinator: dict[str, Any] = {}
170 
171  self._on_close: list[Callable] = []
172 
173  self._options: dict[str, Any] = {
174  CONF_DNSMASQ: DEFAULT_DNSMASQ,
175  CONF_INTERFACE: DEFAULT_INTERFACE,
176  CONF_REQUIRE_IP: True,
177  }
178  self._options.update(entry.options)
179 
180  self._api: AsusWrtBridge = AsusWrtBridge.get_bridge(
181  self.hasshass, dict(self._entry_entry.data), self._options
182  )
183 
184  def _migrate_entities_unique_id(self) -> None:
185  """Migrate router entities to new unique id format."""
186  _ENTITY_MIGRATION_ID = {
187  "sensor_connected_device": "Devices Connected",
188  "sensor_rx_bytes": "Download",
189  "sensor_tx_bytes": "Upload",
190  "sensor_rx_rates": "Download Speed",
191  "sensor_tx_rates": "Upload Speed",
192  "sensor_load_avg1": "Load Avg (1m)",
193  "sensor_load_avg5": "Load Avg (5m)",
194  "sensor_load_avg15": "Load Avg (15m)",
195  "2.4GHz": "2.4GHz Temperature",
196  "5.0GHz": "5GHz Temperature",
197  "CPU": "CPU Temperature",
198  }
199 
200  entity_reg = er.async_get(self.hasshass)
201  router_entries = er.async_entries_for_config_entry(
202  entity_reg, self._entry_entry.entry_id
203  )
204 
205  migrate_entities: dict[str, str] = {}
206  for entry in router_entries:
207  if entry.domain == TRACKER_DOMAIN:
208  continue
209  old_unique_id = entry.unique_id
210  if not old_unique_id.startswith(DOMAIN):
211  continue
212  for new_id, old_id in _ENTITY_MIGRATION_ID.items():
213  if old_unique_id.endswith(old_id):
214  migrate_entities[entry.entity_id] = slugify(
215  f"{self.unique_id}_{new_id}"
216  )
217  break
218 
219  for entity_id, unique_id in migrate_entities.items():
220  entity_reg.async_update_entity(entity_id, new_unique_id=unique_id)
221 
222  async def setup(self) -> None:
223  """Set up a AsusWrt router."""
224  try:
225  await self._api.async_connect()
226  except (AsusWrtError, OSError) as exc:
227  raise ConfigEntryNotReady from exc
228  if not self._api.is_connected:
229  raise ConfigEntryNotReady
230 
231  # Load tracked entities from registry
232  entity_reg = er.async_get(self.hasshass)
233  track_entries = er.async_entries_for_config_entry(
234  entity_reg, self._entry_entry.entry_id
235  )
236  for entry in track_entries:
237  if entry.domain != TRACKER_DOMAIN:
238  continue
239  device_mac = format_mac(entry.unique_id)
240 
241  # migrate entity unique ID if wrong formatted
242  if device_mac != entry.unique_id:
243  existing_entity_id = entity_reg.async_get_entity_id(
244  TRACKER_DOMAIN, DOMAIN, device_mac
245  )
246  if existing_entity_id:
247  # entity with uniqueid properly formatted already
248  # exists in the registry, we delete this duplicate
249  entity_reg.async_remove(entry.entity_id)
250  continue
251 
252  entity_reg.async_update_entity(
253  entry.entity_id, new_unique_id=device_mac
254  )
255 
256  self._devices[device_mac] = AsusWrtDevInfo(device_mac, entry.original_name)
257 
258  # Migrate entities to new unique id format
259  self._migrate_entities_unique_id_migrate_entities_unique_id()
260 
261  # Update devices
262  await self.update_devicesupdate_devices()
263 
264  # Init Sensors
265  await self.init_sensors_coordinatorinit_sensors_coordinator()
266 
267  self.async_on_closeasync_on_close(
268  async_track_time_interval(self.hasshass, self.update_allupdate_all, SCAN_INTERVAL)
269  )
270 
271  async def update_all(self, now: datetime | None = None) -> None:
272  """Update all AsusWrt platforms."""
273  await self.update_devicesupdate_devices()
274 
275  async def update_devices(self) -> None:
276  """Update AsusWrt devices tracker."""
277  new_device = False
278  _LOGGER.debug("Checking devices for ASUS router %s", self.hosthost)
279  try:
280  wrt_devices = await self._api.async_get_connected_devices()
281  except (OSError, AsusWrtError) as exc:
282  if not self._connect_error_connect_error:
283  self._connect_error_connect_error = True
284  _LOGGER.error(
285  "Error connecting to ASUS router %s for device update: %s",
286  self.hosthost,
287  exc,
288  )
289  return
290 
291  if self._connect_error_connect_error:
292  self._connect_error_connect_error = False
293  _LOGGER.warning("Reconnected to ASUS router %s", self.hosthost)
294 
295  self._connected_devices_connected_devices = len(wrt_devices)
296  consider_home: int = self._options.get(
297  CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds()
298  )
299  track_unknown: bool = self._options.get(
300  CONF_TRACK_UNKNOWN, DEFAULT_TRACK_UNKNOWN
301  )
302 
303  for device_mac, device in self._devices.items():
304  dev_info = wrt_devices.pop(device_mac, None)
305  device.update(dev_info, consider_home)
306 
307  for device_mac, dev_info in wrt_devices.items():
308  if not track_unknown and not dev_info.name:
309  continue
310  new_device = True
311  device = AsusWrtDevInfo(device_mac)
312  device.update(dev_info)
313  self._devices[device_mac] = device
314 
315  async_dispatcher_send(self.hasshass, self.signal_device_updatesignal_device_update)
316  if new_device:
317  async_dispatcher_send(self.hasshass, self.signal_device_newsignal_device_new)
318  await self._update_unpolled_sensors_update_unpolled_sensors()
319 
320  async def init_sensors_coordinator(self) -> None:
321  """Init AsusWrt sensors coordinators."""
322  if self._sensors_data_handler_sensors_data_handler:
323  return
324 
325  self._sensors_data_handler_sensors_data_handler = AsusWrtSensorDataHandler(self.hasshass, self._api)
326  self._sensors_data_handler_sensors_data_handler.update_device_count(self._connected_devices_connected_devices)
327 
328  sensors_types = await self._api.async_get_available_sensors()
329  sensors_types[SENSORS_TYPE_COUNT] = {KEY_SENSORS: SENSORS_CONNECTED_DEVICE}
330 
331  for sensor_type, sensor_def in sensors_types.items():
332  if not (sensor_names := sensor_def.get(KEY_SENSORS)):
333  continue
334  coordinator = await self._sensors_data_handler_sensors_data_handler.get_coordinator(
335  sensor_type, update_method=sensor_def.get(KEY_METHOD)
336  )
337  self._sensors_coordinator[sensor_type] = {
338  KEY_COORDINATOR: coordinator,
339  KEY_SENSORS: sensor_names,
340  }
341 
342  async def _update_unpolled_sensors(self) -> None:
343  """Request refresh for AsusWrt unpolled sensors."""
344  if not self._sensors_data_handler_sensors_data_handler:
345  return
346 
347  if SENSORS_TYPE_COUNT in self._sensors_coordinator:
348  coordinator = self._sensors_coordinator[SENSORS_TYPE_COUNT][KEY_COORDINATOR]
349  if self._sensors_data_handler_sensors_data_handler.update_device_count(self._connected_devices_connected_devices):
350  await coordinator.async_refresh()
351 
352  async def close(self) -> None:
353  """Close the connection."""
354  if self._api is not None:
355  await self._api.async_disconnect()
356 
357  for func in self._on_close:
358  func()
359  self._on_close.clear()
360 
361  @callback
362  def async_on_close(self, func: CALLBACK_TYPE) -> None:
363  """Add a function to call when router is closed."""
364  self._on_close.append(func)
365 
366  def update_options(self, new_options: MappingProxyType[str, Any]) -> bool:
367  """Update router options."""
368  req_reload = False
369  for name, new_opt in new_options.items():
370  if name in CONF_REQ_RELOAD:
371  old_opt = self._options.get(name)
372  if old_opt is None or old_opt != new_opt:
373  req_reload = True
374  break
375 
376  self._options.update(new_options)
377  return req_reload
378 
379  @property
380  def device_info(self) -> DeviceInfo:
381  """Return the device information."""
382  info = DeviceInfo(
383  identifiers={(DOMAIN, self._entry_entry.unique_id or "AsusWRT")},
384  name=self.hosthost,
385  model=self._api.model or "Asus Router",
386  manufacturer="Asus",
387  configuration_url=f"http://{self.host}",
388  )
389  if self._api.firmware:
390  info["sw_version"] = self._api.firmware
391 
392  return info
393 
394  @property
395  def signal_device_new(self) -> str:
396  """Event specific per AsusWrt entry to signal new device."""
397  return f"{DOMAIN}-device-new"
398 
399  @property
400  def signal_device_update(self) -> str:
401  """Event specific per AsusWrt entry to signal updates in devices."""
402  return f"{DOMAIN}-device-update"
403 
404  @property
405  def host(self) -> str:
406  """Return router hostname."""
407  return self._api.host
408 
409  @property
410  def unique_id(self) -> str:
411  """Return router unique id."""
412  return self._entry_entry.unique_id or self._entry_entry.entry_id
413 
414  @property
415  def devices(self) -> dict[str, AsusWrtDevInfo]:
416  """Return devices."""
417  return self._devices
418 
419  @property
420  def sensors_coordinator(self) -> dict[str, Any]:
421  """Return sensors coordinators."""
422  return self._sensors_coordinator
None update(self, WrtDevice|None dev_info=None, int consider_home=0)
Definition: router.py:112
None __init__(self, str mac, str|None name=None)
Definition: router.py:104
None __init__(self, HomeAssistant hass, ConfigEntry entry)
Definition: router.py:159
None update_all(self, datetime|None now=None)
Definition: router.py:271
dict[str, AsusWrtDevInfo] devices(self)
Definition: router.py:415
None async_on_close(self, CALLBACK_TYPE func)
Definition: router.py:362
bool update_options(self, MappingProxyType[str, Any] new_options)
Definition: router.py:366
None __init__(self, HomeAssistant hass, AsusWrtBridge api)
Definition: router.py:56
DataUpdateCoordinator get_coordinator(self, str sensor_type, Callable[[], Any]|None update_method=None)
Definition: router.py:77
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679