"""AsusRouter bridge module.""" from __future__ import annotations from collections.abc import Callable import dataclasses import logging from typing import Any import aiohttp from asusrouter import AsusRouter from asusrouter.config import ARConfig, ARConfigKey as ARConfKey from asusrouter.const import DEFAULT_PORT_HTTP, DEFAULT_PORT_HTTPS from asusrouter.error import AsusRouterError from asusrouter.modules.aimesh import AiMeshDevice from asusrouter.modules.client import AsusClient from asusrouter.modules.data import AsusData from asusrouter.modules.homeassistant import ( convert_to_ha_data, convert_to_ha_sensors, convert_to_ha_state_bool, ) from asusrouter.modules.identity import AsusDevice from asusrouter.modules.parental_control import ParentalControlRule, PCRuleType from asusrouter.tools.connection import get_cookie_jar from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_USERNAME, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.update_coordinator import UpdateFailed from . import helpers from .const import ( AURA, BOOTTIME, CONF_CACHE_TIME, CONF_DEFAULT_CACHE_TIME, CONF_DEFAULT_MODE, CONF_DEFAULT_PORT, CONF_MODE, CPU, DDNS, DEFAULT_SENSORS, DOMAIN, DSL, FIRMWARE, GWLAN, LED, LIST, LIST_PORTS, METHOD, MODE_SENSORS, NETWORK, PARENTAL_CONTROL, PORT_FORWARDING, PORTS, RAM, SENSORS, SENSORS_BOOTTIME, SENSORS_FIRMWARE, SENSORS_LED, SENSORS_PARENTAL_CONTROL, SENSORS_PORT_FORWARDING, SENSORS_RAM, STATE, SYSINFO, TEMPERATURE, WLAN, ) from .const_v1 import DEFAULT_IDENTITY_BRAND, DEFAULT_IDENTITY_NAME from .modules.aura import aura_to_ha from .modules.firmware import to_ha as firmware_to_ha _LOGGER = logging.getLogger(__name__) class ARBridge: """Bridge to the AsusRouter library.""" def __init__( self, hass: HomeAssistant, configs: dict[str, Any], options: dict[str, Any] | None = None, ) -> None: """Initialize bridge to the library.""" self.hass = hass # Save all the HA configs and options self._configs = configs.copy() if options: self._configs.update(options) # Get session from HA # By default, don't verify SSL <- this is a temp solution # which should be done properly in the future session = async_create_clientsession( hass, verify_ssl=False, cookie_jar=get_cookie_jar(), ) # Prepare configs config = self._get_api_config() # Initialize API self._api = self._get_api(self._configs, session, config) # Switch API to robust mode # Robust boottime will avoid 1 second jitter due to the raw data # uncertainty. This can provide up to 1 second overestimation # of the boottime, but will avoid saving extra data when the # integration restarts and loses the previous boottime data. ARConfig.set(ARConfKey.ROBUST_BOOTTIME, True) self._host = self._configs[CONF_HOST] self._identity: AsusDevice | None = None # Define properties port = self._configs.get(CONF_PORT, None) if not port: port = ( DEFAULT_PORT_HTTPS if self._configs.get(CONF_SSL, False) else DEFAULT_PORT_HTTP ) self._configuration_url = ( f"http{'s' if self._configs.get(CONF_SSL, False) else ''}" f"://{self._host}:{port}" ) self._identifiers: set[tuple[str, str]] = set() self._manufacturer = DEFAULT_IDENTITY_BRAND self._model: str | None = None self._model_id: str | None = None self._name: str = DEFAULT_IDENTITY_NAME self._serial_number: str | None = None self._sw_version: str | None = None @staticmethod def _get_api( configs: dict[str, Any], session: aiohttp.ClientSession, config: dict[ARConfKey, Any], ) -> AsusRouter: """Get AsusRouter API.""" return AsusRouter( hostname=configs[CONF_HOST], username=configs[CONF_USERNAME], password=configs[CONF_PASSWORD], port=configs.get(CONF_PORT, CONF_DEFAULT_PORT), use_ssl=configs[CONF_SSL], cache_time=configs.get(CONF_CACHE_TIME, CONF_DEFAULT_CACHE_TIME), session=session, config=config, ) def _get_api_config(self) -> dict[ARConfKey, Any]: """Get configuration for AsusRouter instance.""" return { # Enable automatic temperature fix ARConfKey.OPTIMISTIC_TEMPERATURE: True, # Disable log warning message ARConfKey.NOTIFIED_OPTIMISTIC_TEMPERATURE: True, } @property def api(self) -> AsusRouter: """Return API.""" return self._api @property def configuration_url(self) -> str: """Return device configuration URL.""" return self._configuration_url @property def connected(self) -> bool: """Return connection state.""" return self._api.connected @property def identifiers(self) -> set[tuple[str, str]]: """Return device identifiers.""" return self._identifiers @property def identity(self) -> AsusDevice | None: """Return device identity.""" return self._identity @property def manufacturer(self) -> str: """Return device manufacturer.""" return self._manufacturer @property def model(self) -> str | None: """Return device model.""" return self._model @property def model_id(self) -> str | None: """Return device model ID.""" return self._model_id @property def name(self) -> str: """Return device name.""" return self._name @property def serial_number(self) -> str | None: """Return device serial number.""" return self._serial_number @property def sw_version(self) -> str | None: """Return device software version.""" return self._sw_version # -------------------- # Connection --> # -------------------- async def async_connect(self) -> None: """Connect to the device.""" _LOGGER.debug("Connecting to the API") await self.api.async_connect() identity = await self.api.async_get_identity() self._identity = identity # Set properties self._identifiers = set() if identity.mac is not None: self._identifiers.add((DOMAIN, format_mac(identity.mac))) if identity.serial is not None: self._identifiers.add((DOMAIN, identity.serial)) self._manufacturer = identity.brand self._model = identity.model self._model_id = identity.product_id self._name = identity.model or DEFAULT_IDENTITY_NAME self._serial_number = identity.serial self._sw_version = ( str(identity.firmware) if identity.firmware else None ) async def async_disconnect(self) -> None: """Disconnect from the device.""" _LOGGER.debug("Disconnecting from the API") await self.api.async_disconnect() async def async_clean(self) -> None: """Cleanup.""" _LOGGER.debug("Cleaning up") await self.api.async_cleanup() # -------------------- # <-- Connection # -------------------- async def async_cleanup_sensors( self, sensors: dict[str, Any] ) -> dict[str, Any]: """Cleanup sensors depending on the device mode.""" mode = self._configs.get(CONF_MODE, CONF_DEFAULT_MODE) available = MODE_SENSORS[mode] _LOGGER.debug("Available sensors for mode=`%s`: %s", mode, available) return { group: details for group, details in sensors.items() if group in available } async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]: """Get available sensors.""" sensors = { AURA: { SENSORS: await self._get_sensors_modern(AsusData.AURA), METHOD: self._get_data_aura, }, BOOTTIME: { SENSORS: SENSORS_BOOTTIME, METHOD: self._get_data_boottime, }, CPU: { SENSORS: await self._get_sensors_modern(AsusData.CPU), METHOD: self._get_data_cpu, }, DDNS: { SENSORS: await self._get_sensors_modern(AsusData.DDNS), METHOD: self._get_data_ddns, }, DSL: { SENSORS: await self._get_sensors_modern(AsusData.DSL), METHOD: self._get_data_dsl, }, FIRMWARE: { SENSORS: SENSORS_FIRMWARE, METHOD: self._get_data_firmware, }, GWLAN: { SENSORS: await self._get_sensors_modern(AsusData.GWLAN), METHOD: self._get_data_gwlan, }, LED: { SENSORS: SENSORS_LED, METHOD: self._get_data_led, }, NETWORK: { SENSORS: await self._get_sensors_modern(AsusData.NETWORK), METHOD: self._get_data_network, }, "ovpn_client": { SENSORS: await self._get_sensors_modern( AsusData.OPENVPN_CLIENT ), METHOD: self._get_data_ovpn_client, }, "ovpn_server": { SENSORS: await self._get_sensors_ovpn_server(), METHOD: self._get_data_ovpn_server, }, PARENTAL_CONTROL: { SENSORS: SENSORS_PARENTAL_CONTROL, METHOD: self._get_data_parental_control, }, PORT_FORWARDING: { SENSORS: SENSORS_PORT_FORWARDING, METHOD: self._get_data_port_forwarding, }, PORTS: { SENSORS: await self._get_sensors_ports(), METHOD: self._get_data_ports, }, RAM: {SENSORS: SENSORS_RAM, METHOD: self._get_data_ram}, SYSINFO: { SENSORS: await self._get_sensors_modern(AsusData.SYSINFO), METHOD: self._get_data_sysinfo, }, TEMPERATURE: { SENSORS: await self._get_sensors_modern(AsusData.TEMPERATURE), METHOD: self._get_data_temperature, }, "wan": { SENSORS: await self._get_sensors_modern(AsusData.WAN), METHOD: self._get_data_wan, }, "wireguard_client": { SENSORS: await self._get_sensors_modern( AsusData.WIREGUARD_CLIENT ), METHOD: self._get_data_wireguard_client, }, "wireguard_server": { SENSORS: await self._get_sensors_modern( AsusData.WIREGUARD_SERVER ), METHOD: self._get_data_wireguard_server, }, WLAN: { SENSORS: await self._get_sensors_modern(AsusData.WLAN), METHOD: self._get_data_wlan, }, } # Cleanup sensors if needed return await self.async_cleanup_sensors(sensors) # GET DATA FROM DEVICE -> # General method async def _get_data( self, datatype: AsusData, process: Callable[[dict[str, Any]], dict[str, Any]] | None = None, force: bool = False, ) -> dict[str, Any]: """Get data from the device. This is a generic method.""" try: raw = await self.api.async_get_data(datatype, force=force) if raw is None: raw = {} if process is not None: return process(raw) return self._process_data(raw) except AsusRouterError as ex: raise UpdateFailed(ex) from ex async def _get_data_modern( self, datatype: AsusData, force: bool = False, ) -> dict[str, Any]: """Get data from the device. This is a generic method.""" try: raw = await self.api.async_get_data(datatype, force=force) return self._process_data_modern(raw) except AsusRouterError as ex: raise UpdateFailed(ex) from ex # AiMesh nodes async def async_get_aimesh_nodes(self) -> dict[str, AiMeshDevice]: """Get dict of AiMesh nodes.""" return await self._get_data(AsusData.AIMESH) # Connected devices async def async_get_clients(self) -> dict[str, AsusClient]: """Get clients.""" return await self._get_data(AsusData.CLIENTS, force=True) # Sensor-specific methods async def _get_data_aura(self) -> dict[str, Any]: """Get Aura data from the device.""" data = await self._get_data_modern(AsusData.AURA) return aura_to_ha(data) async def _get_data_boottime(self) -> dict[str, Any]: """Get `boottime` data from the device.""" return await self._get_data(AsusData.BOOTTIME) async def _get_data_cpu(self) -> dict[str, Any]: """Get CPU data from the device.""" return await self._get_data(AsusData.CPU) async def _get_data_ddns(self) -> dict[str, Any]: """Get DDNS data from the device.""" return await self._get_data_modern(AsusData.DDNS) async def _get_data_dsl(self) -> dict[str, Any]: """Get DSL data from the device.""" return await self._get_data_modern(AsusData.DSL) async def _get_data_firmware(self) -> dict[str, Any]: """Get firmware data from the device.""" data = await self._get_data_modern(AsusData.FIRMWARE) return firmware_to_ha(data) async def _get_data_gwlan(self) -> dict[str, Any]: """Get GWLAN data from the device.""" return await self._get_data(AsusData.GWLAN) async def _get_data_led(self) -> dict[str, Any]: """Get light data from the device.""" return await self._get_data(AsusData.LED) async def _get_data_network(self) -> dict[str, Any]: """Get network data from device.""" return await self._get_data(AsusData.NETWORK) async def _get_data_ovpn_client(self) -> dict[str, Any]: """Get OpenVPN client data from the device.""" return await self._get_data_modern(AsusData.OPENVPN_CLIENT) async def _get_data_ovpn_server(self) -> dict[str, Any]: """Get OpenVPN server data from the device.""" return await self._get_data(AsusData.OPENVPN_SERVER) async def _get_data_parental_control(self) -> dict[str, Any]: """Get parental control data from the device.""" return await self._get_data( AsusData.PARENTAL_CONTROL, self._process_data_parental_control, ) async def _get_data_port_forwarding(self) -> dict[str, Any]: """Get port forwarding data from the device.""" return await self._get_data( AsusData.PORT_FORWARDING, self._process_data_port_forwarding, ) async def _get_data_ports(self) -> dict[str, dict[str, int]]: """Get ports data from the device.""" return await self._get_data(AsusData.PORTS, self._process_data_ports) async def _get_data_ram(self) -> dict[str, Any]: """Get RAM data from the device.""" return await self._get_data(AsusData.RAM) async def _get_data_sysinfo(self) -> dict[str, Any]: """Get sysinfo data from the device.""" return await self._get_data(AsusData.SYSINFO) async def _get_data_temperature(self) -> dict[str, Any]: """Get temperarture data from the device.""" return await self._get_data_modern(AsusData.TEMPERATURE) async def _get_data_wan(self) -> dict[str, Any]: """Get WAN data from the device.""" return await self._get_data_modern(AsusData.WAN) async def _get_data_wireguard_client(self) -> dict[str, Any]: """Get WireGuard client data from the device.""" return await self._get_data_modern(AsusData.WIREGUARD_CLIENT) async def _get_data_wireguard_server(self) -> dict[str, Any]: """Get WireGuard server data from the device.""" return await self._get_data_modern(AsusData.WIREGUARD_SERVER) async def _get_data_wlan(self) -> dict[str, Any]: """Get WLAN data from the device.""" return await self._get_data(AsusData.WLAN) # <- GET DATA FROM DEVICE # PROCESS DATA -> @staticmethod def _process_data(raw: dict[str, Any]) -> dict[str, Any]: """Process data received from the device. This is a generic method.""" return helpers.as_dict(helpers.flatten_dict(raw)) @staticmethod def _process_data_modern(raw: dict[str, Any]) -> dict[str, Any]: """Process `ovpn_client` data.""" return helpers.clean_dict(convert_to_ha_data(raw)) @staticmethod def _process_data_parental_control(raw: dict[str, Any]) -> dict[str, Any]: """Process `parental control` data.""" rules_list = [] rules = raw.get("rules") if rules is not None: for rule in raw["rules"]: device = dataclasses.asdict(raw["rules"][rule]) device.pop("timemap") device["type"] = device["type"].name.lower() rules_list.append(device) data = convert_to_ha_data(raw) data["list"] = rules_list.copy() data["block_all"] = convert_to_ha_state_bool(raw.get("block_all")) return data @staticmethod def _process_data_port_forwarding(raw: dict[str, Any]) -> dict[str, Any]: """Process `port forwarding` data.""" data: dict[str, Any] = {} data[STATE] = convert_to_ha_state_bool(raw.get(STATE)) devices = [] rules = raw.get("rules") if rules is not None: for rule in rules: device = dataclasses.asdict(rule) devices.append(device) data[LIST] = devices.copy() return data @staticmethod def _process_data_ports(raw: dict[str, Any]) -> dict[str, Any]: """Process `ports` data.""" data: dict[str, Any] = {} for port_type in LIST_PORTS: # Mark port type as disconnected data[port_type] = False # Skip if no data is provided from API if port_type not in raw: continue # Create ports list data[f"{port_type}_{LIST}"] = {} ports_by_type: dict[int, dict[str, Any]] = raw[port_type] for port_number, port_description in ports_by_type.items(): # Mark port type connected if port_description.get(STATE): data[port_type] = True # Copy port data to the list data[f"{port_type}_{LIST}"][port_number] = port_description return data # <- PROCESS DATA # GET SENSORS LIST -> async def _get_sensors( self, datatype: AsusData, process: Callable[[dict[str, Any]], list[str]] | None = None, sensor_type: str | None = None, defaults: bool = False, ) -> list[str]: """Get the available sensors. This is a generic method.""" sensors = [] try: data = await self.api.async_get_data(datatype) _LOGGER.debug( "Raw `%s` sensors of type (%s): %s", datatype, type(data), data ) sensors = ( process(data) if process is not None else self._process_sensors(data) ) _LOGGER.debug("Available `%s` sensors: %s", sensor_type, sensors) except AsusRouterError as ex: if sensor_type in DEFAULT_SENSORS and defaults: sensors = DEFAULT_SENSORS[sensor_type] _LOGGER.debug( "Cannot get available `%s` sensors with exception: %s. \ Will use the following list: {sensors}", sensor_type, ex, ) return sensors async def _get_sensors_modern(self, datatype: AsusData) -> list[str]: """Get the available sensors. This is a generic method.""" sensors = [] try: data = await self.api.async_get_data(datatype) _LOGGER.debug( "Raw `%s` sensors of type (%s): %s", datatype, type(data), data ) sensors = convert_to_ha_sensors(data, datatype) _LOGGER.debug( "Available `%s` sensors: %s", datatype.value, sensors ) except AsusRouterError as ex: if datatype.value in DEFAULT_SENSORS: sensors = DEFAULT_SENSORS[datatype.value] _LOGGER.debug( "Cannot get available `%s` sensors with exception: %s. \ Will use the following list: {sensors}", datatype.value, ex, ) return sensors async def _get_sensors_ovpn_server(self) -> list[str]: """Get the available OpenVPN server sensors.""" return await self._get_sensors( AsusData.OPENVPN_SERVER, self._process_sensors_ovpn_server, sensor_type="ovpn_server", ) async def _get_sensors_ports(self) -> list[str]: """Get the available ports sensors.""" return await self._get_sensors( AsusData.PORTS, self._process_sensors_ports, sensor_type=PORTS, ) # <- GET SENSORS LIST # PROCESS SENSORS LIST-> @staticmethod def _process_sensors(raw: dict[str, Any]) -> list[str]: """Process sensors from the backend library. This is a generic method. For the most of sensors which are returned as nested dicts and only the top level keys are the one we are looking for. """ flat = helpers.as_dict(helpers.flatten_dict(raw)) return helpers.list_from_dict(flat) @staticmethod def _process_sensors_ports(raw: dict[str, Any]) -> list[str]: """Process ports sensors.""" sensors = [] for port_type in LIST_PORTS: sensors.append(port_type) sensors.append(f"{port_type}_{LIST}") return sensors @staticmethod def _process_sensors_ovpn_server(raw: dict[str, Any]) -> list[str]: """Process OpenVPN server sensors.""" return convert_to_ha_sensors(raw, AsusData.OPENVPN_SERVER) # <- PROCESS SENSORS LIST # -------------------- # Services --> # -------------------- def _pc_device2rule( self, device: dict[str, Any], rule_type: PCRuleType ) -> ParentalControlRule | None: """Convert device to parental control rule.""" mac = device.get("mac") if mac is None: return None return ParentalControlRule( mac=mac.upper(), name=device.get("name", ""), type=rule_type, ) async def async_pc_rule(self, **kwargs: Any) -> bool: # noqa: C901, PLR0912 """Change parental control rule(s).""" # Get the passed data raw = kwargs.get("raw") # Abort if no data is passed if raw is None: return False # Get the state to set state = raw.get("state", None) match state: case a if a in ("disable", "allow"): rule_type = PCRuleType.DISABLE case "block": rule_type = PCRuleType.BLOCK case "remove": rule_type = PCRuleType.REMOVE case _: _LOGGER.warning("Unknown parental control state: %s", state) return False # Get the targets to set devices = raw.get("devices", []) entities = raw.get("entities", []) # Prepare the rules list rules_to_set = [] # Process entities if any if len(entities) > 0: entity_reg = er.async_get(self.hass) for entity in entities: reg_value = entity_reg.async_get(entity) if not isinstance(reg_value, er.RegistryEntry): continue capabilities: dict[str, Any] = helpers.as_dict( reg_value.capabilities ) devices.append(capabilities) # Convert devices to rules for device in devices: rule = self._pc_device2rule(device, rule_type) if rule is not None: rules_to_set.append(rule) # Set the rules for rule in rules_to_set: result = await self.api.async_set_state(rule) if result is True: _LOGGER.debug("Parental control rule set: %s", rule) else: _LOGGER.warning("Cannot set parental control rule: %s", rule) return True # -------------------- # <-- Services # -------------------- # --------------------